@zhuxixi/pi-agent-board 0.7.0 → 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 +26 -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-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-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.mjs +8 -7
- package/runner/pty-runner.mjs +616 -27
- package/runner/state-coordinator.mjs +43 -17
- package/runner/state-runner.mjs +6 -5
- 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/core/control-protocol.mjs +482 -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/locks.mjs +68 -14
- package/src/core/paths.mjs +35 -3
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +27 -3
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +20 -0
- package/src/core/state-commands.mjs +88 -6
- 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/index.ts +12 -4
- package/src/runtime/service.mjs +284 -16
- package/src/ui/dashboard.ts +41 -33
- package/src/ui/pty-attach.ts +236 -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, detectCursorDesync, 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,6 +40,13 @@ 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;
|
|
45
52
|
/** Desync detection window: how long output must stay silent before a
|
|
@@ -66,6 +73,9 @@ const ITERM2_FILE_PREFIX = "\x1b]1337;File=";
|
|
|
66
73
|
interface XtermLike {
|
|
67
74
|
write(data: string, cb?: () => void): void;
|
|
68
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;
|
|
69
79
|
buffer: {
|
|
70
80
|
active: {
|
|
71
81
|
baseY: number;
|
|
@@ -77,6 +87,7 @@ interface XtermLike {
|
|
|
77
87
|
};
|
|
78
88
|
};
|
|
79
89
|
_core?: {
|
|
90
|
+
coreService?: { isCursorHidden?: boolean };
|
|
80
91
|
_oscLinkService?: {
|
|
81
92
|
getLinkData?: (id: number) => { uri?: string } | undefined;
|
|
82
93
|
_dataByLinkId?: Map<number, { data?: { uri?: string } }>;
|
|
@@ -142,6 +153,21 @@ export class PtyAttachComponent implements Component {
|
|
|
142
153
|
},
|
|
143
154
|
clearTimeoutFn: (t) => clearTimeout(t as ReturnType<typeof setTimeout>),
|
|
144
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;
|
|
145
171
|
private osc52Carry = "";
|
|
146
172
|
private passthroughCarry = "";
|
|
147
173
|
private readonly connectStartedAt = Date.now();
|
|
@@ -175,7 +201,8 @@ export class PtyAttachComponent implements Component {
|
|
|
175
201
|
private lastClickPoint: MousePoint | null = null;
|
|
176
202
|
private lastClickAt = 0;
|
|
177
203
|
/** Authoritative editor emptiness pushed by the child Pi extension via the
|
|
178
|
-
* control socket (issue #68). null = unknown —
|
|
204
|
+
* control socket (issue #68). null = unknown — forward conservatively
|
|
205
|
+
* (spec §D1); only true detaches. */
|
|
179
206
|
private editorEmpty: boolean | null = null;
|
|
180
207
|
// Whether any PTY output (live or replayed) has been shown yet. Until then we paint a
|
|
181
208
|
// loading banner instead of an empty buffer so a slow (cold) host start doesn't leave
|
|
@@ -275,7 +302,15 @@ export class PtyAttachComponent implements Component {
|
|
|
275
302
|
// escape unconditionally — the view must always be exitable, even
|
|
276
303
|
// after the host crashes mid-output (issue #48). Note: ctrl+] is NOT
|
|
277
304
|
// a detach key — it passes through to Pi (tui.editor.jumpForward).
|
|
278
|
-
|
|
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)) {
|
|
279
314
|
this.detach();
|
|
280
315
|
return;
|
|
281
316
|
}
|
|
@@ -305,7 +340,7 @@ export class PtyAttachComponent implements Component {
|
|
|
305
340
|
}
|
|
306
341
|
const header =
|
|
307
342
|
this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
|
|
308
|
-
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`);
|
|
309
344
|
return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
|
|
310
345
|
}
|
|
311
346
|
|
|
@@ -321,7 +356,7 @@ export class PtyAttachComponent implements Component {
|
|
|
321
356
|
out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
|
|
322
357
|
out.push(center(this.theme.fg("muted", detail), width));
|
|
323
358
|
out.push("");
|
|
324
|
-
out.push(center(this.theme.fg("dim", "
|
|
359
|
+
out.push(center(this.theme.fg("dim", "Ctrl+← to detach"), width));
|
|
325
360
|
while (out.length < height) out.push("");
|
|
326
361
|
return out.slice(0, height);
|
|
327
362
|
}
|
|
@@ -344,60 +379,15 @@ export class PtyAttachComponent implements Component {
|
|
|
344
379
|
// the socket immediately after receiving detach, so sending detach first
|
|
345
380
|
// can drop the G3 restore resize (issue #42).
|
|
346
381
|
this.jiggleRetry.restoreAndStop();
|
|
347
|
-
|
|
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
|
+
}
|
|
348
387
|
this.close(true);
|
|
349
388
|
this.done({ action: "detached" });
|
|
350
389
|
}
|
|
351
390
|
|
|
352
|
-
/** Bottom-most line whose cells include an inverse-video cell — Pi renders
|
|
353
|
-
* its editor cursor as an inverse "fake cursor" (`ESC[7m`), and the cell
|
|
354
|
-
* persists in the buffer even while streaming differential frames skip
|
|
355
|
-
* repainting the editor line. */
|
|
356
|
-
private findLastInverseCellLine(active: {
|
|
357
|
-
baseY: number;
|
|
358
|
-
length: number;
|
|
359
|
-
getLine(index: number): BufferLineLike | undefined;
|
|
360
|
-
}): number | null {
|
|
361
|
-
for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
|
|
362
|
-
const line = active.getLine(y);
|
|
363
|
-
if (!line) continue;
|
|
364
|
-
for (let x = 0; x < line.length; x++) {
|
|
365
|
-
if (line.getCell(x)?.isInverse()) return y;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
return null;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
private childInputLooksEmpty(): boolean {
|
|
372
|
-
if (!this.receivedOutput) return true;
|
|
373
|
-
const active = this.term.buffer.active;
|
|
374
|
-
// The terminal cursor is not a reliable anchor for the editor line:
|
|
375
|
-
// while Pi streams output (or right after attach) the cursor rests on
|
|
376
|
-
// working/output lines, never the input line, so a genuinely empty
|
|
377
|
-
// editor was misread as non-empty and ← stopped detaching (issue #66).
|
|
378
|
-
// Pi's editor line always carries an inverse-video fake-cursor cell,
|
|
379
|
-
// so anchor on that instead.
|
|
380
|
-
const fakeCursorLine = this.findLastInverseCellLine(active);
|
|
381
|
-
if (fakeCursorLine !== null) {
|
|
382
|
-
const line = active.getLine(fakeCursorLine)?.translateToString(true) ?? "";
|
|
383
|
-
return isProbablyEmptyPiInputLine(line);
|
|
384
|
-
}
|
|
385
|
-
// Fallback: Pi variants that render no fake cursor — look for an EMPTY
|
|
386
|
-
// prompt-glyph line. Only an empty glyph line proves an empty editor:
|
|
387
|
-
// content glyph lines (markdown table rows `│ … │`, quotes `> …`, or a
|
|
388
|
-
// real draft in a no-fake-cursor Pi variant) cannot be told apart, and
|
|
389
|
-
// trapping the user is worse than a spurious detach (issue #69) — skip
|
|
390
|
-
// them and keep scanning; the loop-end escape below stays authoritative.
|
|
391
|
-
for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
|
|
392
|
-
const line = active.getLine(y)?.translateToString(true) ?? "";
|
|
393
|
-
if (isProbablyPiInputLine(line) && isProbablyEmptyPiInputLine(line)) return true;
|
|
394
|
-
}
|
|
395
|
-
// No editor line recoverable (e.g. a garbled replay buffer): treat the
|
|
396
|
-
// input as empty — ← is the only detach key left on the attach surface,
|
|
397
|
-
// so it must always escape rather than trap the user.
|
|
398
|
-
return true;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
391
|
private connect(): void {
|
|
402
392
|
if (this.closed || this.connected || this.socket) return;
|
|
403
393
|
// On Windows the control socket is a named pipe — it never exists as a
|
|
@@ -424,7 +414,18 @@ export class PtyAttachComponent implements Component {
|
|
|
424
414
|
this.disconnectedAt = null;
|
|
425
415
|
this.status = "attached";
|
|
426
416
|
this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
|
|
427
|
-
|
|
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();
|
|
428
429
|
this.enableMouseScroll();
|
|
429
430
|
this.scheduleRender();
|
|
430
431
|
this.startAttachSettle();
|
|
@@ -440,6 +441,11 @@ export class PtyAttachComponent implements Component {
|
|
|
440
441
|
this.socket = null;
|
|
441
442
|
this.connected = false;
|
|
442
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();
|
|
443
449
|
if (!this.closed && this.status !== "host exited") {
|
|
444
450
|
this.status = "disconnected";
|
|
445
451
|
this.scheduleReconnect();
|
|
@@ -455,6 +461,10 @@ export class PtyAttachComponent implements Component {
|
|
|
455
461
|
}
|
|
456
462
|
this.socket = null;
|
|
457
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();
|
|
458
468
|
try { socket.destroy(); } catch {}
|
|
459
469
|
this.disconnectedAt ??= Date.now();
|
|
460
470
|
if (this.closed) return;
|
|
@@ -586,6 +596,13 @@ export class PtyAttachComponent implements Component {
|
|
|
586
596
|
*/
|
|
587
597
|
private checkDesync(): void {
|
|
588
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;
|
|
589
606
|
const chain = this.jiggleRetry.getState();
|
|
590
607
|
if (!chain.tuiFrameSeen) return; // gate 2: shell/vim children never heal
|
|
591
608
|
if (!chain.stopped || chain.held) return; // gate 5: attach/heal chain active
|
|
@@ -606,6 +623,37 @@ export class PtyAttachComponent implements Component {
|
|
|
606
623
|
this.mouseRefreshTimers = [];
|
|
607
624
|
}
|
|
608
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
|
+
|
|
609
657
|
private clearRetry(): void {
|
|
610
658
|
if (!this.retryTimer) return;
|
|
611
659
|
clearTimeout(this.retryTimer);
|
|
@@ -973,7 +1021,13 @@ export class PtyAttachComponent implements Component {
|
|
|
973
1021
|
}
|
|
974
1022
|
|
|
975
1023
|
private sendResize(cols = this.cols, rows = this.rows): void {
|
|
976
|
-
|
|
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
|
+
}
|
|
977
1031
|
this.clampViewportTop(this.bodyHeight());
|
|
978
1032
|
}
|
|
979
1033
|
|
|
@@ -1045,6 +1099,12 @@ export class PtyAttachComponent implements Component {
|
|
|
1045
1099
|
if (!line.trim()) continue;
|
|
1046
1100
|
try {
|
|
1047
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;
|
|
1048
1108
|
if (msg.type === "output" && typeof msg.data === "string") {
|
|
1049
1109
|
this.pushOutput(msg.data, { forwardProtocols: true });
|
|
1050
1110
|
this.checkClearSequence(msg.data);
|
|
@@ -1067,6 +1127,95 @@ export class PtyAttachComponent implements Component {
|
|
|
1067
1127
|
if (needsRender) this.scheduleRender();
|
|
1068
1128
|
}
|
|
1069
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
|
+
|
|
1070
1219
|
private forwardTerminalProtocols(data: string): void {
|
|
1071
1220
|
const toWrite: string[] = [];
|
|
1072
1221
|
if (process.env.AGENT_BOARD_FORWARD_OSC52 !== "0") {
|
|
@@ -1088,6 +1237,15 @@ export class PtyAttachComponent implements Component {
|
|
|
1088
1237
|
}
|
|
1089
1238
|
}
|
|
1090
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
|
+
*/
|
|
1091
1249
|
private replayScreenLog(): void {
|
|
1092
1250
|
if (!this.opts.screenLogPath || !existsSync(this.opts.screenLogPath)) return;
|
|
1093
1251
|
try {
|
|
@@ -1147,8 +1305,9 @@ export class PtyAttachComponent implements Component {
|
|
|
1147
1305
|
// the child terminal); cursorX may equal cols (one past the last cell). Only render
|
|
1148
1306
|
// it when it lands inside the projected viewport.
|
|
1149
1307
|
const cursor = projectPtyCursor(buf, start, height);
|
|
1308
|
+
const cursorHidden = isPtyCursorHidden(this.term);
|
|
1150
1309
|
for (let i = start; i < end; i++) {
|
|
1151
|
-
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));
|
|
1152
1311
|
}
|
|
1153
1312
|
if (out.length === 0) out.push("Waiting for PTY output…");
|
|
1154
1313
|
return { lines: out.slice(-height), cursor };
|
|
@@ -1163,6 +1322,7 @@ export class PtyAttachComponent implements Component {
|
|
|
1163
1322
|
}
|
|
1164
1323
|
this.closed = true;
|
|
1165
1324
|
this.imeCoalesceUninstall?.();
|
|
1325
|
+
this.attachClient.close();
|
|
1166
1326
|
this.jiggleRetry.restoreAndStop();
|
|
1167
1327
|
this.disableMouseScroll();
|
|
1168
1328
|
this.clearMouseRefreshTimers();
|
|
@@ -1280,12 +1440,14 @@ function lineToAnsi(
|
|
|
1280
1440
|
lineIndex: number,
|
|
1281
1441
|
selection: NormalizedSelection | null,
|
|
1282
1442
|
cursor: { row: number; col: number } | null,
|
|
1443
|
+
cursorHidden = false,
|
|
1283
1444
|
): string {
|
|
1284
1445
|
const isCursorRow = cursor !== null && cursor.row === lineIndex;
|
|
1285
1446
|
let last = -1;
|
|
1286
1447
|
if (!line) {
|
|
1287
|
-
// No buffer line:
|
|
1288
|
-
|
|
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";
|
|
1289
1451
|
return "";
|
|
1290
1452
|
}
|
|
1291
1453
|
for (let x = 0; x < line.length; x++) {
|
|
@@ -1294,9 +1456,8 @@ function lineToAnsi(
|
|
|
1294
1456
|
if (cell.getChars()) last = x;
|
|
1295
1457
|
}
|
|
1296
1458
|
if (last < 0) {
|
|
1297
|
-
// Empty line:
|
|
1298
|
-
|
|
1299
|
-
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";
|
|
1300
1461
|
return "";
|
|
1301
1462
|
}
|
|
1302
1463
|
|
|
@@ -1313,24 +1474,27 @@ function lineToAnsi(
|
|
|
1313
1474
|
prevUri = uri;
|
|
1314
1475
|
}
|
|
1315
1476
|
const selected = pointWithinSelection(lineIndex, x, selection);
|
|
1316
|
-
//
|
|
1317
|
-
// the
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
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).
|
|
1320
1483
|
const isCursor = isCursorRow && x === cursor!.col;
|
|
1321
1484
|
if (isCursor) out += CURSOR_MARKER;
|
|
1322
|
-
const
|
|
1485
|
+
const paintCursor = isCursor && !cursorHidden;
|
|
1486
|
+
const key = attrKey(cell, selected, paintCursor);
|
|
1323
1487
|
if (key !== prevAttr) {
|
|
1324
|
-
out += attrsToAnsi(cell, selected,
|
|
1488
|
+
out += attrsToAnsi(cell, selected, paintCursor);
|
|
1325
1489
|
prevAttr = key;
|
|
1326
1490
|
}
|
|
1327
1491
|
out += cell.getChars() || " ";
|
|
1328
1492
|
}
|
|
1329
1493
|
if (prevUri) out += closeOsc8();
|
|
1330
1494
|
// Cursor past the end of the line content (cursorX == cols or beyond last cell):
|
|
1331
|
-
// append an inverse space so
|
|
1495
|
+
// append an inverse space so a VISIBLE position shows, keeping the marker either way.
|
|
1332
1496
|
if (isCursorRow && cursor!.col > last) {
|
|
1333
|
-
out += CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
1497
|
+
out += CURSOR_MARKER + (cursorHidden ? "" : "\x1b[7m \x1b[0m");
|
|
1334
1498
|
}
|
|
1335
1499
|
return out + "\x1b[0m";
|
|
1336
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
|
-
}
|