@zhuxixi/pi-agent-board 0.7.0 → 0.9.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 (70) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +16 -12
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/RELEASE.md +148 -0
  6. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  7. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  8. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  9. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  10. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  11. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  12. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  13. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  14. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  15. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  16. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  17. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  18. package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +25 -0
  19. package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +393 -0
  20. package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +51 -0
  21. package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +58 -0
  22. package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +698 -0
  23. package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +605 -0
  24. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  25. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  26. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  27. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  28. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  29. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  30. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  31. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  32. package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +43 -0
  33. package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +95 -0
  34. package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +63 -0
  35. package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +57 -0
  36. package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +98 -0
  37. package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +190 -0
  38. package/package.json +3 -2
  39. package/runner/job-runner.mjs +8 -7
  40. package/runner/pty-runner.mjs +633 -31
  41. package/runner/state-coordinator.mjs +43 -17
  42. package/runner/state-runner.mjs +6 -5
  43. package/scripts/release.mjs +446 -0
  44. package/scripts/run-perf-gate.mjs +40 -0
  45. package/src/commands/agent-board.ts +8 -8
  46. package/src/commands/attach-flow.ts +5 -5
  47. package/src/core/control-protocol.mjs +482 -0
  48. package/src/core/editor-state-reporter.mjs +11 -1
  49. package/src/core/foreground-preview-cache.mjs +117 -0
  50. package/src/core/host-protocol.mjs +62 -0
  51. package/src/core/launch-options.mjs +37 -0
  52. package/src/core/locks.mjs +68 -14
  53. package/src/core/paths.mjs +35 -3
  54. package/src/core/pid.mjs +32 -1
  55. package/src/core/pty-attach-jiggle-controller.mjs +27 -3
  56. package/src/core/pty-attach-reconnect.mjs +13 -6
  57. package/src/core/pty-attach-render.mjs +20 -0
  58. package/src/core/state-commands.mjs +88 -6
  59. package/src/core/status-consistency.mjs +98 -0
  60. package/src/core/store.mjs +59 -13
  61. package/src/core/terminal-attach-client.mjs +809 -0
  62. package/src/core/terminal-attach-protocol.mjs +252 -0
  63. package/src/core/terminal-model.mjs +222 -0
  64. package/src/core/terminal-query-sequences.mjs +132 -0
  65. package/src/core/terminal-snapshot.mjs +440 -0
  66. package/src/index.ts +12 -4
  67. package/src/runtime/service.mjs +284 -16
  68. package/src/ui/dashboard.ts +61 -41
  69. package/src/ui/pty-attach.ts +318 -72
  70. package/src/core/pty-input.mjs +0 -47
@@ -5,12 +5,13 @@ 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
+ import { extractOscQuerySequences, toColorSchemeReport } from "../core/terminal-query-sequences.mjs";
14
15
  import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs";
15
16
 
16
17
  export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null };
@@ -40,6 +41,13 @@ const LOADING_TICK_MS = 120;
40
41
  const ATTACH_SETTLE_MS = 250;
41
42
  /** Hard cap on the attach transition so a silent session can't stall the banner. */
42
43
  const ATTACH_HARD_TIMEOUT_MS = 2500;
44
+ /** Protocol-race guard for legacy jiggle arming (issue #91 phase 4): the
45
+ * undecided window must not pulse the child before the snapshot probe
46
+ * resolves — a resize-free attach is the protocol-mode contract. Invisible
47
+ * for legacy (the probe timeout decides at 1500ms ≫ 100ms) and the protocol
48
+ * answer (~ms) reliably beats it; a pathologically late snapshot merely costs
49
+ * one harmless shrink pulse that the protocol mode event unwinds. */
50
+ const LEGACY_JIGGLE_ARM_DELAY_MS = 100;
43
51
  /** Give ordered detach packets time to flush before using destroy as a fallback. */
44
52
  const GRACEFUL_SOCKET_CLOSE_MS = 1000;
