@zhuxixi/pi-agent-board 0.5.0 → 0.5.2

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 (31) hide show
  1. package/PROGRESS.md +18 -3
  2. package/README.md +298 -76
  3. package/VERIFY.md +3 -3
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -3
  5. package/docs/superpowers/plans/2026-08-30-circular-navigation.md +308 -0
  6. package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +311 -0
  7. package/docs/superpowers/plans/2026-08-30-readme-v2.md +294 -0
  8. package/docs/superpowers/plans/2026-09-01-attach-detach-gate-cursor-anchor.md +284 -0
  9. package/docs/superpowers/plans/2026-09-02-attach-detach-editor-state.md +722 -0
  10. package/docs/superpowers/plans/2026-09-03-detach-gate-glyph-fallback.md +146 -0
  11. package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +4 -0
  12. package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +4 -0
  13. package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +47 -0
  14. package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +105 -0
  15. package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +115 -0
  16. package/docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md +78 -0
  17. package/docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md +120 -0
  18. package/docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md +99 -0
  19. package/package.json +1 -1
  20. package/runner/pty-runner.mjs +64 -17
  21. package/src/core/code-refs-store.mjs +3 -0
  22. package/src/core/editor-state-reporter.mjs +102 -0
  23. package/src/core/launch.mjs +6 -0
  24. package/src/core/pty-attach-jiggle-controller.mjs +71 -17
  25. package/src/core/pty-input.mjs +32 -0
  26. package/src/core/pty-scroll.mjs +4 -3
  27. package/src/core/repo.mjs +3 -0
  28. package/src/core/worktree.mjs +1 -0
  29. package/src/index.ts +12 -1
  30. package/src/ui/dashboard.ts +6 -2
  31. package/src/ui/pty-attach.ts +148 -32
package/src/index.ts CHANGED
@@ -6,9 +6,10 @@
6
6
  * the count of sessions needing attention. See docs/EXPLORATION.md for the design.
7
7
  */
8
8
  import { fileURLToPath } from "node:url";
9
+ import { createConnection } from "node:net";
9
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
11
  import { resolvePiInvocation } from "./core/invocation.mjs";
11
- import { defaultRoot } from "./core/paths.mjs";
12
+ import { controlSocketPathFor, defaultRoot } from "./core/paths.mjs";
12
13
  import { listRows } from "./core/store.mjs";
13
14
  import { createService } from "./runtime/service.mjs";
14
15
  import { openDashboard, registerAgentBoardCommand } from "./commands/agent-board.js";
