@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 +1 -0
- package/docs/superpowers/plans/2026-08-22-jiggle-shrink-and-hold.md +337 -0
- package/docs/superpowers/plans/2026-08-22-question-tool-grouping.md +211 -0
- package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +107 -0
- package/docs/superpowers/specs/2026-08-22-question-tool-grouping-design.md +114 -0
- package/package.json +1 -1
- package/src/core/events.mjs +7 -3
- package/src/core/ime-cursor-coalesce.mjs +193 -0
- package/src/core/pty-attach-jiggle-controller.mjs +158 -45
- package/src/ui/pty-attach.ts +23 -55
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Spec: Recognize pi `question`/`questionnaire` tools as pending-question sources
|
|
2
|
+
|
|
3
|
+
- **Issue**: zhuxixi/pi-agent-board#26
|
|
4
|
+
- **Date**: 2026-08-22
|
|
5
|
+
- **Status**: draft — awaiting user confirmation before implementation
|
|
6
|
+
- **Type**: bug fix (event reducer + arg extraction), no data-model change
|
|
7
|
+
|
|
8
|
+
## 1. Problem
|
|
9
|
+
|
|
10
|
+
A pi session blocked on the built-in `question` (or `questionnaire`) tool shows a
|
|
11
|
+
"Question" summary but stays in the **RUNNING** group instead of **NEEDS ANSWER**.
|
|
12
|
+
|
|
13
|
+
Observed 2026-08-22 01:26 on view `view_e809b1a3a2` ("jfox moc密度PR审查与监控"):
|
|
14
|
+
row summary "Question", `semanticState=working`, `pendingQuestions=[]`.
|
|
15
|
+
|
|
16
|
+
## 2. Root cause
|
|
17
|
+
|
|
18
|
+
The reducer only recognizes ONE question-tool name — `ask_questions`
|
|
19
|
+
(`src/core/events.mjs` L83/L97). Pi's actual question tools are named
|
|
20
|
+
`question` and `questionnaire` (user extensions:
|
|
21
|
+
`~/.pi/agent/extensions/question.ts`, `questionnaire.ts`).
|
|
22
|
+
|
|
23
|
+
When `tool_execution_start` arrives with `toolName: "question"`:
|
|
24
|
+
1. Name check fails → `else if (pendingQuestions.length === 0)` branch runs →
|
|
25
|
+
`semanticState = "working"`, `currentTool.summary = toolSummary("question")` =
|
|
26
|
+
`capitalize("question")` = `"Question"` (heuristics.mjs default branch).
|
|
27
|
+
2. `preservePendingQuestion` finds no pending question → no-op.
|
|
28
|
+
3. Result: row grouped by `semanticState` = "working" → RUNNING, summary "Question".
|
|
29
|
+
|
|
30
|
+
Correction to issue body: the "design gap B" claim (pending questions don't affect
|
|
31
|
+
grouping) is **wrong** — `preservePendingQuestion` already sets
|
|
32
|
+
`semanticState = "needs_input"` when `pendingQuestions` is non-empty. The ONLY gap is
|
|
33
|
+
tool-name recognition (plus arg-shape extraction). See research/root-cause.md.
|
|
34
|
+
|
|
35
|
+
## 3. Design
|
|
36
|
+
|
|
37
|
+
All changes in `src/core/events.mjs` + tests. No store/schema/type changes.
|
|
38
|
+
|
|
39
|
+
### 3.1 Question-tool name set
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
const QUESTION_TOOL_NAMES = new Set(["ask_questions", "question", "questionnaire"]);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Replace both `name === "ask_questions"` checks (tool_execution_start L83,
|
|
46
|
+
tool_execution_end L97) with `QUESTION_TOOL_NAMES.has(name)` /
|
|
47
|
+
`QUESTION_TOOL_NAMES.has(event.toolName ?? "")`.
|
|
48
|
+
|
|
49
|
+
Rationale: static set matches the existing hardcoded-name precedent; all three names
|
|
50
|
+
are in the family of pi/agent question tools. A configurable list is deferred (4.2).
|
|
51
|
+
|
|
52
|
+
### 3.2 `questionFromArgs` — support pi arg shapes
|
|
53
|
+
|
|
54
|
+
Current: reads `args.questions[].question` (ask_questions shape).
|
|
55
|
+
|
|
56
|
+
Extend to:
|
|
57
|
+
|
|
58
|
+
| Tool | Args shape | Extract from |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `ask_questions` | `{ questions: [{ question }] }` | `item.question` |
|
|
61
|
+
| `question` (pi) | `{ question: string, options: [...] }` | `args.question` |
|
|
62
|
+
| `questionnaire` (pi) | `{ questions: [{ prompt, options }] }` | `item.prompt`, fallback `item.question` |
|
|
63
|
+
|
|
64
|
+
Priority in `questions[]` items: `item.question ?? item.prompt`. Keep the
|
|
65
|
+
"Answer the pending question" fallback.
|
|
66
|
+
|
|
67
|
+
### 3.3 Downstream flow (already correct — verify, don't change)
|
|
68
|
+
|
|
69
|
+
- `tool_execution_start` (interactive, question tool): `upsertPendingQuestion` →
|
|
70
|
+
`preservePendingQuestion` → `semanticState = "needs_input"`, `currentTool = null`,
|
|
71
|
+
`question = first.question`. Row moves to NEEDS ANSWER group; summary shows the
|
|
72
|
+
question text (deriveSummary: needs_input + question).
|
|
73
|
+
- `tool_execution_end` (interactive, question tool): `removePendingQuestion`,
|
|
74
|
+
`semanticState = "working"`, `question = null` — back to RUNNING after the answer.
|
|
75
|
+
- Non-interactive (detached worker) path: `opts.interactive` gate unchanged →
|
|
76
|
+
question tools keep the old generic behavior (no pending-question tracking).
|
|
77
|
+
|
|
78
|
+
### 3.4 Tests (`test/events.test.mjs`)
|
|
79
|
+
|
|
80
|
+
Mirror the existing `ask_questions` coverage (L109-160):
|
|
81
|
+
|
|
82
|
+
1. `tool_execution_start` with `toolName: "question"` (args `{ question: "Approve?" }`),
|
|
83
|
+
interactive → `pendingQuestions = [{ toolCallId, question: "Approve?" }]`,
|
|
84
|
+
`semanticState === "needs_input"`, `currentTool === null`, summary = question text.
|
|
85
|
+
2. Same for `toolName: "questionnaire"` with args `{ questions: [{ prompt: "Pick scope" }] }`
|
|
86
|
+
→ extracted question "Pick scope".
|
|
87
|
+
3. `tool_execution_end` with `toolName: "question"` → pendingQuestions empty,
|
|
88
|
+
`semanticState === "working"`.
|
|
89
|
+
4. Detached (no `interactive`) `question` start → unchanged legacy behavior
|
|
90
|
+
(`currentTool.name === "question"`, `pendingQuestions = []`).
|
|
91
|
+
|
|
92
|
+
## 4. Non-goals / deferred
|
|
93
|
+
|
|
94
|
+
1. No configurable question-tool list (env/config escape hatch). Add later only if a
|
|
95
|
+
harness with a different question-tool name appears. Keeping the set static avoids
|
|
96
|
+
new config surface.
|
|
97
|
+
2. No changes to `finalizeRun`, `projectViewState`, `deriveSummary`, or grouping logic.
|
|
98
|
+
3. Non-interactive path behavior unchanged.
|
|
99
|
+
4. No i18n / label changes.
|
|
100
|
+
|
|
101
|
+
## 5. Edge cases
|
|
102
|
+
|
|
103
|
+
- Two concurrent question tool calls: keyed by `toolCallId`; first pending question wins
|
|
104
|
+
display. Unchanged from `ask_questions` behavior.
|
|
105
|
+
- Non-TUI pi question tool returns an error result immediately → start/end pair clears
|
|
106
|
+
the pending question within the same cycle; transient needs_input is harmless.
|
|
107
|
+
- `agent_start` / `input` events already clear `pendingQuestions` in the foreground
|
|
108
|
+
path (service.mjs L524-535) — no interaction.
|
|
109
|
+
|
|
110
|
+
## 6. Verification
|
|
111
|
+
|
|
112
|
+
- `npm run verify` (project gate) with the new events tests.
|
|
113
|
+
- Manual: start a pi session under the board, have it call the `question` tool →
|
|
114
|
+
row shows the question text in NEEDS ANSWER group; answer it → row returns to RUNNING.
|
package/package.json
CHANGED
package/src/core/events.mjs
CHANGED
|
@@ -17,6 +17,9 @@ import { assistantText, detectNeedsInput, toolPath, toolSummary, truncate } from
|
|
|
17
17
|
|
|
18
18
|
const PREVIEW_MAX = 240;
|
|
19
19
|
|
|
20
|
+
/** Tool names whose interactive execution blocks on a user answer. */
|
|
21
|
+
const QUESTION_TOOL_NAMES = new Set(["ask_questions", "question", "questionnaire"]);
|
|
22
|
+
|
|
20
23
|
/**
|
|
21
24
|
* Build the initial status for a freshly-launched run.
|
|
22
25
|
* @param {RunConfig} config
|
|
@@ -80,7 +83,7 @@ export function reduceEvent(status, event, now, opts = {}) {
|
|
|
80
83
|
const name = event.toolName ?? event.args?.name ?? "tool";
|
|
81
84
|
const args = event.args ?? {};
|
|
82
85
|
status.toolCount += 1;
|
|
83
|
-
if (opts.interactive && name
|
|
86
|
+
if (opts.interactive && QUESTION_TOOL_NAMES.has(name)) {
|
|
84
87
|
upsertPendingQuestion(status, event.toolCallId, questionFromArgs(args));
|
|
85
88
|
} else if (pendingQuestions(status).length === 0) {
|
|
86
89
|
status.currentTool = { name, path: toolPath(args), summary: toolSummary(name, args) };
|
|
@@ -94,7 +97,7 @@ export function reduceEvent(status, event, now, opts = {}) {
|
|
|
94
97
|
}
|
|
95
98
|
case "tool_execution_end": {
|
|
96
99
|
if (event.isError) status.error = `Tool ${event.toolName ?? ""} failed`.trim();
|
|
97
|
-
if (opts.interactive && event.toolName
|
|
100
|
+
if (opts.interactive && QUESTION_TOOL_NAMES.has(event.toolName ?? "")) removePendingQuestion(status, event.toolCallId);
|
|
98
101
|
status.currentTool = null;
|
|
99
102
|
status.semanticState = "working";
|
|
100
103
|
status.question = null;
|
|
@@ -227,8 +230,9 @@ function pendingQuestions(status) {
|
|
|
227
230
|
}
|
|
228
231
|
|
|
229
232
|
function questionFromArgs(args) {
|
|
233
|
+
if (typeof args?.question === "string" && args.question.trim()) return args.question.trim();
|
|
230
234
|
for (const item of Array.isArray(args?.questions) ? args.questions : []) {
|
|
231
|
-
const question = String(item?.question ?? "").trim();
|
|
235
|
+
const question = String(item?.question ?? item?.prompt ?? "").trim();
|
|
232
236
|
if (question) return question;
|
|
233
237
|
}
|
|
234
238
|
return "Answer the pending question";
|
|
@@ -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
|
+
}
|
|
@@ -1,17 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Injectable orchestration for the attach
|
|
2
|
+
* Injectable orchestration for the attach shrink-and-hold jiggle protocol.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
4
|
+
* Replaces the pulse-pair jiggle (shrink → 200ms → restore) with a
|
|
5
|
+
* shrink-and-hold protocol: on connect we resize the child PTY down one
|
|
6
|
+
* column/row and KEEP it there until the child's own rendering proves it
|
|
7
|
+
* observed the width change (a full clear, \x1b[2J, which pi-tui emits from
|
|
8
|
+
* fullRender(true) whenever widthChanged fires). Because there is no
|
|
9
|
+
* "restore" that can cancel the shrink before a clear is seen, event-loop
|
|
10
|
+
* coalescing on either side (outer dashboard timers or the child's
|
|
11
|
+
* SIGWINCH/render throttle) can no longer collapse a jiggle into a
|
|
12
|
+
* net-zero size change.
|
|
8
13
|
*
|
|
9
|
-
* Cold-start
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* Cold-start (issue #25): if the child TUI starts rendering AFTER we shrink,
|
|
15
|
+
* its first frame baselines at the shrunk size and never clears. The
|
|
16
|
+
* re-arm — the first \x1b[?2026h frame — therefore restores the original
|
|
17
|
+
* size: the now-rendering child sees a width different from its baseline
|
|
18
|
+
* and fullRenders. Guards:
|
|
19
|
+
* G1 NO_FRAME_RESTORE_MS — no TUI frame within 6s → restore (no renderer
|
|
20
|
+
* to trigger; continuing to hold has no purpose). If the TUI boots even
|
|
21
|
+
* later, its first frame re-arms a fresh hold (F1 slow-boot probe), so
|
|
22
|
+
* healing still fires the moment the child actually starts rendering.
|
|
23
|
+
* G2 backoff budget exhausted without a clear → restore (non-pi children,
|
|
24
|
+
* dead sessions).
|
|
25
|
+
* G3 restoreAndStop() — component close/detach restores while the socket
|
|
26
|
+
* is still usable.
|
|
27
|
+
* G4 notifyExternalResize() — a real user resize cancels the hold and
|
|
28
|
+
* adopts the new size.
|
|
29
|
+
* G5 start() restores any previous hold before arming a new one (reconnect).
|
|
30
|
+
*
|
|
31
|
+
* 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.
|
|
15
33
|
*/
|
|
16
34
|
import {
|
|
17
35
|
advanceRetry,
|
|
@@ -21,92 +39,187 @@ import {
|
|
|
21
39
|
stopRetry,
|
|
22
40
|
} from "./pty-attach-jiggle-retry.mjs";
|
|
23
41
|
|
|
42
|
+
/** Restore the held (shrunk) child PTY when no TUI frame arrives this long. */
|
|
43
|
+
const NO_FRAME_RESTORE_MS = 6000;
|
|
44
|
+
|
|
24
45
|
/**
|
|
25
46
|
* @typedef {Object} JiggleRetryControllerDeps
|
|
26
|
-
* @property {() => void}
|
|
47
|
+
* @property {(cols: number, rows: number) => void} sendResize - Resize the child PTY.
|
|
27
48
|
* @property {(fn: () => void, ms: number) => unknown} setTimeoutFn - Timer factory.
|
|
28
49
|
* @property {(timer: unknown) => void} clearTimeoutFn - Timer canceller.
|
|
29
|
-
* @property {() => boolean} [shouldFire] - Guard on retry fire; false stops the chain without firing.
|
|
30
50
|
*/
|
|
31
51
|
|
|
32
52
|
/**
|
|
33
53
|
* @param {JiggleRetryControllerDeps} deps
|
|
34
54
|
*/
|
|
35
55
|
export function createJiggleRetryController(deps) {
|
|
36
|
-
const {
|
|
56
|
+
const { sendResize, setTimeoutFn, clearTimeoutFn } = deps;
|
|
37
57
|
let state = createJiggleRetryState();
|
|
38
58
|
let carry = "";
|
|
39
59
|
let tuiFrameSeen = false;
|
|
60
|
+
/** True while the child PTY is parked at the shrunk size. */
|
|
61
|
+
let held = false;
|
|
62
|
+
/** True once the original size has been sent back (restore is one-shot). */
|
|
63
|
+
let restored = false;
|
|
64
|
+
/** @type {[number, number]} */
|
|
65
|
+
let originalCols = 0;
|
|
66
|
+
let originalRows = 0;
|
|
40
67
|
/** @type {unknown | null} */
|
|
41
|
-
let
|
|
68
|
+
let chainTimer = null;
|
|
69
|
+
/** @type {unknown | null} */
|
|
70
|
+
let g1Timer = null;
|
|
71
|
+
|
|
72
|
+
function clearChainTimer() {
|
|
73
|
+
if (chainTimer === null) return;
|
|
74
|
+
clearTimeoutFn(chainTimer);
|
|
75
|
+
chainTimer = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function clearG1Timer() {
|
|
79
|
+
if (g1Timer === null) return;
|
|
80
|
+
clearTimeoutFn(g1Timer);
|
|
81
|
+
g1Timer = null;
|
|
82
|
+
}
|
|
42
83
|
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
timer = null;
|
|
84
|
+
function clearAllTimers() {
|
|
85
|
+
clearChainTimer();
|
|
86
|
+
clearG1Timer();
|
|
47
87
|
}
|
|
48
88
|
|
|
49
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Restore the child PTY to the original size exactly once. No-op while
|
|
91
|
+
* not held or after the restore already happened.
|
|
92
|
+
*/
|
|
93
|
+
function restoreIfHeld() {
|
|
94
|
+
if (!held || restored) return;
|
|
95
|
+
sendResize(originalCols, originalRows);
|
|
96
|
+
restored = true;
|
|
97
|
+
held = false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Backoff chain is a pure countdown under the hold protocol: firing a
|
|
102
|
+
* retry schedules the next backoff (no pulse is sent — the shrink is
|
|
103
|
+
* already held). When the budget runs out, G2 restores the size.
|
|
104
|
+
*/
|
|
105
|
+
function scheduleNextRetry() {
|
|
50
106
|
const delay = nextRetryDelay(state);
|
|
51
107
|
if (delay === null) {
|
|
52
108
|
state = stopRetry(state);
|
|
109
|
+
restoreIfHeld(); // G2
|
|
53
110
|
return;
|
|
54
111
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (shouldFire && !shouldFire()) {
|
|
58
|
-
state = stopRetry(state);
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
sendJiggle();
|
|
112
|
+
chainTimer = setTimeoutFn(() => {
|
|
113
|
+
chainTimer = null;
|
|
62
114
|
state = advanceRetry(state);
|
|
63
|
-
|
|
115
|
+
scheduleNextRetry();
|
|
64
116
|
}, delay);
|
|
65
117
|
}
|
|
66
118
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
119
|
+
function armG1() {
|
|
120
|
+
clearG1Timer();
|
|
121
|
+
g1Timer = setTimeoutFn(() => {
|
|
122
|
+
g1Timer = null;
|
|
123
|
+
if (!tuiFrameSeen) restoreIfHeld(); // G1
|
|
124
|
+
}, NO_FRAME_RESTORE_MS);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Arm a fresh hold for a (re)connected attach at the given PTY size.
|
|
129
|
+
* Restores any previous hold first (G5), then shrinks and holds.
|
|
130
|
+
* @param {number} cols
|
|
131
|
+
* @param {number} rows
|
|
132
|
+
*/
|
|
133
|
+
function start(cols, rows) {
|
|
134
|
+
clearAllTimers();
|
|
135
|
+
if (held) {
|
|
136
|
+
sendResize(originalCols, originalRows); // G5: unwind previous hold
|
|
137
|
+
restored = true;
|
|
138
|
+
held = false;
|
|
139
|
+
}
|
|
70
140
|
state = createJiggleRetryState();
|
|
71
141
|
carry = "";
|
|
72
142
|
tuiFrameSeen = false;
|
|
73
|
-
|
|
143
|
+
originalCols = cols;
|
|
144
|
+
originalRows = rows;
|
|
145
|
+
sendResize(cols, rows);
|
|
146
|
+
sendResize(cols - 1, rows - 1);
|
|
147
|
+
held = true;
|
|
148
|
+
restored = false;
|
|
149
|
+
armG1();
|
|
150
|
+
scheduleNextRetry();
|
|
74
151
|
}
|
|
75
152
|
|
|
76
153
|
/**
|
|
77
|
-
* Feed one socket output chunk.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
154
|
+
* Feed one socket output chunk. A clear wins over the re-arm when both
|
|
155
|
+
* appear in one chunk. The first TUI frame restores the held size (the
|
|
156
|
+
* child is now rendering and will fullRender on the width delta) and
|
|
157
|
+
* does NOT reschedule the chain; if G1 already released the hold before
|
|
158
|
+
* the TUI booted, the frame instead re-arms a fresh hold so the running
|
|
159
|
+
* child still sees a width delta (F1 slow-boot probe).
|
|
80
160
|
* @param {string} data
|
|
81
161
|
*/
|
|
82
162
|
function feed(data) {
|
|
83
163
|
if (state.clearDetected) return; // chain done; nothing left to detect
|
|
164
|
+
if (state.stopped) return; // chain ended (G2/G3/G4); output is inert
|
|
84
165
|
const result = feedOutput(state, data, carry);
|
|
85
166
|
state = result.state;
|
|
86
167
|
carry = result.carry;
|
|
87
168
|
if (result.clearFound) {
|
|
88
|
-
|
|
169
|
+
clearAllTimers();
|
|
170
|
+
restoreIfHeld();
|
|
89
171
|
state = stopRetry({ ...state, clearDetected: true });
|
|
90
172
|
return;
|
|
91
173
|
}
|
|
92
|
-
if (result.frameStartFound && !tuiFrameSeen
|
|
174
|
+
if (result.frameStartFound && !tuiFrameSeen) {
|
|
93
175
|
tuiFrameSeen = true;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
176
|
+
clearG1Timer();
|
|
177
|
+
if (held) {
|
|
178
|
+
restoreIfHeld(); // fast path: child is rendering, width delta now lands
|
|
179
|
+
} else {
|
|
180
|
+
// Slow boot: G1 released the hold before the TUI came up, so the
|
|
181
|
+
// child baselined at the original size. Re-arm a fresh hold — its
|
|
182
|
+
// next frame then sees a width delta and fullRenders. Guards: the
|
|
183
|
+
// clear path (primary) and G2 budget exhaustion (chain still
|
|
184
|
+
// ticking). G1 is NOT re-armed: frames are now flowing.
|
|
185
|
+
sendResize(originalCols - 1, originalRows - 1);
|
|
186
|
+
held = true;
|
|
187
|
+
restored = false;
|
|
188
|
+
}
|
|
97
189
|
}
|
|
98
190
|
}
|
|
99
191
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Restore the held size (if any) and stop all chain activity. Used by the
|
|
194
|
+
* component on close/detach while the socket is still usable (G3).
|
|
195
|
+
*/
|
|
196
|
+
function restoreAndStop() {
|
|
197
|
+
clearAllTimers();
|
|
198
|
+
restoreIfHeld();
|
|
199
|
+
state = stopRetry(state);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* A real user resize supersedes the hold protocol: cancel all timers,
|
|
204
|
+
* mark the hold void, adopt the new size as the new original, and stop
|
|
205
|
+
* the chain (G4).
|
|
206
|
+
* @param {number} cols
|
|
207
|
+
* @param {number} rows
|
|
208
|
+
*/
|
|
209
|
+
function notifyExternalResize(cols, rows) {
|
|
210
|
+
clearAllTimers();
|
|
211
|
+
held = false;
|
|
212
|
+
restored = false;
|
|
213
|
+
originalCols = cols;
|
|
214
|
+
originalRows = rows;
|
|
103
215
|
state = stopRetry(state);
|
|
104
216
|
}
|
|
105
217
|
|
|
106
218
|
return {
|
|
107
219
|
start,
|
|
108
220
|
feed,
|
|
109
|
-
|
|
110
|
-
|
|
221
|
+
restoreAndStop,
|
|
222
|
+
notifyExternalResize,
|
|
223
|
+
getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows }),
|
|
111
224
|
};
|
|
112
225
|
}
|