@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.
@@ -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 无可感知劣化。
@@ -0,0 +1,97 @@
1
+ # Spec: 修复 locks.mjs acquireLock 无眠死循环(issue #33)
2
+
3
+ > Draft 状态:待用户确认设计后进 worktree 实现(github-issue-driven 步 4 暂停点)。
4
+
5
+ ## 问题
6
+
7
+ `src/core/locks.mjs` `acquireLock` 在锁持续不可得时(根目录被删 / 只读 / 锁状态损坏),30s 等待窗过期后退化为零睡眠忙等循环:100% 单核 CPU、事件循环冻死、定时器全灭、进程永远不退出。现网两个 job-runner 僵尸进程分别空转 10.5 天 / 4.4 天(#33 现场证据)。
8
+
9
+ ## 根因(两处叠加)
10
+
11
+ ```js
12
+ while (true) {
13
+ try {
14
+ mkdirSync(lockPath);
15
+ writeFileSync(path.join(lockPath, "owner.json"), ...);
16
+ return;
17
+ } catch (err) {
18
+ // 缺陷 1:睡眠只在初始窗口内生效,窗口一过永不睡眠
19
+ if (!isLockStale(lockPath, staleMs) && Date.now() - started < Math.max(250, staleMs)) {
20
+ Atomics.wait(...20ms);
21
+ continue;
22
+ }
23
+ // 缺陷 2:releaseLock 静默吞错,循环无条件继续 → 无眠紧循环
24
+ releaseLock(lockPath);
25
+ }
26
+ }
27
+ ```
28
+
29
+ **触发机制(现场还原)**:teardown 的 `rmSync(root, {recursive})` 与 runner 收尾链在时间上系统性重叠(runner 快速收尾链 ~10-50ms 到达锁 vs 测试 waitFor 轮询 ~50ms + 断言后才 rmSync ~50-150ms)。目录树遍历删掉 `ensureDir` 刚验证过的父目录后,`mkdirSync` 从此永远 ENOENT(**ensureDir 只在循环外跑一次,循环内永不重建父目录**)→ 30s 窗口后零睡眠死循环。另两类等价失败:owner.json 写失败(半成品锁被误判 stale → 删了重建无限循环)、锁目录删不掉(rmSync 失败被吞)。
30
+
31
+ ## 设计决策
32
+
33
+ ### 决策表
34
+
35
+ | # | 决策点 | 选择 | 理由 |
36
+ |---|---|---|---|
37
+ | D1 | 强夺失败后的行为 | **有界尝试后抛错**(`Error: lock timeout: <path>`) | 锁不可得属环境故障,忙等无意义;抛错让调用层决定降级 |
38
+ | D2 | 强夺(窗口后偷锁)语义 | **保留**:窗口过期 → releaseLock → 立即重试一次 | 现有测试「fresh lock 等窗口后强夺」固化此语义,改动会破坏契约 |
39
+ | D3 | 重试上限 | 等待窗内无限重试(带睡眠,窗口 = `max(250, staleMs)`,保留现有 floor);窗口后**最多 2 次强夺**(含 owner.json 写失败路径),仍失败即抛 | 覆盖 rm 失败 / mkdir 仍失败 / 写失败三类;有界即无死循环 |
40
+ | D4 | 睡眠策略 | 保留 `Atomics.wait(20ms)`;循环内任何 continue 前必有睡眠或已抛错 | 反证 D1 的失败模式,杜绝任何无眠路径 |
41
+ | D4b | **循环内自愈**:每次 catch 后重跑 `ensureDir(dirname)`(ensureDir 自身失败计为一次失败尝试) | 现场最高频竞态(teardown rmSync 删掉父目录)从「等窗口后抛错」升级为「瞬时自愈、正常拿锁退出」;有界性不变 |
42
+ | D5 | fs 注入 | 仿 `screen-log.mjs` `defaultScreenLogFs` 先例,加 `locksFs` 参数(默认 node:fs) | 现有测试无注入,注入后才能确定性复现「mkdir 永败」等场景 |
43
+ | D6 | 队列层错误传播 | follow-up-queue.mjs 5 个入口 try/catch **catch-all**(含 fn 内 writeFollowUpQueue 的 fs 错误,非仅锁错误)→ `{ok:false, error}` | 保持 {ok} 返回值约定,service.mjs 无需改动;已确认 follow-up-queue.test.mjs 无 throw 断言,catch-all 安全 |
44
+ | D7 | job-runner 兜底 | `.finally` 链里 `finalizeSteeringIfNeeded` + `drainQueuedFollowUp` 各自 try/catch | 锁层抛错永远不会阻止 `process.exit`——僵尸进程防线最后一道 |
45
+ | D8 | 默认 staleMs | 不变(30s) | 现有测试与调用方依赖 |
46
+ | D9 | 测试 harness 清理 | 全部 7 处 launchRun 都捕获 pid(现有 4 处丢弃,含出过僵尸的 dash 测试)→ finally 里 TERM → 短等待 → KILL → 再 rmSync(root) | 现场两个僵尸的直接源头是 teardown 只删目录不杀 detached runner;这层保证测试不再产出孤儿(launch.test.mjs 的 detached fake-pi 已确认自然退出,无需处理) |
47
+
48
+ ### 数据流(修复后)
49
+
50
+ ```
51
+ claimNextFollowUp(root, viewId)
52
+ → withViewLockSync(root, viewId, "queue", fn)
53
+ → acquireLock: [wait loop w/ sleep] → 窗口过期 → steal attempt ×2 → 失败 → throw
54
+ → catch → { ok: false, error: "follow-up queue lock unavailable: ..." }
55
+ → job-runner drainQueuedFollowUp: claimNextFollowUp 返回 {ok:false} → 直接 return(不进 launch)
56
+ → .finally → process.exit 必达
57
+ ```
58
+
59
+ ### 组件契约
60
+
61
+ - `withFileLockSync` / `withViewLockSync`:成功返回 fn 结果;**新行为**——锁超时抛 `Error`(message 含 lockPath 与耗时)。
62
+ - follow-up-queue 5 个导出(enqueue/claim/complete/release/remove/clear):任何锁失败 → `{ok:false, error}`,不再抛出。
63
+ - service.mjs:零改动(已按 {ok} 消费)。
64
+ - job-runner.mjs:收尾链不因锁失败挂起。
65
+
66
+ ### 降级行为
67
+
68
+ - 锁失败时队列操作静默失败并写 diagnostics(job-runner 用 appendDiagnostic;service 层已有该模式),用户可感知但系统不挂。
69
+ - 不引入锁重试队列、不引入跨进程 watchdog——超出本 issue 范围。
70
+
71
+ ## 非目标
72
+
73
+ - 不改锁的 mkdir 实现(不换 flock/其他机制)
74
+ - 不处理「锁持有者崩溃残留」之外的竞争语义
75
+ - 不改 30s staleMs 默认值
76
+ - 不引入异步锁
77
+
78
+ ## 测试计划(red-green)
79
+
80
+ test/locks.test.mjs 现有 5 测试全绿;新增(全部带 `{ timeout: 5000 }` 防挂):
81
+
82
+ 1. **mkdir 永败**(注入 fs):`withFileLockSync(..., { staleMs: 50 })` 在 ~250ms 窗口(`max(250, staleMs)` floor)后抛错,不在 5s 内挂起。
83
+ 2. **写 owner.json 永败**(注入 fs):mkdir 成功但 writeFileSync 抛 → 有界强夺后抛错。
84
+ 3. **rmSync 永败**(注入 fs):窗口后强夺 rm 失败 → 抛错。
85
+ 4. **争用正常恢复**:注入 fs 模拟「前 N 次 mkdir EEXIST、之后成功」→ 等待窗内获取成功(保语义 1)。
86
+ 4b. **父目录被删后自愈**(D4b):真实 fs,锁获取前删掉父目录 → ensureDir 在循环内重建 → 正常拿锁(验证现场最高频竞态透明自愈)。
87
+ 5. **窗口后强夺仍成功**:复用现有测试 4(不回归)。
88
+ 6. follow-up-queue 层:锁失败 → `{ok:false, error}`(用注入 fs 或真实坏路径)。
89
+ 7. job-runner 收尾:若可低成本导出/集成测试则覆盖「锁坏时 drainQueuedFollowUp 不挂起、process 退出」;否则以代码评审 + 手动验证为准。
90
+ 8. 集成测试 teardown:runner 被杀后进程表里不再残留 job-runner(现有集成测试全绿即可证明 kill 生效)。
91
+
92
+ ## 验收
93
+
94
+ - `npm test`(或 `npm run verify`)全绿
95
+ - 35s 复现脚本(/sys 只读路径)修复后应立即抛错而非空转
96
+ - 跑完集成测试后 `ps` 无残留 job-runner
97
+ - 代码评审通过后 PR + Zima CR
@@ -0,0 +1,35 @@
1
+ # Issue #34 — CI flaky: pty-runner integration test times out waiting for pre-connect output
2
+
3
+ ## Root cause
4
+
5
+ `test/pty-runner.integration.test.mjs` (test "pty-runner creates host socket, broadcasts output, forwards input, finalizes") waits for the boot banner `fake pi ready` **over the control socket**. But the socket only carries *live* output: `broadcast()` iterates `clients`, which is empty until a client connects. If the child emits `fake pi ready` before the test's socket connects (CI runners start the child fast), the output is broadcast to zero clients and lost — the 3s `waitFor` times out.
6
+
7
+ History output is intentionally NOT replayed over the socket; the UI attach path replays it from the screen log file (`src/ui/pty-attach.ts` `replayScreenLog`). The test's assumption contradicts the protocol design.
8
+
9
+ ### Evidence
10
+
11
+ - CI run 32554867604 (main, #32): `not ok 181 ... error: 'timed out waiting'` at `test/pty-runner.integration.test.mjs:65`, both Node 22 and Node 24.
12
+ - PR branch run 32554619699 failed on a *different* test (`runner.integration.test.mjs:195`, auto-done idle), passed on rerun — separate timing-sensitive spot, out of scope.
13
+ - Reproduced deterministically locally with a forced "output before connect" script: 3/3 timeouts. Same test passes 5/5 under normal timing.
14
+ - Local runs after fix: 6/6 pass; late-connect scenario: 3/3 pass; full suite: 313/313 pass.
15
+
16
+ ## Fix design
17
+
18
+ Only the test changes; no product code change (socket protocol behavior is by design).
19
+
20
+ | Step | File | Change |
21
+ |------|------|--------|
22
+ | 1 | `test/pty-runner.integration.test.mjs` | Replace the socket wait for `fake pi ready` with a screen-log read wait (`P.screenLogPath(root, "v1")` contains `fake pi ready`) — mirrors UI attach replay semantics |
23
+
24
+ Assertions that remain on the socket (post-connect realtime events, timing-safe):
25
+ - `echo:hello` output (live broadcast + input forwarding)
26
+ - resize → `readHost().cols === 100`
27
+ - `exit` → `endedAt` set, state `exited`
28
+
29
+ Screen-log assertions (file-based, timing-safe):
30
+ - boot banner `fake pi ready` present (already asserted at test end via `assert.match`)
31
+
32
+ ## Non-goals
33
+
34
+ - No change to `runner/pty-runner.mjs` socket protocol.
35
+ - No change to `runner.integration.test.mjs` flaky spot (tracked separately if it recurs).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -53,8 +53,9 @@
53
53
  "postinstall": "node scripts/patch-vulns.mjs",
54
54
  "typecheck": "tsc --noEmit",
55
55
  "test": "node --test test/*.test.mjs",
56
+ "test:coverage": "c8 node --test test/*.test.mjs",
56
57
  "pack:dry": "npm pack --dry-run",
57
- "verify": "npm run typecheck && npm test && npm run pack:dry"
58
+ "verify": "npm run typecheck && npm test && npm run test:coverage && npm run pack:dry"
58
59
  },
59
60
  "peerDependencies": {
60
61
  "@earendil-works/pi-coding-agent": "*",
@@ -72,6 +73,7 @@
72
73
  "@earendil-works/pi-coding-agent": "0.79.8",
73
74
  "@earendil-works/pi-tui": "0.79.8",
74
75
  "@types/node": "^25.9.1",
76
+ "c8": "^12.0.0",
75
77
  "typescript": "^5.9.3"
76
78
  },
77
79
  "dependencies": {
@@ -228,8 +228,18 @@ function main() {
228
228
  })
229
229
  .catch(() => {})
230
230
  .finally(() => {
231
- finalizeSteeringIfNeeded(config, status, evidence);
232
- drainQueuedFollowUp(config, status);
231
+ // The finalize chain must never prevent process.exit: a lock/fs failure
232
+ // here used to pin the runner as a 100% CPU zombie (issue #33).
233
+ try {
234
+ finalizeSteeringIfNeeded(config, status, evidence);
235
+ } catch (err) {
236
+ tryAppendDiagnostic(config, "finalize_steering_failed", err);
237
+ }
238
+ try {
239
+ drainQueuedFollowUp(config, status);
240
+ } catch (err) {
241
+ tryAppendDiagnostic(config, "follow_up_drain_failed", err);
242
+ }
233
243
  process.exit(stoppedByUser ? 0 : (code ?? 0));
234
244
  });
235
245
  });