@@ -19,6 +20,8 @@ const PTY_RUNNER_SCRIPT = fileURLToPath(new URL("../runner/pty-runner.mjs", impo
19
20
  const TITLE_RUNNER_SCRIPT = fileURLToPath(new URL("../runner/title-runner.mjs", import.meta.url));
20
21
  const AUTO_STATE_RUNNER_SCRIPT = fileURLToPath(new URL("../runner/state-runner.mjs", import.meta.url));
21
22
 
23
+ let hostedEditorReporter: { start(): void; stop(): void } | null = null;
24
+
22
25
  export default function piAgentBoard(pi: ExtensionAPI): void {
23
26
  const root = defaultRoot();
24
27
  const { piCommand, piArgsPrefix } = resolvePiInvocation();
@@ -71,6 +74,14 @@ export default function piAgentBoard(pi: ExtensionAPI): void {
71
74
  };
72
75
 
73
76
  pi.on("session_start", async (event, ctx) => {
77
+ if (isHostedChild && !hostedEditorReporter && typeof ctx.ui?.getEditorText === "function" && hostedViewId) {
78
+ const { createEditorStateReporter } = await import("./core/editor-state-reporter.mjs");
79
+ hostedEditorReporter = createEditorStateReporter({
80
+ getEditorText: () => ctx.ui.getEditorText(),
81
+ connect: () => createConnection(controlSocketPathFor(process.platform as "win32" | "linux" | "darwin", root, hostedViewId)),
82
+ });
83
+ hostedEditorReporter.start();
84
+ }
74
85
  updateStatus(ctx);
75
86
  if (event.reason === "startup" && !isHostedChild && pi.getFlag("agent-board") === true && ctx.hasUI) {
76
87
  const service = createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: ctx.cwd });
@@ -250,7 +250,9 @@ export class DashboardComponent implements Component {
250
250
  private moveSelection(delta: number): void {
251
251
  if (this.orderedIds.length === 0) return;
252
252
  const cur = this.selectedId ? this.orderedIds.indexOf(this.selectedId) : 0;
253
- const next = Math.max(0, Math.min(this.orderedIds.length - 1, (cur < 0 ? 0 : cur) + delta));
253
+ const len = this.orderedIds.length;
254
+ // Wrap around both ends: down past last -> first, up past first -> last.
255
+ const next = (((cur < 0 ? 0 : cur) + delta) % len + len) % len;
254
256
  const nextId = this.orderedIds[next];
255
257
  if (nextId === this.selectedId) return;
256
258
  this.selectedId = nextId;
@@ -1095,7 +1097,9 @@ export class DashboardComponent implements Component {
1095
1097
  if (!this.peekId) return;
1096
1098
  const idx = this.orderedIds.indexOf(this.peekId);
1097
1099
  if (idx < 0) return;
1098
- const next = Math.max(0, Math.min(this.orderedIds.length - 1, idx + delta));
1100
+ const len = this.orderedIds.length;
1101
+ // Wrap around both ends, same as moveSelection().
1102
+ const next = ((idx + delta) % len + len) % len;
1099
1103
  this.peekId = this.orderedIds[next];
1100
1104
  this.selectedId = this.peekId;
1101
1105
  }
@@ -5,7 +5,7 @@ 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 } from "../core/pty-input.mjs";
8
+ import { isProbablyEmptyPiInputLine, isProbablyPiInputLine, resolveEditorEmpty } from "../core/pty-input.mjs";
9
9
  import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
10
10
  import { createAttachOutputRenderScheduler, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
11
11
  import { evaluateAttachReconnect, shouldEscapeAttach } from "../core/pty-attach-reconnect.mjs";
@@ -29,7 +29,7 @@ export interface PtyAttachOptions {
29
29
  const require = createRequire(import.meta.url);
30
30
  const { Terminal } = require("@xterm/headless") as { Terminal: new (opts: Record<string, unknown>) => XtermLike };
31
31
 
32
- const DETACH_KEYS = new Set(["\x1d"]); // ctrl+]
32
+
33
33
  const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
34
34
  const MOUSE_DISABLE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";
35
35
  const XTSHIFTESCAPE_SELECT = "\x1b[>0s";
@@ -40,6 +40,8 @@ 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
+ /** Give ordered detach packets time to flush before using destroy as a fallback. */
44
+ const GRACEFUL_SOCKET_CLOSE_MS = 1000;
43
45
  /** How many tail bytes of the screen log to replay on attach. Read from the file tail
44
46
  * (not the whole file) so multi-MB logs don't block startup; ~60KB covers the last
45
47
  * handful of screens, which is all a fresh attach needs. */
@@ -156,6 +158,9 @@ export class PtyAttachComponent implements Component {
156
158
  private pendingClickTimer: ReturnType<typeof setTimeout> | null = null;
157
159
  private lastClickPoint: MousePoint | null = null;
158
160
  private lastClickAt = 0;
161
+ /** Authoritative editor emptiness pushed by the child Pi extension via the
162
+ * control socket (issue #68). null = unknown — fall back to the heuristic. */
163
+ private editorEmpty: boolean | null = null;
159
164
  // Whether any PTY output (live or replayed) has been shown yet. Until then we paint a
160
165
  // loading banner instead of an empty buffer so a slow (cold) host start doesn't leave
161
166
  // the previous screen visible.
@@ -166,6 +171,7 @@ export class PtyAttachComponent implements Component {
166
171
  private attaching = true;
167
172
  private attachSettleTimer: ReturnType<typeof setTimeout> | null = null;
168
173
  private attachHardTimeout: ReturnType<typeof setTimeout> | null = null;
174
+ private gracefulSocketCloseTimer: ReturnType<typeof setTimeout> | null = null;
169
175
  // Force a single full-clear on the first paint so the prior session/dashboard can't
170
176
  // ghost behind this overlay; every later paint uses the TUI's coalesced, throttled,
171
177
  // differential renderer so wheel/output bursts don't each trigger a full repaint.
@@ -238,15 +244,12 @@ export class PtyAttachComponent implements Component {
238
244
  this.send({ type: "input", data });
239
245
  return;
240
246
  }
241
- if (
242
- DETACH_KEYS.has(data) ||
243
- matchesKey(data, Key.left) ||
244
- matchesKey(data, Key.ctrl("]"))
245
- ) {
247
+ if (matchesKey(data, Key.left)) {
246
248
  // While the socket is down the key can never reach the child, so
247
249
  // escape unconditionally — the view must always be exitable, even
248
- // after the host crashes mid-output (issue #48).
249
- if (shouldEscapeAttach(this.connected, this.childInputLooksEmpty())) {
250
+ // after the host crashes mid-output (issue #48). Note: ctrl+] is NOT
251
+ // a detach key — it passes through to Pi (tui.editor.jumpForward).
252
+ if (shouldEscapeAttach(this.connected, resolveEditorEmpty(this.editorEmpty, this.childInputLooksEmpty()))) {
250
253
  this.detach();
251
254
  return;
252
255
  }
@@ -268,7 +271,7 @@ export class PtyAttachComponent implements Component {
268
271
  const bodyHeight = Math.max(1, height - 2);
269
272
  let body: string[];
270
273
  if (!this.attaching && this.receivedOutput) {
271
- const projected = this.project(bodyHeight, width);
274
+ const projected = this.project(bodyHeight);
272
275
  body = projected.lines;
273
276
  while (body.length < bodyHeight) body.unshift("");
274
277
  } else {
@@ -276,7 +279,7 @@ export class PtyAttachComponent implements Component {
276
279
  }
277
280
  const header =
278
281
  this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
279
- this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · ← detach · ctrl+] detach`);
282
+ this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · ← detach`);
280
283
  return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
281
284
  }
282
285
 
@@ -292,7 +295,7 @@ export class PtyAttachComponent implements Component {
292
295
  out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
293
296
  out.push(center(this.theme.fg("muted", detail), width));
294
297
  out.push("");
295
- out.push(center(this.theme.fg("dim", "← or ctrl+] to detach"), width));
298
+ out.push(center(this.theme.fg("dim", "← to detach"), width));
296
299
  while (out.length < height) out.push("");
297
300
  return out.slice(0, height);
298
301
  }
@@ -311,17 +314,62 @@ export class PtyAttachComponent implements Component {
311
314
  }
312
315
 
313
316
  private detach(): void {
317
+ // Restore a held PTY before ending the control socket. The runner closes
318
+ // the socket immediately after receiving detach, so sending detach first
319
+ // can drop the G3 restore resize (issue #42).
320
+ this.jiggleRetry.restoreAndStop();
314
321
  this.send({ type: "detach" });
315
- this.close();
322
+ this.close(true);
316
323
  this.done({ action: "detached" });
317
324
  }
318
325
 
326
+ /** Bottom-most line whose cells include an inverse-video cell — Pi renders
327
+ * its editor cursor as an inverse "fake cursor" (`ESC[7m`), and the cell
328
+ * persists in the buffer even while streaming differential frames skip
329
+ * repainting the editor line. */
330
+ private findLastInverseCellLine(active: {
331
+ baseY: number;
332
+ length: number;
333
+ getLine(index: number): BufferLineLike | undefined;
334
+ }): number | null {
335
+ for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
336
+ const line = active.getLine(y);
337
+ if (!line) continue;
338
+ for (let x = 0; x < line.length; x++) {
339
+ if (line.getCell(x)?.isInverse()) return y;
340
+ }
341
+ }
342
+ return null;
343
+ }
344
+
319
345
  private childInputLooksEmpty(): boolean {
320
346
  if (!this.receivedOutput) return true;
321
347
  const active = this.term.buffer.active;
322
- if (typeof active.cursorY !== "number") return false;
323
- const line = active.getLine(active.baseY + active.cursorY)?.translateToString(true) ?? "";
324
- return isProbablyEmptyPiInputLine(line);
348
+ // The terminal cursor is not a reliable anchor for the editor line:
349
+ // while Pi streams output (or right after attach) the cursor rests on
350
+ // working/output lines, never the input line, so a genuinely empty
351
+ // editor was misread as non-empty and ← stopped detaching (issue #66).
352
+ // Pi's editor line always carries an inverse-video fake-cursor cell,
353
+ // so anchor on that instead.
354
+ const fakeCursorLine = this.findLastInverseCellLine(active);
355
+ if (fakeCursorLine !== null) {
356
+ const line = active.getLine(fakeCursorLine)?.translateToString(true) ?? "";
357
+ return isProbablyEmptyPiInputLine(line);
358
+ }
359
+ // Fallback: Pi variants that render no fake cursor — look for an EMPTY
360
+ // prompt-glyph line. Only an empty glyph line proves an empty editor:
361
+ // content glyph lines (markdown table rows `│ … │`, quotes `> …`, or a
362
+ // real draft in a no-fake-cursor Pi variant) cannot be told apart, and
363
+ // trapping the user is worse than a spurious detach (issue #69) — skip
364
+ // them and keep scanning; the loop-end escape below stays authoritative.
365
+ for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
366
+ const line = active.getLine(y)?.translateToString(true) ?? "";
367
+ if (isProbablyPiInputLine(line) && isProbablyEmptyPiInputLine(line)) return true;
368
+ }
369
+ // No editor line recoverable (e.g. a garbled replay buffer): treat the
370
+ // input as empty — ← is the only detach key left on the attach surface,
371
+ // so it must always escape rather than trap the user.
372
+ return true;
325
373
  }
326
374
 
327
375
  private connect(): void {
@@ -336,7 +384,14 @@ export class PtyAttachComponent implements Component {
336
384
  }
337
385
  const socket = createConnection(this.opts.socketPath);
338
386
  this.socket = socket;
387
+ // A partial protocol line left over from a failed socket would prefix the
388
+ // replacement connection's first line; start each socket with a clean buffer.
389
+ this.parserBuffer = "";
339
390
  socket.on("connect", () => {
391
+ if (this.socket !== socket) {
392
+ try { socket.destroy(); } catch {}
393
+ return;
394
+ }
340
395
  this.clearRetry();
341
396
  this.connected = true;
342
397
  this.everConnected = true;
@@ -348,8 +403,14 @@ export class PtyAttachComponent implements Component {
348
403
  this.scheduleRender();
349
404
  this.startAttachSettle();
350
405
  });
351
- socket.on("data", (chunk) => this.onSocketData(chunk.toString("utf8")));
406
+ socket.on("data", (chunk) => {
407
+ if (this.socket !== socket) return;
408
+ this.onSocketData(chunk.toString("utf8"));
409
+ });
352
410
  socket.on("close", () => {
411
+ // A failed socket can close after a replacement connection has already
412
+ // succeeded. Never let that stale event clear the replacement state.
413
+ if (this.socket !== socket) return;
353
414
  this.socket = null;
354
415
  this.connected = false;
355
416
  this.disconnectedAt ??= Date.now();
@@ -360,8 +421,15 @@ export class PtyAttachComponent implements Component {
360
421
  if (!this.closed) this.scheduleRender();
361
422
  });
362
423
  socket.on("error", (err) => {
424
+ // The error may belong to an old socket after reconnect. Dispose that
425
+ // socket, but do not alter the state of the current connection.
426
+ if (this.socket !== socket) {
427
+ try { socket.destroy(); } catch {}
428
+ return;
429
+ }
363
430
  this.socket = null;
364
431
  this.connected = false;
432
+ try { socket.destroy(); } catch {}
365
433
  this.disconnectedAt ??= Date.now();
366
434
  if (this.closed) return;
367
435
  this.status = `waiting for host… ${err.message}`;
@@ -476,12 +544,20 @@ export class PtyAttachComponent implements Component {
476
544
  this.retryTimer = null;
477
545
  }
478
546
 
547
+ private clearGracefulSocketCloseTimer(): void {
548
+ if (!this.gracefulSocketCloseTimer) return;
549
+ clearTimeout(this.gracefulSocketCloseTimer);
550
+ this.gracefulSocketCloseTimer = null;
551
+ }
552
+
479
553
  private enableMouseScroll(): void {
480
554
  if (!this.mouseScrollEnabled()) return;
481
555
  try {
482
556
  this.tui.terminal.write(XTSHIFTESCAPE_SELECT);
483
557
  this.tui.terminal.write(MOUSE_ENABLE);
484
- } catch {}
558
+ } catch {
559
+ /* best-effort: some terminals reject these sequences; mouse reporting is optional */
560
+ }
485
561
  }
486
562
 
487
563
  private mouseScrollEnabled(): boolean {
@@ -507,7 +583,9 @@ export class PtyAttachComponent implements Component {
507
583
  private disableMouseScroll(): void {
508
584
  try {
509
585
  this.tui.terminal.write(MOUSE_DISABLE);
510
- } catch {}
586
+ } catch {
587
+ /* best-effort: terminal may already be gone at teardown */
588
+ }
511
589
  }
512
590
 
513
591
  private handleMouseInputChunk(events: Array<{ raw: string; mouse: { button: number; row: number; col: number; action: string } }>): boolean {
@@ -721,7 +799,9 @@ export class PtyAttachComponent implements Component {
721
799
  if (seq) {
722
800
  try {
723
801
  this.tui.terminal.write(seq);
724
- } catch {}
802
+ } catch {
803
+ /* best-effort: OSC52 clipboard support is optional */
804
+ }
725
805
  }
726
806
  // Also mirror the selection into the X11 PRIMARY selection so the rest of the
727
807
  // desktop can middle-click-paste it — closes the loop with pastePrimarySelection().
@@ -742,7 +822,9 @@ export class PtyAttachComponent implements Component {
742
822
  const timer = setTimeout(() => {
743
823
  try {
744
824
  child.kill("SIGKILL");
745
- } catch {}
825
+ } catch {
826
+ /* the child may have already exited before the timeout fired */
827
+ }
746
828
  }, 800);
747
829
  child.stdout?.on("data", (chunk: Buffer) => {
748
830
  out += chunk.toString("utf8");
@@ -752,7 +834,9 @@ export class PtyAttachComponent implements Component {
752
834
  clearTimeout(timer);
753
835
  if (!this.closed && out) this.send({ type: "input", data: out });
754
836
  });
755
- } catch {}
837
+ } catch {
838
+ /* silent no-op when xclip is absent — documented contract of this helper */
839
+ }
756
840
  }
757
841
 
758
842
  /** Write `text` to the X11 PRIMARY selection so other apps can middle-click-paste it. */
@@ -763,7 +847,9 @@ export class PtyAttachComponent implements Component {
763
847
  child.stdin?.on("error", () => {});
764
848
  child.on("error", () => {});
765
849
  child.stdin?.end(text);
766
- } catch {}
850
+ } catch {
851
+ /* silent no-op when xclip is absent */
852
+ }
767
853
  }
768
854
 
769
855
  private selectionText(): string {
@@ -792,6 +878,9 @@ export class PtyAttachComponent implements Component {
792
878
  }
793
879
 
794
880
  private currentSize(): { cols: number; rows: number } {
881
+ // SAFETY: duck-typed read — Pi TUI's Terminal type does not consistently expose
882
+ // cols/columns/rows across versions (see resizeIfNeeded below). Runtime
883
+ // fallbacks (120/24) keep this safe when the fields are absent.
795
884
  const term = this.tui.terminal as unknown as { cols?: number; columns?: number; rows?: number } | undefined;
796
885
  return {
797
886
  cols: Math.max(20, term?.cols ?? term?.columns ?? 120),
@@ -893,8 +982,12 @@ export class PtyAttachComponent implements Component {
893
982
  this.checkClearSequence(msg.data);
894
983
  continue;
895
984
  }
896
- if (msg.type === "hello" || msg.type === "status") this.status = "attached";
897
- else if (msg.type === "exit") {
985
+ if (msg.type === "hello" || msg.type === "status") {
986
+ this.status = "attached";
987
+ if (msg.type === "hello") this.editorEmpty = typeof msg.editorEmpty === "boolean" ? msg.editorEmpty : null;
988
+ } else if (msg.type === "editor_state") {
989
+ this.editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
990
+ } else if (msg.type === "exit") {
898
991
  this.status = "host exited";
899
992
  this.done({ action: "closed", exitCode: msg.exitCode ?? null });
900
993
  } else if (msg.type === "error") this.status = `error: ${msg.message ?? "host error"}`;
@@ -921,7 +1014,9 @@ export class PtyAttachComponent implements Component {
921
1014
  for (const seq of toWrite) {
922
1015
  try {
923
1016
  this.tui.terminal.write(seq);
924
- } catch {}
1017
+ } catch {
1018
+ /* best-effort: forwarded sequences are enhancements, never critical */
1019
+ }
925
1020
  }
926
1021
  }
927
1022
 
@@ -948,7 +1043,9 @@ export class PtyAttachComponent implements Component {
948
1043
  } finally {
949
1044
  closeSync(fd);
950
1045
  }
951
- } catch {}
1046
+ } catch {
1047
+ /* best-effort: a missing or racing screen.log must not block attach */
1048
+ }
952
1049
  }
953
1050
 
954
1051
  private pushOutput(data: string, opts: { forwardProtocols?: boolean } = {}): void {
@@ -967,7 +1064,7 @@ export class PtyAttachComponent implements Component {
967
1064
  });
968
1065
  }
969
1066
 
970
- private project(height: number, width: number): { lines: string[]; cursor: { row: number; col: number } | null } {
1067
+ private project(height: number): { lines: string[]; cursor: { row: number; col: number } | null } {
971
1068
  const out: string[] = [];
972
1069
  const buf = this.term.buffer.active;
973
1070
  const selection = normalizeSelection(this.selection);
@@ -986,7 +1083,13 @@ export class PtyAttachComponent implements Component {
986
1083
  return { lines: out.slice(-height), cursor };
987
1084
  }
988
1085
 
989
- private close(): void {
1086
+ private close(gracefulSocket = false): void {
1087
+ if (this.closed && !this.socket) {
1088
+ // Already closed (e.g. dispose() after detach()); a pending graceful-close
1089
+ // fallback timer stays armed on purpose — it self-clears on socket close
1090
+ // or destroys the socket after the grace window.
1091
+ return;
1092
+ }
990
1093
  this.closed = true;
991
1094
  this.imeCoalesceUninstall?.();
992
1095
  this.jiggleRetry.restoreAndStop();
@@ -1005,11 +1108,24 @@ export class PtyAttachComponent implements Component {
1005
1108
  clearTimeout(this.attachHardTimeout);
1006
1109
  this.attachHardTimeout = null;
1007
1110
  }
1008
- try {
1009
- this.socket?.destroy();
1010
- } catch {}
1111
+ this.clearGracefulSocketCloseTimer();
1112
+ const socket = this.socket;
1011
1113
  this.socket = null;
1012
1114
  this.connected = false;
1115
+ if (!socket) return;
1116
+ if (!gracefulSocket) {
1117
+ try { socket.destroy(); } catch {}
1118
+ return;
1119
+ }
1120
+ // socket.end() preserves the ordering of the already-buffered restore and
1121
+ // detach packets. destroy() would discard buffered writes under backpressure.
1122
+ socket.once("close", () => this.clearGracefulSocketCloseTimer());
1123
+ try { socket.end(); } catch {}
1124
+ this.gracefulSocketCloseTimer = setTimeout(() => {
1125
+ this.gracefulSocketCloseTimer = null;
1126
+ try { socket.destroy(); } catch {}
1127
+ }, GRACEFUL_SOCKET_CLOSE_MS);
1128
+ this.gracefulSocketCloseTimer.unref?.();
1013
1129
  }
1014
1130
  }
1015
1131