@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.
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * Tiny dependency-free synchronous file lock helpers for local agent-board artifacts.
3
3
  * Locks use atomic mkdir on a sibling .lock directory and are cleaned up in finally.
4
+ *
5
+ * Failure model (issue #33): acquisition failures are classified.
6
+ * - EEXIST (contention): wait in 20ms ticks until the stale window passes, then
7
+ * force-steal (bounded to MAX_STEAL_ATTEMPTS). This preserves the original
8
+ * wait/steal contract (see test/locks.test.mjs).
9
+ * - Anything else (deleted parent, read-only fs, permissions, disk full, ...):
10
+ * MAX_ENV_ATTEMPTS quick retries — each retry re-runs ensureDir so a parent
11
+ * deleted mid-acquisition self-heals — then throw LOCK_TIMEOUT. Never spin.
4
12
  */
5
13
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
14
  import * as path from "node:path";
@@ -8,22 +16,33 @@ import { ensureDir } from "./atomic.mjs";
8
16
  import * as P from "./paths.mjs";
9
17
 
10
18
  const DEFAULT_STALE_MS = 30_000;
19
+ /** Minimum contention window before a fresh lock can be force-stolen. */
20
+ const MIN_WINDOW_MS = 250;
21
+ const WAIT_TICK_MS = 20;
22
+ /** Max stale-lock steal attempts before giving up. */
23
+ const MAX_STEAL_ATTEMPTS = 2;
24
+ /** Max quick retries for environmental failures before giving up. */
25
+ const MAX_ENV_ATTEMPTS = 3;
26
+
27
+ export const defaultLocksFs = Object.freeze({ existsSync, mkdirSync, readFileSync, rmSync, writeFileSync });
11
28
 
12
29
  /**
13
30
  * @template T
14
31
  * @param {string} lockPath
15
32
  * @param {() => T} fn
16
- * @param {{ staleMs?: number }} [opts]
33
+ * @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
17
34
  * @returns {T}
18
35
  */
19
36
  export function withFileLockSync(lockPath, fn, opts = {}) {
20
- const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
21
- acquireLock(lockPath, staleMs);
37
+ const fs = opts.fs ?? defaultLocksFs;
38
+ acquireLock(lockPath, opts.staleMs ?? DEFAULT_STALE_MS, fs);
39
+ let result;
22
40
  try {
23
- return fn();
41
+ result = fn();
24
42
  } finally {
25
- releaseLock(lockPath);
43
+ releaseLock(lockPath, fs);
26
44
  }
45
+ return result;
27
46
  }
28
47
 
29
48
  /**
@@ -32,37 +51,74 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
32
51
  * @param {string} viewId
33
52
  * @param {string} name
34
53
  * @param {() => T} fn
35
- * @param {{ staleMs?: number }} [opts]
54
+ * @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
36
55
  * @returns {T}
37
56
  */
38
57
  export function withViewLockSync(root, viewId, name, fn, opts = {}) {
39
58
  return withFileLockSync(P.viewLockPath(root, viewId, name), fn, opts);
40
59
  }
41
60
 
42
- /** @param {string} lockPath @param {number} staleMs */
43
- function acquireLock(lockPath, staleMs) {
44
- ensureDir(path.dirname(lockPath));
45
- const started = Date.now();
46
- while (true) {
61
+ /** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
62
+ function acquireLock(lockPath, staleMs, fs) {
63
+ const deadline = Date.now() + Math.max(MIN_WINDOW_MS, staleMs);
64
+ let steals = 0;
65
+ let envAttempts = 0;
66
+ for (;;) {
67
+ let created = false;
47
68
  try {
48
- mkdirSync(lockPath);
49
- writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify({ pid: process.pid, at: Date.now() }), "utf8");
69
+ // Re-run every attempt: a parent deleted mid-acquisition self-heals here.
70
+ ensureDir(path.dirname(lockPath));
71
+ fs.mkdirSync(lockPath);
72
+ created = true;
73
+ fs.writeFileSync(
74
+ path.join(lockPath, "owner.json"),
75
+ JSON.stringify({ pid: process.pid, at: Date.now() }),
76
+ "utf8",
77
+ );
50
78
  return;
51
79
  } catch (err) {
52
- if (!isLockStale(lockPath, staleMs) && Date.now() - started < Math.max(250, staleMs)) {
53
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
80
+ if (err && err.code === "EEXIST") {
81
+ const expired = Date.now() >= deadline;
82
+ if (isLockStale(lockPath, staleMs, fs) || expired) {
83
+ if (steals >= MAX_STEAL_ATTEMPTS) {
84
+ throw lockError(lockPath, `stale lock could not be stolen after ${steals} attempts`);
85
+ }
86
+ steals += 1;
87
+ releaseLock(lockPath, fs);
88
+ continue;
89
+ }
90
+ sleep(WAIT_TICK_MS);
54
91
  continue;
55
92
  }
56
- releaseLock(lockPath);
93
+ // Environmental failure: bounded quick retries, then fail fast.
94
+ if (created) releaseLock(lockPath, fs);
95
+ envAttempts += 1;
96
+ if (envAttempts >= MAX_ENV_ATTEMPTS) {
97
+ const reason = (err && (err.code || err.message)) || "unknown error";
98
+ throw lockError(lockPath, `lock path unusable (${reason})`);
99
+ }
100
+ sleep(WAIT_TICK_MS);
57
101
  }
58
102
  }
59
103
  }
60
104
 
61
- /** @param {string} lockPath @param {number} staleMs */
62
- function isLockStale(lockPath, staleMs) {
105
+ /** @param {string} lockPath @param {string} reason */
106
+ function lockError(lockPath, reason) {
107
+ const err = new Error(`file lock unavailable: ${lockPath} (${reason})`);
108
+ err.code = "LOCK_TIMEOUT";
109
+ return err;
110
+ }
111
+
112
+ /** @param {number} ms */
113
+ function sleep(ms) {
114
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
115
+ }
116
+
117
+ /** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
118
+ function isLockStale(lockPath, staleMs, fs) {
63
119
  try {
64
- if (!existsSync(lockPath)) return false;
65
- const raw = readFileSync(path.join(lockPath, "owner.json"), "utf8");
120
+ if (!fs.existsSync(lockPath)) return false;
121
+ const raw = fs.readFileSync(path.join(lockPath, "owner.json"), "utf8");
66
122
  const owner = JSON.parse(raw);
67
123
  return Date.now() - Number(owner.at ?? 0) > staleMs;
68
124
  } catch {
@@ -70,10 +126,10 @@ function isLockStale(lockPath, staleMs) {
70
126
  }
71
127
  }
72
128
 
73
- /** @param {string} lockPath */
74
- function releaseLock(lockPath) {
129
+ /** @param {string} lockPath @param {typeof defaultLocksFs} fs */
130
+ function releaseLock(lockPath, fs) {
75
131
  try {
76
- rmSync(lockPath, { recursive: true, force: true });
132
+ fs.rmSync(lockPath, { recursive: true, force: true });
77
133
  } catch {
78
134
  /* best effort */
79
135
  }
@@ -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
  }
@@ -8,6 +8,7 @@ import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@
8
8
  import { isProbablyEmptyPiInputLine } from "../core/pty-input.mjs";
9
9
  import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
10
10
  import { createAttachOutputRenderScheduler, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
11
+ import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs";
11
12
  import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
12
13
  import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs";
13
14
 
@@ -47,11 +48,6 @@ const OSC52_MAX_BYTES = 1_000_000;
47
48
  const OSC52_CARRY_MAX_BYTES = OSC52_MAX_BYTES + 4096;
48
49
  const TERMINAL_PASSTHROUGH_MAX_BYTES = 5_000_000;
49
50
  const TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES = TERMINAL_PASSTHROUGH_MAX_BYTES + 4096;
50
- /** Delay between the shrink and restore halves of a resize jiggle. 200ms keeps
51
- * the two SIGWINCHs apart well beyond pi-tui's 16ms render throttle, so a busy
52
- * (booting) child processes them as two separate renders instead of coalescing
53
- * the pair into a net-zero size change. */
54
- const JIGGLE_RESTORE_MS = 200;
55
51
  const KITTY_IMAGE_PREFIX = "\x1b_G";
56
52
  const ITERM2_FILE_PREFIX = "\x1b]1337;File=";
57
53
 
@@ -117,16 +113,16 @@ export class PtyAttachComponent implements Component {
117
113
  private parserBuffer = "";
118
114
  private retryTimer: ReturnType<typeof setTimeout> | null = null;
119
115
  private loadingTimer: ReturnType<typeof setInterval> | null = null;
120
- private redrawTimer: ReturnType<typeof setTimeout> | null = null;
121
116
  private mouseRefreshTimers: Array<ReturnType<typeof setTimeout>> = [];
122
- // Jiggle retry chain: re-send resize jiggle until we see a full-clear sequence
123
- // in the PTY output, proving the child pi-tui did a fullRender and the replay
124
- // garbage has been flushed. The controller re-arms once on the child TUI's
125
- // first frame so cold-start attaches get a fresh budget exactly when the
126
- // child can finally observe a resize (issue #10).
117
+ // Shrink-and-hold jiggle protocol (issue #25): on attach we resize the child to
118
+ // (cols-1, rows-1) and hold it there until the child emits a full clear
119
+ // (\x1b[2J) — any render the child makes while held sees a width delta and
120
+ // must fullRender, so boot-storm coalescing of ±1 pulses can no longer
121
+ // produce a net-zero change. The controller restores the original size on
122
+ // clear / first TUI frame (re-arm) / no-frame 6s guard / budget exhaustion /
123
+ // close / external resize (guards G1-G5, see controller module).
127
124
  private readonly jiggleRetry = createJiggleRetryController({
128
- sendJiggle: () => this.forceChildRedraw(),
129
- shouldFire: () => !this.closed && this.connected,
125
+ sendResize: (cols, rows) => this.sendResize(cols, rows),
130
126
  setTimeoutFn: (fn, ms) => {
131
127
  const t = setTimeout(fn, ms);
132
128
  t.unref?.();
@@ -170,6 +166,10 @@ export class PtyAttachComponent implements Component {
170
166
  // into many small chunks; painting after each chunk would drive the outer TUI to its
171
167
  // frame cap and expose intermediate frames (visible as flicker on a busy session).
172
168
  private readonly outputRenderScheduler = createAttachOutputRenderScheduler(() => this.scheduleRender());
169
+ // Fold pi-tui's post-frame cursor-park writes back into the frame's sync block so the
170
+ // terminal reports one stable IME cursor rect per frame instead of two (issue #28).
171
+ // Never active when AGENT_BOARD_IME_FIX=0; no-op passthrough if pi-tui changes shape.
172
+ private readonly imeCoalesceUninstall: (() => void) | null;
173
173
 
174
174
  constructor(
175
175
  private readonly tui: TUI,
@@ -181,6 +181,9 @@ export class PtyAttachComponent implements Component {
181
181
  const size = this.currentSize();
182
182
  this.cols = size.cols;
183
183
  this.rows = size.rows;
184
+ // Assigned in the body (not as a field initializer) so it runs after the `tui`
185
+ // parameter property is set regardless of the TS loader's field-init semantics.
186
+ this.imeCoalesceUninstall = installImeCursorCoalesce(this.tui);
184
187
  this.term = new Terminal({ cols: this.cols, rows: this.rows, scrollback: 2000, allowProposedApi: true });
185
188
  // Keep mouse reporting enabled by default so wheel scrolling and local drag-to-copy
186
189
  // selection can coexist inside the attach surface. Set AGENT_BOARD_ATTACH_MOUSE=0
@@ -324,9 +327,7 @@ export class PtyAttachComponent implements Component {
324
327
  this.connected = true;
325
328
  this.status = "attached";
326
329
  this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
327
- this.sendResize();
328
- this.forceChildRedraw();
329
- this.jiggleRetry.start();
330
+ this.jiggleRetry.start(this.cols, this.rows);
330
331
  this.enableMouseScroll();
331
332
  this.scheduleRender();
332
333
  this.startAttachSettle();
@@ -390,7 +391,7 @@ export class PtyAttachComponent implements Component {
390
391
  /**
391
392
  * Attach transition lifecycle. Keep the loading banner up while the screen-log replay
392
393
  * and the initial resize-jiggle redraws settle, so the buffer doesn't visibly scroll or
393
- * flash through the viewport on attach. Each `forceChildRedraw` defers the settle
394
+ * flash through the viewport on attach. Each output chunk defers the settle
394
395
  * window; a hard timeout guards against a session that never produces output.
395
396
  */
396
397
  private startAttachSettle(): void {
@@ -435,13 +436,6 @@ export class PtyAttachComponent implements Component {
435
436
  this.scheduleRender(true);
436
437
  }
437
438
 
438
- private clearRedrawTimer(): void {
439
- if (this.redrawTimer) {
440
- clearTimeout(this.redrawTimer);
441
- this.redrawTimer = null;
442
- }
443
- }
444
-
445
439
  private clearMouseRefreshTimers(): void {
446
440
  for (const timer of this.mouseRefreshTimers) clearTimeout(timer);
447
441
  this.mouseRefreshTimers = [];
@@ -784,6 +778,9 @@ export class PtyAttachComponent implements Component {
784
778
  if (size.cols === this.cols && size.rows === this.rows) return;
785
779
  this.cols = size.cols;
786
780
  this.rows = size.rows;
781
+ // A real terminal resize supersedes the hold protocol: cancel any armed
782
+ // hold and adopt the new size as the baseline (guard G4, issue #25).
783
+ this.jiggleRetry.notifyExternalResize(size.cols, size.rows);
787
784
  this.term.resize(this.cols, this.rows);
788
785
  this.sendResize();
789
786
  this.enableMouseScroll();
@@ -794,28 +791,6 @@ export class PtyAttachComponent implements Component {
794
791
  this.clampViewportTop(this.bodyHeight());
795
792
  }
796
793
 
797
- private forceChildRedraw(): void {
798
- this.clearRedrawTimer();
799
- if (!this.connected) return;
800
- const cols = this.cols;
801
- const rows = this.rows;
802
- const jiggle = localResizeJiggleSize(cols, rows);
803
- if (!jiggle) return;
804
- // A completed-session reattach often starts from an old screen.log recorded at
805
- // a different terminal size. Real terminal zoom fixes that by causing SIGWINCH;
806
- // do the same proactively so the child Pi redraws for the attach viewport.
807
- this.sendResize(jiggle.cols, jiggle.rows);
808
- this.deferAttachSettle();
809
- this.redrawTimer = setTimeout(() => {
810
- this.redrawTimer = null;
811
- if (!this.closed && this.connected) {
812
- this.sendResize(cols, rows);
813
- this.deferAttachSettle();
814
- }
815
- }, JIGGLE_RESTORE_MS);
816
- this.redrawTimer.unref?.();
817
- }
818
-
819
794
  /** Feed socket output into the jiggle retry controller (clear/frame detection). */
820
795
  private checkClearSequence(data: string): void {
821
796
  this.jiggleRetry.feed(data);
@@ -984,13 +959,13 @@ export class PtyAttachComponent implements Component {
984
959
 
985
960
  private close(): void {
986
961
  this.closed = true;
987
- this.jiggleRetry.stop();
962
+ this.imeCoalesceUninstall?.();
963
+ this.jiggleRetry.restoreAndStop();
988
964
  this.disableMouseScroll();
989
965
  this.clearMouseRefreshTimers();
990
966
  this.clearPendingClick();
991
967
  this.clearSelectionAutoScroll();
992
968
  this.clearRetry();
993
- this.clearRedrawTimer();
994
969
  this.stopLoadingTicker();
995
970
  this.outputRenderScheduler.dispose();
996
971
  if (this.attachSettleTimer) {
@@ -1009,13 +984,6 @@ export class PtyAttachComponent implements Component {
1009
984
  }
1010
985
  }
1011
986
 
1012
- function localResizeJiggleSize(cols: number, rows: number): { cols: number; rows: number } | null {
1013
- if (cols > 21 && rows > 6) return { cols: cols - 1, rows: rows - 1 };
1014
- if (rows > 6) return { cols, rows: rows - 1 };
1015
- if (cols > 21) return { cols: cols - 1, rows };
1016
- return null;
1017
- }
1018
-
1019
987
  function sameMousePoint(a: MousePoint, b: MousePoint): boolean {
1020
988
  return a.line === b.line && Math.abs(a.col - b.col) <= 1;
1021
989
  }