@zhuxixi/pi-agent-board 0.7.0 → 0.8.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.
- package/CHANGELOG.md +26 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner.mjs +8 -7
- package/runner/pty-runner.mjs +616 -27
- package/runner/state-coordinator.mjs +43 -17
- package/runner/state-runner.mjs +6 -5
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +35 -3
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +27 -3
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +20 -0
- package/src/core/state-commands.mjs +88 -6
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +284 -16
- package/src/ui/dashboard.ts +41 -33
- package/src/ui/pty-attach.ts +236 -72
- package/src/core/pty-input.mjs +0 -47
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# Attach Cursor DECTCEM Gate 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:** Make the attach projection paint its solid inverse PTY-cursor block only when the child terminal reports the cursor as visible (DECTCEM), while keeping the zero-width `CURSOR_MARKER` that positions the hardware cursor for IME.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A new pure reader `isPtyCursorHidden(term)` in `src/core/pty-attach-render.mjs` duck-types `term._core.coreService.isCursorHidden` and falls back to "visible" for unknown shapes. `PtyAttachComponent.project()` reads it once per frame and passes a boolean into `lineToAnsi()`, which keeps position-driven `CURSOR_MARKER` emission but gates the visible inverse block (and the past-end inverse space) on that boolean.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node ESM (`.mjs` pure core), TypeScript UI component exercised via `node --experimental-transform-types` smoke harnesses, `@xterm/headless` ^6.0.0, `node --test`, c8 coverage gates.
|
|
10
|
+
|
|
11
|
+
**Spec of record:** `docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md` (commit `fbc0f09`).
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- No new dependencies. `@xterm/headless` stays `^6.0.0` (production dependency, already used by `src/ui/pty-attach.ts`).
|
|
16
|
+
- Work only inside the worktree: `$WT=/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-102-attach-cursor-dectcem-gate`. Every path in this plan is relative to `$WT`; `git` commands use `git -C $WT`. `main` stays clean.
|
|
17
|
+
- Do not change the contracts of `projectPtyCursor()` or `detectCursorDesync()` — the 4 regression tests from issue #24 in `test/pty-attach-render.test.mjs` must pass unchanged.
|
|
18
|
+
- Do not touch the `CURSOR_MARKER` / synchronized-output machinery (#24/#28) or the desync heal (#11).
|
|
19
|
+
- `npm test` runs `node --test test/*.test.mjs`; coverage gates are lines 85 / functions 80 / branches 70 with `src/ui/*.ts`, `test/**`, `test-support/**` excluded by `.c8rc.json`.
|
|
20
|
+
- `npm run verify` = `tsc --noEmit` && `npm test` && `npm run test:coverage` && `npm run pack:dry`.
|
|
21
|
+
- `git add <file>` per file, never `git add -A`.
|
|
22
|
+
|
|
23
|
+
## File Structure
|
|
24
|
+
|
|
25
|
+
| File | Responsibility | Change |
|
|
26
|
+
|------|----------------|--------|
|
|
27
|
+
| `src/core/pty-attach-render.mjs` | Pure projection helpers (`projectPtyCursor`, `detectCursorDesync`, render scheduler). Holds the new duck-typed DECTCEM reader. | modify (add one function) |
|
|
28
|
+
| `src/ui/pty-attach.ts` | Attach component: buffer projection pipeline + rendering. | modify (type field, `project()`, `lineToAnsi()`) |
|
|
29
|
+
| `test/pty-attach-render.test.mjs` | Pure-function unit tests. | modify (add 2 tests) |
|
|
30
|
+
| `test-support/cursor-visibility-smoke.ts` | New component-level smoke: feed raw PTY bytes, render the real component, report booleans. | create |
|
|
31
|
+
| `test/pty-attach-cursor-visibility.test.mjs` | New wrapper that spawns the smoke under `--experimental-transform-types` and asserts its booleans. | create |
|
|
32
|
+
|
|
33
|
+
Task boundaries: Task 1 delivers a testable pure function (A1/A2). Task 2 delivers the user-visible behavior change plus its component-level proof (A3/A4/A5). Task 3 runs the repo-wide gate and the acceptance ledger (A6 + U1–U3 handoff) — it can fail independently of Tasks 1–2 (coverage/typecheck/pack), so it is its own task.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
### Task 1: DECTCEM reader as a pure function
|
|
38
|
+
|
|
39
|
+
**Files:**
|
|
40
|
+
- Modify: `src/core/pty-attach-render.mjs` (insert after `projectPtyCursor`, which ends at the closing brace of that function)
|
|
41
|
+
- Test: `test/pty-attach-render.test.mjs` (append at end of file)
|
|
42
|
+
|
|
43
|
+
**Interfaces:**
|
|
44
|
+
- Consumes: nothing (new leaf function).
|
|
45
|
+
- Produces: `isPtyCursorHidden(term) -> boolean` — `true` only when xterm explicitly reports `term._core.coreService.isCursorHidden === true`; `false` for every other shape (missing `_core`, missing `coreService`, non-boolean value, throwing accessor, `null`/`undefined` term). Exported from `src/core/pty-attach-render.mjs`; Task 2 imports it.
|
|
46
|
+
|
|
47
|
+
- [ ] **Step 1: Write the failing tests**
|
|
48
|
+
|
|
49
|
+
Append to `test/pty-attach-render.test.mjs`. First add the two imports at the top of the file (after `import test from "node:test";` and after the existing `../src/core/pty-attach-render.mjs` import block):
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { Terminal } from "@xterm/headless";
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import {
|
|
57
|
+
createAttachOutputRenderScheduler,
|
|
58
|
+
detectCursorDesync,
|
|
59
|
+
isPtyCursorHidden,
|
|
60
|
+
nextAttachRender,
|
|
61
|
+
projectPtyCursor,
|
|
62
|
+
shouldScheduleAttachRenderForMessage,
|
|
63
|
+
} from "../src/core/pty-attach-render.mjs";
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Then append the tests:
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
test("isPtyCursorHidden tracks the child terminal's DECTCEM state", async () => {
|
|
70
|
+
const term = new Terminal({ cols: 40, rows: 10 });
|
|
71
|
+
assert.equal(isPtyCursorHidden(term), false, "a fresh terminal reports a visible cursor");
|
|
72
|
+
await new Promise((resolve) => term.write("\x1b[?25l", resolve));
|
|
73
|
+
assert.equal(isPtyCursorHidden(term), true, "?25l must read as hidden");
|
|
74
|
+
await new Promise((resolve) => term.write("\x1b[?25h", resolve));
|
|
75
|
+
assert.equal(isPtyCursorHidden(term), false, "?25h must read as visible again");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("isPtyCursorHidden falls back to visible for unknown terminal shapes", () => {
|
|
79
|
+
assert.equal(isPtyCursorHidden(undefined), false);
|
|
80
|
+
assert.equal(isPtyCursorHidden(null), false);
|
|
81
|
+
assert.equal(isPtyCursorHidden({}), false);
|
|
82
|
+
assert.equal(isPtyCursorHidden({ _core: {} }), false);
|
|
83
|
+
assert.equal(isPtyCursorHidden({ _core: { coreService: {} } }), false);
|
|
84
|
+
assert.equal(isPtyCursorHidden({ _core: { coreService: { isCursorHidden: false } } }), false);
|
|
85
|
+
assert.equal(isPtyCursorHidden({ _core: { coreService: { isCursorHidden: undefined } } }), false);
|
|
86
|
+
assert.equal(isPtyCursorHidden({ _core: { coreService: { isCursorHidden: "true" } } }), false);
|
|
87
|
+
assert.equal(
|
|
88
|
+
isPtyCursorHidden({
|
|
89
|
+
get _core() {
|
|
90
|
+
throw new Error("upstream shape change");
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
false,
|
|
94
|
+
"a throwing accessor must degrade to visible, not break the projection",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- [ ] **Step 2: Run the tests to verify they fail**
|
|
100
|
+
|
|
101
|
+
Run: `cd $WT && node --test test/pty-attach-render.test.mjs`
|
|
102
|
+
Expected: FAIL — `isPtyCursorHidden is not a function` (TypeError) for both new tests; the pre-existing tests pass.
|
|
103
|
+
|
|
104
|
+
- [ ] **Step 3: Implement the reader**
|
|
105
|
+
|
|
106
|
+
In `src/core/pty-attach-render.mjs`, insert the new function immediately after `projectPtyCursor()` (i.e. between that function's closing brace and the `/** Coalesce PTY parser callbacks … */` comment block of `createAttachOutputRenderScheduler`):
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
/**
|
|
110
|
+
* Duck-typed read of the child terminal's DECTCEM visibility state.
|
|
111
|
+
*
|
|
112
|
+
* pi-tui hides the hardware cursor (ESC[?25l) on essentially every frame and
|
|
113
|
+
* still parks it for IME positioning, so the xterm cursor position outlives its
|
|
114
|
+
* visibility: it is a rendering byproduct, not a request to show a cursor. The
|
|
115
|
+
* attach projection must not resurrect that parked cell as a visible block, and
|
|
116
|
+
* must equally not hide a cursor the child wants shown (shells, vim,
|
|
117
|
+
* PI_HARDWARE_CURSOR=1). Only an explicit `true` from xterm's cursor service
|
|
118
|
+
* counts as hidden; anything unknown (renamed internals, another @xterm build)
|
|
119
|
+
* falls back to visible, which is the pre-#102 behavior.
|
|
120
|
+
*/
|
|
121
|
+
export function isPtyCursorHidden(term) {
|
|
122
|
+
try {
|
|
123
|
+
return term?._core?.coreService?.isCursorHidden === true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- [ ] **Step 4: Run the tests to verify they pass**
|
|
131
|
+
|
|
132
|
+
Run: `cd $WT && node --test test/pty-attach-render.test.mjs`
|
|
133
|
+
Expected: PASS — all tests in the file, including the 4 `projectPtyCursor` regressions.
|
|
134
|
+
|
|
135
|
+
- [ ] **Step 5: Commit**
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
cd $WT && git add src/core/pty-attach-render.mjs test/pty-attach-render.test.mjs
|
|
139
|
+
git -C $WT commit -m "feat(core): read the child terminal's DECTCEM cursor visibility (issue #102)"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
### Task 2: Gate the visible cursor block on DECTCEM
|
|
145
|
+
|
|
146
|
+
**Files:**
|
|
147
|
+
- Modify: `src/ui/pty-attach.ts` (`XtermLike._core`, the import from `../core/pty-attach-render.mjs`, `project()`, `lineToAnsi()`)
|
|
148
|
+
- Create: `test-support/cursor-visibility-smoke.ts`
|
|
149
|
+
- Create: `test/pty-attach-cursor-visibility.test.mjs`
|
|
150
|
+
|
|
151
|
+
**Interfaces:**
|
|
152
|
+
- Consumes: `isPtyCursorHidden(term) -> boolean` from Task 1.
|
|
153
|
+
- Produces: `lineToAnsi(line, reusable, term, lineIndex, selection, cursor, cursorHidden = false)` — private to `src/ui/pty-attach.ts`; `cursorHidden` defaults to `false`, so a missing argument keeps today's visible behavior. No public API changes.
|
|
154
|
+
|
|
155
|
+
- [ ] **Step 1: Write the failing smoke harness**
|
|
156
|
+
|
|
157
|
+
Create `test-support/cursor-visibility-smoke.ts`:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// Cursor-visibility regression harness (issue #102): the attach projection must
|
|
161
|
+
// honor the child terminal's DECTCEM state. A hidden cursor must never be painted
|
|
162
|
+
// as a solid inverse block, while the zero-width CURSOR_MARKER (hardware cursor
|
|
163
|
+
// positioning for IME and PI_HARDWARE_CURSOR=1) must survive either way.
|
|
164
|
+
// Run via `node --experimental-transform-types` (TS parameter properties).
|
|
165
|
+
import { PtyAttachComponent } from "../src/ui/pty-attach.ts";
|
|
166
|
+
|
|
167
|
+
const CURSOR_MARKER = "\x1b_pi:c\x07";
|
|
168
|
+
|
|
169
|
+
const tui = {
|
|
170
|
+
terminal: { rows: 12, cols: 40, columns: 40, write: () => {} },
|
|
171
|
+
requestRender: () => {},
|
|
172
|
+
};
|
|
173
|
+
const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
|
|
174
|
+
const keybindings = {} as never;
|
|
175
|
+
|
|
176
|
+
function makeAttach(): PtyAttachComponent {
|
|
177
|
+
return new PtyAttachComponent(
|
|
178
|
+
tui as never,
|
|
179
|
+
theme,
|
|
180
|
+
keybindings,
|
|
181
|
+
() => {},
|
|
182
|
+
{ socketPath: "/no/such/socket", title: "cursor-visibility" },
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Feed raw PTY bytes, then settle the attach transition so render() projects the buffer. */
|
|
187
|
+
async function writeToTerm(attach: PtyAttachComponent, data: string): Promise<void> {
|
|
188
|
+
await new Promise<void>((resolve) => {
|
|
189
|
+
(attach as unknown as { term: { write: (d: string, cb: () => void) => void } }).term.write(data, resolve);
|
|
190
|
+
});
|
|
191
|
+
(attach as unknown as { receivedOutput: boolean }).receivedOutput = true;
|
|
192
|
+
(attach as unknown as { finishAttachTransition: () => void }).finishAttachTransition();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** SGR parameter list emitted right after the CURSOR_MARKER, or null when no marker is rendered. */
|
|
196
|
+
function markerSgrFields(lines: string[]): string[] | null {
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
const at = line.indexOf(CURSOR_MARKER);
|
|
199
|
+
if (at === -1) continue;
|
|
200
|
+
const sgr = line.slice(at + CURSOR_MARKER.length).match(/^\x1b\[([\d;]*)m/);
|
|
201
|
+
return sgr ? sgr[1].split(";") : [];
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function hasInverseAttribute(lines: string[]): boolean {
|
|
207
|
+
return markerSgrFields(lines)?.includes("7") ?? false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function hasInverseSpace(lines: string[]): boolean {
|
|
211
|
+
return lines.some((line) => line.includes("\x1b[7m"));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function hiddenOnContentCell(): Promise<boolean> {
|
|
215
|
+
const attach = makeAttach();
|
|
216
|
+
// Cursor parked on the "e" of "hello" (row 0, col 1); the child says HIDDEN.
|
|
217
|
+
await writeToTerm(attach, "hello\r\x1b[2G\x1b[?25l");
|
|
218
|
+
const lines = attach.render(40);
|
|
219
|
+
const ok = markerSgrFields(lines) !== null && !hasInverseAttribute(lines) && !hasInverseSpace(lines);
|
|
220
|
+
attach.dispose();
|
|
221
|
+
return ok;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function hiddenOnEmptyLine(): Promise<boolean> {
|
|
225
|
+
const attach = makeAttach();
|
|
226
|
+
// Cursor on the empty line below "hello": the empty-line early-return path.
|
|
227
|
+
await writeToTerm(attach, "hello\r\n\x1b[?25l");
|
|
228
|
+
const lines = attach.render(40);
|
|
229
|
+
const ok = markerSgrFields(lines) !== null && !hasInverseAttribute(lines) && !hasInverseSpace(lines);
|
|
230
|
+
attach.dispose();
|
|
231
|
+
return ok;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function hiddenPastEndOfLine(): Promise<boolean> {
|
|
235
|
+
const attach = makeAttach();
|
|
236
|
+
// Cursor one column past "abc": the past-end-of-content branch.
|
|
237
|
+
await writeToTerm(attach, "abc\x1b[?25l");
|
|
238
|
+
const lines = attach.render(40);
|
|
239
|
+
const ok = markerSgrFields(lines) !== null && !hasInverseSpace(lines);
|
|
240
|
+
attach.dispose();
|
|
241
|
+
return ok;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function visibleOnContentCell(): Promise<boolean> {
|
|
245
|
+
const attach = makeAttach();
|
|
246
|
+
await writeToTerm(attach, "hello\r\x1b[2G\x1b[?25h");
|
|
247
|
+
const lines = attach.render(40);
|
|
248
|
+
const ok = hasInverseAttribute(lines);
|
|
249
|
+
attach.dispose();
|
|
250
|
+
return ok;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function visibleByDefaultOnContentCell(): Promise<boolean> {
|
|
254
|
+
const attach = makeAttach();
|
|
255
|
+
// No DECTCEM sequence at all: an unknown state must keep today's behavior.
|
|
256
|
+
await writeToTerm(attach, "hello\r\x1b[2G");
|
|
257
|
+
const lines = attach.render(40);
|
|
258
|
+
const ok = hasInverseAttribute(lines);
|
|
259
|
+
attach.dispose();
|
|
260
|
+
return ok;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function visiblePastEndOfLine(): Promise<boolean> {
|
|
264
|
+
const attach = makeAttach();
|
|
265
|
+
await writeToTerm(attach, "abc\x1b[?25h");
|
|
266
|
+
const lines = attach.render(40);
|
|
267
|
+
const ok = markerSgrFields(lines) !== null && hasInverseSpace(lines);
|
|
268
|
+
attach.dispose();
|
|
269
|
+
return ok;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const out: Record<string, boolean> = {
|
|
273
|
+
hiddenOnContentCellOmitsInverseBlock: await hiddenOnContentCell(),
|
|
274
|
+
hiddenOnEmptyLineKeepsMarkerWithoutBlock: await hiddenOnEmptyLine(),
|
|
275
|
+
hiddenPastEndOmitsInverseSpace: await hiddenPastEndOfLine(),
|
|
276
|
+
visibleOnContentCellPaintsInverseBlock: await visibleOnContentCell(),
|
|
277
|
+
visibleByDefaultOnContentCell: await visibleByDefaultOnContentCell(),
|
|
278
|
+
visiblePastEndPaintsInverseSpace: await visiblePastEndOfLine(),
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
console.log(JSON.stringify(out));
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
- [ ] **Step 2: Write the wrapper test**
|
|
285
|
+
|
|
286
|
+
Create `test/pty-attach-cursor-visibility.test.mjs`:
|
|
287
|
+
|
|
288
|
+
```js
|
|
289
|
+
import assert from "node:assert/strict";
|
|
290
|
+
import { execFileSync } from "node:child_process";
|
|
291
|
+
import { fileURLToPath } from "node:url";
|
|
292
|
+
import { join } from "node:path";
|
|
293
|
+
import test from "node:test";
|
|
294
|
+
|
|
295
|
+
const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
|
|
296
|
+
const SMOKE_SCRIPT = join(ROOT_DIR, "test-support", "cursor-visibility-smoke.ts");
|
|
297
|
+
|
|
298
|
+
// Issue #102: the projection must honor the child's DECTCEM state. pi-tui hides the
|
|
299
|
+
// hardware cursor (ESC[?25l) on nearly every frame, so an unconditional inverse block
|
|
300
|
+
// shows up as a ghost cell at the last diff-write/park position.
|
|
301
|
+
test("attach projection honors the child's cursor visibility (issue #102)", () => {
|
|
302
|
+
const out = execFileSync(process.execPath, ["--experimental-transform-types", SMOKE_SCRIPT], {
|
|
303
|
+
encoding: "utf8",
|
|
304
|
+
timeout: 30_000,
|
|
305
|
+
});
|
|
306
|
+
const parsed = JSON.parse(out);
|
|
307
|
+
assert.equal(parsed.hiddenOnContentCellOmitsInverseBlock, true, "a hidden cursor must not paint an inverse block on a content cell");
|
|
308
|
+
assert.equal(parsed.hiddenOnEmptyLineKeepsMarkerWithoutBlock, true, "a hidden cursor on an empty line keeps the marker but paints no block");
|
|
309
|
+
assert.equal(parsed.hiddenPastEndOmitsInverseSpace, true, "a hidden cursor past the line content must not append an inverse space");
|
|
310
|
+
assert.equal(parsed.visibleOnContentCellPaintsInverseBlock, true, "a visible cursor still paints the inverse block");
|
|
311
|
+
assert.equal(parsed.visibleByDefaultOnContentCell, true, "an unknown DECTCEM state must keep today's visible behavior");
|
|
312
|
+
assert.equal(parsed.visiblePastEndPaintsInverseSpace, true, "a visible cursor past the line content still appends an inverse space");
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
- [ ] **Step 3: Run the smoke to verify the hidden cases fail**
|
|
317
|
+
|
|
318
|
+
Run: `cd $WT && node --test test/pty-attach-cursor-visibility.test.mjs`
|
|
319
|
+
Expected: FAIL — `hiddenOnContentCellOmitsInverseBlock` (false: the block is still painted), `hiddenOnEmptyLineKeepsMarkerWithoutBlock` (false), `hiddenPastEndOmitsInverseSpace` (false). The three `visible*` assertions already pass (they are regression guards).
|
|
320
|
+
Sanity-check the raw output too: `node --experimental-transform-types test-support/cursor-visibility-smoke.ts`
|
|
321
|
+
|
|
322
|
+
- [ ] **Step 4: Wire the visibility flag through the component**
|
|
323
|
+
|
|
324
|
+
Edit `src/ui/pty-attach.ts`.
|
|
325
|
+
|
|
326
|
+
(a) Extend the existing `_core` declaration in `interface XtermLike` (around line 78):
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
_core?: {
|
|
330
|
+
coreService?: { isCursorHidden?: boolean };
|
|
331
|
+
_oscLinkService?: {
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
(b) Add `isPtyCursorHidden` to the existing import from `../core/pty-attach-render.mjs` (around line 10), keeping the alphabetical order used there.
|
|
335
|
+
|
|
336
|
+
(c) In `project()`, read the state once per frame and pass it down:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
const cursor = projectPtyCursor(buf, start, height);
|
|
340
|
+
const cursorHidden = isPtyCursorHidden(this.term);
|
|
341
|
+
for (let i = start; i < end; i++) {
|
|
342
|
+
out.push(lineToAnsi(buf.getLine(i), reusable, this.term, i, selection, cursor, cursorHidden));
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
(d) `lineToAnsi()`: add the parameter and split the two concerns.
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
function lineToAnsi(
|
|
350
|
+
line: BufferLineLike | undefined,
|
|
351
|
+
reusable: BufferCellLike,
|
|
352
|
+
term: XtermLike,
|
|
353
|
+
lineIndex: number,
|
|
354
|
+
selection: NormalizedSelection | null,
|
|
355
|
+
cursor: { row: number; col: number } | null,
|
|
356
|
+
cursorHidden = false,
|
|
357
|
+
): string {
|
|
358
|
+
const isCursorRow = cursor !== null && cursor.row === lineIndex;
|
|
359
|
+
let last = -1;
|
|
360
|
+
if (!line) {
|
|
361
|
+
// No buffer line: keep the marker (IME positioning) and paint the inverse block
|
|
362
|
+
// only while the child reports a visible cursor.
|
|
363
|
+
if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
364
|
+
return "";
|
|
365
|
+
}
|
|
366
|
+
for (let x = 0; x < line.length; x++) {
|
|
367
|
+
const cell = line.getCell(x, reusable);
|
|
368
|
+
if (!cell || cell.getWidth() === 0) continue;
|
|
369
|
+
if (cell.getChars()) last = x;
|
|
370
|
+
}
|
|
371
|
+
if (last < 0) {
|
|
372
|
+
// Empty line: same split as above.
|
|
373
|
+
if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
374
|
+
return "";
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
then in the cell loop replace the cursor block with:
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
// Position and visibility are separate concerns: the zero-width CURSOR_MARKER
|
|
382
|
+
// (stripped by the TUI) always marks where the hardware cursor belongs for IME
|
|
383
|
+
// and PI_HARDWARE_CURSOR=1 terminals, while the solid inverse block is only
|
|
384
|
+
// painted when the child terminal itself reports the cursor as visible. pi-tui
|
|
385
|
+
// parks a hidden cursor at a diff-write byproduct position, so painting it
|
|
386
|
+
// unconditionally showed a ghost block (issue #102).
|
|
387
|
+
const isCursor = isCursorRow && x === cursor!.col;
|
|
388
|
+
if (isCursor) out += CURSOR_MARKER;
|
|
389
|
+
const paintCursor = isCursor && !cursorHidden;
|
|
390
|
+
const key = attrKey(cell, selected, paintCursor);
|
|
391
|
+
if (key !== prevAttr) {
|
|
392
|
+
out += attrsToAnsi(cell, selected, paintCursor);
|
|
393
|
+
prevAttr = key;
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
and finally the past-end branch:
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
// Cursor past the end of the line content (cursorX == cols or beyond last cell):
|
|
401
|
+
// append an inverse space so a VISIBLE position shows, keeping the marker either way.
|
|
402
|
+
if (isCursorRow && cursor!.col > last) {
|
|
403
|
+
out += CURSOR_MARKER + (cursorHidden ? "" : "\x1b[7m \x1b[0m");
|
|
404
|
+
}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
- [ ] **Step 5: Run the smoke and the neighbouring suites**
|
|
408
|
+
|
|
409
|
+
Run: `cd $WT && node --test test/pty-attach-cursor-visibility.test.mjs test/pty-attach-render.test.mjs test/pty-attach-detach-gate.test.mjs test/pty-attach-desync-heal.test.mjs test/ui-smoke.test.mjs`
|
|
410
|
+
Expected: PASS — all 6 smoke booleans true; the #24/#66/#11 suites unchanged.
|
|
411
|
+
|
|
412
|
+
- [ ] **Step 6: Commit**
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
cd $WT && git add src/ui/pty-attach.ts test-support/cursor-visibility-smoke.ts test/pty-attach-cursor-visibility.test.mjs
|
|
416
|
+
git -C $WT commit -m "fix(attach): stop painting the PTY cursor block for a hidden cursor (issue #102)"
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
### Task 3: Repo-wide gate and acceptance ledger
|
|
422
|
+
|
|
423
|
+
**Files:**
|
|
424
|
+
- No source changes expected. If the gate surfaces a gap, fix it in the file the gap belongs to and commit with an `fix(attach): …` message.
|
|
425
|
+
|
|
426
|
+
**Interfaces:**
|
|
427
|
+
- Consumes: Tasks 1–2 deliverables.
|
|
428
|
+
- Produces: verified evidence for spec items A1–A6 and a handoff checklist for U1–U3 (manual verification on a real terminal).
|
|
429
|
+
|
|
430
|
+
- [ ] **Step 1: Run the full gate**
|
|
431
|
+
|
|
432
|
+
Run: `cd $WT && npm run verify`
|
|
433
|
+
Expected: PASS — `tsc --noEmit` clean; all `test/*.test.mjs` green; c8 reports the gates (lines 85 / functions 80 / branches 70) satisfied; `pack:dry` succeeds.
|
|
434
|
+
|
|
435
|
+
- [ ] **Step 2: Confirm the new pure function is covered**
|
|
436
|
+
|
|
437
|
+
Run: `cd $WT && npm run test:coverage 2>&1 | rg -A 2 "pty-attach-render"`
|
|
438
|
+
Expected: `src/core/pty-attach-render.mjs` at 100% (or at least no line for `isPtyCursorHidden` in the uncovered list). `src/ui/pty-attach.ts` is intentionally absent — `.c8rc.json` excludes `src/ui/*.ts`, which is why the behavior assertions live in the component smoke.
|
|
439
|
+
|
|
440
|
+
- [ ] **Step 3: Re-run the issue-#102 reproduction harness against the fix**
|
|
441
|
+
|
|
442
|
+
Run the two research harnesses (outside the repo, so nothing gets committed):
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
node /home/elling/.claude/github-issue-driven/zhuxixi/pi-agent-board/issue-102/research/replay-cursor.mjs \
|
|
446
|
+
~/.pi/agent/agent-board/views/view_6e2de515c0/screen.log 120 36
|
|
447
|
+
cd $WT && node --experimental-transform-types \
|
|
448
|
+
/home/elling/.claude/github-issue-driven/zhuxixi/pi-agent-board/issue-102/research/replay-component.ts \
|
|
449
|
+
~/.pi/agent/agent-board/views/view_6e2de515c0/screen.log 120 36
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Expected: `isCursorHidden` still `true`; `replay-component.ts` now reports `blockPaintedAtMarker: false` with a non-empty `markerLines` array (marker kept, block gone). Record both outputs for the acceptance ledger.
|
|
453
|
+
|
|
454
|
+
- [ ] **Step 4: Fill the acceptance ledger**
|
|
455
|
+
|
|
456
|
+
Record, per spec ID, the exact command and observed result: A1/A2 (`node --test test/pty-attach-render.test.mjs`), A3/A4/A5 (`node --test test/pty-attach-cursor-visibility.test.mjs`), A6 (`npm run verify`). Mark U1/U2/U3 as `pending` with their manual steps from the spec — a green test suite does not stand in for them.
|
|
457
|
+
|
|
458
|
+
- [ ] **Step 5: Commit any gate fix**
|
|
459
|
+
|
|
460
|
+
Only if Steps 1–2 required a change. Stage the files the gate actually flagged (for example `git -C $WT add src/core/pty-attach-render.mjs`), then commit with a message naming that gap:
|
|
461
|
+
|
|
462
|
+
```bash
|
|
463
|
+
cd $WT && git add src/core/pty-attach-render.mjs
|
|
464
|
+
# or: git add src/ui/pty-attach.ts test/pty-attach-render.test.mjs
|
|
465
|
+
# whichever files the failing gate flagged — never `git add -A`
|
|
466
|
+
git -C $WT commit -m "fix(attach): cover the cursor-visibility branch the coverage gate reported (issue #102)"
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Otherwise leave the branch at the Task 2 commit and proceed to local code review (`superpowers:requesting-code-review` or the `workflow` tool's `code-review` mode), then stop for explicit user approval before pushing/opening the PR.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Plan: Attach Switch to Snapshot+Subscribe (issue #91 Phase 4, D2 completion)
|
|
2
|
+
|
|
3
|
+
Spec: `docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md` (§ D2 attach flow, §8.4, acceptance A5/A5c/A6, §9 snapshot/subscribe protocol layer)
|
|
4
|
+
Parent issue: #91 (do NOT close)
|
|
5
|
+
|
|
6
|
+
## Goal
|
|
7
|
+
|
|
8
|
+
The dashboard attach switches from「screen.log tail replay + fire-and-forget live + shrink-and-hold jiggle」to「snapshot hydrate + seq-checked subscribe」。After this phase: attach 画面由 runner-owned canonical snapshot 重建(A5/A5c/A6 e2e 收口),UI 本地 buffer 降级为可丢弃缓存,screen.log 退出正确性路径(保留调试/历史),jiggle 降级为旧 runner fallback。Legacy runners(已部署旧二进制)走显式降级路径。
|
|
9
|
+
|
|
10
|
+
## Non-goals
|
|
11
|
+
|
|
12
|
+
- 控制命令生命周期 accepted/applied/observed、reconcile 顺序(Phase 5)
|
|
13
|
+
- 删 `childInputLooksEmpty()`、`shouldEscapeAttach` 改造(Phase 6)
|
|
14
|
+
- screen.log 写入/删除本身(保留 runner 侧写入 + GC,仅 UI 不再依赖)
|
|
15
|
+
|
|
16
|
+
## Ground truth (verified, do not re-litigate)
|
|
17
|
+
|
|
18
|
+
- Runner (Phase 3): both mains handle `subscribe_terminal`; snapshot flow = `snapshot_begin{snapshotSeq,cols,rows,frameVersion,empty?,resnapshot?}` → `snapshot_frame{data}` → `snapshot_end{nextSeq}` → live `{type:"output",seq,data}`; `resnapshot_required{lastSeq,missing}` on gap/ring-pressure/interruption; `sinceSeq===evictedThrough` → complete replay; empty model → `begin.empty:true`, nextSeq 1; `frameVersion` mismatch → typed error. Old binaries silently ignore `subscribe_terminal` (no default case).
|
|
19
|
+
- Frame: self-contained on dirty terminals (DECSTR+2J/3J+r preamble); wrap-pending/DECOM-safe cursor park; modes closure edges (DECSTBM/SO-SI/tab stops/DECSC) don't round-trip — content-equivalent only.
|
|
20
|
+
- UI today (`src/ui/pty-attach.ts`, 1520 lines): constructor does `replayScreenLog()` → own `@xterm/headless` surface; `connect()` → hello + `jiggleRetry.start` + `startAttachSettle`; `onSocketData` → `output`→`pushOutput`+`checkClearSequence`(jiggle clear 检测); reconnect = re-jiggle; `pushOutput` forwards OSC52/passthrough protocols.
|
|
21
|
+
- UI test infra: attach-flow / pty-attach-cold-start-e2e / desync-heal(-e2e) / hot-session-e2e / reconnect / jiggle-* / detach-gate / render — real-runner e2e suites exist and will exercise the new path automatically.
|
|
22
|
+
|
|
23
|
+
## Architecture
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
src/core/terminal-attach-client.mjs # NEW: pure client-side protocol state machine (send/onEvent injected)
|
|
27
|
+
src/ui/pty-attach.ts # switch: protocol mode primary, legacy mode fallback
|
|
28
|
+
test/terminal-snapshot.integration.test.mjs # NEW: A5/A5c real-runner e2e
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Client module contract
|
|
32
|
+
|
|
33
|
+
- `createTerminalAttachClient({send, emit, probeTimeoutMs=1500, frameVersion=1, forceLegacy})`
|
|
34
|
+
- `start()` → client sends `hello` (unchanged) then `subscribe_terminal` probe. States: `probing → protocol:collecting → protocol:live` or `probing → legacy` (probe timeout, no `snapshot_begin`).
|
|
35
|
+
- `handleMessage(msg)` consumes: snapshot_begin/frame/end (frame assembly), output.seq contiguity (first live seq MUST equal `snapshot_end.nextSeq`; subsequent +1), `resnapshot_required` → emit resubscribe, seq gap → emit resubscribe, `frame_version_mismatch` error → legacy fallback (treat runner as incompatible).
|
|
36
|
+
- Emits: `mode(protocol|legacy)` (once decided), `snapshotReady({frame|empty, nextSeq})` (UI: `term.reset()` + `write(frame)`; empty → loading baseline), `output(data)` (live, UI: pushOutput without jiggle feed), `resubscribe(sinceSeq)` (UI: send `subscribe_terminal{sinceSeq}`), `legacy` (UI: start jiggle + keep screen.log replay path).
|
|
37
|
+
- `reconnect(lastSeq)`: skip probe (protocol already achieved), subscribe with `sinceSeq`; ring replay → seamless continue (no reset); evicted/empty/foreign cursor → fresh snapshot (reset+hydrate).
|
|
38
|
+
- Interruption (`snapshot_begin`+`frame` then `resnapshot_required` instead of `end`): discard partial, emit resubscribe(0). Empty-model `sinceSeq:0` replay = zero messages until first live chunk (documented; client stays live-ready).
|
|
39
|
+
- Env `AGENT_BOARD_TERMINAL_SNAPSHOT=0` → forceLegacy (escape hatch + deterministic legacy tests).
|
|
40
|
+
- Probe timeout: local socket + capture awaits parser idle (ms-scale between chunks) → 1500ms generous; on timeout the connection still works (legacy output path) — no user-visible failure.
|
|
41
|
+
|
|
42
|
+
### UI switch rules
|
|
43
|
+
|
|
44
|
+
- Protocol mode: no jiggle (never `jiggleRetry.start`), no `checkClearSequence`; `snapshotReady` → `term.reset()` + frame write + settle/loading end at `snapshot_end`; `output` → `pushOutput(data, {forwardProtocols:true})` only; `resubscribe` → resend; reconnect → `client.reconnect(lastSeq)` (seamless replay or fresh snapshot — NO re-jiggle, NO screen.log re-replay in protocol mode).
|
|
45
|
+
- Legacy mode (probe timeout / mismatch / env): exact current behavior (screen.log replay at constructor, jiggle on connect, clear detection) — zero regression for mixed fleet.
|
|
46
|
+
- `editor_state`/`exit`/`error`/`hello`/`status` handling unchanged in both modes. Detach/Ctrl+Left unchanged (Phase 6).
|
|
47
|
+
- Loading ticker: protocol mode ends at `snapshot_end` (or first live output if empty baseline); legacy unchanged.
|
|
48
|
+
|
|
49
|
+
## Tasks (bite-sized, commit per task)
|
|
50
|
+
|
|
51
|
+
### Task 1 — client protocol module + unit matrix (`src/core/terminal-attach-client.mjs`, `test/terminal-attach-client.test.mjs`)
|
|
52
|
+
Full matrix: probe→protocol happy path (frame assembly, nextSeq continuity); probe timeout→legacy; forceLegacy env; mismatch error→legacy; gap→resubscribe(+1 from lastSeq); resnapshot_required→resubscribe; interrupted snapshot→resubscribe(0); empty baseline→snapshotReady(empty)+live seq 1; reconnect replay (no reset) vs fresh (resnapshot flag); duplicate/stale seq ignored; output before snapshot_end (tolerate? no — protocol violation→resubscribe(0), pin it); legacy `output` (no seq) never counted in protocol mode. Pure logic, fake send/emit.
|
|
53
|
+
|
|
54
|
+
### Task 2 — real-runner e2e + stress/containment pre-flight (`test/terminal-snapshot.integration.test.mjs`)
|
|
55
|
+
A5: subscribe mid-stream over real socket; no gap/dup from nextSeq through live; second subscriber + legacy client concurrently. A5c: kill runner → new runner+child → empty:true baseline → new baseline from new child output (no old screen). Reconnect replay vs evicted→resnapshot over real socket. Firehose probe (pre-flight from Phase 3 final review): sustained multi-MB/s garbage+mixed stream ~3s → bounded lag, no crash, snapshot still capturable; parser containment: malformed byte flood cannot escape into uncaughtException (assert runner alive + document observed behavior). Reuse the pty-runner integration fixture pattern (AGENT_BOARD_ROOT/PI_CODING_AGENT_DIR isolation, tracked coordinator helper, finally-kill).
|
|
56
|
+
|
|
57
|
+
### Task 3 — UI switch (`src/ui/pty-attach.ts`)
|
|
58
|
+
Per Architecture rules. Inventory existing UI e2e: jiggle-specific suites (jiggle-retry, desync-heal*) force legacy via env; hot-session/cold-start/reconnect/render must pass in protocol mode (adapt fixtures only if they pin legacy-only specifics — no assertion weakening). Typecheck mandatory.
|
|
59
|
+
|
|
60
|
+
### Task 4 — A6 e2e + acceptance sweep
|
|
61
|
+
A6 (client+real socket): kill-runner mid-stream → reconnect → fresh snapshot hydrate; local-buffer pollution (garbage into term) → next fresh frame overwrites (self-contained preamble already proven; assert via client events + independent parser compare where feasible). Full regression `node --test test/*.test.mjs` + typecheck + zero strays. Update acceptance table (A5/A5c/A6 → CLOSED) + residual ledger in this plan.
|
|
62
|
+
|
|
63
|
+
## Verification per task
|
|
64
|
+
|
|
65
|
+
Targeted tests green → FULL suite green → typecheck → conventional commit (explicit git add). SDD task reviews; whole-branch final review before PR.
|
|
66
|
+
|
|
67
|
+
## Risks
|
|
68
|
+
|
|
69
|
+
- **Settle/loading interactions**: `startAttachSettle` assumes jiggle redraws; protocol mode needs its own settle end — read both before wiring (Task 3 first step).
|
|
70
|
+
- **Protocol-mode UI e2e fixtures**: existing suites assume jiggle/clear sequences — misclassified pins would either break (visible) or silently weaken (reviewer checks assertion equivalence, not just green).
|
|
71
|
+
- **Probe timeout under parser busy**: heavy stream may delay snapshot_begin; 1500ms + no user-visible failure on fallback keeps it safe.
|
|
72
|
+
- **Two-mode complexity is TEMPORARY**: legacy path gets deleted only after fleet refresh — document as Phase 6+ cleanup, don't gold-plate.
|
|
73
|
+
|
|
74
|
+
## Residual ledger (Phase 4)
|
|
75
|
+
|
|
76
|
+
### Acceptance table
|
|
77
|
+
|
|
78
|
+
| ID | Verdict | Evidence |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| A5 (snapshot/subscribe 无 gap 无重复) | **CLOSED** (wire-level) | `test/terminal-snapshot.integration.test.mjs` A5 mid-stream (real runner+socket+client module, second subscriber seq-contiguity pin, legacy client coexistence) + A5 burst (frame→flush→end window pin, 400 lines exactly-once across frame∪flush∪live) |
|
|
81
|
+
| A5c (runner 重启新基线) | **CLOSED** (wire + component) | same file A5c (wire: foreign cursor → empty baseline, nextSeq restarts at 1, no old-screen in any post-restart frame/stream) + `pty-attach-restart-scenario.ts` via `pty-attach-protocol-e2e.test.mjs` (component: real PtyAttachComponent, kill mid-stream, hold-mode new child, wipe signal, new-echo convergence, zero resizes) |
|
|
82
|
+
| A6 (UI buffer 可丢弃恢复) | **CLOSED** (component + client) | component restart scenario (poisoned screen.log never renders; old echo absent from the same viewport that holds the new echo — F3 wipe pinned without scroll masking) + wire A6 (kill→reconnect→rebased resnapshot/empty; poisoned screen.log never rendered (never in frames, never in streams; the read path is constructor-only by source inspection); pollution-overwrite proof: dirty vs clean independent headless parsers converge byte-identically on the fresh frame) |
|
|
83
|
+
|
|
84
|
+
### Accepted residuals / documented deltas
|
|
85
|
+
|
|
86
|
+
- **Runner stderr parse-error volume**: @xterm/headless contains parser errors internally (runner process survives; verified with ~1.3MB/s malformed flood) but logs them to runner stderr — sustained garbage for minutes produces large logs. Rate-limit as later hygiene (not #91 scope).
|
|
87
|
+
- **Catch-up-flush branch is defensive-only on the real wire**: the capture continuation is microtask-atomic today, so chunks never arrive between frame and end (Task 2 review: the branch is unreachable on the current wire; kept + tested as defense-in-depth against future runner refactors).
|
|
88
|
+
- **Collecting-state has no stall timer**: a snapshot that never completes (runner wedged mid-capture) behaves like the legacy silence path — the socket close/reconnect cycle is the recovery. Inherited-equivalent to legacy; not new risk.
|
|
89
|
+
- **Legacy attach timing deltas**: ≤100ms jiggle arm delay in the undecided window (`LEGACY_JIGGLE_ARM_DELAY_MS`); protocol-mode loading banner may persist up to ~2500ms on the steady-stream fixture before settle. Both invisible by design; documented for future bisects.
|
|
90
|
+
- **A5-burst load flake**: 15s waitFor ceiling can trip under full-suite parallel load (machine with 6+ production board runners); passes isolated and in consecutive full runs. Ceilings are eventually-predicates, not timing bounds.
|
|
91
|
+
- **Wire flush-window evidence line logs counts, not bytes** (`EVIDENCE firehose` payload) — byte-volume claims come from the Task 2 report's measured ~1.1KB/chunk × chunk count.
|
|
92
|
+
- **Foreign-cursor ambiguity after restart**: a restarted runner whose new model already exceeds the client's old cursor serves a RING REPLAY (cursor within its own contiguous range) instead of a fresh snapshot — the protocol has no runner-generation/epoch token. Bounded honestly: every replayed byte comes from the NEW runner's ring, so no old-runner content and no fabrication is possible; but the viewport becomes a splice (pre-restart rows kept on top, new-child lines appended) that self-heals only by scroll-out (~a viewport-height of new content) — if the new child goes quiet, the stale mix persists until detach. This is a mild staleness REGRESSION vs legacy recovery in this corner (a legacy reconnect full-viewport-redrew the new runner's screen); trigger window is narrow (kill mid-stream + replacement child producing ≥ cursorBefore chunks before the reconnect subscribe lands). The Phase 5 generation-token is the structural fix: without it the client cannot distinguish same-runner replay from new-runner coincidental seq overlap, so it cannot request a fresh snapshot proactively. (Epoch token is a Phase 5 candidate — control-command lifecycle owns reconciliation semantics.)
|