@zhuxixi/pi-agent-board 0.4.1 → 0.4.3

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`. |
@@ -100,6 +101,7 @@ If the board reports `node-pty unavailable`, press `!` in the dashboard for diag
100
101
  npm install
101
102
  npm run typecheck
102
103
  npm test
104
+ npm run test:coverage
103
105
  npm run pack:dry
104
106
  ```
105
107
 
@@ -109,7 +111,20 @@ Run all checks with:
109
111
  npm run verify
110
112
  ```
111
113
 
112
- `npm run verify` runs typecheck, tests, and a dry npm pack.
114
+ `npm run verify` runs typecheck, tests, coverage, and a dry npm pack.
115
+
116
+ ### QA baseline
117
+
118
+ Every push and PR runs the same checks in CI (`.github/workflows/ci.yml`, Node 22 + 24),
119
+ and `main` branch protection requires both CI checks to pass before merging.
120
+
121
+ Coverage is enforced by `c8` with thresholds configured in `.c8rc.json`
122
+ (lines ≥ 85%, functions ≥ 80%, branches ≥ 70%). The TS UI layer
123
+ (`src/ui/*.ts`, `src/commands/*.ts`) is covered by a smoke test
124
+ (`test/ui-smoke.test.mjs`) that constructs and renders the real entrypoints;
125
+ it is excluded from the coverage thresholds by design.
126
+
127
+ Current baseline: 300+ tests, ~92% line coverage on the core modules.
113
128
 
114
129
  ## Publish
115
130
 
@@ -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 任务)