@zhuxixi/pi-agent-board 0.5.1 → 0.5.2
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/docs/superpowers/plans/2026-09-01-attach-detach-gate-cursor-anchor.md +284 -0
- package/docs/superpowers/plans/2026-09-02-attach-detach-editor-state.md +722 -0
- package/docs/superpowers/plans/2026-09-03-detach-gate-glyph-fallback.md +146 -0
- package/docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md +78 -0
- package/docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md +120 -0
- package/docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md +99 -0
- package/package.json +1 -1
- package/runner/pty-runner.mjs +12 -2
- package/src/core/editor-state-reporter.mjs +102 -0
- package/src/core/pty-input.mjs +32 -0
- package/src/index.ts +12 -1
- package/src/ui/pty-attach.ts +55 -7
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
# editor_state Push Detach 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:** Replace the render-heuristic detach gate with the child Pi's authoritative editor state, pushed over the existing control socket (`editor_state`), with the heuristic kept as fallback.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Child Pi extension polls `ctx.ui.getEditorText()` every 100ms and reports on change → runner caches + broadcasts `{type:"editor_state", empty}` and seeds it in `hello` → attach panel caches `editorEmpty` and judges `resolveEditorEmpty(editorEmpty, heuristic)`. All pieces are dependency-injected/unit-testable; the production wiring is one hook in `src/index.ts`.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript (pty-attach.ts), plain ESM (mjs), `node:test`, @xterm/headless (smoke harness).
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md`
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- Coverage gates: lines 85 / funcs 80 / branches 70 (`npm run test:coverage`); CI Node 22/24 must be green.
|
|
16
|
+
- Do NOT remove or change the existing three-tier render heuristic (`findLastInverseCellLine` / glyph fallback / escape fallback) — it becomes the fallback only.
|
|
17
|
+
- `ctrl+]` semantics unchanged; `←` keeps its gate (never unconditionally detach while connected).
|
|
18
|
+
- Protocol: `{type:"editor_state", empty: boolean}`; `hello` gains `editorEmpty: boolean | null`. Old peers ignore unknown fields — backward compatible.
|
|
19
|
+
- `resolveEditorEmpty(editorEmpty, heuristic)`: `editorEmpty === null || editorEmpty === undefined ? heuristic : editorEmpty`.
|
|
20
|
+
- All edits inside worktree `WT=.pi/worktrees/issue-68-attach-detach-editor-state`; git ops via `git -C $WT`. You are the only agent; do NOT dispatch subagents.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
### Task 1: `resolveEditorEmpty` pure function + unit tests (A5)
|
|
25
|
+
|
|
26
|
+
**Files:**
|
|
27
|
+
- Modify: `src/core/pty-input.mjs`
|
|
28
|
+
- Test: `test/pty-input.test.mjs`
|
|
29
|
+
|
|
30
|
+
**Interfaces:**
|
|
31
|
+
- Produces: `export function resolveEditorEmpty(editorEmpty: boolean | null | undefined, heuristic: boolean): boolean`
|
|
32
|
+
|
|
33
|
+
- [ ] **Step 1: Write the failing test**
|
|
34
|
+
|
|
35
|
+
Append to `test/pty-input.test.mjs` (keep existing tests untouched):
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
import { isProbablyEmptyPiInputLine, isProbablyPiInputLine, resolveEditorEmpty } from "../src/core/pty-input.mjs";
|
|
39
|
+
|
|
40
|
+
test("resolveEditorEmpty prefers the pushed editor state, falls back on null/undefined", () => {
|
|
41
|
+
assert.equal(resolveEditorEmpty(true, false), true);
|
|
42
|
+
assert.equal(resolveEditorEmpty(false, true), false);
|
|
43
|
+
assert.equal(resolveEditorEmpty(null, true), true);
|
|
44
|
+
assert.equal(resolveEditorEmpty(null, false), false);
|
|
45
|
+
assert.equal(resolveEditorEmpty(undefined, true), true);
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
50
|
+
|
|
51
|
+
Run: `node --test test/pty-input.test.mjs`
|
|
52
|
+
Expected: FAIL — `resolveEditorEmpty is not a function` (import error).
|
|
53
|
+
|
|
54
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
55
|
+
|
|
56
|
+
Append to `src/core/pty-input.mjs`:
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the ← detach gate's emptiness signal: when the child Pi pushes its
|
|
61
|
+
* authoritative editor state (boolean), it wins; when it is unknown (null/
|
|
62
|
+
* undefined — child extension missing or socket never connected), fall back
|
|
63
|
+
* to the render heuristic.
|
|
64
|
+
* @param {boolean | null | undefined} editorEmpty
|
|
65
|
+
* @param {boolean} heuristic
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
export function resolveEditorEmpty(editorEmpty, heuristic) {
|
|
69
|
+
return editorEmpty === null || editorEmpty === undefined ? heuristic : editorEmpty;
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
74
|
+
|
|
75
|
+
Run: `node --test test/pty-input.test.mjs`
|
|
76
|
+
Expected: PASS (all pty-input tests including the new one).
|
|
77
|
+
|
|
78
|
+
- [ ] **Step 5: Commit**
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
git add test/pty-input.test.mjs src/core/pty-input.mjs
|
|
82
|
+
git commit -m "feat: add resolveEditorEmpty gate resolution helper (issue #68)"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
### Task 2: `createEditorStateReporter` + unit tests (A1, A2)
|
|
88
|
+
|
|
89
|
+
**Files:**
|
|
90
|
+
- Create: `src/core/editor-state-reporter.mjs`
|
|
91
|
+
- Test: `test/editor-state-reporter.test.mjs`
|
|
92
|
+
|
|
93
|
+
**Interfaces:**
|
|
94
|
+
- Produces: `export function createEditorStateReporter({ getEditorText, connect, intervalMs = 100, scheduler = defaultScheduler }): { start(): void, stop(): void }`
|
|
95
|
+
- `getEditorText(): string` — returns the child Pi editor text (`""` = empty).
|
|
96
|
+
- `connect(): SocketLike` — opens the control socket; throws on failure. `SocketLike` = `{ write(jsonLine: string): void; on?(event: "close" | "error", fn: () => void): void }`.
|
|
97
|
+
- `scheduler = { interval(fn, ms): handle; timeout(fn, ms): handle; clear(handle): void }` — defaults to setInterval/setTimeout wrappers.
|
|
98
|
+
- Behavior: connect immediately on `start()` (retry with backoff 1s→2s→4s→5s cap, forever); after connect, poll `getEditorText()` every `intervalMs` and send `{type:"editor_state", empty: text.length === 0}` ONLY when the text changed; on socket `close`/`error` (or a write throw), tear down, clear the poll, and re-enter the reconnect backoff (backoff resets to 1s after a successful connect); `stop()` is idempotent and ends everything.
|
|
99
|
+
|
|
100
|
+
- [ ] **Step 1: Write the failing tests**
|
|
101
|
+
|
|
102
|
+
Create `test/editor-state-reporter.test.mjs`:
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
import assert from "node:assert/strict";
|
|
106
|
+
import { test } from "node:test";
|
|
107
|
+
import { createEditorStateReporter } from "../src/core/editor-state-reporter.mjs";
|
|
108
|
+
|
|
109
|
+
/** Manual-clock scheduler: interval/timeout register callbacks; fire() runs the oldest due one. */
|
|
110
|
+
function fakeScheduler() {
|
|
111
|
+
const pending = []; // { at, fn, handle }
|
|
112
|
+
let nextId = 1;
|
|
113
|
+
return {
|
|
114
|
+
pending,
|
|
115
|
+
interval(fn, ms) { return this._push(fn, ms); },
|
|
116
|
+
timeout(fn, ms) { return this._push(fn, ms); },
|
|
117
|
+
_push(fn, ms) {
|
|
118
|
+
const handle = nextId++;
|
|
119
|
+
pending.push({ handle, fn, ms, due: 0 });
|
|
120
|
+
return handle;
|
|
121
|
+
},
|
|
122
|
+
clear(handle) {
|
|
123
|
+
const i = pending.findIndex((p) => p.handle === handle);
|
|
124
|
+
if (i >= 0) pending.splice(i, 1);
|
|
125
|
+
},
|
|
126
|
+
/** Run the callback whose (re-registration cadence) is due now; returns it or null. */
|
|
127
|
+
fireOne(now) {
|
|
128
|
+
const due = pending.filter((p) => p.due <= now).sort((a, b) => a.due - b.due)[0];
|
|
129
|
+
if (!due) return null;
|
|
130
|
+
const { fn } = due;
|
|
131
|
+
this.clear(due.handle);
|
|
132
|
+
fn();
|
|
133
|
+
return due;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function fakeSocket() {
|
|
139
|
+
const sent = [];
|
|
140
|
+
const handlers = {};
|
|
141
|
+
return {
|
|
142
|
+
sent,
|
|
143
|
+
write(jsonLine) { sent.push(jsonLine); },
|
|
144
|
+
on(event, fn) { handlers[event] = fn; },
|
|
145
|
+
emit(event) { handlers[event]?.(); },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
test("reporter polls and sends only on text change (A1)", () => {
|
|
150
|
+
const sched = fakeScheduler();
|
|
151
|
+
let text = "";
|
|
152
|
+
const socket = fakeSocket();
|
|
153
|
+
const reporter = createEditorStateReporter({
|
|
154
|
+
getEditorText: () => text,
|
|
155
|
+
connect: () => socket,
|
|
156
|
+
scheduler: sched,
|
|
157
|
+
intervalMs: 100,
|
|
158
|
+
});
|
|
159
|
+
reporter.start();
|
|
160
|
+
assert.equal(socket.sent.length, 0); // no send before the first poll
|
|
161
|
+
sched.fireOne(100); // first poll: "" → lastText was null → change → send empty:true
|
|
162
|
+
assert.deepEqual(socket.sent.map((l) => JSON.parse(l)), [{ type: "editor_state", empty: true }]);
|
|
163
|
+
text = "draft";
|
|
164
|
+
sched.fireOne(200);
|
|
165
|
+
assert.equal(socket.sent.length, 2);
|
|
166
|
+
assert.deepEqual(JSON.parse(socket.sent[1]), { type: "editor_state", empty: false });
|
|
167
|
+
text = "draft 2"; // still non-empty → no send
|
|
168
|
+
sched.fireOne(300);
|
|
169
|
+
assert.equal(socket.sent.length, 2);
|
|
170
|
+
text = "";
|
|
171
|
+
sched.fireOne(400);
|
|
172
|
+
assert.equal(socket.sent.length, 3);
|
|
173
|
+
assert.deepEqual(JSON.parse(socket.sent[2]), { type: "editor_state", empty: true });
|
|
174
|
+
reporter.stop();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("reporter stop is idempotent and ends polling (A1)", () => {
|
|
178
|
+
const sched = fakeScheduler();
|
|
179
|
+
const socket = fakeSocket();
|
|
180
|
+
const reporter = createEditorStateReporter({ getEditorText: () => "", connect: () => socket, scheduler: sched, intervalMs: 100 });
|
|
181
|
+
reporter.start();
|
|
182
|
+
sched.fireOne(100);
|
|
183
|
+
reporter.stop();
|
|
184
|
+
reporter.stop();
|
|
185
|
+
assert.equal(sched.pending.length, 0); // poll + reconnect timers all cleared
|
|
186
|
+
assert.equal(socket.sent.length, 1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("reporter retries connect with capped backoff then recovers (A2)", () => {
|
|
190
|
+
const sched = fakeScheduler();
|
|
191
|
+
const socket = fakeSocket();
|
|
192
|
+
let attempts = 0;
|
|
193
|
+
let connect = () => { attempts++; throw new Error("not up yet"); };
|
|
194
|
+
const reporter = createEditorStateReporter({ getEditorText: () => "", connect: () => connect(), scheduler: sched, intervalMs: 100 });
|
|
195
|
+
reporter.start();
|
|
196
|
+
assert.equal(attempts, 1); // immediate try
|
|
197
|
+
// Backoff sequence: 1s, 2s, 4s, 5s, 5s …
|
|
198
|
+
let t = 0;
|
|
199
|
+
for (const expectedGap of [1000, 2000, 4000, 5000, 5000]) {
|
|
200
|
+
const before = attempts;
|
|
201
|
+
const fired = sched.fireOne(t + expectedGap);
|
|
202
|
+
assert.ok(fired, "reconnect timer must fire");
|
|
203
|
+
assert.equal(attempts, before + 1);
|
|
204
|
+
t += expectedGap;
|
|
205
|
+
}
|
|
206
|
+
// Now the server is up — next reconnect succeeds and polling begins, backoff resets.
|
|
207
|
+
connect = () => socket;
|
|
208
|
+
sched.fireOne(t + 5000);
|
|
209
|
+
assert.equal(attempts, 7);
|
|
210
|
+
sched.fireOne(t + 5000 + 100); // first poll after connect
|
|
211
|
+
assert.equal(socket.sent.length, 1);
|
|
212
|
+
assert.deepEqual(JSON.parse(socket.sent[0]), { type: "editor_state", empty: true });
|
|
213
|
+
reporter.stop();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("reporter reconnects after socket close (A2)", () => {
|
|
217
|
+
const sched = fakeScheduler();
|
|
218
|
+
const first = fakeSocket();
|
|
219
|
+
const second = fakeSocket();
|
|
220
|
+
let call = 0;
|
|
221
|
+
const reporter = createEditorStateReporter({
|
|
222
|
+
getEditorText: () => "",
|
|
223
|
+
connect: () => (++call === 1 ? first : second),
|
|
224
|
+
scheduler: sched,
|
|
225
|
+
intervalMs: 100,
|
|
226
|
+
});
|
|
227
|
+
reporter.start();
|
|
228
|
+
sched.fireOne(100); // poll on first socket
|
|
229
|
+
assert.equal(first.sent.length, 1);
|
|
230
|
+
first.emit("close");
|
|
231
|
+
assert.equal(sched.pending.length, 1); // reconnect timer scheduled (backoff 1s)
|
|
232
|
+
sched.fireOne(1100); // reconnect succeeds on second socket
|
|
233
|
+
sched.fireOne(1200); // poll resumes
|
|
234
|
+
assert.equal(second.sent.length, 1);
|
|
235
|
+
reporter.stop();
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
240
|
+
|
|
241
|
+
Run: `node --test test/editor-state-reporter.test.mjs`
|
|
242
|
+
Expected: FAIL — `Cannot find module '../src/core/editor-state-reporter.mjs'`.
|
|
243
|
+
|
|
244
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
245
|
+
|
|
246
|
+
Create `src/core/editor-state-reporter.mjs`:
|
|
247
|
+
|
|
248
|
+
```js
|
|
249
|
+
/** Child-Pi editor-state reporter (issue #68): polls the child Pi's editor text
|
|
250
|
+
* and pushes `{type:"editor_state", empty}` over the control socket whenever the
|
|
251
|
+
* text changes, so the attach surface can gate ← on the authoritative state
|
|
252
|
+
* instead of render heuristics. Dependency-injected for unit testing. */
|
|
253
|
+
|
|
254
|
+
/** @typedef {{ write(jsonLine: string): void; on?(event: "close" | "error", fn: () => void): void }} SocketLike */
|
|
255
|
+
|
|
256
|
+
const defaultScheduler = {
|
|
257
|
+
interval(fn, ms) { return setInterval(fn, ms); },
|
|
258
|
+
timeout(fn, ms) { return setTimeout(fn, ms); },
|
|
259
|
+
clear(handle) { clearInterval(handle); clearTimeout(handle); },
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export function createEditorStateReporter({ getEditorText, connect, intervalMs = 100, scheduler = defaultScheduler }) {
|
|
263
|
+
let started = false;
|
|
264
|
+
let stopped = false;
|
|
265
|
+
let socket = null;
|
|
266
|
+
let pollTimer = null;
|
|
267
|
+
let reconnectTimer = null;
|
|
268
|
+
let backoffMs = 1000;
|
|
269
|
+
let lastText = null;
|
|
270
|
+
|
|
271
|
+
function send(json) {
|
|
272
|
+
if (!socket) return;
|
|
273
|
+
try {
|
|
274
|
+
socket.write(JSON.stringify(json) + "\n");
|
|
275
|
+
} catch {
|
|
276
|
+
teardownSocket();
|
|
277
|
+
scheduleReconnect();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function teardownSocket() {
|
|
282
|
+
if (pollTimer !== null) { scheduler.clear(pollTimer); pollTimer = null; }
|
|
283
|
+
const s = socket;
|
|
284
|
+
socket = null;
|
|
285
|
+
if (s?.on) { try { s.on("close", () => {}); s.on("error", () => {}); } catch { /* already torn down */ } }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function poll() {
|
|
289
|
+
if (stopped || !socket) return;
|
|
290
|
+
const text = getEditorText();
|
|
291
|
+
if (text !== lastText) {
|
|
292
|
+
lastText = text;
|
|
293
|
+
send({ type: "editor_state", empty: text.length === 0 });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function startPolling() {
|
|
298
|
+
if (pollTimer !== null) return;
|
|
299
|
+
lastText = null; // force a first report after (re)connect
|
|
300
|
+
pollTimer = scheduler.interval(poll, intervalMs);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function scheduleReconnect() {
|
|
304
|
+
if (stopped) return;
|
|
305
|
+
teardownSocket();
|
|
306
|
+
reconnectTimer = scheduler.timeout(tryConnect, backoffMs);
|
|
307
|
+
backoffMs = Math.min(backoffMs * 2, 5000);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function tryConnect() {
|
|
311
|
+
reconnectTimer = null;
|
|
312
|
+
if (stopped) return;
|
|
313
|
+
let s;
|
|
314
|
+
try {
|
|
315
|
+
s = connect();
|
|
316
|
+
} catch {
|
|
317
|
+
scheduleReconnect();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
socket = s;
|
|
321
|
+
backoffMs = 1000;
|
|
322
|
+
s?.on?.("close", () => { if (socket === s) scheduleReconnect(); });
|
|
323
|
+
s?.on?.("error", () => { if (socket === s) scheduleReconnect(); });
|
|
324
|
+
startPolling();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function start() {
|
|
328
|
+
if (started) return;
|
|
329
|
+
started = true;
|
|
330
|
+
stopped = false;
|
|
331
|
+
tryConnect();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function stop() {
|
|
335
|
+
stopped = true;
|
|
336
|
+
if (reconnectTimer !== null) { scheduler.clear(reconnectTimer); reconnectTimer = null; }
|
|
337
|
+
teardownSocket();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return { start, stop };
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
345
|
+
|
|
346
|
+
Run: `node --test test/editor-state-reporter.test.mjs`
|
|
347
|
+
Expected: PASS (4 tests).
|
|
348
|
+
|
|
349
|
+
- [ ] **Step 5: Commit**
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
git add src/core/editor-state-reporter.mjs test/editor-state-reporter.test.mjs
|
|
353
|
+
git commit -m "feat: add editor-state reporter with injected scheduler (issue #68)"
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
### Task 3: runner `editor_state` routing + hello seed + exit reset (A3 code)
|
|
359
|
+
|
|
360
|
+
**Files:**
|
|
361
|
+
- Modify: `runner/pty-runner.mjs`
|
|
362
|
+
|
|
363
|
+
**Interfaces:**
|
|
364
|
+
- Consumes: none (protocol only).
|
|
365
|
+
- Produces: runner-side `editorEmpty` cache; `hello` payload `{ type:"hello", status, editorEmpty }`; broadcast `{type:"editor_state", empty}`.
|
|
366
|
+
|
|
367
|
+
- [ ] **Step 1: Implement**
|
|
368
|
+
|
|
369
|
+
In `runner/pty-runner.mjs`:
|
|
370
|
+
|
|
371
|
+
1. Near the top-level state (next to `let crashed = false;` — locate the exact spot when editing; the variables live near `host` init): add
|
|
372
|
+
|
|
373
|
+
```js
|
|
374
|
+
/** Authoritative child editor emptiness, pushed by the child Pi extension
|
|
375
|
+
* (issue #68). null = unknown (extension missing / not yet reported). */
|
|
376
|
+
let editorEmpty = null;
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
2. In the `child.onExit` handler, inside the `if (!crashed) { ... }` block, before `broadcast({ type: "exit", exitCode })`, add:
|
|
380
|
+
|
|
381
|
+
```js
|
|
382
|
+
editorEmpty = null;
|
|
383
|
+
broadcast({ type: "editor_state", empty: null });
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
3. In `handleClientLine`'s switch:
|
|
387
|
+
|
|
388
|
+
```js
|
|
389
|
+
case "hello":
|
|
390
|
+
send(socket, { type: "hello", status: host, editorEmpty });
|
|
391
|
+
break;
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
4. Add a new case (after `get_status`):
|
|
395
|
+
|
|
396
|
+
```js
|
|
397
|
+
case "editor_state": {
|
|
398
|
+
editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
399
|
+
broadcast({ type: "editor_state", empty: editorEmpty });
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
- [ ] **Step 2: Run targeted tests to verify no regression**
|
|
405
|
+
|
|
406
|
+
Run: `node --test test/pty-runner.integration.test.mjs`
|
|
407
|
+
Expected: PASS (existing tests; new assertions come in Task 6).
|
|
408
|
+
|
|
409
|
+
- [ ] **Step 3: Commit**
|
|
410
|
+
|
|
411
|
+
```bash
|
|
412
|
+
git add runner/pty-runner.mjs
|
|
413
|
+
git commit -m "feat: route editor_state through the control socket (issue #68)"
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
### Task 4: attach panel cache + judgment + smoke scenarios H/I (A4)
|
|
419
|
+
|
|
420
|
+
**Files:**
|
|
421
|
+
- Modify: `src/ui/pty-attach.ts`
|
|
422
|
+
- Modify: `test-support/detach-gate-smoke.ts`
|
|
423
|
+
- Modify: `test/pty-attach-detach-gate.test.mjs`
|
|
424
|
+
|
|
425
|
+
**Interfaces:**
|
|
426
|
+
- Consumes: `resolveEditorEmpty` (Task 1).
|
|
427
|
+
- Produces: private `editorEmpty: boolean | null`; smoke keys `leftEditorStateOverridesHeuristicEmpty`, `leftEditorStateBlocksDetachOnDraft`.
|
|
428
|
+
|
|
429
|
+
- [ ] **Step 1: Write the failing smoke scenarios + assertions**
|
|
430
|
+
|
|
431
|
+
In `test-support/detach-gate-smoke.ts`, after scenario F (use the existing `writeToTerm`/`makeAttach`/`out` pattern), add:
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
// H. The pushed editor state is authoritative over the render heuristic: the
|
|
435
|
+
// buffer looks non-empty (garbled line) but the child reports empty → ← detach.
|
|
436
|
+
{
|
|
437
|
+
const { attach, sent, didDetach } = makeAttach();
|
|
438
|
+
await writeToTerm(attach, "chat content\r\n────── ◊◊ ──────");
|
|
439
|
+
(attach as unknown as { onSocketData: (t: string) => void }).onSocketData(JSON.stringify({ type: "editor_state", empty: true }) + "\n");
|
|
440
|
+
(attach as unknown as { connected: boolean }).connected = true;
|
|
441
|
+
attach.handleInput("\x1b[D");
|
|
442
|
+
out.leftEditorStateOverridesHeuristicEmpty = didDetach() && sent.length === 1 && sent[0].type === "detach";
|
|
443
|
+
attach.dispose();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// I. The pushed editor state is authoritative the other way: the heuristic
|
|
447
|
+
// would say "empty" (nothing in the buffer), but the child reports a draft →
|
|
448
|
+
// ← is forwarded (editor protection), NOT detach.
|
|
449
|
+
{
|
|
450
|
+
const { attach, sent, didDetach } = makeAttach();
|
|
451
|
+
await writeToTerm(attach, "chat content\r\n");
|
|
452
|
+
(attach as unknown as { onSocketData: (t: string) => void }).onSocketData(JSON.stringify({ type: "editor_state", empty: false }) + "\n");
|
|
453
|
+
(attach as unknown as { connected: boolean }).connected = true;
|
|
454
|
+
attach.handleInput("\x1b[D");
|
|
455
|
+
out.leftEditorStateBlocksDetachOnDraft = !didDetach() && sent.length === 1 && sent[0].type === "input" && sent[0].data === "\x1b[D";
|
|
456
|
+
attach.dispose();
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
In `test/pty-attach-detach-gate.test.mjs`, after the `leftDetachesOnGlyphLineWithoutFakeCursor` assertion, add:
|
|
461
|
+
|
|
462
|
+
```js
|
|
463
|
+
assert.equal(parsed.leftEditorStateOverridesHeuristicEmpty, true, "← must detach when editor_state says empty even if the buffer looks non-empty");
|
|
464
|
+
assert.equal(parsed.leftEditorStateBlocksDetachOnDraft, true, "← must be forwarded when editor_state reports a draft even if the buffer looks empty");
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
- [ ] **Step 2: Run smoke to verify new scenarios fail**
|
|
468
|
+
|
|
469
|
+
Run: `node --experimental-transform-types test-support/detach-gate-smoke.ts`
|
|
470
|
+
Expected: `leftEditorStateOverridesHeuristicEmpty` and `leftEditorStateBlocksDetachOnDraft` are both `false` (no editor_state handling yet); all 11 existing keys still true.
|
|
471
|
+
|
|
472
|
+
- [ ] **Step 3: Implement**
|
|
473
|
+
|
|
474
|
+
In `src/ui/pty-attach.ts`:
|
|
475
|
+
|
|
476
|
+
1. Update the pty-input import (line ~8):
|
|
477
|
+
|
|
478
|
+
```ts
|
|
479
|
+
import { isProbablyEmptyPiInputLine, isProbablyPiInputLine, resolveEditorEmpty } from "../core/pty-input.mjs";
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
2. Add the cache field next to the other private fields (e.g. right above `private receivedOutput` — find the field block):
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
/** Authoritative editor emptiness pushed by the child Pi extension via the
|
|
486
|
+
* control socket (issue #68). null = unknown — fall back to the heuristic. */
|
|
487
|
+
private editorEmpty: boolean | null = null;
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
3. In `onSocketData`, extend the hello/status branch:
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
if (msg.type === "hello" || msg.type === "status") {
|
|
494
|
+
this.status = "attached";
|
|
495
|
+
if (msg.type === "hello" && typeof msg.editorEmpty === "boolean") this.editorEmpty = msg.editorEmpty;
|
|
496
|
+
} else if (msg.type === "editor_state") {
|
|
497
|
+
this.editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
498
|
+
} else if (msg.type === "exit") {
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
4. In `handleInput`, the `Key.left` branch — replace the judgment call:
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
if (shouldEscapeAttach(this.connected, resolveEditorEmpty(this.editorEmpty, this.childInputLooksEmpty()))) {
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
- [ ] **Step 4: Run smoke + gate tests to verify pass**
|
|
508
|
+
|
|
509
|
+
Run: `node --experimental-transform-types test-support/detach-gate-smoke.ts` then `node --test test/pty-attach-detach-gate.test.mjs test/pty-input.test.mjs test/editor-state-reporter.test.mjs`
|
|
510
|
+
Expected: smoke has all 13 keys true (11 existing + H + I); all three test files pass.
|
|
511
|
+
|
|
512
|
+
- [ ] **Step 5: Commit**
|
|
513
|
+
|
|
514
|
+
```bash
|
|
515
|
+
git add src/ui/pty-attach.ts test-support/detach-gate-smoke.ts test/pty-attach-detach-gate.test.mjs
|
|
516
|
+
git commit -m "fix: gate ← on pushed editor_state with heuristic fallback (issue #68)"
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
### Task 5: production wiring in `src/index.ts` (A2 production end)
|
|
522
|
+
|
|
523
|
+
**Files:**
|
|
524
|
+
- Modify: `src/index.ts`
|
|
525
|
+
|
|
526
|
+
**Interfaces:**
|
|
527
|
+
- Consumes: `createEditorStateReporter` (Task 2), `controlSocketPathFor` from `./core/paths.mjs`, `createConnection` from `node:net`.
|
|
528
|
+
|
|
529
|
+
- [ ] **Step 1: Implement**
|
|
530
|
+
|
|
531
|
+
In `src/index.ts`:
|
|
532
|
+
|
|
533
|
+
1. Add imports at the top:
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
import { createConnection } from "node:net";
|
|
537
|
+
import { controlSocketPathFor } from "./core/paths.mjs";
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
2. Add a module-level reporter handle (next to the other module constants):
|
|
541
|
+
|
|
542
|
+
```ts
|
|
543
|
+
let hostedEditorReporter: { stop(): void } | null = null;
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
3. Inside the `pi.on("session_start", ...)` handler, as the FIRST statement (before `updateStatus(ctx)`):
|
|
547
|
+
|
|
548
|
+
```ts
|
|
549
|
+
if (isHostedChild && !hostedEditorReporter && typeof ctx.ui?.getEditorText === "function" && hostedViewId) {
|
|
550
|
+
const { createEditorStateReporter } = await import("./core/editor-state-reporter.mjs");
|
|
551
|
+
hostedEditorReporter = createEditorStateReporter({
|
|
552
|
+
getEditorText: () => ctx.ui.getEditorText(),
|
|
553
|
+
connect: () => createConnection(controlSocketPathFor(process.platform, root, hostedViewId)),
|
|
554
|
+
});
|
|
555
|
+
hostedEditorReporter.start();
|
|
556
|
+
}
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
- [ ] **Step 2: Typecheck**
|
|
560
|
+
|
|
561
|
+
Run: `npm run typecheck`
|
|
562
|
+
Expected: clean (exit 0).
|
|
563
|
+
|
|
564
|
+
- [ ] **Step 3: Commit**
|
|
565
|
+
|
|
566
|
+
```bash
|
|
567
|
+
git add src/index.ts
|
|
568
|
+
git commit -m "feat: start editor_state reporter in hosted child sessions (issue #68)"
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
---
|
|
572
|
+
|
|
573
|
+
### Task 6: runner integration test for editor_state routing (A3)
|
|
574
|
+
|
|
575
|
+
**Files:**
|
|
576
|
+
- Test: `test/pty-runner.integration.test.mjs`
|
|
577
|
+
|
|
578
|
+
**Interfaces:**
|
|
579
|
+
- Consumes: Task 3's runner behavior.
|
|
580
|
+
|
|
581
|
+
- [ ] **Step 1: Write the failing test**
|
|
582
|
+
|
|
583
|
+
Append to `test/pty-runner.integration.test.mjs` (reusing `freshRoot`/`createView`/`atomicWriteJson`/`hostReady`/`connectWhenReady`/`send`/`waitFor`/`stopRunner`/`reapChild` from the existing helpers):
|
|
584
|
+
|
|
585
|
+
```js
|
|
586
|
+
test("pty-runner routes editor_state between clients, seeds hello, resets on exit", async () => {
|
|
587
|
+
const root = freshRoot();
|
|
588
|
+
let runner;
|
|
589
|
+
try {
|
|
590
|
+
const meta = createView(root, { id: "v1", name: "editor-state", cwd: process.cwd() });
|
|
591
|
+
const configPath = P.hostConfigPath(root, "v1");
|
|
592
|
+
atomicWriteJson(configPath, {
|
|
593
|
+
root,
|
|
594
|
+
viewId: "v1",
|
|
595
|
+
sessionFile: meta.sessionFile,
|
|
596
|
+
cwd: process.cwd(),
|
|
597
|
+
initialPrompt: null,
|
|
598
|
+
piCommand: process.execPath,
|
|
599
|
+
piArgsPrefix: [resolve("test-support/fake-pty-pi.mjs")],
|
|
600
|
+
model: null,
|
|
601
|
+
tools: null,
|
|
602
|
+
env: { AGENT_BOARD_ALLOW_PIPE_FALLBACK: "1" },
|
|
603
|
+
cols: 80,
|
|
604
|
+
rows: 24,
|
|
605
|
+
});
|
|
606
|
+
runner = spawn(process.execPath, [resolve("runner/pty-runner.mjs"), configPath], { stdio: ["ignore", "pipe", "pipe"] });
|
|
607
|
+
await waitFor(() => hostReady(root, "v1"));
|
|
608
|
+
|
|
609
|
+
const readMessages = (socket) => {
|
|
610
|
+
let buf = "";
|
|
611
|
+
const messages = [];
|
|
612
|
+
socket.on("data", (chunk) => {
|
|
613
|
+
buf += chunk.toString();
|
|
614
|
+
const lines = buf.split("\n");
|
|
615
|
+
buf = lines.pop() ?? "";
|
|
616
|
+
for (const line of lines) if (line.trim()) messages.push(JSON.parse(line));
|
|
617
|
+
});
|
|
618
|
+
return messages;
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const client1 = createConnection(P.controlSocketPath(root, "v1"));
|
|
622
|
+
await once(client1, "connect");
|
|
623
|
+
const messages1 = readMessages(client1);
|
|
624
|
+
send(client1, { type: "hello" });
|
|
625
|
+
await waitFor(() => messages1.find((m) => m.type === "hello"));
|
|
626
|
+
|
|
627
|
+
// A second client must receive the pushed state as a broadcast.
|
|
628
|
+
const client2 = createConnection(P.controlSocketPath(root, "v1"));
|
|
629
|
+
await once(client2, "connect");
|
|
630
|
+
const messages2 = readMessages(client2);
|
|
631
|
+
send(client1, { type: "editor_state", empty: false });
|
|
632
|
+
await waitFor(() => messages2.find((m) => m.type === "editor_state"));
|
|
633
|
+
assert.equal(messages2.find((m) => m.type === "editor_state").empty, false);
|
|
634
|
+
|
|
635
|
+
// A client connecting afterwards gets the current state seeded in hello.
|
|
636
|
+
const client3 = createConnection(P.controlSocketPath(root, "v1"));
|
|
637
|
+
await once(client3, "connect");
|
|
638
|
+
const messages3 = readMessages(client3);
|
|
639
|
+
send(client3, { type: "hello" });
|
|
640
|
+
await waitFor(() => messages3.find((m) => m.type === "hello"));
|
|
641
|
+
assert.equal(messages3.find((m) => m.type === "hello").editorEmpty, false);
|
|
642
|
+
|
|
643
|
+
// Child exit resets the state to null and broadcasts it.
|
|
644
|
+
send(client1, { type: "input", data: "exit\r" });
|
|
645
|
+
await waitFor(() => messages2.find((m) => m.type === "editor_state" && m.empty === null));
|
|
646
|
+
await waitFor(() => readHost(root, "v1")?.endedAt);
|
|
647
|
+
|
|
648
|
+
client1.destroy();
|
|
649
|
+
client2.destroy();
|
|
650
|
+
client3.destroy();
|
|
651
|
+
} finally {
|
|
652
|
+
await stopRunner(runner);
|
|
653
|
+
reapChild(root, "v1");
|
|
654
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
655
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
- [ ] **Step 2: Run to verify it fails**
|
|
661
|
+
|
|
662
|
+
Run: `node --test test/pty-runner.integration.test.mjs`
|
|
663
|
+
Expected: the new test FAILS (no `editor_state` broadcast before Task 3 — if Task 3 is already in the tree this step's test passes; then simply confirm PASS and skip to commit).
|
|
664
|
+
|
|
665
|
+
- [ ] **Step 3: (If it failed) — Task 3 is already merged in this branch; re-run to confirm PASS**
|
|
666
|
+
|
|
667
|
+
Run: `node --test test/pty-runner.integration.test.mjs`
|
|
668
|
+
Expected: PASS (all runner tests, including the new one).
|
|
669
|
+
|
|
670
|
+
- [ ] **Step 4: Commit**
|
|
671
|
+
|
|
672
|
+
```bash
|
|
673
|
+
git add test/pty-runner.integration.test.mjs
|
|
674
|
+
git commit -m "test: cover editor_state routing, hello seed and exit reset (issue #68)"
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
---
|
|
678
|
+
|
|
679
|
+
### Task 7: Full regression (A6)
|
|
680
|
+
|
|
681
|
+
**Files:** none (verification only).
|
|
682
|
+
|
|
683
|
+
- [ ] **Step 1: Run verify**
|
|
684
|
+
|
|
685
|
+
Run (in worktree): `npm run verify`
|
|
686
|
+
Expected: typecheck clean; all `node --test test/*.test.mjs` pass (430+ tests, zero pre-existing semantics changed); c8 coverage above gates (lines ≥85 / funcs ≥80 / branches ≥70); `npm pack --dry-run` succeeds.
|
|
687
|
+
|
|
688
|
+
- [ ] **Step 2: Commit any incidental fixes**
|
|
689
|
+
|
|
690
|
+
If verify surfaced issues, fix and commit referencing issue #68. Otherwise nothing to commit.
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
### Task 8: Post-implementation manual verification (U1–U4)
|
|
695
|
+
|
|
696
|
+
**Files:** none (user verification; report back into the PR).
|
|
697
|
+
|
|
698
|
+
- [ ] **Step 1: U1 — attach-then-←**
|
|
699
|
+
|
|
700
|
+
1. Update the local extension checkout to the branch (`~/.pi/agent/git/github.com/zhuxixi/pi-agent-board` → `issue-68-attach-detach-editor-state`), restart Pi.
|
|
701
|
+
2. Attach into a pi session; as soon as the surface paints, press `←`.
|
|
702
|
+
Expected: returns to the dashboard immediately.
|
|
703
|
+
|
|
704
|
+
- [ ] **Step 2: U2 — ← while Pi is thinking**
|
|
705
|
+
|
|
706
|
+
1. Send a prompt that triggers streaming (`⠹ Working...` visible).
|
|
707
|
+
2. While the animation runs, press `←`.
|
|
708
|
+
Expected: returns to the dashboard (the #68/#69 failure mode).
|
|
709
|
+
|
|
710
|
+
- [ ] **Step 3: U3 — draft protection**
|
|
711
|
+
|
|
712
|
+
1. Attach, type a draft, press `←`.
|
|
713
|
+
Expected: cursor moves left inside the draft (forwarded), NOT detach.
|
|
714
|
+
|
|
715
|
+
- [ ] **Step 4: U4 — fallback when the reporter is absent**
|
|
716
|
+
|
|
717
|
+
1. Temporarily set `AGENT_BOARD_DISABLE_EDITOR_STATE=1` is NOT part of this plan's product code; instead verify by attaching while the child extension build predates this branch (or just confirm U1-U3 — the heuristic fallback is covered by the existing smoke suite). If U4 cannot be arranged, record it as covered-by-automation (A4 smoke H/I covers the null path).
|
|
718
|
+
Expected: ← still exits (heuristic fallback), recorded in the PR.
|
|
719
|
+
|
|
720
|
+
- [ ] **Step 5: Record results**
|
|
721
|
+
|
|
722
|
+
Write U1-U4 outcomes into the PR description before merge.
|