45
53
  /** Desync detection window: how long output must stay silent before a
@@ -66,6 +74,9 @@ const ITERM2_FILE_PREFIX = "\x1b]1337;File=";
66
74
  interface XtermLike {
67
75
  write(data: string, cb?: () => void): void;
68
76
  resize(cols: number, rows: number): void;
77
+ // Full buffer wipe (@xterm/headless Terminal.reset): the attach client's
78
+ // "empty/resnapshot → UI resets its buffer" contract (phase 4, F3).
79
+ reset(): void;
69
80
  buffer: {
70
81
  active: {
71
82
  baseY: number;
@@ -77,6 +88,7 @@ interface XtermLike {
77
88
  };
78
89
  };
79
90
  _core?: {
91
+ coreService?: { isCursorHidden?: boolean };
80
92
  _oscLinkService?: {
81
93
  getLinkData?: (id: number) => { uri?: string } | undefined;
82
94
  _dataByLinkId?: Map<number, { data?: { uri?: string } }>;
@@ -142,8 +154,24 @@ export class PtyAttachComponent implements Component {
142
154
  },
143
155
  clearTimeoutFn: (t) => clearTimeout(t as ReturnType<typeof setTimeout>),
144
156
  });
157
+ // Snapshot+subscribe attach client (issue #91 phase 4, D2). Created ONCE —
158
+ // reconnects re-point send at the CURRENT socket (this.send routes through
159
+ // this.socket) and resume from the applied cursor; wiring shape per the
160
+ // verified reference in test/terminal-snapshot.integration.test.mjs
161
+ // (wireClient). AGENT_BOARD_TERMINAL_SNAPSHOT=0 forces the legacy path
162
+ // (escape hatch + deterministic legacy tests).
163
+ private readonly attachClient = createTerminalAttachClient({
164
+ send: (msg) => this.send(msg),
165
+ emit: (event, payload) => this.handleAttachEvent(event, payload),
166
+ });
167
+ /** Decided attach path: "undecided" until the probe resolves (snapshot
168
+ * begin → protocol; probe timeout / version mismatch → legacy). Gates
169
+ * jiggle arming and probe-vs-cursor reconnects. */
170
+ private attachMode: "undecided" | "protocol" | "legacy" = "undecided";
171
+ private jiggleStartTimer: ReturnType<typeof setTimeout> | null = null;
145
172
  private osc52Carry = "";
146
173
  private passthroughCarry = "";
174
+ private oscQueryCarry = "";
147
175
  private readonly connectStartedAt = Date.now();
148
176
  // Set once the first socket connect succeeds; drives the reconnect give-up
149
177
  // policy (short window after a live session dies, long window during cold
