@zhuxixi/pi-agent-board 0.4.0 → 0.4.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/README.md CHANGED
@@ -86,6 +86,7 @@ Useful environment variables:
86
86
  | `AGENT_BOARD_TITLE_MODEL=<model>` | Model for generated session titles. Defaults to `openai-codex/gpt-5.5`; use `off` to disable. |
87
87
  | `AGENT_BOARD_TITLE_THINKING_LEVEL=<level>` | Thinking level for title generation. Defaults to `low`; use `off` to omit it. |
88
88
  | `AGENT_BOARD_DISABLE_PTY=1` | Disable PTY attach mode. |
89
+ | `AGENT_BOARD_IME_FIX=0` | Disable the IME cursor-rect coalescer (issue #28). The coalescer folds pi-tui's per-frame cursor-park writes into the frame's synchronized-output block so terminals report one stable IME cursor position per frame instead of two (candidate-window flicker in busy sessions). |
89
90
  | `AGENT_BOARD_FORCE_PTY=1` | Force PTY attach mode. |
90
91
  | `AGENT_BOARD_ATTACH_MOUSE=0` | Disable attach-view mouse handling and use terminal-native selection. |
91
92
  | `AGENT_BOARD_WHEEL_LINES=<1-50>` | Lines scrolled per mouse-wheel event in attach view. Defaults to `1`. |
@@ -0,0 +1,337 @@
1
+ # Jiggle shrink-and-hold 协议改造 Implementation Plan(issue #25)
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:** 把 attach 的 jiggle 从"±1 脉冲对"改成"shrink-and-hold"协议——缩住直到见到全清,冷启动自愈 ≤3s,同时以 G1-G5 守卫保证不破坏任何现有功能。
6
+
7
+ **Architecture:** 协议主体在 `pty-attach-jiggle-controller.mjs`(armed→held→restored 状态 + G1/G2 计时);`pty-attach-jiggle-retry.mjs` 状态机不动(退避表仅作 G2 计时);`pty-attach.ts` 替换 forceChildRedraw 胶水并接入 G3/G4/G5;E2E stub 改 hold 语义并新增 shell 兜底用例。
8
+
9
+ **Tech Stack:** Node 24(node:test)、TypeScript(tsc --noEmit)、node-pty、@xterm/headless。
10
+
11
+ ## Global Constraints
12
+
13
+ - 工作目录:`/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-25-jiggle-shrink-and-hold`(**禁碰主 checkout**)。
14
+ - hold 尺寸 = (cols−1)×(rows−1);restore = 原始尺寸;restore **幂等(只发一次)**。
15
+ - G1 = start 后 6s 无 `\x1b[?2026h` → restore;G2 = 退避表走完(56.12s)无全清 → restore。
16
+ - re-arm(首个 TUI 帧)→ **restore 原尺寸且不重排链**(无脉冲)。
17
+ - G3:close 时 `restoreAndStop()`;G5:`start()` 前若 held 先 restore;G4:`notifyExternalResize(c,r)` 取消 hold/计时器并把 (c,r) 记为新原尺寸。
18
+ - 删除 `JIGGLE_RESTORE_MS`(无脉冲对)。
19
+ - 代码风格:tab 缩进、双引号、JSDoc、core 模块 .mjs;commit 用 conventional commits;`git add` 按文件。
20
+ - Spec:`docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md`(已提交 b9b3aab)。
21
+
22
+ ---
23
+
24
+ ### Task 1: 控制器协议改造(hold 状态机 + G1/G2/G3/G4/G5)
25
+
26
+ **Files:**
27
+ - Modify: `src/core/pty-attach-jiggle-controller.mjs`
28
+ - Test: `test/pty-attach-jiggle-controller.test.mjs`
29
+
30
+ **Interfaces:**
31
+ - Consumes: `pty-attach-jiggle-retry.mjs` 的 `createJiggleRetryState/nextRetryDelay/advanceRetry/stopRetry` 与 `feedOutput`(含 frameStartFound/clearFound)——**全部原样使用,不改**。
32
+ - Produces(Task 2/3 依赖):
33
+ - deps: `{ sendResize(cols, rows), setTimeoutFn, clearTimeoutFn }`(`sendJiggle`/`shouldFire` **删除**)
34
+ - 返回 `{ start(cols, rows), feed(data), restoreAndStop(), notifyExternalResize(cols, rows), getState() }`
35
+ - `getState()` → `{ held, clearDetected, stopped, tuiFrameSeen, originalCols, originalRows }`(retryIndex 可保留内部)
36
+
37
+ **行为规格(精确):**
38
+
39
+ ```js
40
+ start(cols, rows):
41
+ 1. 清全部计时器;若 held → sendResize(prevOriginalCols, prevOriginalRows)(G5,幂等标记复位)
42
+ 2. originalCols/Rows = (cols, rows)
43
+ 3. sendResize(cols, rows); sendResize(cols-1, rows-1); held = true; restored = false
44
+ 4. tuiFrameSeen = false; clearDetected = false; stopped = false; state = createJiggleRetryState()
45
+ 5. G1 计时器 = 6s:fire 时若 !tuiFrameSeen → restoreIfHeld()
46
+ 6. 预算链照常排(nextRetryDelay/advanceRetry 循环),但回调 fire 时 **no-op**(不发脉冲、不 sendResize;仅推进计数);delay 走完(null)→ stopRetry + restoreIfHeld()(G2)
47
+
48
+ feed(data):
49
+ 1. 若 clearDetected → return(沿用)
50
+ 2. feedOutput 扫描(沿用)
51
+ 3. clearFound → restoreIfHeld(); 停链(stopRetry+clearTimer); clearDetected=true; 取消 G1
52
+ 4. frameStartFound 且 !tuiFrameSeen → tuiFrameSeen=true; 取消 G1; **restoreIfHeld()**; 不重排链、不碰预算
53
+
54
+ restoreIfHeld(): if (held && !restored) { sendResize(originalCols, originalRows); restored = true; held = false; }
55
+
56
+ restoreAndStop(): 清全部计时器; restoreIfHeld(); state = stopRetry(state)
57
+
58
+ notifyExternalResize(cols, rows):
59
+ 清全部计时器; held = false; restored = false(hold 作废,G4)
60
+ originalCols/Rows = (cols, rows)
61
+ state = stopRetry(state)(链终止;本次 attach 语义已让位于真实 resize)
62
+ ```
63
+
64
+ - [ ] **Step 1: 重写单测**(全量替换 test/pty-attach-jiggle-controller.test.mjs,fake scheduler 记录 sendResize 调用序列):
65
+
66
+ ```js
67
+ import assert from "node:assert/strict";
68
+ import test from "node:test";
69
+ import { createJiggleRetryController } from "../src/core/pty-attach-jiggle-controller.mjs";
70
+
71
+ function fakeScheduler() {
72
+ const timers = new Map();
73
+ let nextId = 1;
74
+ return {
75
+ timers,
76
+ setTimeoutFn(fn, ms) {
77
+ const id = nextId++;
78
+ timers.set(id, { fn, ms });
79
+ return id;
80
+ },
81
+ clearTimeoutFn(id) { timers.delete(id); },
82
+ fire(id) { const t = timers.get(id); assert.ok(t, "no such timer"); timers.delete(id); t.fn(); },
83
+ fireNext() { const first = timers.entries().next().value; assert.ok(first, "no pending timer"); this.fire(first[0]); },
84
+ delays() { return [...timers.values()].map((t) => t.ms); },
85
+ findByDelay(ms) { for (const [id, t] of timers) if (t.ms === ms) return id; return null; },
86
+ };
87
+ }
88
+
89
+ function makeController() {
90
+ const scheduler = fakeScheduler();
91
+ const resizes = [];
92
+ const controller = createJiggleRetryController({
93
+ sendResize: (c, r) => resizes.push([c, r]),
94
+ setTimeoutFn: scheduler.setTimeoutFn,
95
+ clearTimeoutFn: scheduler.clearTimeoutFn,
96
+ });
97
+ return { controller, scheduler, resizes };
98
+ }
99
+
100
+ test("start() arms hold: sends original then shrunk size, holds", () => {
101
+ const { controller, scheduler, resizes } = makeController();
102
+ controller.start(170, 36);
103
+ assert.deepEqual(resizes, [[170, 36], [169, 35]]);
104
+ assert.equal(controller.getState().held, true);
105
+ assert.notEqual(scheduler.findByDelay(6000), null, "G1 timer armed"); // 6000 in delays
106
+ assert.ok(scheduler.delays().includes(6000), "G1 timer present");
107
+ });
108
+
109
+ test("feed with clear restores original size exactly once and stops", () => {
110
+ const { controller, scheduler, resizes } = makeController();
111
+ controller.start(170, 36);
112
+ controller.feed("x\x1b[2J\x1b[Hy");
113
+ assert.deepEqual(resizes.slice(-1), [[170, 36]]);
114
+ assert.equal(controller.getState().clearDetected, true);
115
+ assert.equal(controller.getState().stopped, true);
116
+ assert.equal(controller.getState().held, false);
117
+ // 幂等:后续 feed 不再发
118
+ controller.feed("more\x1b[2J");
119
+ assert.equal(resizes.filter(([c]) => c === 170).length, 2); // start 的 + restore 的,无第三发
120
+ });
121
+
122
+ test("re-arm (first TUI frame) restores size and does NOT reschedule chain", () => {
123
+ const { controller, scheduler, resizes } = makeController();
124
+ controller.start(170, 36);
125
+ const budgetBefore = scheduler.delays().length;
126
+ controller.feed("\x1b[?2026h first frame");
127
+ assert.deepEqual(resizes.slice(-1), [[170, 36]]);
128
+ assert.equal(controller.getState().tuiFrameSeen, true);
129
+ assert.equal(scheduler.delays().length, budgetBefore, "no new timers scheduled");
130
+ // G1 已取消
131
+ assert.equal(scheduler.findByDelay(6000), null);
132
+ });
133
+
134
+ test("G1: no TUI frame within 6s restores original size", () => {
135
+ const { controller, scheduler, resizes } = makeController();
136
+ controller.start(170, 36);
137
+ const g1 = scheduler.findByDelay(6000);
138
+ assert.ok(g1);
139
+ scheduler.fire(g1);
140
+ assert.deepEqual(resizes.slice(-1), [[170, 36]]);
141
+ assert.equal(controller.getState().held, false);
142
+ });
143
+
144
+ test("G1 cancelled once a frame arrives (no double restore)", () => {
145
+ const { controller, scheduler, resizes } = makeController();
146
+ controller.start(170, 36);
147
+ controller.feed("\x1b[?2026h frame");
148
+ const g1 = scheduler.findByDelay(6000);
149
+ assert.equal(g1, null);
150
+ });
151
+
152
+ test("G2: backoff budget exhausted without clear restores original size", () => {
153
+ const { controller, scheduler, resizes } = makeController();
154
+ controller.start(170, 36);
155
+ controller.feed("\x1b[?2026h frame"); // re-arm restores, cancels G1
156
+ const before = resizes.length;
157
+ // 排干全部退避计时器(无脉冲 fire 均为推进)
158
+ for (let i = 0; i < 8; i++) scheduler.fireNext();
159
+ assert.equal(controller.getState().stopped, true);
160
+ // hold 已在 re-arm 时恢复,G2 无需再发(幂等)
161
+ assert.equal(resizes.length, before);
162
+ });
163
+
164
+ test("G2 without any frame: budget exhausted restores", () => {
165
+ const { controller, scheduler, resizes } = makeController();
166
+ controller.start(170, 36);
167
+ const g1 = scheduler.findByDelay(6000);
168
+ scheduler.fireNext(); // 第一个退避计时器(120ms)推进,no-op
169
+ for (let i = 0; i < 7; i++) scheduler.fireNext();
170
+ assert.equal(controller.getState().stopped, true);
171
+ assert.deepEqual(resizes.slice(-1), [[170, 36]], "G2 restore fired");
172
+ });
173
+
174
+ test("restoreAndStop() restores then stops (G3)", () => {
175
+ const { controller, scheduler, resizes } = makeController();
176
+ controller.start(170, 36);
177
+ controller.restoreAndStop();
178
+ assert.deepEqual(resizes.slice(-1), [[170, 36]]);
179
+ assert.equal(controller.getState().stopped, true);
180
+ assert.equal(scheduler.timers.size, 0);
181
+ });
182
+
183
+ test("start() after a held start restores previous hold first (G5)", () => {
184
+ const { controller, scheduler, resizes } = makeController();
185
+ controller.start(170, 36);
186
+ controller.start(196, 39); // 新连接
187
+ assert.deepEqual(resizes.slice(0, 4), [[170, 36], [169, 35], [170, 36], [196, 39]]);
188
+ assert.deepEqual(resizes.slice(-1), [[195, 38]]);
189
+ });
190
+
191
+ test("notifyExternalResize cancels hold, adopts new size, stops chain (G4)", () => {
192
+ const { controller, scheduler, resizes } = makeController();
193
+ controller.start(170, 36);
194
+ resizes.length = 0;
195
+ controller.notifyExternalResize(200, 50);
196
+ assert.equal(scheduler.timers.size, 0, "all timers cleared");
197
+ assert.equal(controller.getState().held, false);
198
+ assert.equal(controller.getState().stopped, true);
199
+ assert.equal(controller.getState().originalCols, 200);
200
+ // 再 feed clear 不再触发任何 restore
201
+ controller.feed("\x1b[2J");
202
+ assert.equal(resizes.length, 0);
203
+ });
204
+
205
+ test("full G1 window passes while frames keep arriving → held restored by G2 only", () => {
206
+ const { controller, scheduler, resizes } = makeController();
207
+ controller.start(170, 36);
208
+ controller.feed("\x1b[?2026h frame"); // restores once
209
+ const before = resizes.length;
210
+ // 无全清的持续输出
211
+ for (let i = 0; i < 8; i++) scheduler.fireNext();
212
+ assert.equal(resizes.length, before, "no extra resizes");
213
+ assert.equal(controller.getState().stopped, true);
214
+ });
215
+ ```
216
+
217
+ - [ ] **Step 2: 跑测试确认失败**(接口不匹配即失败)
218
+ Run: `node --test test/pty-attach-jiggle-controller.test.mjs` → FAIL
219
+
220
+ - [ ] **Step 3: 实现控制器改造**(按上方行为规格重写 `src/core/pty-attach-jiggle-controller.mjs`;保留模块头注释并更新为 hold 协议描述;G1 常量 `NO_FRAME_RESTORE_MS = 6000`)
221
+
222
+ - [ ] **Step 4: 跑测试确认通过** → PASS(此时 pty-attach.ts 尚未接线,typecheck 可能因 deps 变化报错——本任务只保证控制器单测绿;组件接线在 Task 2)
223
+
224
+ - [ ] **Step 5: Commit**
225
+ ```bash
226
+ git add src/core/pty-attach-jiggle-controller.mjs test/pty-attach-jiggle-controller.test.mjs
227
+ git commit -m "feat: shrink-and-hold jiggle protocol with G1-G5 guards (issue #25)"
228
+ ```
229
+
230
+ ---
231
+
232
+ ### Task 2: 组件接线(forceChildRedraw → start;G3/G4/G5 接入)
233
+
234
+ **Files:**
235
+ - Modify: `src/ui/pty-attach.ts`
236
+
237
+ **Interfaces:**
238
+ - Consumes: Task 1 的 `{ start(cols, rows), feed, restoreAndStop, notifyExternalResize, getState }`;deps `{ sendResize, setTimeoutFn, clearTimeoutFn }`。
239
+
240
+ **精确改动:**
241
+ 1. 控制器字段初始化改为:
242
+ ```ts
243
+ private readonly jiggleRetry = createJiggleRetryController({
244
+ sendResize: (cols, rows) => this.sendResize(cols, rows),
245
+ setTimeoutFn: (fn, ms) => {
246
+ const t = setTimeout(fn, ms);
247
+ t.unref?.();
248
+ return t;
249
+ },
250
+ clearTimeoutFn: (t) => clearTimeout(t as ReturnType<typeof setTimeout>),
251
+ });
252
+ ```
253
+ 2. connect 处理器:删除 `this.forceChildRedraw()` 与其前独立 `this.sendResize()`(协议已在 start 内含 sendResize 原尺寸);改为 `this.jiggleRetry.start(this.cols, this.rows);`
254
+ 3. `forceChildRedraw()` 方法整个删除;`JIGGLE_RESTORE_MS` 常量及其注释删除;`clearRedrawTimer()` 若仅被 forceChildRedraw/close 使用则一并删除(先 grep 确认无他引用;close() 中 `this.clearRedrawTimer()` 调用改为无操作或删除该行)。
255
+ 4. `resizeIfNeeded`:在 `this.term.resize(...)` 前插入 `this.jiggleRetry.notifyExternalResize(size.cols, size.rows);`(G4)——注意它必须在确认尺寸真的变化后调用(函数内 size 更新之后、sendResize 之前)。
256
+ 5. `close()`:`this.jiggleRetry.stop()` 改为 `this.jiggleRetry.restoreAndStop()`(G3)。
257
+ 6. `checkClearSequence` → `this.jiggleRetry.feed(data)` 不变。
258
+
259
+ - [ ] **Step 1: 应用上述 6 处改动**
260
+ - [ ] **Step 2: 验证**
261
+ Run: `npm run typecheck`(必须干净);`node --test test/*.test.mjs`(除 E2E 外全绿;E2E 在 Task 3 重写)
262
+ - [ ] **Step 3: Commit**
263
+ ```bash
264
+ git add src/ui/pty-attach.ts
265
+ git commit -m "feat: wire shrink-and-hold controller into attach component (issue #25)"
266
+ ```
267
+
268
+ ---
269
+
270
+ ### Task 3: E2E 重写(hold 语义)+ shell 兜底用例
271
+
272
+ **Files:**
273
+ - Modify: `test-support/fake-coldstart-tui-pi.mjs`
274
+ - Test: `test/pty-attach-cold-start-e2e.test.mjs`(重写)
275
+
276
+ **stub 新语义(精确):**
277
+
278
+ ```js
279
+ #!/usr/bin/env node
280
+ // Fake cold-booting pi for the attach shrink-and-hold E2E (issue #25).
281
+ // Boot noise first; after STUB_TUI_DELAY_MS "starts the TUI": installs the
282
+ // resize listener and emits \x1b[?2026h-wrapped frames. Holds the protocol:
283
+ // whenever the PTY size becomes (cols, rows) where cols/rows differ from the
284
+ // size at TUI start (the hold's promised width delta), the running TUI
285
+ // responds with a fullRender containing \x1b[2J — because pi-tui's
286
+ // widthChanged path fires for ANY width change from its baseline.
287
+ const delay = Number(process.env.STUB_TUI_DELAY_MS ?? 8000);
288
+ process.stdout.write("boot: loading extensions...\n");
289
+ let started = false;
290
+ let baseline = null;
291
+ function startTui() {
292
+ if (started) return;
293
+ started = true;
294
+ process.stdout.on("resize", () => {
295
+ const c = process.stdout.columns;
296
+ const r = process.stdout.rows;
297
+ if (baseline === null) baseline = [c, r]; // 首个见到的尺寸=基线
298
+ if (c === baseline[0] && r === baseline[1]) return; // 无变化不全清
299
+ baseline = [c, r]; // 全清后新基线
300
+ process.stdout.write(
301
+ `\x1b[?2026h\x1b[2J\x1b[H\x1b[3Jframe@${c}x${r}\x1b[?2026l`,
302
+ );
303
+ });
304
+ process.stdout.write("\x1b[?2004h\x1b[?25l");
305
+ frame();
306
+ }
307
+ function frame() { process.stdout.write("\x1b[?2026hstub: working...\x1b[?2026l"); }
308
+ setTimeout(startTui, delay);
309
+ setInterval(() => { if (started) frame(); }, 100);
310
+ process.on("SIGTERM", () => process.exit(0));
311
+ process.on("SIGINT", () => process.exit(0));
312
+ ```
313
+
314
+ **E2E 用例 1(冷启动 ≤3s 自愈 + 终态原尺寸)**:runner + stub(delay 8000)+ 真控制器。connect 时仅 `controller.start(196, 39)`(不再手动 sendResize/jiggle——协议在 start 内)。喂全部 output 给 feed。断言:
315
+ - 首个 `\x1b[2J` 到达时刻 − 首帧(tuiFrameSeen 翻转)时刻 ≤ **3000ms**(frameAt/clearAt 都取自 controller.getState() 翻转的采样点,沿用 #10 的采样法)
316
+ - `getState().held === false` 且收到过 resize(196,39)(终态原尺寸;从 runner 侧回读 host.json cols/rows == 196/39 亦可作证)
317
+
318
+ **E2E 用例 2(shell 型子进程 G1 兜底)**:piArgsPrefix 指向 `test-support/fake-pty-pi.mjs`(既有,无 2026h、无 resize 响应)。connect 后 `controller.start(120, 36)`;喂 output;**手动 fire G1**不可行(真计时器)——改为等真 6s:断言 8s 内收到 sendResize(120, 36)(G1 restore)。测试时长 ~8s 可接受。
319
+
320
+ **E2E 用例 3(可选,若前两个已覆盖 G4 则略)**:略——G4 由单测覆盖,不在 E2E 层重复。
321
+
322
+ - [ ] **Step 1: 重写 stub 与 E2E**(保留 node-pty skip 守卫、freshRoot/waitFor/send 约定)
323
+ - [ ] **Step 2: 跑 E2E**
324
+ Run: `node --test test/pty-attach-cold-start-e2e.test.mjs` → 两用例 PASS(用例 1 ~10-12s;用例 2 ~8s)
325
+ - [ ] **Step 3: Commit**
326
+ ```bash
327
+ git add test-support/fake-coldstart-tui-pi.mjs test/pty-attach-cold-start-e2e.test.mjs
328
+ git commit -m "test: hold-protocol E2E cold-start and shell-fallback cases (issue #25)"
329
+ ```
330
+
331
+ ---
332
+
333
+ ### Task 4: 全量验证 + spec 对照
334
+
335
+ - [ ] **Step 1**: `npm run typecheck` 干净;`npm test` 全绿(含新 E2E)
336
+ - [ ] **Step 2**: 对照 spec 验收 1/2/3/4:E2E ≤3s ✓;shell G1 兜底 ✓;8 风险面守卫测试映射清单(写进 commit 后的验证评论)✓
337
+ - [ ] **Step 3**: `git status --short` 空;`git log --oneline main..HEAD` = 5 commits(spec+plan+3 任务)
@@ -0,0 +1,211 @@
1
+ # Question/Questionnaire Tool Grouping Fix 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 pi sessions blocked on the `question`/`questionnaire` tools group under "Needs answer" instead of "Running".
6
+
7
+ **Architecture:** Two minimal changes to `src/core/events.mjs`: (1) extend the private `questionFromArgs` helper to read pi's arg shapes; (2) replace the hardcoded `=== "ask_questions"` name checks with a `QUESTION_TOOL_NAMES` set. All downstream behavior (needs_input state, grouping, summary) already exists via `preservePendingQuestion` and `deriveSummary` — verified in spec section 3.3, nothing else changes.
8
+
9
+ **Tech Stack:** Node.js (`.mjs` ESM, no deps), `node:test` + `node:assert/strict` for tests.
10
+
11
+ ## Global Constraints
12
+
13
+ - All code changes confined to `src/core/events.mjs` and `test/events.test.mjs`.
14
+ - No store/schema/type changes; no changes to `finalizeRun`, `projectViewState`, `deriveSummary`, `rows.mjs`, `service.mjs`.
15
+ - Non-interactive (detached) reduction path keeps its current behavior — the `opts.interactive` gate stays.
16
+ - Static name set, no config surface.
17
+ - Commit messages in conventional commits format; stage files individually (`git add <file>`), never `git add -A`.
18
+ - Test command: `node --test test/events.test.mjs`; project gate: `npm run verify`.
19
+ - Work in worktree `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-26-question-tool-grouping`; never touch main.
20
+
21
+ ---
22
+
23
+ ### Task 1: Extend `questionFromArgs` for pi arg shapes
24
+
25
+ **Files:**
26
+ - Modify: `src/core/events.mjs:229-235`
27
+ - Test: `test/events.test.mjs` (append new test near the ask_questions tests, after line 160)
28
+
29
+ **Interfaces:**
30
+ - Consumes: nothing new.
31
+ - Produces: `questionFromArgs(args)` (private) returns the first non-empty question text from: `args.question` (string), or `args.questions[]` items' `question` or `prompt` fields; falls back to `"Answer the pending question"`. Task 2's name-recognition relies on this extraction.
32
+
33
+ - [ ] **Step 1: Write the failing test**
34
+
35
+ Append to `test/events.test.mjs` (after the "interactive questions remain visible..." test):
36
+
37
+ ```js
38
+ test("questionFromArgs extracts pi question/questionnaire arg shapes", () => {
39
+ // pi `question` tool shape: args.question is a plain string.
40
+ const s1 = createRunStatus(cfg(), 1, 1000);
41
+ reduceEvent(s1, {
42
+ type: "tool_execution_start",
43
+ toolCallId: "q1",
44
+ toolName: "ask_questions",
45
+ args: { question: "Approve the plan?" },
46
+ }, 2000, { interactive: true });
47
+ assert.equal(s1.question, "Approve the plan?");
48
+
49
+ // pi `questionnaire` tool shape: args.questions[].prompt.
50
+ const s2 = createRunStatus(cfg(), 1, 1000);
51
+ reduceEvent(s2, {
52
+ type: "tool_execution_start",
53
+ toolCallId: "q1",
54
+ toolName: "ask_questions",
55
+ args: { questions: [{ prompt: "Pick the scope?" }] },
56
+ }, 2000, { interactive: true });
57
+ assert.equal(s2.question, "Pick the scope?");
58
+ });
59
+ ```
60
+
61
+ Note: the tests route through the reducer with the already-recognized `ask_questions` name because `questionFromArgs` is private and the reducer is the public surface; name recognition for the pi tools lands in Task 2.
62
+
63
+ - [ ] **Step 2: Run test to verify it fails**
64
+
65
+ Run: `node --test test/events.test.mjs`
66
+ Expected: FAIL — both assertions get `"Answer the pending question"` (the current extractor only reads `questions[].question`).
67
+
68
+ - [ ] **Step 3: Implement the extractor**
69
+
70
+ Replace the body of `questionFromArgs` in `src/core/events.mjs` (L229-235):
71
+
72
+ ```js
73
+ function questionFromArgs(args) {
74
+ if (typeof args?.question === "string" && args.question.trim()) return args.question.trim();
75
+ for (const item of Array.isArray(args?.questions) ? args.questions : []) {
76
+ const question = String(item?.question ?? item?.prompt ?? "").trim();
77
+ if (question) return question;
78
+ }
79
+ return "Answer the pending question";
80
+ }
81
+ ```
82
+
83
+ - [ ] **Step 4: Run tests to verify they pass**
84
+
85
+ Run: `node --test test/events.test.mjs`
86
+ Expected: PASS (all 17 existing + 1 new test).
87
+
88
+ - [ ] **Step 5: Commit**
89
+
90
+ ```bash
91
+ git add src/core/events.mjs test/events.test.mjs
92
+ git commit -m "fix: support pi question arg shapes in questionFromArgs (issue #26)"
93
+ ```
94
+
95
+ ---
96
+
97
+ ### Task 2: Recognize `question`/`questionnaire` as pending-question tools
98
+
99
+ **Files:**
100
+ - Modify: `src/core/events.mjs:25-27` (insert set), `src/core/events.mjs:83`, `src/core/events.mjs:97`
101
+ - Test: `test/events.test.mjs` (append three tests after the Task 1 test)
102
+
103
+ **Interfaces:**
104
+ - Consumes: `questionFromArgs` extraction from Task 1.
105
+ - Produces: `QUESTION_TOOL_NAMES` (module-private `Set<string>`). `reduceEvent` treats any interactive tool_execution_start/end whose tool name is in the set as a pending-question event — `upsertPendingQuestion`/`removePendingQuestion` plus the existing `preservePendingQuestion` flow (needs_input state, `currentTool = null`).
106
+
107
+ - [ ] **Step 1: Write the failing tests**
108
+
109
+ Append to `test/events.test.mjs`:
110
+
111
+ ```js
112
+ test("interactive pi question tool is treated as a pending question", () => {
113
+ const s = createRunStatus(cfg(), 1, 1000);
114
+ reduceEvent(s, {
115
+ type: "tool_execution_start",
116
+ toolCallId: "q1",
117
+ toolName: "question",
118
+ args: { question: "Approve the plan?", options: [{ label: "Yes" }, { label: "No" }] },
119
+ }, 2000, { interactive: true });
120
+ assert.equal(s.semanticState, "needs_input");
121
+ assert.equal(s.question, "Approve the plan?");
122
+ assert.equal(s.currentTool, null);
123
+ assert.equal(s.summary, "Approve the plan?");
124
+ assert.deepEqual(s.pendingQuestions, [{ toolCallId: "q1", question: "Approve the plan?" }]);
125
+ assert.equal(projectViewState(s, 2100).needsInput, true);
126
+
127
+ reduceEvent(s, { type: "tool_execution_end", toolCallId: "q1", toolName: "question", isError: false }, 2400, { interactive: true });
128
+ assert.equal(s.semanticState, "working");
129
+ assert.equal(s.question, null);
130
+ assert.deepEqual(s.pendingQuestions, []);
131
+ });
132
+
133
+ test("interactive questionnaire tool extracts prompt and clears on end", () => {
134
+ const s = createRunStatus(cfg(), 1, 1000);
135
+ reduceEvent(s, {
136
+ type: "tool_execution_start",
137
+ toolCallId: "q1",
138
+ toolName: "questionnaire",
139
+ args: { questions: [{ prompt: "Pick the scope?" }] },
140
+ }, 2000, { interactive: true });
141
+ assert.equal(s.semanticState, "needs_input");
142
+ assert.equal(s.question, "Pick the scope?");
143
+ reduceEvent(s, { type: "tool_execution_end", toolCallId: "q1", toolName: "questionnaire", isError: false }, 2400, { interactive: true });
144
+ assert.equal(s.semanticState, "working");
145
+ });
146
+
147
+ test("detached question tool keeps legacy currentTool behavior", () => {
148
+ const s = createRunStatus(cfg(), 1, 1000);
149
+ reduceEvent(s, { type: "tool_execution_start", toolCallId: "q1", toolName: "question", args: { question: "Approve?" } }, 2000);
150
+ assert.equal(s.semanticState, "working");
151
+ assert.equal(s.currentTool.name, "question");
152
+ assert.deepEqual(s.pendingQuestions, []);
153
+ });
154
+ ```
155
+
156
+ - [ ] **Step 2: Run tests to verify they fail**
157
+
158
+ Run: `node --test test/events.test.mjs`
159
+ Expected: FAIL — question/questionnaire names are not recognized: first test asserts `needs_input` but gets `working` with `currentTool.name === "question"`.
160
+
161
+ - [ ] **Step 3: Add the name set and wire the two checks**
162
+
163
+ Insert after the imports at the top of `src/core/events.mjs` (before `createRunStatus`, ~L25):
164
+
165
+ ```js
166
+ /** Tool names whose interactive execution blocks on a user answer. */
167
+ const QUESTION_TOOL_NAMES = new Set(["ask_questions", "question", "questionnaire"]);
168
+ ```
169
+
170
+ Replace L83:
171
+
172
+ ```js
173
+ if (opts.interactive && QUESTION_TOOL_NAMES.has(name)) {
174
+ ```
175
+
176
+ Replace L97:
177
+
178
+ ```js
179
+ if (opts.interactive && QUESTION_TOOL_NAMES.has(event.toolName ?? "")) removePendingQuestion(status, event.toolCallId);
180
+ ```
181
+
182
+ - [ ] **Step 4: Run tests to verify they pass**
183
+
184
+ Run: `node --test test/events.test.mjs`
185
+ Expected: PASS (21 tests total: 17 existing + 1 Task 1 + 3 new). Existing `ask_questions` tests must pass unchanged.
186
+
187
+ - [ ] **Step 5: Full project gate**
188
+
189
+ Run: `npm run verify`
190
+ Expected: typecheck, all tests, and pack dry-run pass.
191
+
192
+ - [ ] **Step 6: Commit**
193
+
194
+ ```bash
195
+ git add src/core/events.mjs test/events.test.mjs
196
+ git commit -m "fix: recognize question/questionnaire tools as pending questions (issue #26)"
197
+ ```
198
+
199
+ ---
200
+
201
+ ## Self-Review
202
+
203
+ **Spec coverage:**
204
+ - Spec 3.1 (QUESTION_TOOL_NAMES replacing both hardcoded checks) → Task 2 steps 3. ✓
205
+ - Spec 3.2 (questionFromArgs pi shapes: `args.question`, `items[].prompt`, fallback `item.question`) → Task 1 step 3. ✓
206
+ - Spec 3.4 tests 1-4 (question start→needs_input, questionnaire extraction, end→working, detached unchanged) → Task 2 step 1 + Task 1 step 1. ✓
207
+ - Spec 3.3 downstream flow unchanged → asserted via `summary`/`needsInput` in Task 2 test 1; no production code touched outside `events.mjs`. ✓
208
+
209
+ **Placeholder scan:** every step carries concrete code or exact commands; no TBD/TODO/"similar to". ✓
210
+
211
+ **Type consistency:** `QUESTION_TOOL_NAMES` used identically in both replace steps; arg fields (`question`, `questions[].prompt`) match the pi extension schemas (`~/.pi/agent/extensions/question.ts`, `questionnaire.ts`). ✓
@@ -0,0 +1,107 @@
1
+ # Spec:attach jiggle 协议改造 — shrink-and-hold(issue #25)
2
+
3
+ ## 日期
4
+ 2026-08-22
5
+
6
+ ## 问题
7
+
8
+ #10 修复后冷启动 attach 自愈延迟 10-25s(re-arm 生效但后续 ±1/200ms 脉冲对被启动风暴合并成净零尺寸变化,子端渲染时看不到宽度变化 → 不触发 fullRender 全清)。目标:冷启动 ≤3s 自愈,热 attach ≤300ms 不劣化,且不破坏任何现有功能。
9
+
10
+ ## 协议设计(核心状态机)
11
+
12
+ **旧协议**:connect → shrink(−1) → [200ms] → restore → 每次重试重复脉冲对 → 见全清停。
13
+ **新协议**:
14
+
15
+ ```
16
+ connect:
17
+ 1. sendResize(W×H) # 原始尺寸(保持现有语义)
18
+ 2. shrink 到 (W−1)×(H−1) 并 HOLD # "armed":与子端基线之间存在待兑现的宽度差
19
+ 3. 启动安全计时器(见"守卫 G1/G2")
20
+
21
+ 子端输出处理(沿用 feed):
22
+ - 见 \x1b[2J(全清)→ restore(W×H),链停 # 子端渲染时读到窄宽 → fullRender → 我们恢复
23
+ - 见首个 \x1b[?2026h(re-arm)→ restore(W×H) 并停在那里
24
+ # 冷启动死锁解法:TUI 若在 shrink 后才启动,首帧把窄宽当基线;
25
+ # restore 让"正在渲染的子端"下一帧看到宽 1 列 → 必然 fullRender → 走上面的全清分支
26
+
27
+ 守卫:
28
+ G1 无帧兜底: connect 后 6s 内无任何 TUI 帧 → restore(W×H)(没有渲染器可触发,继续缩无意义)
29
+ G2 预算兜底: 全部退避预算(56.12s)走完无全清 → restore(W×H)(非 pi 子进程/死 session)
30
+ G3 close/detach: close() 时若 hold 生效 → 先 restore 再断(socket 尚可用)
31
+ G4 真实 resize: resizeIfNeeded(新宽) 进入时若 hold 生效 → 取消 hold 状态、按新尺寸 sendResize、
32
+ 之后由下一次 attach 语义重启链(hold 的"原尺寸"取新值)
33
+ G5 reconnect: start() 重置前若 hold 生效 → 先 restore(socket 刚连上)
34
+ ```
35
+
36
+ **为什么合并不再重要**:旧协议的净零来自"缩"与"恢复"互相抵消;新协议在见到全清前根本不存在"恢复",任何一侧(外层 timer / 子端 SIGWINCH / 渲染节流)的合并最多推迟信号到达,不能消除"宽度与基线不同"这个事实。子端只要渲染任何一帧,全清必然发生。
37
+
38
+ ## 模块改动
39
+
40
+ ### 1. `src/core/pty-attach-jiggle-retry.mjs`(纯逻辑,微调)
41
+ - 不变:退避表、`hasTuiFrameStart`/`hasFullClearSequence`、carry=7。
42
+ - `JiggleRetryState` 增加 `held: boolean`(是否正缩着)与 `originalCols/originalRows`。由控制器维护,状态机保持零副作用。
43
+
44
+ ### 2. `src/core/pty-attach-jiggle-controller.mjs`(协议主体)
45
+ - deps 增加:`sendResize(cols, rows)`(发送任意尺寸;sendJiggle 语义被 hold 协议取代,删除或保留为内部组合)。
46
+ - `start()`:若前次 hold 生效 → 先 `sendResize(original)`(G5);随后 `sendResize(W,H)`、`sendResize(W−1,H−1)` 置 `held=true`(armed);启动 G1 计时器;预算链照常排(作 G2 计时用,重试回调在 hold 下为 no-op——不发脉冲)。
47
+ - `feed()`:见全清 → `restoreIfHeld()` + 停链 + 置 clearDetected;见首帧(re-arm)→ `restoreIfHeld()`(G-rearm),**预算链不重置不重排**(hold 协议下 re-arm 后无需再脉冲;若全清一直不来,由 G1/G2 兜底)。G1 计时器见帧后取消。
48
+ - `stop()`:restoreIfHeld 不在此做(组件 close 语义不同——见 G3,由组件在 socket 可用时显式调用 `restoreAndStop()`)。
49
+ - 新增 API:`restoreAndStop()`(G3)、`notifyExternalResize(cols, rows)`(G4:取消 hold/计时器、更新 original、可选重启链)。
50
+ - 计时器仍全部走注入的 setTimeoutFn/clearTimeoutFn,unref。
51
+
52
+ ### 3. `src/ui/pty-attach.ts`(薄胶水)
53
+ - `forceChildRedraw` 语义替换:connect 处理器改调 `jiggleRetry.start()`(内含 armed shrink);`JIGGLE_RESTORE_MS` 删除(无脉冲)。
54
+ - `resizeIfNeeded`:尺寸变化时调 `jiggleRetry.notifyExternalResize(newCols, newRows)`,再照常 `term.resize + sendResize`。
55
+ - `close()`:`jiggleRetry.restoreAndStop()`。
56
+ - `checkClearSequence` → `jiggleRetry.feed(data)` 不变。
57
+
58
+ ### 4. 测试
59
+ - 单测(controller):armed 后见全清→restore+停;见首帧→restore 且不重排;G1 6s 无帧 restore;G2 预算耗尽 restore;close restoreAndStop;notifyExternalResize 取消 hold 并更新原尺寸;重复 start 先 restore 旧 hold;所有 restore 只发一次(幂等)。
60
+ - E2E 重写(stub 语义改为 hold 协议):
61
+ - 冷启动:stub 延迟 8s 启动 TUI;收到 shrink 不动作;**收到 restore(原尺寸) 且 TUI 已启动** → 发全清。断言全清 ≤3s 内到达(re-arm 后 ~一帧间隔),且 PTY 终态=原尺寸。
62
+ - shell 型子进程(永不发全清):断言 G1 兜底 6s 后收到 restore(原尺寸)。
63
+ - (可选)hold 中途外部 resize:模拟 notifyExternalResize,断言不发旧尺寸。
64
+ - 既有 208 测试适配(controller 单测大改、retry 状态机测试微调)。
65
+
66
+ ## 决策表
67
+
68
+ | 决策点 | 选择 | 理由 |
69
+ |---|---|---|
70
+ | hold 的宽度 | cols−1 且 rows−1 | 沿用现有 jiggle 尺寸语义;宽高都变确保 heightChanged 路径也可触发 |
71
+ | re-arm 后行为 | 只 restore,不再脉冲 | 子端已在渲染,restore 即待兑现差值;脉冲回归旧脆弱性 |
72
+ | G1 时长 | 6s | > 最慢正常 boot 出首帧(实测 ~5.2s)+ 余量;< G2 预算 |
73
+ | G2 | 退避表走完(56.12s) | 与 #10 验收窗口一致;hold 下重试回调 no-op,表仅作计时 |
74
+ | restore 幂等 | 只发一次 | 防止 close/reconnect/兜底叠加多发 |
75
+ | `JIGGLE_RESTORE_MS` | 删除 | 无脉冲对 |
76
+
77
+ ## 风险面 → 守卫映射(用户重点确认区)
78
+
79
+ | # | 风险面 | 守卫 | 验证 |
80
+ |---|---|---|---|
81
+ | R1 | 非 pi 子进程/死 session | G1(6s)+G2(56s) restore | E2E shell stub |
82
+ | R2 | detach/close 中途 | G3 close 先 restore | 单测 |
83
+ | R3 | 真实 resize 打架 | G4 中断+取新值 | 单测 |
84
+ | R4 | 子端窄 1 列渲染 | 瞬态,restore 全清重绘 | 人工+E2E 终态尺寸断言 |
85
+ | R5 | 本地投影 1 列差 | 流内自洽,无影响 | 人工 |
86
+ | R6 | reconnect 残留 | G5 start 先 restore | 单测 |
87
+ | R7 | settle 后 UX | 无脉冲(频次低于现状) | 人工 |
88
+ | R8 | 测试有效性 | stub 改 hold 语义+兜底用例 | E2E |
89
+
90
+ ## 降级
91
+
92
+ - 若 pi-tui 改掉 `\x1b[?2026h`:re-arm 不触发,退化为 G1(6s) restore——**比 #10 修复前更好**(6s 内恢复正确尺寸,画面可能仍脏但不卡窄宽)。
93
+ - 若全清信号消失:同上走 G1/G2,终态尺寸正确。
94
+
95
+ ## 非目标
96
+
97
+ - 失同步渲染检测(#11 范围,若 hold 后仍有可见脏窗再评估)。
98
+ - runner / pi-tui 侧改动。
99
+ - screen.log 重放锚点问题。
100
+
101
+ ## 验收标准
102
+
103
+ 1. 冷启动 E2E:全清 ≤3s 到达且终态=原尺寸(旧协议同场景 >10s,反证有效)。
104
+ 2. shell stub E2E:G1 兜底 6s 后 restore 原尺寸。
105
+ 3. 8 个风险面守卫全部有单测/E2E 覆盖。
106
+ 4. 全套测试通过、typecheck 干净、CR 收敛。
107
+ 5. 人工实机:home 目录冷启动 attach ≤3s 自愈;热 attach 无可感知劣化。