@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +6 -3
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  6. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  7. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  8. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  9. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  10. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  11. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  12. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  13. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  14. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  15. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  16. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  17. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  18. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  19. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  20. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  21. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  22. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  23. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  24. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  25. package/package.json +3 -2
  26. package/runner/job-runner.mjs +8 -7
  27. package/runner/pty-runner.mjs +616 -27
  28. package/runner/state-coordinator.mjs +43 -17
  29. package/runner/state-runner.mjs +6 -5
  30. package/scripts/run-perf-gate.mjs +40 -0
  31. package/src/commands/agent-board.ts +8 -8
  32. package/src/commands/attach-flow.ts +5 -5
  33. package/src/core/control-protocol.mjs +482 -0
  34. package/src/core/editor-state-reporter.mjs +11 -1
  35. package/src/core/foreground-preview-cache.mjs +117 -0
  36. package/src/core/host-protocol.mjs +24 -0
  37. package/src/core/locks.mjs +68 -14
  38. package/src/core/paths.mjs +35 -3
  39. package/src/core/pid.mjs +32 -1
  40. package/src/core/pty-attach-jiggle-controller.mjs +27 -3
  41. package/src/core/pty-attach-reconnect.mjs +13 -6
  42. package/src/core/pty-attach-render.mjs +20 -0
  43. package/src/core/state-commands.mjs +88 -6
  44. package/src/core/status-consistency.mjs +98 -0
  45. package/src/core/store.mjs +59 -13
  46. package/src/core/terminal-attach-client.mjs +803 -0
  47. package/src/core/terminal-attach-protocol.mjs +252 -0
  48. package/src/core/terminal-model.mjs +222 -0
  49. package/src/core/terminal-snapshot.mjs +440 -0
  50. package/src/index.ts +12 -4
  51. package/src/runtime/service.mjs +284 -16
  52. package/src/ui/dashboard.ts +41 -33
  53. package/src/ui/pty-attach.ts +236 -72
  54. package/src/core/pty-input.mjs +0 -47
@@ -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 — fall back to the heuristic. */
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
- if (shouldEscapeAttach(this.connected, resolveEditorEmpty(this.editorEmpty, this.childInputLooksEmpty()))) {
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 · ←/Ctrl+← detach`);
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", "←/Ctrl+← to detach"), width));
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
- this.send({ type: "detach" });
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
- this.jiggleRetry.start(this.cols, this.rows);
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
- this.send({ type: "resize", cols, rows });
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: show the cursor as an inverse block at the start of the line.
1288
- if (isCursorRow && cursor!.col >= 0) return CURSOR_MARKER + "\x1b[7m \x1b[0m";
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: show the cursor as a full inverse block at the start of the line
1298
- // (or as an inverse space when it sits past the end of the content).
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
- // The PTY cursor renders as a solid inverse block so it stays visible even though
1317
- // the outer TUI hides the hardware cursor by default. The zero-width CURSOR_MARKER
1318
- // (stripped by the TUI) additionally positions the hardware cursor for IME and
1319
- // PI_HARDWARE_CURSOR=1 terminals; truncateToWidth keeps or drops it with the cell.
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 key = attrKey(cell, selected, isCursor);
1485
+ const paintCursor = isCursor && !cursorHidden;
1486
+ const key = attrKey(cell, selected, paintCursor);
1323
1487
  if (key !== prevAttr) {
1324
- out += attrsToAnsi(cell, selected, isCursor);
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 the position is still visible.
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
  }
@@ -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
- }