@zhuxixi/pi-agent-board 0.5.0 → 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/PROGRESS.md +18 -3
- package/README.md +298 -76
- package/VERIFY.md +3 -3
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -3
- package/docs/superpowers/plans/2026-08-30-circular-navigation.md +308 -0
- package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +311 -0
- package/docs/superpowers/plans/2026-08-30-readme-v2.md +294 -0
- 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-08-21-attach-coldstart-jiggle-rearm-design.md +4 -0
- package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +4 -0
- package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +47 -0
- package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +105 -0
- package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +115 -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 +64 -17
- package/src/core/code-refs-store.mjs +3 -0
- package/src/core/editor-state-reporter.mjs +102 -0
- package/src/core/launch.mjs +6 -0
- package/src/core/pty-attach-jiggle-controller.mjs +71 -17
- package/src/core/pty-input.mjs +32 -0
- package/src/core/pty-scroll.mjs +4 -3
- package/src/core/repo.mjs +3 -0
- package/src/core/worktree.mjs +1 -0
- package/src/index.ts +12 -1
- package/src/ui/dashboard.ts +6 -2
- package/src/ui/pty-attach.ts +148 -32
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# SPEC:#69 tier-2 字形兜底收紧 —— fallback 路径下 ← 恒可逃生
|
|
2
|
+
|
|
3
|
+
- Issue: zhuxixi/pi-agent-board#69
|
|
4
|
+
- 基线: main @ 9a61dd5(含 PR #71 editor_state 门禁)
|
|
5
|
+
- 状态: 已批准(方案 B,2026-09-03)
|
|
6
|
+
- 调研依据: `~/.claude/github-issue-driven/zhuxixi/pi-agent-board/issue-69/research/`(R1 代码影响面 / R2 KB / R3 PR 上下文),结论已评论回 issue
|
|
7
|
+
|
|
8
|
+
## 1. 背景与根因(已实锤,不再重复调试)
|
|
9
|
+
|
|
10
|
+
`←` detach 门禁三层结构(#71 后):
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
handleInput(←)
|
|
14
|
+
└─ connected? ── no ──→ 无条件逃生(#48,不动)
|
|
15
|
+
└─ yes → resolveEditorEmpty(editorEmpty, heuristic)
|
|
16
|
+
├─ editorEmpty ≠ null → 权威子进程状态(#71,不动)
|
|
17
|
+
└─ editorEmpty = null → 启发式 childInputLooksEmpty()
|
|
18
|
+
├─ tier-1: 反色假光标锚点(不动)
|
|
19
|
+
├─ tier-2: 字形兜底 ← ★ 本次唯一改动点 ★
|
|
20
|
+
└─ 兜底: return true 逃生
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**根因**:fallback 模式(子会话无 editor-state reporter:旧版 pi 无 `ctx.ui.getEditorText`、扩展未加载、socket 未建立)下,真实 attach buffer 常零反色 cell → tier-1 落空 → tier-2 自底向上扫到 `isProbablyPiInputLine` 命中的聊天区 markdown 表格行(`│ … │`)或引用行(`> …`)→ `isProbablyEmptyPiInputLine` 判非空 → return false → `←` 被转发给子进程,用户被困。已在 main(9a61dd5) 上以 smoke 场景 K 复现(零反色 + `│ Issue #778 │ open │` + `> quote` + editorEmpty=null → didDetach=false)。
|
|
24
|
+
|
|
25
|
+
## 2. 修复设计
|
|
26
|
+
|
|
27
|
+
### 2.1 tier-2 行为决策表(唯一行为变化面)
|
|
28
|
+
|
|
29
|
+
| tier-2 扫到的行(自底向上首个 glyph 行) | 现行为 | 新行为 | 理由 |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| 空 glyph 行(`> `、`› `、`┃ `) | true(detach) | true(不变) | 编辑器为空 |
|
|
32
|
+
| 内容 glyph 行(`│ table │`、`> quote`、真草稿 `> draft`) | **false(gated,#69 bug)** | **true(detach)** | fallback 下无法区分表格/引用/草稿;可退出性优先 |
|
|
33
|
+
| 无 glyph 行 / 扫不到 | true | true(不变) | 既有逃生 |
|
|
34
|
+
|
|
35
|
+
**收紧后 tier-2 语义恒为 true**(内容行跳过 + 扫不到逃生)——fallback 恒放行,这是有意取舍:草稿保护的可靠路径已由 #71 editor_state 承担;fallback 模式下误判代价不对称(误 detach = 意外退视图、草稿不丢、重新 attach 即回;误 gate = 用户被困)。与 #42/#48「视图必须始终可退出」哲学同向。
|
|
36
|
+
|
|
37
|
+
### 2.2 改动点(两案行为完全等价,纯代码形态选择)
|
|
38
|
+
|
|
39
|
+
**方案 B(推荐,与维护者在 #69 评论中已本地验证的实现一致)**:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// src/ui/pty-attach.ts childInputLooksEmpty() tier-2 循环
|
|
43
|
+
for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
|
|
44
|
+
const line = active.getLine(y)?.translateToString(true) ?? "";
|
|
45
|
+
// Only an EMPTY glyph line proves an empty editor. Content glyph lines
|
|
46
|
+
// (markdown table rows `│ … │`, quotes `> …`, or a real draft in a
|
|
47
|
+
// no-fake-cursor Pi variant) cannot be told apart, and trapping the
|
|
48
|
+
// user is worse than a spurious detach (issue #69) — skip and keep
|
|
49
|
+
// scanning; the loop-end escape stays authoritative.
|
|
50
|
+
if (isProbablyPiInputLine(line) && isProbablyEmptyPiInputLine(line)) return true;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- 保留扫描骨架:为中期方案(按 pi TUI dock 结构:底部 `─` 分隔线与 footer 间定位编辑器行,可恢复 fallback 草稿保护)留结构;注释固化取舍理由。
|
|
56
|
+
- `isProbablyPiInputLine` 继续有生产调用点,不需要删除。
|
|
57
|
+
|
|
58
|
+
**方案 A(备选)**:直接删循环 `return true` + 注释。语义/测试与 B 完全一致,diff 更小,但丢掉骨架与意图表达。
|
|
59
|
+
|
|
60
|
+
### 2.3 数据流与组件契约
|
|
61
|
+
|
|
62
|
+
- 无新组件、无协议消息、无公共 API 变化;`resolveEditorEmpty`、tier-1、editor_state 链路(reporter → runner broadcast → attach 缓存 → hello 重置)一概不动。
|
|
63
|
+
- 唯一触碰的生产文件:`src/ui/pty-attach.ts`(tier-2 循环体)。
|
|
64
|
+
- 触发窗口说明:收紧只影响 editorEmpty=null 的降级判定(reporter 缺席);reporter 活跃时 `resolveEditorEmpty` 短路,主路径零变化。
|
|
65
|
+
|
|
66
|
+
## 3. 验收矩阵
|
|
67
|
+
|
|
68
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
69
|
+
|----|--------|----------|----------|----------|
|
|
70
|
+
| A1 | #69 真实场景放行:零反色 + 聊天区 `│` 表格行/`>` 引用行 + editorEmpty=null + connected=true → `←` detach | 自动化验证(integration:detach-gate smoke,经 `test/pty-attach-detach-gate.test.mjs` 驱动) | `npm test`;新增 smoke key `leftDetachesOnTableRowsWithoutFakeCursor` | 新 key 断言 true,全量测试通过 |
|
|
71
|
+
| A2 | 有意回归被钉住:内容 glyph 行(真草稿形态 `> draft`)+ 零反色 + editorEmpty=null → `←` detach(fallback 草稿保护失效是有意取舍,防止未来被"顺手修")。**与既有 smoke B 互为对照**:同为草稿形态,B 带 inverse 走 tier-1 → gated,A2 零反色走 fallback → detach——两种判定不矛盾,正是本取舍的教科书示例(smoke 场景注释中须写明此对照) | 自动化验证(integration:同上) | 新增 smoke key `leftDetachesOnContentGlyphFallback` | 新 key 断言 true |
|
|
72
|
+
| A3 | 既有行为零回归:B(tier-1 草稿 gate)、J(hello null 重置)、空 glyph 行 detach、editor_state 系列(H/J 全部 key)、ctrl+] 透传、断线逃生等 | 自动化验证(integration + unit 全量) | `npm test`(基线 437;新 smoke key 是既有 test 块内的新断言,node:test 计数不变) | 全量 437 全绿,既有 key 结果不变 |
|
|
73
|
+
| A4 | 类型/静态约束 | 自动化验证(static/build) | `npm run typecheck` | 0 错误(CI Node 22/24 等价覆盖) |
|
|
74
|
+
| U1 | 真实仪表盘复测 #69 场景:attach 一个聊天区含 markdown 表格输出的 warm 会话,输入框空时按 `←` | 用户实测 | 在运行副本 `~/.pi/agent/git/github.com/zhuxixi/pi-agent-board` 里 `git fetch && git checkout <PR 分支>`(#67 实测已验证的本地加载法),重启 pi 后打开 dashboard → attach → 空输入按 `←`;测完 checkout 回 main 并重启 | 回到 dashboard,不被困(可执行时机:实现完成、PR 分支推送后、合并前) |
|
|
75
|
+
| U2 | 主路径草稿保护不受影响:reporter 活跃会话输入草稿后按 `←` | 用户实测 | 同 U1 环境,attach 后输入草稿,按 `←` | 光标在草稿内左移,不 detach(复测 #67 U3 同款;可执行时机同 U1) |
|
|
76
|
+
|
|
77
|
+
**用户实测不可自动化的原因(U1/U2)**:真实 dashboard attach 涉及宿主 pi TUI 差分渲染 + pty-runner + editor-state reporter socket 的全链路,headless harness 用内存 xterm 无法复现真实终端渲染与扩展加载,只能人测。注意:实测需重启 pi,当前开发会话所在的 pi 实例会中断——在另一个终端/pi 实例里执行。
|
|
78
|
+
|
|
79
|
+
## 4. 可测性拆分设计(自动化项)
|
|
80
|
+
|
|
81
|
+
- **纯函数层(不变)**:`isProbablyEmptyPiInputLine` / `isProbablyPiInputLine` / `resolveEditorEmpty` 已在 `test/pty-input.test.mjs` 有 unit 钉语义;本次不改其行为,不新增纯函数——收紧后 tier-2 决策恒为 true,为常量行为建纯函数无判别价值,强行抽取只会产生恒真断言的空转测试。
|
|
82
|
+
- **组件决策层(改动面)**:`childInputLooksEmpty()` 本身无副作用(只读 xterm buffer + 纯判定),判别性(detach vs forward)落在组件级 smoke harness(`test-support/detach-gate-smoke.ts`,经 `test/pty-attach-detach-gate.test.mjs` 以子进程运行并断言全部 key)——沿用 #42/#48/#66/#68 的既有测试边界,不引入新测试形态。
|
|
83
|
+
- **测试边界总结**:unit 钉 helper 语义(A3 覆盖)→ integration smoke 钉组件门禁决策(A1/A2/A3)→ static/typecheck 钉类型(A4)。A1/A2 ↔ smoke 新场景;A3 ↔ smoke 既有 key + unit 全量;A4 ↔ typecheck。双向可追溯。
|
|
84
|
+
- **smoke 惯例**:在线 gate 场景必须显式 pin `connected=true`(B 段既有惯例;不 pin 时断线逃生语义会抢跑,测不到在线门禁);新场景 K1(A1)/K2(A2)同样遵守。
|
|
85
|
+
|
|
86
|
+
## 5. 非目标
|
|
87
|
+
|
|
88
|
+
- ❌ 中期 dock 结构锚点(底部 `─` 分隔线与 footer 间定位编辑器行)——未来若要恢复 fallback 草稿保护再立项
|
|
89
|
+
- ❌ 触碰 tier-1 反色锚点、editor_state 链路(#71 成果)
|
|
90
|
+
- ❌ 删除渲染启发式整体(继续作为 #71 的 fallback 层存在)
|
|
91
|
+
- ❌ 方案 A 下的 `isProbablyPiInputLine` 去留不在本 spec 讨论(选 B 则无此问题)
|
|
92
|
+
- ❌ 不改 README:line 96/195 的 `←` 行为描述以受支持主路径(reporter 活跃)为准,fallback 降级细节属实现层,由 A2 钉住——此为明确决定,防止未来被当成文档漂移
|
|
93
|
+
|
|
94
|
+
## 6. 风险与降级
|
|
95
|
+
|
|
96
|
+
- 风险 1:fallback 下真草稿按 `←` 变为 detach(有意回归,A2 钉住;草稿不丢,重新 attach 即回)。
|
|
97
|
+
- 风险 2:`←` 在 attach 初建窗口(hello 尚未带回 editorEmpty、reporter 首推未达)若子进程恰有草稿,会 detach——窗口极短且后果同上,接受。
|
|
98
|
+
- 无运行时降级路径需求:本改动本身就是降级路径的加固;不合并时现状是「fallback 可被困」(更差)。
|
|
99
|
+
- **post-merge 生效**:运行副本 `~/.pi/agent/git/github.com/zhuxixi/pi-agent-board`(当前停在 0.5.1 / fac9e91,连 #66 都没有)需 `git pull`(或 `pi update`)并重启 pi 后才带本修复——人工部署步骤,不在自动化环内。
|
package/package.json
CHANGED
package/runner/pty-runner.mjs
CHANGED
|
@@ -60,12 +60,21 @@ function main() {
|
|
|
60
60
|
let childPid = null;
|
|
61
61
|
let child = null;
|
|
62
62
|
let exitCode = null;
|
|
63
|
-
let
|
|
63
|
+
let shutdownStarted = false;
|
|
64
|
+
let shutdownExitCode = null;
|
|
65
|
+
let childExited = false;
|
|
66
|
+
let resolveChildExit;
|
|
67
|
+
const childExitPromise = new Promise((resolve) => {
|
|
68
|
+
resolveChildExit = resolve;
|
|
69
|
+
});
|
|
64
70
|
// Set when the uncaughtException crash handler finalizes the host. Guards
|
|
65
71
|
// child.onExit against clobbering the persisted "failed" state with an
|
|
66
72
|
// "exited" update (the handler kills the child, so its exit callback fires
|
|
67
73
|
// inside the 50ms flush window — CR round-1, issue #48).
|
|
68
74
|
let crashed = false;
|
|
75
|
+
/** Authoritative child editor emptiness, pushed by the child Pi extension
|
|
76
|
+
* (issue #68). null = unknown (extension missing / not yet reported). */
|
|
77
|
+
let editorEmpty = null;
|
|
69
78
|
/** @type {import("../src/core/types.mjs").HostStatus} */
|
|
70
79
|
let host = {
|
|
71
80
|
version: 1,
|
|
@@ -182,25 +191,30 @@ function main() {
|
|
|
182
191
|
broadcast({ type: "output", data });
|
|
183
192
|
});
|
|
184
193
|
child.onExit((code) => {
|
|
194
|
+
childExited = true;
|
|
195
|
+
resolveChildExit?.();
|
|
185
196
|
exitCode = code ?? 0;
|
|
186
197
|
// After a crash the handler already persisted "failed" and broadcast
|
|
187
198
|
// exit; this callback must not overwrite that state.
|
|
188
199
|
if (!crashed) {
|
|
189
|
-
update({ state:
|
|
200
|
+
update({ state: "exited", endedAt: Date.now(), exitCode, childPid: null });
|
|
201
|
+
editorEmpty = null;
|
|
202
|
+
broadcast({ type: "editor_state", empty: null });
|
|
190
203
|
broadcast({ type: "exit", exitCode });
|
|
191
204
|
}
|
|
192
|
-
setTimeout(() => process.exit(exitCode ?? 0), 50).unref?.();
|
|
205
|
+
if (!shutdownStarted) setTimeout(() => process.exit(exitCode ?? 0), 50).unref?.();
|
|
193
206
|
});
|
|
194
207
|
child.onError((err) => {
|
|
195
208
|
update({ state: "failed", endedAt: Date.now(), exitCode: 1, error: err instanceof Error ? err.message : String(err) });
|
|
196
209
|
broadcast({ type: "error", message: host.error || "child error" });
|
|
197
|
-
|
|
210
|
+
void shutdown(1);
|
|
198
211
|
});
|
|
199
212
|
|
|
200
|
-
|
|
213
|
+
let server;
|
|
214
|
+
server = createServer((socket) => {
|
|
201
215
|
clients.add(socket);
|
|
202
216
|
update({ attachedEver: true });
|
|
203
|
-
socket.write(JSON.stringify({ type: "hello", status: host }) + "\n");
|
|
217
|
+
socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty }) + "\n");
|
|
204
218
|
let buffer = "";
|
|
205
219
|
socket.on("data", (chunk) => {
|
|
206
220
|
buffer += chunk.toString("utf8");
|
|
@@ -219,8 +233,7 @@ function main() {
|
|
|
219
233
|
});
|
|
220
234
|
server.on("error", (err) => {
|
|
221
235
|
update({ state: "failed", endedAt: Date.now(), error: err instanceof Error ? err.message : String(err), exitCode: 1 });
|
|
222
|
-
|
|
223
|
-
process.exit(1);
|
|
236
|
+
void shutdown(1);
|
|
224
237
|
});
|
|
225
238
|
server.listen(socketPath, () => update({ socketPath, state: "alive" }));
|
|
226
239
|
|
|
@@ -230,7 +243,7 @@ function main() {
|
|
|
230
243
|
try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
|
|
231
244
|
switch (msg.type) {
|
|
232
245
|
case "hello":
|
|
233
|
-
send(socket, { type: "hello", status: host });
|
|
246
|
+
send(socket, { type: "hello", status: host, editorEmpty });
|
|
234
247
|
break;
|
|
235
248
|
case "input":
|
|
236
249
|
if (typeof msg.data === "string") child.write(msg.data);
|
|
@@ -246,7 +259,6 @@ function main() {
|
|
|
246
259
|
child.write("\x1b");
|
|
247
260
|
break;
|
|
248
261
|
case "terminate": {
|
|
249
|
-
stopping = true;
|
|
250
262
|
killChild(child, childPid, "SIGTERM");
|
|
251
263
|
setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
|
|
252
264
|
break;
|
|
@@ -257,6 +269,11 @@ function main() {
|
|
|
257
269
|
case "get_status":
|
|
258
270
|
send(socket, { type: "status", status: host });
|
|
259
271
|
break;
|
|
272
|
+
case "editor_state": {
|
|
273
|
+
editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
274
|
+
broadcast({ type: "editor_state", empty: editorEmpty });
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
260
277
|
}
|
|
261
278
|
}
|
|
262
279
|
|
|
@@ -265,15 +282,45 @@ function main() {
|
|
|
265
282
|
}, HEARTBEAT_MS);
|
|
266
283
|
heartbeat.unref?.();
|
|
267
284
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
285
|
+
function waitForChildExit(timeoutMs) {
|
|
286
|
+
if (childExited) return Promise.resolve(true);
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
let settled = false;
|
|
289
|
+
let timer;
|
|
290
|
+
const finish = (exited) => {
|
|
291
|
+
if (settled) return;
|
|
292
|
+
settled = true;
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
resolve(exited);
|
|
295
|
+
};
|
|
296
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
297
|
+
childExitPromise.then(() => finish(true));
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function shutdown(requestedExitCode = null) {
|
|
302
|
+
if (requestedExitCode !== null) shutdownExitCode = requestedExitCode;
|
|
303
|
+
if (shutdownStarted) return;
|
|
304
|
+
shutdownStarted = true;
|
|
305
|
+
try { server?.close(); } catch {}
|
|
271
306
|
try { if (existsSync(socketPath)) unlinkSync(socketPath); } catch {}
|
|
307
|
+
for (const client of clients) {
|
|
308
|
+
try { client.end(); } catch {}
|
|
309
|
+
}
|
|
272
310
|
killChild(child, childPid, "SIGTERM");
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
311
|
+
if (!(await waitForChildExit(4000)) && !childExited) {
|
|
312
|
+
killChild(child, childPid, "SIGKILL");
|
|
313
|
+
if (!(await waitForChildExit(1000)) && !childExited) {
|
|
314
|
+
// The child abstraction has no portable liveness probe. Exit only
|
|
315
|
+
// after the escalation window so normal children are always awaited;
|
|
316
|
+
// an unkillable platform child is left to the OS.
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
process.exit(shutdownExitCode ?? exitCode ?? 0);
|
|
321
|
+
}
|
|
322
|
+
process.on("SIGTERM", () => { void shutdown(); });
|
|
323
|
+
process.on("SIGINT", () => { void shutdown(); });
|
|
277
324
|
}
|
|
278
325
|
|
|
279
326
|
function spawnInteractive(command, args, opts) {
|
|
@@ -141,6 +141,9 @@ function currentBranch(cwd) {
|
|
|
141
141
|
encoding: "utf8",
|
|
142
142
|
stdio: ["ignore", "pipe", "ignore"],
|
|
143
143
|
timeout: 2000,
|
|
144
|
+
// Windows: no console window when spawned from a console-less worker
|
|
145
|
+
// (issue #49 follow-up — these git spawns created visible WT windows).
|
|
146
|
+
windowsHide: true,
|
|
144
147
|
});
|
|
145
148
|
branch = out.trim() || null;
|
|
146
149
|
} catch {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Child-Pi editor-state reporter (issue #68): polls the child Pi's editor text
|
|
2
|
+
* and pushes `{type:"editor_state", empty}` over the control socket whenever the
|
|
3
|
+
* text changes, so the attach surface can gate ← on the authoritative state
|
|
4
|
+
* instead of render heuristics. Dependency-injected for unit testing. */
|
|
5
|
+
|
|
6
|
+
/** @typedef {{ write(jsonLine: string): void; on?(event: "close" | "error", fn: () => void): void }} SocketLike */
|
|
7
|
+
|
|
8
|
+
const defaultScheduler = {
|
|
9
|
+
interval(fn, ms) { const h = setInterval(fn, ms); h.unref?.(); return h; },
|
|
10
|
+
timeout(fn, ms) { const h = setTimeout(fn, ms); h.unref?.(); return h; },
|
|
11
|
+
clear(handle) { clearInterval(handle); clearTimeout(handle); },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function createEditorStateReporter({ getEditorText, connect, intervalMs = 100, scheduler = defaultScheduler }) {
|
|
15
|
+
let started = false;
|
|
16
|
+
let stopped = false;
|
|
17
|
+
let socket = null;
|
|
18
|
+
let pollTimer = null;
|
|
19
|
+
let reconnectTimer = null;
|
|
20
|
+
let backoffMs = 1000;
|
|
21
|
+
let lastText = null;
|
|
22
|
+
|
|
23
|
+
function send(json) {
|
|
24
|
+
if (!socket) return;
|
|
25
|
+
try {
|
|
26
|
+
socket.write(JSON.stringify(json) + "\n");
|
|
27
|
+
} catch {
|
|
28
|
+
teardownSocket();
|
|
29
|
+
scheduleReconnect();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function teardownSocket() {
|
|
34
|
+
if (pollTimer !== null) { scheduler.clear(pollTimer); pollTimer = null; }
|
|
35
|
+
const s = socket;
|
|
36
|
+
socket = null;
|
|
37
|
+
try { s?.destroy?.(); } catch { /* already torn down */ }
|
|
38
|
+
try { s?.removeAllListeners?.(); } catch { /* already torn down */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function poll() {
|
|
42
|
+
if (stopped || !socket) return;
|
|
43
|
+
let text;
|
|
44
|
+
try {
|
|
45
|
+
text = getEditorText();
|
|
46
|
+
} catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
text = typeof text === "string" ? text : "";
|
|
50
|
+
if (text !== lastText) {
|
|
51
|
+
lastText = text;
|
|
52
|
+
send({ type: "editor_state", empty: text.length === 0 });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function startPolling() {
|
|
57
|
+
if (pollTimer !== null) return;
|
|
58
|
+
lastText = null; // force a first report after (re)connect
|
|
59
|
+
pollTimer = scheduler.interval(poll, intervalMs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scheduleReconnect() {
|
|
63
|
+
if (stopped) return;
|
|
64
|
+
teardownSocket();
|
|
65
|
+
reconnectTimer = scheduler.timeout(tryConnect, backoffMs);
|
|
66
|
+
backoffMs = Math.min(backoffMs * 2, 5000);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tryConnect() {
|
|
70
|
+
reconnectTimer = null;
|
|
71
|
+
if (stopped) return;
|
|
72
|
+
let s;
|
|
73
|
+
try {
|
|
74
|
+
s = connect();
|
|
75
|
+
} catch {
|
|
76
|
+
scheduleReconnect();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
socket = s;
|
|
80
|
+
s?.resume?.();
|
|
81
|
+
s?.unref?.();
|
|
82
|
+
s?.on?.("data", () => {}); // consume and discard broadcast traffic
|
|
83
|
+
s?.on?.("connect", () => { if (socket === s) { backoffMs = 1000; startPolling(); } });
|
|
84
|
+
s?.on?.("close", () => { if (socket === s) scheduleReconnect(); });
|
|
85
|
+
s?.on?.("error", () => { if (socket === s) scheduleReconnect(); });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function start() {
|
|
89
|
+
if (started) return;
|
|
90
|
+
started = true;
|
|
91
|
+
stopped = false;
|
|
92
|
+
tryConnect();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stop() {
|
|
96
|
+
stopped = true;
|
|
97
|
+
if (reconnectTimer !== null) { scheduler.clear(reconnectTimer); reconnectTimer = null; }
|
|
98
|
+
teardownSocket();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { start, stop };
|
|
102
|
+
}
|
package/src/core/launch.mjs
CHANGED
|
@@ -33,6 +33,9 @@ export function launchRun(root, config, opts) {
|
|
|
33
33
|
detached: true,
|
|
34
34
|
stdio: "ignore",
|
|
35
35
|
env: process.env,
|
|
36
|
+
// Windows: detached children get their own console window unless
|
|
37
|
+
// suppressed (CREATE_NO_WINDOW; no-op on POSIX) — issue #49.
|
|
38
|
+
windowsHide: true,
|
|
36
39
|
});
|
|
37
40
|
child.unref();
|
|
38
41
|
|
|
@@ -61,6 +64,7 @@ export function launchHost(root, config, opts) {
|
|
|
61
64
|
detached: true,
|
|
62
65
|
stdio: "ignore",
|
|
63
66
|
env: process.env,
|
|
67
|
+
windowsHide: true,
|
|
64
68
|
});
|
|
65
69
|
child.unref();
|
|
66
70
|
|
|
@@ -85,6 +89,7 @@ export function launchTitle(root, config, opts) {
|
|
|
85
89
|
detached: true,
|
|
86
90
|
stdio: "ignore",
|
|
87
91
|
env: process.env,
|
|
92
|
+
windowsHide: true,
|
|
88
93
|
});
|
|
89
94
|
child.unref();
|
|
90
95
|
|
|
@@ -109,6 +114,7 @@ export function launchAutoState(root, config, opts) {
|
|
|
109
114
|
detached: true,
|
|
110
115
|
stdio: "ignore",
|
|
111
116
|
env: process.env,
|
|
117
|
+
windowsHide: true,
|
|
112
118
|
});
|
|
113
119
|
child.unref();
|
|
114
120
|
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
* Injectable orchestration for the attach shrink-and-hold jiggle protocol.
|
|
3
3
|
*
|
|
4
4
|
* Replaces the pulse-pair jiggle (shrink → 200ms → restore) with a
|
|
5
|
-
* shrink-and-hold protocol: on connect we resize the child PTY
|
|
6
|
-
*
|
|
7
|
-
* observed the
|
|
8
|
-
* fullRender(true) whenever widthChanged fires).
|
|
5
|
+
* shrink-and-hold protocol: on connect we resize the child PTY to a safe
|
|
6
|
+
* alternate size and KEEP it there until the child's own rendering proves it
|
|
7
|
+
* observed the size change (a full clear, \x1b[2J, which pi-tui emits from
|
|
8
|
+
* fullRender(true) whenever widthChanged fires). At normal sizes the alternate
|
|
9
|
+
* size is one column/row smaller; near the minimum it changes only a safe
|
|
10
|
+
* dimension, and at 20x5 no jiggle is attempted. Because there is no
|
|
9
11
|
* "restore" that can cancel the shrink before a clear is seen, event-loop
|
|
10
12
|
* coalescing on either side (outer dashboard timers or the child's
|
|
11
13
|
* SIGWINCH/render throttle) can no longer collapse a jiggle into a
|
|
@@ -27,10 +29,19 @@
|
|
|
27
29
|
* G4 notifyExternalResize() — a real user resize cancels the hold and
|
|
28
30
|
* adopts the new size.
|
|
29
31
|
* G5 start() restores any previous hold before arming a new one (reconnect).
|
|
32
|
+
* G6 post-restore verify (issue #42): the frameStart fast path restores the
|
|
33
|
+
* hold on the FIRST frame, which may predate the shrink — if the restore
|
|
34
|
+
* then collapses both resizes into a net-zero change at the child, the
|
|
35
|
+
* child renders nothing and never clears. While a frame has been seen
|
|
36
|
+
* and no clear followed, each backoff tick re-arms the shrink (which
|
|
37
|
+
* lands alone and cannot collapse), bounded by the same retry budget.
|
|
30
38
|
*
|
|
31
39
|
* If pi-tui ever drops the 2026h sequence, re-arm degrades to G1: the PTY
|
|
32
|
-
* is restored within 6s — still better than the pre-#10 behavior.
|
|
40
|
+
* is restored within 6s — still better than the pre-#10 behavior. At the
|
|
41
|
+
* minimum supported PTY size (20x5), jiggle is disabled because the runner's
|
|
42
|
+
* clamp leaves no valid alternate size.
|
|
33
43
|
*/
|
|
44
|
+
import { resizeJiggleSize } from "./pty-scroll.mjs";
|
|
34
45
|
import {
|
|
35
46
|
advanceRetry,
|
|
36
47
|
createJiggleRetryState,
|
|
@@ -41,6 +52,13 @@ import {
|
|
|
41
52
|
|
|
42
53
|
/** Restore the held (shrunk) child PTY when no TUI frame arrives this long. */
|
|
43
54
|
const NO_FRAME_RESTORE_MS = 6000;
|
|
55
|
+
/**
|
|
56
|
+
* Grace window after the frameStart fast-path restore before the chain may
|
|
57
|
+
* re-poke (issue #42). A healthy child proves the restore landed with a
|
|
58
|
+
* fullRender clear within a few hundred ms even on large screens; without the
|
|
59
|
+
* grace a slow-but-healthy clear would trigger a pointless re-shrink flicker.
|
|
60
|
+
*/
|
|
61
|
+
const POST_RESTORE_VERIFY_MS = 900;
|
|
44
62
|
|
|
45
63
|
/**
|
|
46
64
|
* @typedef {Object} JiggleRetryControllerDeps
|
|
@@ -64,6 +82,8 @@ export function createJiggleRetryController(deps) {
|
|
|
64
82
|
/** @type {[number, number]} */
|
|
65
83
|
let originalCols = 0;
|
|
66
84
|
let originalRows = 0;
|
|
85
|
+
/** The safe alternate size used while a jiggle hold is active, or null when unsupported. */
|
|
86
|
+
let holdSize = null;
|
|
67
87
|
/** @type {unknown | null} */
|
|
68
88
|
let chainTimer = null;
|
|
69
89
|
/** @type {unknown | null} */
|
|
@@ -98,10 +118,31 @@ export function createJiggleRetryController(deps) {
|
|
|
98
118
|
}
|
|
99
119
|
|
|
100
120
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
121
|
+
* One chain tick: advance the backoff budget, then re-arm the hold if the
|
|
122
|
+
* child has a renderer (a TUI frame was seen) but no clear ever proved the
|
|
123
|
+
* last size delta landed and the hold is currently inactive (issue #42:
|
|
124
|
+
* the frameStart fast-path restore can race the shrink into a net-zero
|
|
125
|
+
* size change at the child — its throttled renderer reads back the
|
|
126
|
+
* original size and stays silent forever — so a clear-less restore must
|
|
127
|
+
* be followed by a fresh shrink, which lands alone and cannot collapse).
|
|
128
|
+
* Children that never rendered a frame (shells, cold boots) keep the old
|
|
129
|
+
* pure-countdown behavior and are handled by G1/G2 only.
|
|
104
130
|
*/
|
|
131
|
+
function tickChain() {
|
|
132
|
+
chainTimer = null;
|
|
133
|
+
state = advanceRetry(state);
|
|
134
|
+
ensureHold();
|
|
135
|
+
if (state.clearDetected || state.stopped) return;
|
|
136
|
+
scheduleNextRetry();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ensureHold() {
|
|
140
|
+
if (!tuiFrameSeen || held || state.clearDetected || state.stopped || !holdSize) return;
|
|
141
|
+
sendResize(holdSize.cols, holdSize.rows);
|
|
142
|
+
held = true;
|
|
143
|
+
restored = false;
|
|
144
|
+
}
|
|
145
|
+
|
|
105
146
|
function scheduleNextRetry() {
|
|
106
147
|
const delay = nextRetryDelay(state);
|
|
107
148
|
if (delay === null) {
|
|
@@ -109,11 +150,7 @@ export function createJiggleRetryController(deps) {
|
|
|
109
150
|
restoreIfHeld(); // G2
|
|
110
151
|
return;
|
|
111
152
|
}
|
|
112
|
-
chainTimer = setTimeoutFn(
|
|
113
|
-
chainTimer = null;
|
|
114
|
-
state = advanceRetry(state);
|
|
115
|
-
scheduleNextRetry();
|
|
116
|
-
}, delay);
|
|
153
|
+
chainTimer = setTimeoutFn(tickChain, delay);
|
|
117
154
|
}
|
|
118
155
|
|
|
119
156
|
function armG1() {
|
|
@@ -142,8 +179,18 @@ export function createJiggleRetryController(deps) {
|
|
|
142
179
|
tuiFrameSeen = false;
|
|
143
180
|
originalCols = cols;
|
|
144
181
|
originalRows = rows;
|
|
182
|
+
holdSize = resizeJiggleSize(cols, rows);
|
|
145
183
|
sendResize(cols, rows);
|
|
146
|
-
|
|
184
|
+
if (!holdSize) {
|
|
185
|
+
// The runner clamps below 20x5, so a smaller request would be a
|
|
186
|
+
// net-zero resize and cannot trigger a redraw. Leave the child at the
|
|
187
|
+
// requested original size and disable this attach's jiggle chain.
|
|
188
|
+
state = stopRetry(state);
|
|
189
|
+
held = false;
|
|
190
|
+
restored = true;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
sendResize(holdSize.cols, holdSize.rows);
|
|
147
194
|
held = true;
|
|
148
195
|
restored = false;
|
|
149
196
|
armG1();
|
|
@@ -176,14 +223,20 @@ export function createJiggleRetryController(deps) {
|
|
|
176
223
|
clearG1Timer();
|
|
177
224
|
if (held) {
|
|
178
225
|
restoreIfHeld(); // fast path: child is rendering, width delta now lands
|
|
226
|
+
// …unless that frame was in flight BEFORE the shrink and the restore
|
|
227
|
+
// collapses both into a net-zero change at the child (issue #42).
|
|
228
|
+
// Restart the chain on the post-restore grace window: no clear within
|
|
229
|
+
// POST_RESTORE_VERIFY_MS → tickChain re-arms the hold (ensureHold).
|
|
230
|
+
clearChainTimer();
|
|
231
|
+
chainTimer = setTimeoutFn(tickChain, POST_RESTORE_VERIFY_MS);
|
|
179
232
|
} else {
|
|
180
233
|
// Slow boot: G1 released the hold before the TUI came up, so the
|
|
181
234
|
// child baselined at the original size. Re-arm a fresh hold — its
|
|
182
235
|
// next frame then sees a width delta and fullRenders. Guards: the
|
|
183
236
|
// clear path (primary) and G2 budget exhaustion (chain still
|
|
184
237
|
// ticking). G1 is NOT re-armed: frames are now flowing.
|
|
185
|
-
sendResize(
|
|
186
|
-
held =
|
|
238
|
+
if (holdSize) sendResize(holdSize.cols, holdSize.rows);
|
|
239
|
+
held = holdSize !== null;
|
|
187
240
|
restored = false;
|
|
188
241
|
}
|
|
189
242
|
}
|
|
@@ -212,6 +265,7 @@ export function createJiggleRetryController(deps) {
|
|
|
212
265
|
restored = false;
|
|
213
266
|
originalCols = cols;
|
|
214
267
|
originalRows = rows;
|
|
268
|
+
holdSize = null;
|
|
215
269
|
state = stopRetry(state);
|
|
216
270
|
}
|
|
217
271
|
|
|
@@ -220,6 +274,6 @@ export function createJiggleRetryController(deps) {
|
|
|
220
274
|
feed,
|
|
221
275
|
restoreAndStop,
|
|
222
276
|
notifyExternalResize,
|
|
223
|
-
getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows }),
|
|
277
|
+
getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows, holdSize }),
|
|
224
278
|
};
|
|
225
279
|
}
|
package/src/core/pty-input.mjs
CHANGED
|
@@ -13,3 +13,35 @@ export function isProbablyEmptyPiInputLine(line) {
|
|
|
13
13
|
const content = withoutRightPadding.replace(/^[\s\u00a0›>┃│|┆╎╏:]+/u, "");
|
|
14
14
|
return content.length === 0;
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
/** Glyphs Pi uses to render editor prompt / continuation lines (`>` main prompt,
|
|
18
|
+
* `›`/`┃`/`│` and variants in older releases). Must stay in sync with the
|
|
19
|
+
* trim charset of isProbablyEmptyPiInputLine below. */
|
|
20
|
+
const PROMPT_GLYPHS = "›>┃│|┆╎╏:";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether the given terminal line looks like a Pi editor input line: leading
|
|
24
|
+
* whitespace followed by a prompt/continuation glyph. The attach surface uses
|
|
25
|
+
* this to locate the editor line inside the buffer instead of trusting the
|
|
26
|
+
* terminal cursor, which wanders onto output/working lines while Pi streams
|
|
27
|
+
* (issue #66).
|
|
28
|
+
* @param {string} line
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
export function isProbablyPiInputLine(line) {
|
|
32
|
+
const withoutLeftPadding = String(line || "").replace(/^[\s\u00a0]+/u, "");
|
|
33
|
+
return withoutLeftPadding.length > 0 && PROMPT_GLYPHS.includes(withoutLeftPadding[0]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the ← detach gate's emptiness signal: when the child Pi pushes its
|
|
38
|
+
* authoritative editor state (boolean), it wins; when it is unknown (null/
|
|
39
|
+
* undefined — child extension missing or socket never connected), fall back
|
|
40
|
+
* to the render heuristic.
|
|
41
|
+
* @param {boolean | null | undefined} editorEmpty
|
|
42
|
+
* @param {boolean} heuristic
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function resolveEditorEmpty(editorEmpty, heuristic) {
|
|
46
|
+
return editorEmpty === null || editorEmpty === undefined ? heuristic : editorEmpty;
|
|
47
|
+
}
|
package/src/core/pty-scroll.mjs
CHANGED
|
@@ -141,9 +141,10 @@ export function selectionDragScrollLines(row, bodyHeight) {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
|
-
* Return a
|
|
145
|
-
*
|
|
146
|
-
*
|
|
144
|
+
* Return a safe temporary PTY size for an attach jiggle hold. The attach controller
|
|
145
|
+
* keeps the child at this size until a clear or guard restores the original size;
|
|
146
|
+
* near the minimum supported dimensions, only one safe dimension is changed, and
|
|
147
|
+
* at 20x5 there is no valid alternate size.
|
|
147
148
|
*/
|
|
148
149
|
export function resizeJiggleSize(cols, rows) {
|
|
149
150
|
const c = Math.max(1, Math.floor(cols));
|
package/src/core/repo.mjs
CHANGED
|
@@ -12,6 +12,7 @@ export function gitRepoRoot(cwd) {
|
|
|
12
12
|
const out = execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
|
|
13
13
|
encoding: "utf8",
|
|
14
14
|
stdio: ["ignore", "pipe", "ignore"],
|
|
15
|
+
windowsHide: true,
|
|
15
16
|
});
|
|
16
17
|
const root = out.trim();
|
|
17
18
|
return root || null;
|
|
@@ -40,6 +41,7 @@ export function isDirty(repoRoot) {
|
|
|
40
41
|
const out = execFileSync("git", ["-C", repoRoot, "status", "--porcelain"], {
|
|
41
42
|
encoding: "utf8",
|
|
42
43
|
stdio: ["ignore", "pipe", "ignore"],
|
|
44
|
+
windowsHide: true,
|
|
43
45
|
});
|
|
44
46
|
return out.trim().length > 0;
|
|
45
47
|
} catch {
|
|
@@ -70,6 +72,7 @@ export function gitRemoteUrl(repoRoot) {
|
|
|
70
72
|
encoding: "utf8",
|
|
71
73
|
stdio: ["ignore", "pipe", "ignore"],
|
|
72
74
|
timeout: 2000,
|
|
75
|
+
windowsHide: true,
|
|
73
76
|
});
|
|
74
77
|
url = out.trim() || null;
|
|
75
78
|
} catch {
|
package/src/core/worktree.mjs
CHANGED
|
@@ -15,6 +15,7 @@ function git(repoRoot, args) {
|
|
|
15
15
|
const stdout = execFileSync("git", ["-C", repoRoot, ...args], {
|
|
16
16
|
encoding: "utf8",
|
|
17
17
|
stdio: ["ignore", "pipe", "pipe"],
|
|
18
|
+
windowsHide: true,
|
|
18
19
|
});
|
|
19
20
|
return { ok: true, stdout: stdout.trim(), error: null };
|
|
20
21
|
} catch (err) {
|