@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.
- package/README.md +16 -1
- package/docs/superpowers/plans/2026-08-22-jiggle-shrink-and-hold.md +337 -0
- package/docs/superpowers/plans/2026-08-27-locks-acquirelock-spin.md +614 -0
- package/docs/superpowers/plans/2026-08-27-pty-runner-test-flaky.md +32 -0
- package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +107 -0
- package/docs/superpowers/specs/2026-08-27-locks-acquirelock-spin-design.md +97 -0
- package/docs/superpowers/specs/2026-08-27-pty-runner-test-flaky-design.md +35 -0
- package/package.json +4 -2
- package/runner/job-runner.mjs +28 -2
- package/src/core/follow-up-queue.mjs +23 -5
- package/src/core/ime-cursor-coalesce.mjs +193 -0
- package/src/core/locks.mjs +79 -23
- package/src/core/pty-attach-jiggle-controller.mjs +158 -45
- package/src/ui/pty-attach.ts +23 -55
package/src/core/locks.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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 (
|
|
53
|
-
|
|
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
|
-
|
|
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 {
|
|
62
|
-
function
|
|
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
|
|
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
|
}
|
package/src/ui/pty-attach.ts
CHANGED
|
@@ -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
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
}
|