@zhuxixi/pi-agent-board 0.6.2 → 0.8.0

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