@zhuxixi/pi-agent-board 0.6.2 → 0.8.0
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/CHANGELOG.md +43 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +371 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +685 -58
- package/runner/state-coordinator.mjs +429 -0
- package/runner/state-runner.mjs +90 -15
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/commands/bg.ts +2 -1
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +48 -0
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +83 -6
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +50 -0
- package/src/core/state-commands.mjs +699 -0
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/core/types.mjs +2 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +694 -121
- package/src/ui/dashboard.ts +67 -92
- package/src/ui/pty-attach.ts +298 -72
- package/src/core/pty-input.mjs +0 -47
package/src/ui/pty-attach.ts
CHANGED
|
@@ -5,12 +5,12 @@ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
|
5
5
|
import { createConnection, type Socket } from "node:net";
|
|
6
6
|
import type { Component, KeybindingsManager, TUI } from "@earendil-works/pi-tui";
|
|
7
7
|
import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
-
import { isProbablyEmptyPiInputLine, isProbablyPiInputLine, resolveEditorEmpty } from "../core/pty-input.mjs";
|
|
9
8
|
import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
|
|
10
|
-
import { createAttachOutputRenderScheduler, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
|
|
9
|
+
import { createAttachOutputRenderScheduler, detectCursorDesync, isPtyCursorHidden, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
|
|
11
10
|
import { evaluateAttachReconnect, shouldEscapeAttach } from "../core/pty-attach-reconnect.mjs";
|
|
12
11
|
import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs";
|
|
13
12
|
import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
|
|
13
|
+
import { createTerminalAttachClient } from "../core/terminal-attach-client.mjs";
|
|
14
14
|
import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs";
|
|
15
15
|
|
|
16
16
|
export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null };
|
|
@@ -40,8 +40,24 @@ const LOADING_TICK_MS = 120;
|
|
|
40
40
|
const ATTACH_SETTLE_MS = 250;
|
|
41
41
|
/** Hard cap on the attach transition so a silent session can't stall the banner. */
|
|
42
42
|
const ATTACH_HARD_TIMEOUT_MS = 2500;
|
|
43
|
+
/** Protocol-race guard for legacy jiggle arming (issue #91 phase 4): the
|
|
44
|
+
* undecided window must not pulse the child before the snapshot probe
|
|
45
|
+
* resolves — a resize-free attach is the protocol-mode contract. Invisible
|
|
46
|
+
* for legacy (the probe timeout decides at 1500ms ≫ 100ms) and the protocol
|
|
47
|
+
* answer (~ms) reliably beats it; a pathologically late snapshot merely costs
|
|
48
|
+
* one harmless shrink pulse that the protocol mode event unwinds. */
|
|
49
|
+
const LEGACY_JIGGLE_ARM_DELAY_MS = 100;
|
|
43
50
|
/** Give ordered detach packets time to flush before using destroy as a fallback. */
|
|
44
51
|
const GRACEFUL_SOCKET_CLOSE_MS = 1000;
|
|
52
|
+
/** Desync detection window: how long output must stay silent before a
|
|
53
|
+
* misaligned cursor counts as desync (issue #11). Streaming keeps the cursor
|
|
54
|
+
* on plain output cells legitimately; a healthy idle child re-parks it on the
|
|
55
|
+
* inverse fake-cursor cell on its last rendered frame. */
|
|
56
|
+
const DESYNC_QUIET_MS = 1500;
|
|
57
|
+
/** How often the post-settle probe runs checkDesync() (issue #11). */
|
|
58
|
+
const DESYNC_PROBE_INTERVAL_MS = 2000;
|
|
59
|
+
/** Minimum spacing between two runtime heals (issue #11). */
|
|
60
|
+
const HEAL_RATELIMIT_MS = 10000;
|
|
45
61
|
/** How many tail bytes of the screen log to replay on attach. Read from the file tail
|
|
46
62
|
* (not the whole file) so multi-MB logs don't block startup; ~60KB covers the last
|
|
47
63
|
* handful of screens, which is all a fresh attach needs. */
|
|
@@ -57,6 +73,9 @@ const ITERM2_FILE_PREFIX = "\x1b]1337;File=";
|
|
|
57
73
|
interface XtermLike {
|
|
58
74
|
write(data: string, cb?: () => void): void;
|
|
59
75
|
resize(cols: number, rows: number): void;
|
|
76
|
+
// Full buffer wipe (@xterm/headless Terminal.reset): the attach client's
|
|
77
|
+
// "empty/resnapshot → UI resets its buffer" contract (phase 4, F3).
|
|
78
|
+
reset(): void;
|
|
60
79
|
buffer: {
|
|
61
80
|
active: {
|
|
62
81
|
baseY: number;
|
|
@@ -68,6 +87,7 @@ interface XtermLike {
|
|
|
68
87
|
};
|
|
69
88
|
};
|
|
70
89
|
_core?: {
|
|
90
|
+
coreService?: { isCursorHidden?: boolean };
|
|
71
91
|
_oscLinkService?: {
|
|
72
92
|
getLinkData?: (id: number) => { uri?: string } | undefined;
|
|
73
93
|
_dataByLinkId?: Map<number, { data?: { uri?: string } }>;
|
|
@@ -133,6 +153,21 @@ export class PtyAttachComponent implements Component {
|
|
|
133
153
|
},
|
|
134
154
|
clearTimeoutFn: (t) => clearTimeout(t as ReturnType<typeof setTimeout>),
|
|
135
155
|
});
|
|
156
|
+
// Snapshot+subscribe attach client (issue #91 phase 4, D2). Created ONCE —
|
|
157
|
+
// reconnects re-point send at the CURRENT socket (this.send routes through
|
|
158
|
+
// this.socket) and resume from the applied cursor; wiring shape per the
|
|
159
|
+
// verified reference in test/terminal-snapshot.integration.test.mjs
|
|
160
|
+
// (wireClient). AGENT_BOARD_TERMINAL_SNAPSHOT=0 forces the legacy path
|
|
161
|
+
// (escape hatch + deterministic legacy tests).
|
|
162
|
+
private readonly attachClient = createTerminalAttachClient({
|
|
163
|
+
send: (msg) => this.send(msg),
|
|
164
|
+
emit: (event, payload) => this.handleAttachEvent(event, payload),
|
|
165
|
+
});
|
|
166
|
+
/** Decided attach path: "undecided" until the probe resolves (snapshot
|
|
167
|
+
* begin → protocol; probe timeout / version mismatch → legacy). Gates
|
|
168
|
+
* jiggle arming and probe-vs-cursor reconnects. */
|
|
169
|
+
private attachMode: "undecided" | "protocol" | "legacy" = "undecided";
|
|
170
|
+
private jiggleStartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
136
171
|
private osc52Carry = "";
|
|
137
172
|
private passthroughCarry = "";
|
|
138
173
|
private readonly connectStartedAt = Date.now();
|
|
@@ -150,6 +185,13 @@ export class PtyAttachComponent implements Component {
|
|
|
150
185
|
private rows = 24;
|
|
151
186
|
// Absolute buffer line shown at the top of the viewport. null means follow bottom.
|
|
152
187
|
private viewportTop: number | null = null;
|
|
188
|
+
// Runtime desync backstop state (issue #11): last socket-output timestamp,
|
|
189
|
+
// last heal timestamp, the probe timer, and an injectable clock for tests.
|
|
190
|
+
private lastOutputAt = 0;
|
|
191
|
+
private lastHealAt = 0;
|
|
192
|
+
private desyncProbeTimer: ReturnType<typeof setInterval> | null = null;
|
|
193
|
+
/** Injectable clock for desync gating (tests override this). */
|
|
194
|
+
private nowFn: () => number = () => Date.now();
|
|
153
195
|
private selection: MouseSelection | null = null;
|
|
154
196
|
private selectionDragging = false;
|
|
155
197
|
private selectionAutoScrollTimer: ReturnType<typeof setInterval> | null = null;
|
|
@@ -159,7 +201,8 @@ export class PtyAttachComponent implements Component {
|
|
|
159
201
|
private lastClickPoint: MousePoint | null = null;
|
|
160
202
|
private lastClickAt = 0;
|
|
161
203
|
/** Authoritative editor emptiness pushed by the child Pi extension via the
|
|
162
|
-
* control socket (issue #68). null = unknown —
|
|
204
|
+
* control socket (issue #68). null = unknown — forward conservatively
|
|
205
|
+
* (spec §D1); only true detaches. */
|
|
163
206
|
private editorEmpty: boolean | null = null;
|
|
164
207
|
// Whether any PTY output (live or replayed) has been shown yet. Until then we paint a
|
|
165
208
|
// loading banner instead of an empty buffer so a slow (cold) host start doesn't leave
|
|
@@ -259,7 +302,15 @@ export class PtyAttachComponent implements Component {
|
|
|
259
302
|
// escape unconditionally — the view must always be exitable, even
|
|
260
303
|
// after the host crashes mid-output (issue #48). Note: ctrl+] is NOT
|
|
261
304
|
// a detach key — it passes through to Pi (tui.editor.jumpForward).
|
|
262
|
-
|
|
305
|
+
// Issue #91 Phase 6 (spec §D1): the gate reads ONLY the pushed
|
|
306
|
+
// editorEmpty side channel — true detaches; false and null (child
|
|
307
|
+
// extension missing, hello not yet arrived, old runner) forward via
|
|
308
|
+
// the explicit conservative policy. The old terminal-buffer
|
|
309
|
+
// heuristics are deleted for good: rendered bytes carry no
|
|
310
|
+
// input-buffer semantics, so that chain guessed at a fact it
|
|
311
|
+
// could not know (issues #42/#66/#69/#103). Escape without a pushed
|
|
312
|
+
// state is Ctrl+← (issue #89), never a buffer guess.
|
|
313
|
+
if (shouldEscapeAttach(this.connected, this.editorEmpty === true)) {
|
|
263
314
|
this.detach();
|
|
264
315
|
return;
|
|
265
316
|
}
|
|
@@ -289,7 +340,7 @@ export class PtyAttachComponent implements Component {
|
|
|
289
340
|
}
|
|
290
341
|
const header =
|
|
291
342
|
this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
|
|
292
|
-
this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies ·
|
|
343
|
+
this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · Ctrl+← detach`);
|
|
293
344
|
return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
|
|
294
345
|
}
|
|
295
346
|
|
|
@@ -305,7 +356,7 @@ export class PtyAttachComponent implements Component {
|
|
|
305
356
|
out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
|
|
306
357
|
out.push(center(this.theme.fg("muted", detail), width));
|
|
307
358
|
out.push("");
|
|
308
|
-
out.push(center(this.theme.fg("dim", "
|
|
359
|
+
out.push(center(this.theme.fg("dim", "Ctrl+← to detach"), width));
|
|
309
360
|
while (out.length < height) out.push("");
|
|
310
361
|
return out.slice(0, height);
|
|
311
362
|
}
|
|
@@ -328,60 +379,15 @@ export class PtyAttachComponent implements Component {
|
|
|
328
379
|
// the socket immediately after receiving detach, so sending detach first
|
|
329
380
|
// can drop the G3 restore resize (issue #42).
|
|
330
381
|
this.jiggleRetry.restoreAndStop();
|
|
331
|
-
|
|
382
|
+
// Phase 5 (D4): enveloped detach (correlated applied ack) with legacy
|
|
383
|
+
// fallback; both reach the same runner detach handler.
|
|
384
|
+
if (!this.attachClient.sendControl("detach")) {
|
|
385
|
+
this.send({ type: "detach" });
|
|
386
|
+
}
|
|
332
387
|
this.close(true);
|
|
333
388
|
this.done({ action: "detached" });
|
|
334
389
|
}
|
|
335
390
|
|
|
336
|
-
/** Bottom-most line whose cells include an inverse-video cell — Pi renders
|
|
337
|
-
* its editor cursor as an inverse "fake cursor" (`ESC[7m`), and the cell
|
|
338
|
-
* persists in the buffer even while streaming differential frames skip
|
|
339
|
-
* repainting the editor line. */
|
|
340
|
-
private findLastInverseCellLine(active: {
|
|
341
|
-
baseY: number;
|
|
342
|
-
length: number;
|
|
343
|
-
getLine(index: number): BufferLineLike | undefined;
|
|
344
|
-
}): number | null {
|
|
345
|
-
for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
|
|
346
|
-
const line = active.getLine(y);
|
|
347
|
-
if (!line) continue;
|
|
348
|
-
for (let x = 0; x < line.length; x++) {
|
|
349
|
-
if (line.getCell(x)?.isInverse()) return y;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
return null;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
private childInputLooksEmpty(): boolean {
|
|
356
|
-
if (!this.receivedOutput) return true;
|
|
357
|
-
const active = this.term.buffer.active;
|
|
358
|
-
// The terminal cursor is not a reliable anchor for the editor line:
|
|
359
|
-
// while Pi streams output (or right after attach) the cursor rests on
|
|
360
|
-
// working/output lines, never the input line, so a genuinely empty
|
|
361
|
-
// editor was misread as non-empty and ← stopped detaching (issue #66).
|
|
362
|
-
// Pi's editor line always carries an inverse-video fake-cursor cell,
|
|
363
|
-
// so anchor on that instead.
|
|
364
|
-
const fakeCursorLine = this.findLastInverseCellLine(active);
|
|
365
|
-
if (fakeCursorLine !== null) {
|
|
366
|
-
const line = active.getLine(fakeCursorLine)?.translateToString(true) ?? "";
|
|
367
|
-
return isProbablyEmptyPiInputLine(line);
|
|
368
|
-
}
|
|
369
|
-
// Fallback: Pi variants that render no fake cursor — look for an EMPTY
|
|
370
|
-
// prompt-glyph line. Only an empty glyph line proves an empty editor:
|
|
371
|
-
// content glyph lines (markdown table rows `│ … │`, quotes `> …`, or a
|
|
372
|
-
// real draft in a no-fake-cursor Pi variant) cannot be told apart, and
|
|
373
|
-
// trapping the user is worse than a spurious detach (issue #69) — skip
|
|
374
|
-
// them and keep scanning; the loop-end escape below stays authoritative.
|
|
375
|
-
for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
|
|
376
|
-
const line = active.getLine(y)?.translateToString(true) ?? "";
|
|
377
|
-
if (isProbablyPiInputLine(line) && isProbablyEmptyPiInputLine(line)) return true;
|
|
378
|
-
}
|
|
379
|
-
// No editor line recoverable (e.g. a garbled replay buffer): treat the
|
|
380
|
-
// input as empty — ← is the only detach key left on the attach surface,
|
|
381
|
-
// so it must always escape rather than trap the user.
|
|
382
|
-
return true;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
391
|
private connect(): void {
|
|
386
392
|
if (this.closed || this.connected || this.socket) return;
|
|
387
393
|
// On Windows the control socket is a named pipe — it never exists as a
|
|
@@ -408,7 +414,18 @@ export class PtyAttachComponent implements Component {
|
|
|
408
414
|
this.disconnectedAt = null;
|
|
409
415
|
this.status = "attached";
|
|
410
416
|
this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
|
|
411
|
-
|
|
417
|
+
// Snapshot+subscribe attach (issue #91 phase 4): the first connect
|
|
418
|
+
// probes with subscribe_terminal (old runners stay silent → probe
|
|
419
|
+
// timeout → legacy); protocol reconnects resume from the applied
|
|
420
|
+
// cursor over the SAME client instance (ring replay is seamless;
|
|
421
|
+
// eviction/restart answers a fresh snapshot). hello stays UI-owned
|
|
422
|
+
// and precedes the client's subscribe (established ordering).
|
|
423
|
+
if (this.attachMode === "undecided") this.attachClient.start();
|
|
424
|
+
else if (this.attachMode === "protocol") this.attachClient.reconnect(this.attachClient.getLastSeq());
|
|
425
|
+
// The shrink-and-hold redraw protocol is a LEGACY-path correctness
|
|
426
|
+
// tool: snapshot mode owns screen correctness via canonical frames,
|
|
427
|
+
// so the jiggle never runs there (armLegacyJiggle guards the race).
|
|
428
|
+
this.armLegacyJiggle();
|
|
412
429
|
this.enableMouseScroll();
|
|
413
430
|
this.scheduleRender();
|
|
414
431
|
this.startAttachSettle();
|
|
@@ -424,6 +441,11 @@ export class PtyAttachComponent implements Component {
|
|
|
424
441
|
this.socket = null;
|
|
425
442
|
this.connected = false;
|
|
426
443
|
this.disconnectedAt ??= Date.now();
|
|
444
|
+
// A drop inside the probe window must not leave the absolute 1500ms
|
|
445
|
+
// deadline armed: firing during the dead window would permanently
|
|
446
|
+
// downgrade a protocol-capable runner to legacy (CR R1 advisory). The
|
|
447
|
+
// reconnect's start()/reconnect() re-arms on the new socket.
|
|
448
|
+
this.attachClient.onDisconnect();
|
|
427
449
|
if (!this.closed && this.status !== "host exited") {
|
|
428
450
|
this.status = "disconnected";
|
|
429
451
|
this.scheduleReconnect();
|
|
@@ -439,6 +461,10 @@ export class PtyAttachComponent implements Component {
|
|
|
439
461
|
}
|
|
440
462
|
this.socket = null;
|
|
441
463
|
this.connected = false;
|
|
464
|
+
// Notify before nulling: the close event that follows will hit the
|
|
465
|
+
// stale-socket guard (this.socket no longer matches) and must not be
|
|
466
|
+
// the only path that cancels the probe deadline (CR R1 advisory).
|
|
467
|
+
this.attachClient.onDisconnect();
|
|
442
468
|
try { socket.destroy(); } catch {}
|
|
443
469
|
this.disconnectedAt ??= Date.now();
|
|
444
470
|
if (this.closed) return;
|
|
@@ -541,6 +567,55 @@ export class PtyAttachComponent implements Component {
|
|
|
541
567
|
// Force a full clear so the loading banner is replaced atomically by the settled
|
|
542
568
|
// buffer, instead of diffing banner lines into buffer lines.
|
|
543
569
|
this.scheduleRender(true);
|
|
570
|
+
this.startDesyncProbe();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private startDesyncProbe(): void {
|
|
574
|
+
this.stopDesyncProbe();
|
|
575
|
+
// The probe is deliberately NOT hooked into the render path: rendering is
|
|
576
|
+
// event-driven (socket output / keypress / resize) and stops exactly when
|
|
577
|
+
// desync strikes (output goes quiet). A self-contained timer is the only
|
|
578
|
+
// way an idle desynced screen gets its self-heal without user action.
|
|
579
|
+
this.desyncProbeTimer = setInterval(() => this.checkDesync(), DESYNC_PROBE_INTERVAL_MS);
|
|
580
|
+
this.desyncProbeTimer.unref?.();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private stopDesyncProbe(): void {
|
|
584
|
+
if (this.desyncProbeTimer) {
|
|
585
|
+
clearInterval(this.desyncProbeTimer);
|
|
586
|
+
this.desyncProbeTimer = null;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Runtime desync backstop (issue #11). Seven gates, cheapest first:
|
|
592
|
+
* settled+connected, child is a TUI (frame seen), chain idle, output quiet,
|
|
593
|
+
* heal rate limit, misaligned cursor. All pass → heal() re-arms
|
|
594
|
+
* shrink-and-hold; the child's fullRender clear then restores the size
|
|
595
|
+
* and repaints a consistent screen.
|
|
596
|
+
*/
|
|
597
|
+
private checkDesync(): void {
|
|
598
|
+
if (this.closed || this.attaching || !this.connected) return; // gates 1, 7
|
|
599
|
+
// Protocol mode owns screen correctness via canonical frames (issue #91
|
|
600
|
+
// phase 4, CR R1 advisory): a "misaligned cursor" there is frame content
|
|
601
|
+
// or a stale-frame transient that resync heals — a jiggle heal would fire
|
|
602
|
+
// resize pulses in a mode whose e2e pins zero resizes. The undecided
|
|
603
|
+
// window keeps legacy semantics on purpose: until the snapshot answer
|
|
604
|
+
// arrives the jiggle IS armed and the legacy fallthrough owns the screen.
|
|
605
|
+
if (this.attachMode === "protocol") return;
|
|
606
|
+
const chain = this.jiggleRetry.getState();
|
|
607
|
+
if (!chain.tuiFrameSeen) return; // gate 2: shell/vim children never heal
|
|
608
|
+
if (!chain.stopped || chain.held) return; // gate 5: attach/heal chain active
|
|
609
|
+
const now = this.nowFn();
|
|
610
|
+
if (now - this.lastOutputAt <= DESYNC_QUIET_MS) return; // gate 4: streaming
|
|
611
|
+
if (now - this.lastHealAt <= HEAL_RATELIMIT_MS) return; // gate 6: rate limit
|
|
612
|
+
const height = this.bodyHeight();
|
|
613
|
+
this.clampViewportTop(height);
|
|
614
|
+
const start = this.viewportTop ?? this.bottomViewportTop(height);
|
|
615
|
+
const buf = this.term.buffer.active;
|
|
616
|
+
if (detectCursorDesync(buf, projectPtyCursor(buf, start, height)) !== "misaligned") return; // gate 3
|
|
617
|
+
this.lastHealAt = now;
|
|
618
|
+
this.jiggleRetry.heal(this.cols, this.rows);
|
|
544
619
|
}
|
|
545
620
|
|
|
546
621
|
private clearMouseRefreshTimers(): void {
|
|
@@ -548,6 +623,37 @@ export class PtyAttachComponent implements Component {
|
|
|
548
623
|
this.mouseRefreshTimers = [];
|
|
549
624
|
}
|
|
550
625
|
|
|
626
|
+
/**
|
|
627
|
+
* Legacy-path jiggle arming with a protocol-race guard (issue #91 phase 4).
|
|
628
|
+
* Legacy decided immediately (env-forced) → start now; undecided → start
|
|
629
|
+
* after the guard delay unless the snapshot answer wins (protocol). The
|
|
630
|
+
* timer guard accepts ANY non-protocol mode, so a fast in-flight decision
|
|
631
|
+
* (e.g. frame_version_mismatch landing ~1ms into the probe window) still
|
|
632
|
+
* arms the chain — an early-decided legacy session must not end up
|
|
633
|
+
* jiggle-less. start() re-arms idempotently, so a late duplicate is safe.
|
|
634
|
+
*/
|
|
635
|
+
private armLegacyJiggle(): void {
|
|
636
|
+
this.clearJiggleStartTimer();
|
|
637
|
+
if (this.attachMode === "protocol") return;
|
|
638
|
+
if (this.attachMode === "legacy") {
|
|
639
|
+
if (this.connected) this.jiggleRetry.start(this.cols, this.rows);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
this.jiggleStartTimer = setTimeout(() => {
|
|
643
|
+
this.jiggleStartTimer = null;
|
|
644
|
+
if (!this.closed && this.connected && this.attachMode !== "protocol") {
|
|
645
|
+
this.jiggleRetry.start(this.cols, this.rows);
|
|
646
|
+
}
|
|
647
|
+
}, LEGACY_JIGGLE_ARM_DELAY_MS);
|
|
648
|
+
this.jiggleStartTimer.unref?.();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private clearJiggleStartTimer(): void {
|
|
652
|
+
if (!this.jiggleStartTimer) return;
|
|
653
|
+
clearTimeout(this.jiggleStartTimer);
|
|
654
|
+
this.jiggleStartTimer = null;
|
|
655
|
+
}
|
|
656
|
+
|
|
551
657
|
private clearRetry(): void {
|
|
552
658
|
if (!this.retryTimer) return;
|
|
553
659
|
clearTimeout(this.retryTimer);
|
|
@@ -915,7 +1021,13 @@ export class PtyAttachComponent implements Component {
|
|
|
915
1021
|
}
|
|
916
1022
|
|
|
917
1023
|
private sendResize(cols = this.cols, rows = this.rows): void {
|
|
918
|
-
|
|
1024
|
+
// Phase 5 (D4): enveloped resize when the host has an instance fence
|
|
1025
|
+
// (correlated applied/superseded acks; starting-window host_starting is
|
|
1026
|
+
// retried client-side); legacy plain message otherwise. Both paths reach
|
|
1027
|
+
// the same runner resize handler.
|
|
1028
|
+
if (!this.attachClient.sendControl("resize", { cols, rows })) {
|
|
1029
|
+
this.send({ type: "resize", cols, rows });
|
|
1030
|
+
}
|
|
919
1031
|
this.clampViewportTop(this.bodyHeight());
|
|
920
1032
|
}
|
|
921
1033
|
|
|
@@ -987,6 +1099,12 @@ export class PtyAttachComponent implements Component {
|
|
|
987
1099
|
if (!line.trim()) continue;
|
|
988
1100
|
try {
|
|
989
1101
|
const msg = JSON.parse(line);
|
|
1102
|
+
// Protocol-managed messages (snapshot windows, seq-checked live
|
|
1103
|
+
// output, recovery) are consumed by the attach client and re-emitted
|
|
1104
|
+
// through handleAttachEvent. UI-owned messages (hello/status/
|
|
1105
|
+
// editor_state/exit/error) and pre-decision broadcast output return
|
|
1106
|
+
// false and fall through to the legacy path below.
|
|
1107
|
+
if (this.attachClient.handleMessage(msg)) continue;
|
|
990
1108
|
if (msg.type === "output" && typeof msg.data === "string") {
|
|
991
1109
|
this.pushOutput(msg.data, { forwardProtocols: true });
|
|
992
1110
|
this.checkClearSequence(msg.data);
|
|
@@ -1009,6 +1127,95 @@ export class PtyAttachComponent implements Component {
|
|
|
1009
1127
|
if (needsRender) this.scheduleRender();
|
|
1010
1128
|
}
|
|
1011
1129
|
|
|
1130
|
+
/**
|
|
1131
|
+
* Attach-client events (issue #91 phase 4). The client owns protocol
|
|
1132
|
+
* recovery (it resubscribes internally); the UI only switches paths and
|
|
1133
|
+
* hydrates frames.
|
|
1134
|
+
*/
|
|
1135
|
+
private handleAttachEvent(
|
|
1136
|
+
event: "mode" | "snapshotBegin" | "snapshotReady" | "output" | "resubscribing" | "protocolError" | "cmdAck" | "reconciled" | "epochReset",
|
|
1137
|
+
payload: any,
|
|
1138
|
+
): void {
|
|
1139
|
+
if (event === "mode") {
|
|
1140
|
+
const prev = this.attachMode;
|
|
1141
|
+
this.attachMode = payload as "protocol" | "legacy";
|
|
1142
|
+
if (this.attachMode === "protocol") {
|
|
1143
|
+
// Snapshot mode owns screen correctness: cancel any pending legacy
|
|
1144
|
+
// jiggle arming and unwind anything already armed (restores a held
|
|
1145
|
+
// resize; no-ops otherwise).
|
|
1146
|
+
this.clearJiggleStartTimer();
|
|
1147
|
+
this.jiggleRetry.restoreAndStop();
|
|
1148
|
+
} else if (prev === "protocol") {
|
|
1149
|
+
// Downgrade after a protocol session (recovery budget exhausted):
|
|
1150
|
+
// re-arm the legacy screen-healing machinery. This must run whenever
|
|
1151
|
+
// the socket is alive — not only during the attach transition. A
|
|
1152
|
+
// post-settle downgrade that skipped start() left the chain stopped
|
|
1153
|
+
// forever (restoreAndStop on the protocol side), so feed() early-
|
|
1154
|
+
// returned and raw output could never heal a desynced screen.
|
|
1155
|
+
this.clearJiggleStartTimer();
|
|
1156
|
+
if (this.connected) this.jiggleRetry.start(this.cols, this.rows);
|
|
1157
|
+
}
|
|
1158
|
+
// Legacy decided during the undecided window: the race-guard timer
|
|
1159
|
+
// already fired at 100ms (≪ the 1500ms probe timeout) — nothing to do.
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (event === "snapshotBegin") {
|
|
1163
|
+
// Protocol attach size-sync (CR R1 blocking): the legacy attach resized
|
|
1164
|
+
// the child at every connect (jiggle start); subscribe_terminal carries
|
|
1165
|
+
// no size, so without this the child keeps its host-creation geometry
|
|
1166
|
+
// and full-screen TUI children lay out at a stale size. begin is
|
|
1167
|
+
// authoritative for the runner's CURRENT size; the in-flight frame stays
|
|
1168
|
+
// old-geometry either way, but resizing now starts the child's redraw at
|
|
1169
|
+
// the true size sooner. Matched sizes send nothing (zero-resize
|
|
1170
|
+
// contract for same-size attach stays intact).
|
|
1171
|
+
const beginSize = (payload ?? {}) as { cols?: number; rows?: number };
|
|
1172
|
+
if (
|
|
1173
|
+
typeof beginSize.cols === "number" &&
|
|
1174
|
+
typeof beginSize.rows === "number" &&
|
|
1175
|
+
(beginSize.cols !== this.cols || beginSize.rows !== this.rows)
|
|
1176
|
+
) {
|
|
1177
|
+
this.sendResize();
|
|
1178
|
+
}
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
if (event === "snapshotReady") {
|
|
1182
|
+
const snap = (payload ?? {}) as { frame?: string; empty?: boolean; resnapshot?: boolean };
|
|
1183
|
+
// The synthesized frame is self-contained on dirty terminals (phase 3
|
|
1184
|
+
// torture-proven: DECSTR + clear preamble wipes the constructor
|
|
1185
|
+
// screen.log replay and any pre-probe broadcast bytes), so hydrating is
|
|
1186
|
+
// a plain push: the write callback drives receivedOutput/settle/render
|
|
1187
|
+
// exactly like the legacy replay path. No protocol forwarding — the
|
|
1188
|
+
// frame is runner-synthesized grid content, not child passthrough
|
|
1189
|
+
// sequences. Empty baselines (the COMMON initial attach state: the host
|
|
1190
|
+
// publishes alive+childPid before the child's first output) carry no
|
|
1191
|
+
// frame; the loading banner persists until the first live output.
|
|
1192
|
+
// Empty/resnapshot answers ALWAYS wipe the local buffer, attaching or
|
|
1193
|
+
// not: while attaching the banner hides it (zero visual cost), and the
|
|
1194
|
+
// constructor's screen.log warm-start must not outlive the protocol
|
|
1195
|
+
// answer — a gated reset let the dead session's tail render as a
|
|
1196
|
+
// cold-start ghost once the banner lifted (final-review F1).
|
|
1197
|
+
if (typeof snap.frame === "string") this.pushOutput(snap.frame);
|
|
1198
|
+
else if (snap.empty || snap.resnapshot) this.term.reset();
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
if (event === "output") {
|
|
1202
|
+
// Live/replay protocol output: no jiggle clear-detection — that feed
|
|
1203
|
+
// exists to cancel the shrink-and-hold chain, which never runs in
|
|
1204
|
+
// protocol mode.
|
|
1205
|
+
if (typeof payload === "string") this.pushOutput(payload, { forwardProtocols: true });
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
// "resubscribing": recovery is client-internal (informational only).
|
|
1209
|
+
// "protocolError": observability only — recovery is automatic until the
|
|
1210
|
+
// legacy fallback, whose mode event the UI acts on above.
|
|
1211
|
+
// "cmdAck"/"reconciled"/"epochReset" (phase 5): correlation/observability
|
|
1212
|
+
// only — applied/superseded bookkeeping, reconcile baselines and the
|
|
1213
|
+
// generation epoch rule live inside the client; the UI's behavioral
|
|
1214
|
+
// surface (hydrate, mode switching, size-sync) is unchanged. host_starting
|
|
1215
|
+
// resize retries are also client-internal (parity with the legacy runner-
|
|
1216
|
+
// side cachedResize).
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1012
1219
|
private forwardTerminalProtocols(data: string): void {
|
|
1013
1220
|
const toWrite: string[] = [];
|
|
1014
1221
|
if (process.env.AGENT_BOARD_FORWARD_OSC52 !== "0") {
|
|
@@ -1030,6 +1237,15 @@ export class PtyAttachComponent implements Component {
|
|
|
1030
1237
|
}
|
|
1031
1238
|
}
|
|
1032
1239
|
|
|
1240
|
+
/** LEGACY FALLBACK (issue #91 phase 6 marking): the screen.log tail replay
|
|
1241
|
+
* is kept only for pre-protocol runners and the
|
|
1242
|
+
* AGENT_BOARD_TERMINAL_SNAPSHOT=0 kill switch. It is NOT a correctness
|
|
1243
|
+
* path of the snapshot+subscribe protocol — the protocol hydrates from
|
|
1244
|
+
* runner-owned snapshots and resumes from a sequence cursor (phase 4),
|
|
1245
|
+
* and this replay runs before the probe resolves. Removal condition: the
|
|
1246
|
+
* installed runner fleet is on the snapshot protocol baseline
|
|
1247
|
+
* (wire-detectable via hello protocol fields).
|
|
1248
|
+
*/
|
|
1033
1249
|
private replayScreenLog(): void {
|
|
1034
1250
|
if (!this.opts.screenLogPath || !existsSync(this.opts.screenLogPath)) return;
|
|
1035
1251
|
try {
|
|
@@ -1060,6 +1276,9 @@ export class PtyAttachComponent implements Component {
|
|
|
1060
1276
|
|
|
1061
1277
|
private pushOutput(data: string, opts: { forwardProtocols?: boolean } = {}): void {
|
|
1062
1278
|
if (data.length === 0) return;
|
|
1279
|
+
// Recorded synchronously BEFORE term.write (whose callback is async): the
|
|
1280
|
+
// desync quiet-window must measure when output ARRIVED, not when parsing finished.
|
|
1281
|
+
this.lastOutputAt = this.nowFn();
|
|
1063
1282
|
if (opts.forwardProtocols) this.forwardTerminalProtocols(data);
|
|
1064
1283
|
// @xterm/headless parses asynchronously; the buffer is only populated once this
|
|
1065
1284
|
// callback fires. Mark the buffer as ready (so the project path can paint it), but
|
|
@@ -1086,8 +1305,9 @@ export class PtyAttachComponent implements Component {
|
|
|
1086
1305
|
// the child terminal); cursorX may equal cols (one past the last cell). Only render
|
|
1087
1306
|
// it when it lands inside the projected viewport.
|
|
1088
1307
|
const cursor = projectPtyCursor(buf, start, height);
|
|
1308
|
+
const cursorHidden = isPtyCursorHidden(this.term);
|
|
1089
1309
|
for (let i = start; i < end; i++) {
|
|
1090
|
-
out.push(lineToAnsi(buf.getLine(i), reusable, this.term, i, selection, cursor));
|
|
1310
|
+
out.push(lineToAnsi(buf.getLine(i), reusable, this.term, i, selection, cursor, cursorHidden));
|
|
1091
1311
|
}
|
|
1092
1312
|
if (out.length === 0) out.push("Waiting for PTY output…");
|
|
1093
1313
|
return { lines: out.slice(-height), cursor };
|
|
@@ -1102,6 +1322,7 @@ export class PtyAttachComponent implements Component {
|
|
|
1102
1322
|
}
|
|
1103
1323
|
this.closed = true;
|
|
1104
1324
|
this.imeCoalesceUninstall?.();
|
|
1325
|
+
this.attachClient.close();
|
|
1105
1326
|
this.jiggleRetry.restoreAndStop();
|
|
1106
1327
|
this.disableMouseScroll();
|
|
1107
1328
|
this.clearMouseRefreshTimers();
|
|
@@ -1110,6 +1331,7 @@ export class PtyAttachComponent implements Component {
|
|
|
1110
1331
|
this.clearRetry();
|
|
1111
1332
|
this.stopLoadingTicker();
|
|
1112
1333
|
this.outputRenderScheduler.dispose();
|
|
1334
|
+
this.stopDesyncProbe();
|
|
1113
1335
|
if (this.attachSettleTimer) {
|
|
1114
1336
|
clearTimeout(this.attachSettleTimer);
|
|
1115
1337
|
this.attachSettleTimer = null;
|
|
@@ -1218,12 +1440,14 @@ function lineToAnsi(
|
|
|
1218
1440
|
lineIndex: number,
|
|
1219
1441
|
selection: NormalizedSelection | null,
|
|
1220
1442
|
cursor: { row: number; col: number } | null,
|
|
1443
|
+
cursorHidden = false,
|
|
1221
1444
|
): string {
|
|
1222
1445
|
const isCursorRow = cursor !== null && cursor.row === lineIndex;
|
|
1223
1446
|
let last = -1;
|
|
1224
1447
|
if (!line) {
|
|
1225
|
-
// No buffer line:
|
|
1226
|
-
|
|
1448
|
+
// No buffer line: keep the marker (IME positioning) and paint the inverse block
|
|
1449
|
+
// only while the child reports a visible cursor.
|
|
1450
|
+
if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
1227
1451
|
return "";
|
|
1228
1452
|
}
|
|
1229
1453
|
for (let x = 0; x < line.length; x++) {
|
|
@@ -1232,9 +1456,8 @@ function lineToAnsi(
|
|
|
1232
1456
|
if (cell.getChars()) last = x;
|
|
1233
1457
|
}
|
|
1234
1458
|
if (last < 0) {
|
|
1235
|
-
// Empty line:
|
|
1236
|
-
|
|
1237
|
-
if (isCursorRow && cursor!.col >= 0) return CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
1459
|
+
// Empty line: same split as above.
|
|
1460
|
+
if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
1238
1461
|
return "";
|
|
1239
1462
|
}
|
|
1240
1463
|
|
|
@@ -1251,24 +1474,27 @@ function lineToAnsi(
|
|
|
1251
1474
|
prevUri = uri;
|
|
1252
1475
|
}
|
|
1253
1476
|
const selected = pointWithinSelection(lineIndex, x, selection);
|
|
1254
|
-
//
|
|
1255
|
-
// the
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1477
|
+
// Position and visibility are separate concerns: the zero-width CURSOR_MARKER
|
|
1478
|
+
// (stripped by the TUI) always marks where the hardware cursor belongs for IME
|
|
1479
|
+
// and PI_HARDWARE_CURSOR=1 terminals, while the solid inverse block is only
|
|
1480
|
+
// painted when the child terminal itself reports the cursor as visible. pi-tui
|
|
1481
|
+
// parks a hidden cursor at a diff-write byproduct position, so painting it
|
|
1482
|
+
// unconditionally showed a ghost block (issue #102).
|
|
1258
1483
|
const isCursor = isCursorRow && x === cursor!.col;
|
|
1259
1484
|
if (isCursor) out += CURSOR_MARKER;
|
|
1260
|
-
const
|
|
1485
|
+
const paintCursor = isCursor && !cursorHidden;
|
|
1486
|
+
const key = attrKey(cell, selected, paintCursor);
|
|
1261
1487
|
if (key !== prevAttr) {
|
|
1262
|
-
out += attrsToAnsi(cell, selected,
|
|
1488
|
+
out += attrsToAnsi(cell, selected, paintCursor);
|
|
1263
1489
|
prevAttr = key;
|
|
1264
1490
|
}
|
|
1265
1491
|
out += cell.getChars() || " ";
|
|
1266
1492
|
}
|
|
1267
1493
|
if (prevUri) out += closeOsc8();
|
|
1268
1494
|
// Cursor past the end of the line content (cursorX == cols or beyond last cell):
|
|
1269
|
-
// append an inverse space so
|
|
1495
|
+
// append an inverse space so a VISIBLE position shows, keeping the marker either way.
|
|
1270
1496
|
if (isCursorRow && cursor!.col > last) {
|
|
1271
|
-
out += CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
1497
|
+
out += CURSOR_MARKER + (cursorHidden ? "" : "\x1b[7m \x1b[0m");
|
|
1272
1498
|
}
|
|
1273
1499
|
return out + "\x1b[0m";
|
|
1274
1500
|
}
|
package/src/core/pty-input.mjs
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/** Helpers for deciding whether local attach shortcuts should be handled. */
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Best-effort detection of Pi's empty prompt/input line from projected terminal text.
|
|
5
|
-
* The attach surface must not steal editing keys (notably ←) while the child Pi editor
|
|
6
|
-
* contains text. Pi renders empty editor lines with prompt/continuation glyphs such as
|
|
7
|
-
* `›`, `┃`, or `│`; once user text is present, non-prompt content remains after this trim.
|
|
8
|
-
* @param {string} line
|
|
9
|
-
* @returns {boolean}
|
|
10
|
-
*/
|
|
11
|
-
export function isProbablyEmptyPiInputLine(line) {
|
|
12
|
-
const withoutRightPadding = String(line || "").replace(/[\s\u00a0]+$/u, "");
|
|
13
|
-
const content = withoutRightPadding.replace(/^[\s\u00a0›>┃│|┆╎╏:]+/u, "");
|
|
14
|
-
return content.length === 0;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Glyphs Pi uses to render editor prompt / continuation lines (`>` main prompt,
|
|
18
|
-
* `›`/`┃`/`│` and variants in older releases). Must stay in sync with the
|
|
19
|
-
* trim charset of isProbablyEmptyPiInputLine below. */
|
|
20
|
-
const PROMPT_GLYPHS = "›>┃│|┆╎╏:";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Whether the given terminal line looks like a Pi editor input line: leading
|
|
24
|
-
* whitespace followed by a prompt/continuation glyph. The attach surface uses
|
|
25
|
-
* this to locate the editor line inside the buffer instead of trusting the
|
|
26
|
-
* terminal cursor, which wanders onto output/working lines while Pi streams
|
|
27
|
-
* (issue #66).
|
|
28
|
-
* @param {string} line
|
|
29
|
-
* @returns {boolean}
|
|
30
|
-
*/
|
|
31
|
-
export function isProbablyPiInputLine(line) {
|
|
32
|
-
const withoutLeftPadding = String(line || "").replace(/^[\s\u00a0]+/u, "");
|
|
33
|
-
return withoutLeftPadding.length > 0 && PROMPT_GLYPHS.includes(withoutLeftPadding[0]);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Resolve the ← detach gate's emptiness signal: when the child Pi pushes its
|
|
38
|
-
* authoritative editor state (boolean), it wins; when it is unknown (null/
|
|
39
|
-
* undefined — child extension missing or socket never connected), fall back
|
|
40
|
-
* to the render heuristic.
|
|
41
|
-
* @param {boolean | null | undefined} editorEmpty
|
|
42
|
-
* @param {boolean} heuristic
|
|
43
|
-
* @returns {boolean}
|
|
44
|
-
*/
|
|
45
|
-
export function resolveEditorEmpty(editorEmpty, heuristic) {
|
|
46
|
-
return editorEmpty === null || editorEmpty === undefined ? heuristic : editorEmpty;
|
|
47
|
-
}
|