@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",
@@ -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 === "ask_questions") {
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 === "ask_questions") removePendingQuestion(status, event.toolCallId);
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 jiggle-retry chain.
2
+ * Injectable orchestration for the attach shrink-and-hold jiggle protocol.
3
3
  *
4
- * Owns the retry state machine, backoff timers, cross-chunk scanning, and the
5
- * one-shot re-arm on the child TUI's first frame (\x1b[?2026h). The attach
6
- * component (and the cold-start E2E) drive it through injected callbacks, so
7
- * the whole choreography is testable without a TUI or socket.
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 design (issue #10): the chain starts at socket connect, but a cold
10
- * child pi-tui installs its SIGWINCH listener only ~5s in every early jiggle
11
- * is lost. When the first TUI frame arrives we re-arm once with a fresh budget,
12
- * so the next jiggle lands on a live TUI and its fullRender emits \x1b[2J,
13
- * which stops the chain. If pi-tui ever drops the 2026h sequence, this degrades
14
- * to the plain connect-time chain (still better than the old one-shot).
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} sendJiggle - Fire one resize jiggle at the child.
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 { sendJiggle, setTimeoutFn, clearTimeoutFn, shouldFire } = deps;
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 timer = null;
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 clearTimer() {
44
- if (timer === null) return;
45
- clearTimeoutFn(timer);
46
- timer = null;
84
+ function clearAllTimers() {
85
+ clearChainTimer();
86
+ clearG1Timer();
47
87
  }
48
88
 
49
- function scheduleNext() {
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
- timer = setTimeoutFn(() => {
56
- timer = null;
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
- scheduleNext();
115
+ scheduleNextRetry();
64
116
  }, delay);
65
117
  }
66
118
 
67
- /** Reset everything (fresh connection) and schedule the first retry. */
68
- function start() {
69
- clearTimer();
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
- scheduleNext();
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. Clear detection wins over re-arm when both
78
- * sequences appear in one chunk (a hot attach's first frame is often the
79
- * fullRender we were waiting for). Re-arm fires at most once per start().
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
- clearTimer();
169
+ clearAllTimers();
170
+ restoreIfHeld();
89
171
  state = stopRetry({ ...state, clearDetected: true });
90
172
  return;
91
173
  }
92
- if (result.frameStartFound && !tuiFrameSeen && !state.clearDetected) {
174
+ if (result.frameStartFound && !tuiFrameSeen) {
93
175
  tuiFrameSeen = true;
94
- clearTimer();
95
- state = createJiggleRetryState();
96
- scheduleNext();
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
- /** Stop the chain (component closed, etc.). */
101
- function stop() {
102
- clearTimer();
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
- stop,
110
- getState: () => ({ ...state, tuiFrameSeen }),
221
+ restoreAndStop,
222
+ notifyExternalResize,
223
+ getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows }),
111
224
  };
112
225
  }