@@ -175,7 +203,8 @@ export class PtyAttachComponent implements Component {
175
203
  private lastClickPoint: MousePoint | null = null;
176
204
  private lastClickAt = 0;
177
205
  /** Authoritative editor emptiness pushed by the child Pi extension via the
178
- * control socket (issue #68). null = unknown — fall back to the heuristic. */
206
+ * control socket (issue #68). null = unknown — forward conservatively
207
+ * (spec §D1); only true detaches. */
179
208
  private editorEmpty: boolean | null = null;
180
209
  // Whether any PTY output (live or replayed) has been shown yet. Until then we paint a
181
210
  // loading banner instead of an empty buffer so a slow (cold) host start doesn't leave
@@ -187,6 +216,10 @@ export class PtyAttachComponent implements Component {
187
216
  private attaching = true;
188
217
  private attachSettleTimer: ReturnType<typeof setTimeout> | null = null;
189
218
  private attachHardTimeout: ReturnType<typeof setTimeout> | null = null;
219
+ // Issue #128 D2: unsubscriber for the color-scheme bridge registered at
220
+ // attach settle; nulled on every terminal path so the TUI never keeps a
221
+ // listener pointing at a dead attach surface.
222
+ private colorSchemeBridgeUnsubscribe: (() => void) | null = null;
190
223
  private gracefulSocketCloseTimer: ReturnType<typeof setTimeout> | null = null;
191
224
  // Force a single full-clear on the first paint so the prior session/dashboard can't
192
225
  // ghost behind this overlay; every later paint uses the TUI's coalesced, throttled,
@@ -260,6 +293,18 @@ export class PtyAttachComponent implements Component {
260
293
  this.send({ type: "input", data });
261
294
  return;
262
295
  }
296
+ if (matchesKey(data, "ctrl+\\")) {
297
+ // Unconditional, terminal-independent escape (issue #126): 0x1c is a
298
+ // single raw ASCII byte every terminal forwards unchanged in raw
299
+ // mode — no modifier-encoding dependency like Ctrl+Left (macOS
300
+ // Terminal.app never emits it, WezTerm consumes it for tab
301
+ // switching). Only caveat (documented in README): layouts where
302
+ // `\` sits on AltGr may never produce this byte; the escape ladder
303
+ // still covers those. matchesKey also covers the kitty CSI-u and
304
+ // modifyOtherKeys encodings of the same chord.
305
+ this.detach();
306
+ return;
307
+ }
263
308
  if (matchesKey(data, Key.ctrl("left"))) {
264
309
  // Explicit detach chord (issue #89): single ← is gated on editor state
265
310
  // (it doubles as cursor-left inside a non-empty draft), so a user with
@@ -275,7 +320,15 @@ export class PtyAttachComponent implements Component {
275
320
  // escape unconditionally — the view must always be exitable, even
276
321
  // after the host crashes mid-output (issue #48). Note: ctrl+] is NOT
277
322
  // a detach key — it passes through to Pi (tui.editor.jumpForward).
278
- if (shouldEscapeAttach(this.connected, resolveEditorEmpty(this.editorEmpty, this.childInputLooksEmpty()))) {
323
+ // Issue #91 Phase 6 (spec §D1): the gate reads ONLY the pushed
324
+ // editorEmpty side channel — true detaches; false and null (child
325
+ // extension missing, hello not yet arrived, old runner) forward via
326
+ // the explicit conservative policy. The old terminal-buffer
327
+ // heuristics are deleted for good: rendered bytes carry no
328
+ // input-buffer semantics, so that chain guessed at a fact it
329
+ // could not know (issues #42/#66/#69/#103). Escape without a pushed
330
+ // state is Ctrl+← (issue #89), never a buffer guess.
331
+ if (shouldEscapeAttach(this.connected, this.editorEmpty === true)) {
279
332
  this.detach();
280
333
  return;
281
334
  }
@@ -305,7 +358,7 @@ export class PtyAttachComponent implements Component {
305
358
  }
306
359
  const header =
307
360
  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`);
361
+ this.theme.fg("muted", `${this.status} · opens links · drag selects+copies · Ctrl+←/Ctrl+\\ detach`);
309
362
  return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
310
363
  }
311
364
 
@@ -321,7 +374,7 @@ export class PtyAttachComponent implements Component {
321
374
  out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
322
375
  out.push(center(this.theme.fg("muted", detail), width));
323
376
  out.push("");
324
- out.push(center(this.theme.fg("dim", "←/Ctrl+← to detach"), width));
377
+ out.push(center(this.theme.fg("dim", "Ctrl+←/Ctrl+\\ to detach"), width));
325
378
  while (out.length < height) out.push("");
326
379
  return out.slice(0, height);
327
380
  }
@@ -344,60 +397,15 @@ export class PtyAttachComponent implements Component {
344
397
  // the socket immediately after receiving detach, so sending detach first
345
398
  // can drop the G3 restore resize (issue #42).
346
399
  this.jiggleRetry.restoreAndStop();
347
- this.send({ type: "detach" });
400
+ // Phase 5 (D4): enveloped detach (correlated applied ack) with legacy
401
+ // fallback; both reach the same runner detach handler.
402
+ if (!this.attachClient.sendControl("detach")) {
403
+ this.send({ type: "detach" });
404
+ }
348
405
  this.close(true);
349
406
  this.done({ action: "detached" });
350
407
  }
351
408
 
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
409
  private connect(): void {
402
410
  if (this.closed || this.connected || this.socket) return;
403
411
  // On Windows the control socket is a named pipe — it never exists as a
@@ -424,7 +432,18 @@ export class PtyAttachComponent implements Component {
424
432
  this.disconnectedAt = null;
425
433
  this.status = "attached";
426
434
  this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
427
- this.jiggleRetry.start(this.cols, this.rows);
435
+ // Snapshot+subscribe attach (issue #91 phase 4): the first connect
436
+ // probes with subscribe_terminal (old runners stay silent → probe
437
+ // timeout → legacy); protocol reconnects resume from the applied
438
+ // cursor over the SAME client instance (ring replay is seamless;
439
+ // eviction/restart answers a fresh snapshot). hello stays UI-owned
440
+ // and precedes the client's subscribe (established ordering).
441
+ if (this.attachMode === "undecided") this.attachClient.start();
442
+ else if (this.attachMode === "protocol") this.attachClient.reconnect(this.attachClient.getLastSeq());
443
+ // The shrink-and-hold redraw protocol is a LEGACY-path correctness
444
+ // tool: snapshot mode owns screen correctness via canonical frames,
445
+ // so the jiggle never runs there (armLegacyJiggle guards the race).
446
+ this.armLegacyJiggle();
428
447
  this.enableMouseScroll();
429
448
  this.scheduleRender();
430
449
  this.startAttachSettle();
@@ -440,6 +459,11 @@ export class PtyAttachComponent implements Component {
440
459
  this.socket = null;
441
460
  this.connected = false;
442
461
  this.disconnectedAt ??= Date.now();
462
+ // A drop inside the probe window must not leave the absolute 1500ms
463
+ // deadline armed: firing during the dead window would permanently
464
+ // downgrade a protocol-capable runner to legacy (CR R1 advisory). The
465
+ // reconnect's start()/reconnect() re-arms on the new socket.
466
+ this.attachClient.onDisconnect();
443
467
  if (!this.closed && this.status !== "host exited") {
444
468
  this.status = "disconnected";
445
469
  this.scheduleReconnect();
@@ -455,6 +479,10 @@ export class PtyAttachComponent implements Component {
455
479
  }
456
480
  this.socket = null;
457
481
  this.connected = false;
482
+ // Notify before nulling: the close event that follows will hit the
483
+ // stale-socket guard (this.socket no longer matches) and must not be
484
+ // the only path that cancels the probe deadline (CR R1 advisory).
485
+ this.attachClient.onDisconnect();
458
486
  try { socket.destroy(); } catch {}
459
487
  this.disconnectedAt ??= Date.now();
460
488
  if (this.closed) return;
@@ -558,6 +586,56 @@ export class PtyAttachComponent implements Component {
558
586
  // buffer, instead of diffing banner lines into buffer lines.
559
587
  this.scheduleRender(true);
560
588
  this.startDesyncProbe();
589
+ // Issue #128: the settle transition is the single point where `attaching`
590
+ // flips false (never re-armed), so both hooks run at most once per attach.
591
+ this.attachColorSchemeBridge();
592
+ this.replayBackgroundQuery();
593
+ }
594
+
595
+ /** Issue #128 D2: the local pi-tui consumes the child's color-scheme
596
+ * traffic (996 queries / 2031 notifications forwarded by D1) — reports
597
+ * never reach the child. onTerminalColorSchemeChange fires from that same
598
+ * consumption point, so the bridge repackages each event as a 997 report
599
+ * on the child pty, where the child's pi-tui parser consumes it. Once per
600
+ * attach (settle is single-shot + explicit guard); kill switch skips it. */
601
+ private attachColorSchemeBridge(): void {
602
+ if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0") return;
603
+ if (this.colorSchemeBridgeUnsubscribe) return;
604
+ try {
605
+ this.colorSchemeBridgeUnsubscribe = this.tui.onTerminalColorSchemeChange((scheme) => {
606
+ const data = toColorSchemeReport(scheme);
607
+ if (data) this.send({ type: "input", data });
608
+ });
609
+ } catch {
610
+ /* best-effort: an unbridgeable TUI keeps pre-#128 behavior */
611
+ }
612
+ }
613
+
614
+ /** Called on every terminal path (close() covers detach/dispose; the exit
615
+ * branch calls done() without close(), so it unsubscribes explicitly). */
616
+ private detachColorSchemeBridge(): void {
617
+ const unsubscribe = this.colorSchemeBridgeUnsubscribe;
618
+ this.colorSchemeBridgeUnsubscribe = null;
619
+ if (!unsubscribe) return;
620
+ try {
621
+ unsubscribe();
622
+ } catch {
623
+ /* best-effort: a stale listener must not block teardown */
624
+ }
625
+ }
626
+
627
+ /** Issue #128 D3: the child probed OSC 11 at spawn, before any attach
628
+ * client existed, so its background-color answer was lost. Re-ask the REAL
629
+ * terminal once now; the reply travels back through the existing input
630
+ * passthrough (handleInput fallback) to the child. Silent on failure — a
631
+ * missing answer keeps the pre-#128 fallback theme. */
632
+ private replayBackgroundQuery(): void {
633
+ if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0") return;
634
+ try {
635
+ this.tui.terminal.write("\x1b]11;?\x07");
636
+ } catch {
637
+ /* best-effort: enhancement, never critical */
638
+ }
561
639
  }
562
640
 
563
641
  private startDesyncProbe(): void {
@@ -586,6 +664,13 @@ export class PtyAttachComponent implements Component {
586
664
  */
587
665
  private checkDesync(): void {
588
666
  if (this.closed || this.attaching || !this.connected) return; // gates 1, 7
667
+ // Protocol mode owns screen correctness via canonical frames (issue #91
668
+ // phase 4, CR R1 advisory): a "misaligned cursor" there is frame content
669
+ // or a stale-frame transient that resync heals — a jiggle heal would fire
670
+ // resize pulses in a mode whose e2e pins zero resizes. The undecided
671
+ // window keeps legacy semantics on purpose: until the snapshot answer
672
+ // arrives the jiggle IS armed and the legacy fallthrough owns the screen.
673
+ if (this.attachMode === "protocol") return;
589
674
  const chain = this.jiggleRetry.getState();
590
675
  if (!chain.tuiFrameSeen) return; // gate 2: shell/vim children never heal
591
676
  if (!chain.stopped || chain.held) return; // gate 5: attach/heal chain active
@@ -606,6 +691,37 @@ export class PtyAttachComponent implements Component {
606
691
  this.mouseRefreshTimers = [];
607
692
  }
608
693
 
694
+ /**
695
+ * Legacy-path jiggle arming with a protocol-race guard (issue #91 phase 4).
696
+ * Legacy decided immediately (env-forced) → start now; undecided → start
697
+ * after the guard delay unless the snapshot answer wins (protocol). The
698
+ * timer guard accepts ANY non-protocol mode, so a fast in-flight decision
699
+ * (e.g. frame_version_mismatch landing ~1ms into the probe window) still
700
+ * arms the chain — an early-decided legacy session must not end up
701
+ * jiggle-less. start() re-arms idempotently, so a late duplicate is safe.
702
+ */
703
+ private armLegacyJiggle(): void {
704
+ this.clearJiggleStartTimer();
705
+ if (this.attachMode === "protocol") return;
706
+ if (this.attachMode === "legacy") {
707
+ if (this.connected) this.jiggleRetry.start(this.cols, this.rows);
708
+ return;
709
+ }
710
+ this.jiggleStartTimer = setTimeout(() => {
711
+ this.jiggleStartTimer = null;
712
+ if (!this.closed && this.connected && this.attachMode !== "protocol") {
713
+ this.jiggleRetry.start(this.cols, this.rows);
714
+ }
715
+ }, LEGACY_JIGGLE_ARM_DELAY_MS);
716
+ this.jiggleStartTimer.unref?.();
717
+ }
718
+
719
+ private clearJiggleStartTimer(): void {
720
+ if (!this.jiggleStartTimer) return;
721
+ clearTimeout(this.jiggleStartTimer);
722
+ this.jiggleStartTimer = null;
723
+ }
724
+
609
725
  private clearRetry(): void {
610
726
  if (!this.retryTimer) return;
611
727
  clearTimeout(this.retryTimer);
@@ -973,7 +1089,13 @@ export class PtyAttachComponent implements Component {
973
1089
  }
974
1090
 
975
1091
  private sendResize(cols = this.cols, rows = this.rows): void {
976
- this.send({ type: "resize", cols, rows });
1092
+ // Phase 5 (D4): enveloped resize when the host has an instance fence
1093
+ // (correlated applied/superseded acks; starting-window host_starting is
1094
+ // retried client-side); legacy plain message otherwise. Both paths reach
1095
+ // the same runner resize handler.
1096
+ if (!this.attachClient.sendControl("resize", { cols, rows })) {
1097
+ this.send({ type: "resize", cols, rows });
1098
+ }
977
1099
  this.clampViewportTop(this.bodyHeight());
978
1100
  }
979
1101
 
@@ -1045,6 +1167,12 @@ export class PtyAttachComponent implements Component {
1045
1167
  if (!line.trim()) continue;
1046
1168
  try {
1047
1169
  const msg = JSON.parse(line);
1170
+ // Protocol-managed messages (snapshot windows, seq-checked live
1171
+ // output, recovery) are consumed by the attach client and re-emitted
1172
+ // through handleAttachEvent. UI-owned messages (hello/status/
1173
+ // editor_state/exit/error) and pre-decision broadcast output return
1174
+ // false and fall through to the legacy path below.
1175
+ if (this.attachClient.handleMessage(msg)) continue;
1048
1176
  if (msg.type === "output" && typeof msg.data === "string") {
1049
1177
  this.pushOutput(msg.data, { forwardProtocols: true });
1050
1178
  this.checkClearSequence(msg.data);
@@ -1057,6 +1185,10 @@ export class PtyAttachComponent implements Component {
1057
1185
  this.editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
1058
1186
  } else if (msg.type === "exit") {
1059
1187
  this.status = "host exited";
1188
+ // The exit path calls done() directly (host disposes later);
1189
+ // drop the #128 bridge now so A7 symmetry doesn't depend on
1190
+ // the host honoring dispose().
1191
+ this.detachColorSchemeBridge();
1060
1192
  this.done({ action: "closed", exitCode: msg.exitCode ?? null });
1061
1193
  } else if (msg.type === "error") this.status = `error: ${msg.message ?? "host error"}`;
1062
1194
  if (shouldScheduleAttachRenderForMessage(msg.type)) needsRender = true;
@@ -1067,6 +1199,95 @@ export class PtyAttachComponent implements Component {
1067
1199
  if (needsRender) this.scheduleRender();
1068
1200
  }
1069
1201
 
1202
+ /**
1203
+ * Attach-client events (issue #91 phase 4). The client owns protocol
1204
+ * recovery (it resubscribes internally); the UI only switches paths and
1205
+ * hydrates frames.
1206
+ */
1207
+ private handleAttachEvent(
1208
+ event: "mode" | "snapshotBegin" | "snapshotReady" | "output" | "resubscribing" | "protocolError" | "cmdAck" | "reconciled" | "epochReset",
1209
+ payload: any,
1210
+ ): void {
1211
+ if (event === "mode") {
1212
+ const prev = this.attachMode;
1213
+ this.attachMode = payload as "protocol" | "legacy";
1214
+ if (this.attachMode === "protocol") {
1215
+ // Snapshot mode owns screen correctness: cancel any pending legacy
1216
+ // jiggle arming and unwind anything already armed (restores a held
1217
+ // resize; no-ops otherwise).
1218
+ this.clearJiggleStartTimer();
1219
+ this.jiggleRetry.restoreAndStop();
1220
+ } else if (prev === "protocol") {
1221
+ // Downgrade after a protocol session (recovery budget exhausted):
1222
+ // re-arm the legacy screen-healing machinery. This must run whenever
1223
+ // the socket is alive — not only during the attach transition. A
1224
+ // post-settle downgrade that skipped start() left the chain stopped
1225
+ // forever (restoreAndStop on the protocol side), so feed() early-
1226
+ // returned and raw output could never heal a desynced screen.
1227
+ this.clearJiggleStartTimer();
1228
+ if (this.connected) this.jiggleRetry.start(this.cols, this.rows);
1229
+ }
1230
+ // Legacy decided during the undecided window: the race-guard timer
1231
+ // already fired at 100ms (≪ the 1500ms probe timeout) — nothing to do.
1232
+ return;
1233
+ }
1234
+ if (event === "snapshotBegin") {
1235
+ // Protocol attach size-sync (CR R1 blocking): the legacy attach resized
1236
+ // the child at every connect (jiggle start); subscribe_terminal carries
1237
+ // no size, so without this the child keeps its host-creation geometry
1238
+ // and full-screen TUI children lay out at a stale size. begin is
1239
+ // authoritative for the runner's CURRENT size; the in-flight frame stays
1240
+ // old-geometry either way, but resizing now starts the child's redraw at
1241
+ // the true size sooner. Matched sizes send nothing (zero-resize
1242
+ // contract for same-size attach stays intact).
1243
+ const beginSize = (payload ?? {}) as { cols?: number; rows?: number };
1244
+ if (
1245
+ typeof beginSize.cols === "number" &&
1246
+ typeof beginSize.rows === "number" &&
1247
+ (beginSize.cols !== this.cols || beginSize.rows !== this.rows)
1248
+ ) {
1249
+ this.sendResize();
1250
+ }
1251
+ return;
1252
+ }
1253
+ if (event === "snapshotReady") {
1254
+ const snap = (payload ?? {}) as { frame?: string; empty?: boolean; resnapshot?: boolean };
1255
+ // The synthesized frame is self-contained on dirty terminals (phase 3
1256
+ // torture-proven: DECSTR + clear preamble wipes the constructor
1257
+ // screen.log replay and any pre-probe broadcast bytes), so hydrating is
1258
+ // a plain push: the write callback drives receivedOutput/settle/render
1259
+ // exactly like the legacy replay path. No protocol forwarding — the
1260
+ // frame is runner-synthesized grid content, not child passthrough
1261
+ // sequences. Empty baselines (the COMMON initial attach state: the host
1262
+ // publishes alive+childPid before the child's first output) carry no
1263
+ // frame; the loading banner persists until the first live output.
1264
+ // Empty/resnapshot answers ALWAYS wipe the local buffer, attaching or
1265
+ // not: while attaching the banner hides it (zero visual cost), and the
1266
+ // constructor's screen.log warm-start must not outlive the protocol
1267
+ // answer — a gated reset let the dead session's tail render as a
1268
+ // cold-start ghost once the banner lifted (final-review F1).
1269
+ if (typeof snap.frame === "string") this.pushOutput(snap.frame);
1270
+ else if (snap.empty || snap.resnapshot) this.term.reset();
1271
+ return;
1272
+ }
1273
+ if (event === "output") {
1274
+ // Live/replay protocol output: no jiggle clear-detection — that feed
1275
+ // exists to cancel the shrink-and-hold chain, which never runs in
1276
+ // protocol mode.
1277
+ if (typeof payload === "string") this.pushOutput(payload, { forwardProtocols: true });
1278
+ return;
1279
+ }
1280
+ // "resubscribing": recovery is client-internal (informational only).
1281
+ // "protocolError": observability only — recovery is automatic until the
1282
+ // legacy fallback, whose mode event the UI acts on above.
1283
+ // "cmdAck"/"reconciled"/"epochReset" (phase 5): correlation/observability
1284
+ // only — applied/superseded bookkeeping, reconcile baselines and the
1285
+ // generation epoch rule live inside the client; the UI's behavioral
1286
+ // surface (hydrate, mode switching, size-sync) is unchanged. host_starting
1287
+ // resize retries are also client-internal (parity with the legacy runner-
1288
+ // side cachedResize).
1289
+ }
1290
+
1070
1291
  private forwardTerminalProtocols(data: string): void {
1071
1292
  const toWrite: string[] = [];
1072
1293
  if (process.env.AGENT_BOARD_FORWARD_OSC52 !== "0") {
@@ -1079,6 +1300,15 @@ export class PtyAttachComponent implements Component {
1079
1300
  this.passthroughCarry = carry;
1080
1301
  toWrite.push(...sequences);
1081
1302
  }
1303
+ if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES !== "0") {
1304
+ // Issue #128 D1: forward the child's terminal capability queries
1305
+ // (OSC 11 background probe, 2031 scheme-notify switch) to the real
1306
+ // terminal; set-forms and kitty/DA negotiation stay excluded (see
1307
+ // extractOscQuerySequences).
1308
+ const { sequences, carry } = extractOscQuerySequences(this.oscQueryCarry + data);
1309
+ this.oscQueryCarry = carry;
1310
+ toWrite.push(...sequences);
1311
+ }
1082
1312
  for (const seq of toWrite) {
1083
1313
  try {
1084
1314
  this.tui.terminal.write(seq);
@@ -1088,6 +1318,15 @@ export class PtyAttachComponent implements Component {
1088
1318
  }
1089
1319
  }
1090
1320
 
1321
+ /** LEGACY FALLBACK (issue #91 phase 6 marking): the screen.log tail replay
1322
+ * is kept only for pre-protocol runners and the
1323
+ * AGENT_BOARD_TERMINAL_SNAPSHOT=0 kill switch. It is NOT a correctness
1324
+ * path of the snapshot+subscribe protocol — the protocol hydrates from
1325
+ * runner-owned snapshots and resumes from a sequence cursor (phase 4),
1326
+ * and this replay runs before the probe resolves. Removal condition: the
1327
+ * installed runner fleet is on the snapshot protocol baseline
1328
+ * (wire-detectable via hello protocol fields).
1329
+ */
1091
1330
  private replayScreenLog(): void {
1092
1331
  if (!this.opts.screenLogPath || !existsSync(this.opts.screenLogPath)) return;
1093
1332
  try {
@@ -1147,8 +1386,9 @@ export class PtyAttachComponent implements Component {
1147
1386
  // the child terminal); cursorX may equal cols (one past the last cell). Only render
1148
1387
  // it when it lands inside the projected viewport.
1149
1388
  const cursor = projectPtyCursor(buf, start, height);
1389
+ const cursorHidden = isPtyCursorHidden(this.term);
1150
1390
  for (let i = start; i < end; i++) {
1151
- out.push(lineToAnsi(buf.getLine(i), reusable, this.term, i, selection, cursor));
1391
+ out.push(lineToAnsi(buf.getLine(i), reusable, this.term, i, selection, cursor, cursorHidden));
1152
1392
  }
1153
1393
  if (out.length === 0) out.push("Waiting for PTY output…");
1154
1394
  return { lines: out.slice(-height), cursor };
@@ -1163,6 +1403,8 @@ export class PtyAttachComponent implements Component {
1163
1403
  }
1164
1404
  this.closed = true;
1165
1405
  this.imeCoalesceUninstall?.();
1406
+ this.detachColorSchemeBridge();
1407
+ this.attachClient.close();
1166
1408
  this.jiggleRetry.restoreAndStop();
1167
1409
  this.disableMouseScroll();
1168
1410
  this.clearMouseRefreshTimers();
@@ -1280,12 +1522,14 @@ function lineToAnsi(
1280
1522
  lineIndex: number,
1281
1523
  selection: NormalizedSelection | null,
1282
1524
  cursor: { row: number; col: number } | null,
1525
+ cursorHidden = false,
1283
1526
  ): string {
1284
1527
  const isCursorRow = cursor !== null && cursor.row === lineIndex;
1285
1528
  let last = -1;
1286
1529
  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";
1530
+ // No buffer line: keep the marker (IME positioning) and paint the inverse block
1531
+ // only while the child reports a visible cursor.
1532
+ if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m";
1289
1533
  return "";
1290
1534
  }
1291
1535
  for (let x = 0; x < line.length; x++) {
@@ -1294,9 +1538,8 @@ function lineToAnsi(
1294
1538
  if (cell.getChars()) last = x;
1295
1539
  }
1296
1540
  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";
1541
+ // Empty line: same split as above.
1542
+ if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m";
1300
1543
  return "";
1301
1544
  }
1302
1545
 
@@ -1313,24 +1556,27 @@ function lineToAnsi(
1313
1556
  prevUri = uri;
1314
1557
  }
1315
1558
  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.
1559
+ // Position and visibility are separate concerns: the zero-width CURSOR_MARKER
1560
+ // (stripped by the TUI) always marks where the hardware cursor belongs for IME
1561
+ // and PI_HARDWARE_CURSOR=1 terminals, while the solid inverse block is only
1562
+ // painted when the child terminal itself reports the cursor as visible. pi-tui
1563
+ // parks a hidden cursor at a diff-write byproduct position, so painting it
1564
+ // unconditionally showed a ghost block (issue #102).
1320
1565
  const isCursor = isCursorRow && x === cursor!.col;
1321
1566
  if (isCursor) out += CURSOR_MARKER;
1322
- const key = attrKey(cell, selected, isCursor);
1567
+ const paintCursor = isCursor && !cursorHidden;
1568
+ const key = attrKey(cell, selected, paintCursor);
1323
1569
  if (key !== prevAttr) {
1324
- out += attrsToAnsi(cell, selected, isCursor);
1570
+ out += attrsToAnsi(cell, selected, paintCursor);
1325
1571
  prevAttr = key;
1326
1572
  }
1327
1573
  out += cell.getChars() || " ";
1328
1574
  }
1329
1575
  if (prevUri) out += closeOsc8();
1330
1576
  // 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.
1577
+ // append an inverse space so a VISIBLE position shows, keeping the marker either way.
1332
1578
  if (isCursorRow && cursor!.col > last) {
1333
- out += CURSOR_MARKER + "\x1b[7m \x1b[0m";
1579
+ out += CURSOR_MARKER + (cursorHidden ? "" : "\x1b[7m \x1b[0m");
1334
1580
  }
1335
1581
  return out + "\x1b[0m";
1336
1582
  }
@@ -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
- }