@@ -283,6 +293,22 @@ function drainQueuedFollowUp(config, status) {
283
293
  }
284
294
  }
285
295
 
296
+ /** @param {import("../src/core/types.mjs").RunConfig} config @param {string} code @param {unknown} err */
297
+ function tryAppendDiagnostic(config, code, err) {
298
+ try {
299
+ appendDiagnostic(config.root, config.viewId, {
300
+ source: "runner",
301
+ runId: config.runId,
302
+ level: "error",
303
+ code,
304
+ message: "Finalize step failed",
305
+ details: { error: err instanceof Error ? err.message : String(err) },
306
+ });
307
+ } catch {
308
+ /* root may be deleted — nothing to persist, exit anyway */
309
+ }
310
+ }
311
+
286
312
  /** @param {import("../src/core/types.mjs").FollowUpItem} item */
287
313
  function runKindForFollowUp(item) {
288
314
  switch (item.kind) {
@@ -5,6 +5,24 @@ import { truncate } from "./heuristics.mjs";
5
5
  import { withViewLockSync } from "./locks.mjs";
6
6
  import * as P from "./paths.mjs";
7
7
 
8
+ /**
9
+ * Run a queue mutation under the view lock, translating any failure (lock
10
+ * unavailable, fs errors inside the mutation) into {ok:false} so callers on
11
+ * the {ok} convention never see a throw (issue #33).
12
+ * @template T
13
+ * @param {string} root
14
+ * @param {string} viewId
15
+ * @param {() => T} fn
16
+ * @returns {T | { ok: false, error: string }}
17
+ */
18
+ function lockedQueueOp(root, viewId, fn) {
19
+ try {
20
+ return withViewLockSync(root, viewId, "queue", fn);
21
+ } catch (err) {
22
+ return { ok: false, error: `follow-up queue lock unavailable: ${err instanceof Error ? err.message : String(err)}` };
23
+ }
24
+ }
25
+
8
26
  /** @param {string} viewId @param {number} [now] @returns {import("./types.mjs").FollowUpQueue} */
9
27
  export function emptyFollowUpQueue(viewId, now = Date.now()) {
10
28
  return { version: 1, viewId, nextSeq: 1, updatedAt: now, items: [] };
@@ -41,7 +59,7 @@ export function summarizeFollowUpQueue(queue) {
41
59
  export function enqueueFollowUp(root, viewId, text, opts = {}) {
42
60
  const clean = String(text || "").trim();
43
61
  if (!clean) return { ok: false, error: "Empty follow-up" };
44
- return withViewLockSync(root, viewId, "queue", () => {
62
+ return lockedQueueOp(root, viewId, () => {
45
63
  const queue = readFollowUpQueue(root, viewId);
46
64
  const now = Date.now();
47
65
  const item = {
@@ -70,7 +88,7 @@ export function enqueueFollowUp(root, viewId, text, opts = {}) {
70
88
 
71
89
  /** @param {string} root @param {string} viewId @param {{ runId?: string|null }} [opts] */
72
90
  export function claimNextFollowUp(root, viewId, opts = {}) {
73
- return withViewLockSync(root, viewId, "queue", () => {
91
+ return lockedQueueOp(root, viewId, () => {
74
92
  const queue = readFollowUpQueue(root, viewId);
75
93
  const item = queue.items.filter((i) => i.status === "queued").sort((a, b) => a.seq - b.seq)[0];
76
94
  if (!item) return { ok: false, error: "No queued follow-up" };
@@ -114,7 +132,7 @@ export function releaseFollowUp(root, viewId, itemId) {
114
132
 
115
133
  /** @param {string} root @param {string} viewId */
116
134
  export function removeLastFollowUp(root, viewId) {
117
- return withViewLockSync(root, viewId, "queue", () => {
135
+ return lockedQueueOp(root, viewId, () => {
118
136
  const queue = readFollowUpQueue(root, viewId);
119
137
  const queued = queue.items.filter((i) => i.status === "queued").sort((a, b) => b.seq - a.seq);
120
138
  const last = queued[0];
@@ -128,7 +146,7 @@ export function removeLastFollowUp(root, viewId) {
128
146
 
129
147
  /** @param {string} root @param {string} viewId */
130
148
  export function clearQueuedFollowUps(root, viewId) {
131
- return withViewLockSync(root, viewId, "queue", () => {
149
+ return lockedQueueOp(root, viewId, () => {
132
150
  const queue = readFollowUpQueue(root, viewId);
133
151
  let cancelled = 0;
134
152
  for (const item of queue.items) {
@@ -145,7 +163,7 @@ export function clearQueuedFollowUps(root, viewId) {
145
163
 
146
164
  /** @param {string} root @param {string} viewId @param {string} itemId @param {(item: import("./types.mjs").FollowUpItem) => void} mutate */
147
165
  function updateItem(root, viewId, itemId, mutate) {
148
- return withViewLockSync(root, viewId, "queue", () => {
166
+ return lockedQueueOp(root, viewId, () => {
149
167
  const queue = readFollowUpQueue(root, viewId);
150
168
  const item = queue.items.find((i) => i.id === itemId);
151
169
  if (!item) return { ok: false, error: "Unknown follow-up" };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * IME cursor-rect flicker fix (issue #28).
3
+ *
4
+ * pi-tui's doRender() emits each frame as separate terminal.write() calls:
5
+ *
6
+ * 1. ESC[?2026h ...content... ESC[?2026l (differential/full frame, sync block)
7
+ * 2. ESC[<n>A/B ESC[<col>G (positionHardwareCursor park write)
8
+ * 3. ESC[?25l (hideCursor - bypasses write())
9
+ *
10
+ * The park sequences sit OUTSIDE the synchronized-output block. Terminals that
11
+ * honor ?2026 (WezTerm et al.) present a frame and report the IME cursor
12
+ * rectangle at each ?2026l boundary, so every frame produces two cursor-rect
13
+ * reports at different positions (diff-write end vs parked input line) and the
14
+ * IME candidate window bounces at frame rate. E2E measured on WezTerm + fcitx5:
15
+ * ~20 position changes / 4s with the split writes, 0 with the park inside the
16
+ * block (see issue #28 for the full experiment).
17
+ *
18
+ * This module wraps a Terminal instance's write/hideCursor/showCursor at runtime
19
+ * and folds the out-of-block park/hide sequences back INSIDE the frame's sync
20
+ * block, re-emitting the frame as a single write. Content is byte-identical up
21
+ * to reordering of the trailing ?2026l; nothing is dropped or added.
22
+ *
23
+ * Safety properties (issue #28):
24
+ * - No match -> passthrough: any write that isn't a pure cursor-park/hide
25
+ * sequence flushes the held frame unchanged first, preserving byte order.
26
+ * - The three writes happen inside one synchronous doRender() stack, so a
27
+ * process.nextTick flush is enough to see them all; the added latency is
28
+ * sub-millisecond.
29
+ * - Uninstall restores the original methods; a WeakMap makes overlapping
30
+ * installs on the same terminal refcounted and idempotent.
31
+ * - Kill switch: AGENT_BOARD_IME_FIX=0 disables installation entirely.
32
+ *
33
+ * If pi-tui ever folds positionHardwareCursor into the sync block upstream,
34
+ * the "pure park sequence following a sync-end write" pattern stops matching
35
+ * and this wrapper degrades to a passthrough (one buffered write per frame,
36
+ * same bytes) - no behavior change.
37
+ */
38
+
39
+ const ESC = String.fromCharCode(27);
40
+ /** ESC[?2026l - end of a synchronized-output block. */
41
+ const SYNC_END = ESC + "[?2026l";
42
+
43
+ /**
44
+ * Cursor sequences positionHardwareCursor() emits after a frame: relative row
45
+ * moves (ESC[<n>A / ESC[<n>B, n optional) and an absolute column set
46
+ * (ESC[<col>G), plus the cursor visibility toggles (ESC[?25l/h). These are
47
+ * the ONLY sequences safe to fold into the block - anything else (line clears,
48
+ * absolute positioning, OSC/DCS queries, content) flushes the held frame.
49
+ */
50
+ const PARK_SEQUENCE = new RegExp(
51
+ "^(?:" + ESC + "\\[\\d*[AB]|" + ESC + "\\[\\d+G|" + ESC + "\\[\\?25[hl])*$",
52
+ );
53
+
54
+ /** True when a write ends a synchronized-output block (pi-tui frame writes do). */
55
+ export function endsWithSyncEnd(data) {
56
+ return typeof data === "string" && data.endsWith(SYNC_END);
57
+ }
58
+
59
+ /** True when data is exclusively cursor park/visibility sequences (see above). */
60
+ export function isPureCursorParking(data) {
61
+ return typeof data === "string" && data.length > 0 && PARK_SEQUENCE.test(data);
62
+ }
63
+
64
+ /** Insert seq just before the trailing SYNC_END of a held frame write. */
65
+ export function mergeIntoSyncBlock(held, seq) {
66
+ return held.slice(0, held.length - SYNC_END.length) + seq + SYNC_END;
67
+ }
68
+
69
+ /** terminal -> active wrapper, so overlapping installs share one wrapper. */
70
+ const activeWrappers = new WeakMap();
71
+
72
+ /**
73
+ * Each wrapTerminalWrites() call returns its own guarded handle: idempotent
74
+ * per handle, refcounted across handles — the patch is torn down only when
75
+ * the LAST caller uninstalls (CR round 1, issue-1).
76
+ */
77
+ function refcountedUninstall(entry) {
78
+ let done = false;
79
+ return () => {
80
+ if (done) return;
81
+ done = true;
82
+ entry.refs -= 1;
83
+ if (entry.refs > 0) return;
84
+ entry.teardown();
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Patch write/hideCursor/showCursor on a terminal instance so each frame's
90
+ * out-of-block park/hide sequences are folded into the frame's sync block and
91
+ * emitted as one write. Returns an uninstall function (idempotent, drops the
92
+ * patch when the last caller uninstalls).
93
+ */
94
+ export function wrapTerminalWrites(terminal) {
95
+ if (!terminal || typeof terminal.write !== "function" || typeof terminal.hideCursor !== "function" || typeof terminal.showCursor !== "function") {
96
+ throw new Error("terminal must expose write/hideCursor/showCursor");
97
+ }
98
+ const existing = activeWrappers.get(terminal);
99
+ if (existing) {
100
+ existing.refs += 1;
101
+ return refcountedUninstall(existing);
102
+ }
103
+
104
+ const original = {
105
+ write: terminal.write.bind(terminal),
106
+ hideCursor: terminal.hideCursor.bind(terminal),
107
+ showCursor: terminal.showCursor.bind(terminal),
108
+ };
109
+ /** Frame write ending in SYNC_END, accumulating park/hide merges. */
110
+ let held = null;
111
+ let flushScheduled = false;
112
+
113
+ const flush = () => {
114
+ if (held === null) return;
115
+ const out = held;
116
+ held = null;
117
+ original.write(out);
118
+ };
119
+
120
+ const scheduleFlush = () => {
121
+ if (flushScheduled) return;
122
+ flushScheduled = true;
123
+ process.nextTick(() => {
124
+ flushScheduled = false;
125
+ flush();
126
+ });
127
+ };
128
+
129
+ /** Fold a park/hide seq into the held frame, or emit it standalone. */
130
+ const mergeOrEmit = (seq, directEmit) => {
131
+ if (held !== null) {
132
+ held = mergeIntoSyncBlock(held, seq);
133
+ return;
134
+ }
135
+ directEmit();
136
+ };
137
+
138
+ terminal.write = (data) => {
139
+ if (typeof data !== "string" || data.length === 0) {
140
+ flush();
141
+ original.write(data);
142
+ return;
143
+ }
144
+ if (held !== null) {
145
+ // Only a pure park/hide burst may join the held frame; anything
146
+ // else means this isn't a doRender park tail - flush unchanged.
147
+ if (isPureCursorParking(data)) {
148
+ held = mergeIntoSyncBlock(held, data);
149
+ return;
150
+ }
151
+ flush();
152
+ }
153
+ if (endsWithSyncEnd(data)) {
154
+ held = data;
155
+ scheduleFlush();
156
+ return;
157
+ }
158
+ original.write(data);
159
+ };
160
+
161
+ terminal.hideCursor = () => mergeOrEmit(ESC + "[?25l", original.hideCursor);
162
+ terminal.showCursor = () => mergeOrEmit(ESC + "[?25h", original.showCursor);
163
+
164
+ const entry = { refs: 1, teardown: null };
165
+ entry.teardown = () => {
166
+ flush();
167
+ terminal.write = original.write;
168
+ terminal.hideCursor = original.hideCursor;
169
+ terminal.showCursor = original.showCursor;
170
+ activeWrappers.delete(terminal);
171
+ };
172
+
173
+ activeWrappers.set(terminal, entry);
174
+ return refcountedUninstall(entry);
175
+ }
176
+
177
+ /**
178
+ * Install the coalescer on a TUI's terminal. Never throws: on any surprise
179
+ * (shape change upstream, kill switch) it returns null and behavior stays
180
+ * exactly as today.
181
+ */
182
+ export function installImeCursorCoalesce(tui) {
183
+ if (process.env.AGENT_BOARD_IME_FIX === "0") return null;
184
+ try {
185
+ const terminal = tui && tui.terminal;
186
+ if (!terminal || typeof terminal.write !== "function" || typeof terminal.hideCursor !== "function" || typeof terminal.showCursor !== "function") {
187
+ return null;
188
+ }
189
+ return wrapTerminalWrites(terminal);
190
+ } catch {
191
+ return null;
192
+ }
193
+ }