@zhuxixi/pi-agent-board 0.4.3 → 0.5.1

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 (39) hide show
  1. package/PROGRESS.md +18 -3
  2. package/README.md +295 -72
  3. package/VERIFY.md +3 -3
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -3
  5. package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +223 -0
  6. package/docs/superpowers/plans/2026-08-30-circular-navigation.md +308 -0
  7. package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +37 -0
  8. package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +311 -0
  9. package/docs/superpowers/plans/2026-08-30-readme-v2.md +294 -0
  10. package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +4 -0
  11. package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +4 -0
  12. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +128 -0
  13. package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +47 -0
  14. package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +77 -0
  15. package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +80 -0
  16. package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +105 -0
  17. package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +115 -0
  18. package/package.json +1 -1
  19. package/runner/job-runner.mjs +29 -3
  20. package/runner/pty-runner.mjs +139 -25
  21. package/runner/state-runner.mjs +7 -2
  22. package/runner/title-runner.mjs +1 -1
  23. package/src/core/atomic.mjs +40 -1
  24. package/src/core/code-refs-store.mjs +315 -0
  25. package/src/core/code-refs.mjs +861 -0
  26. package/src/core/host-crash.mjs +39 -0
  27. package/src/core/launch.mjs +6 -0
  28. package/src/core/paths.mjs +24 -1
  29. package/src/core/pty-attach-jiggle-controller.mjs +71 -17
  30. package/src/core/pty-attach-reconnect.mjs +43 -0
  31. package/src/core/pty-scroll.mjs +4 -3
  32. package/src/core/repo.mjs +56 -0
  33. package/src/core/rows.mjs +50 -0
  34. package/src/core/store.mjs +4 -1
  35. package/src/core/types.mjs +12 -0
  36. package/src/core/worktree.mjs +1 -0
  37. package/src/runtime/service.mjs +7 -1
  38. package/src/ui/dashboard.ts +20 -4
  39. package/src/ui/pty-attach.ts +124 -27
@@ -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
  }
@@ -1362,8 +1366,9 @@ export class DashboardComponent implements Component {
1362
1366
  const contentWidth = Math.max(20, width - indent);
1363
1367
  const glyph = stateGlyph(rv.state, rv.alive, rv.hostAlive, rv.unread);
1364
1368
  const marker = stageFg(rv.state, selected ? `›${glyph}` : ` ${glyph}`);
1365
- const statusBadges = `${rv.reviewReady ? "✓ " : ""}${rv.diagnosticStalled ? "⏳ " : ""}${rv.evidenceErrorCount || rv.diagnosticErrorCount ? "! " : ""}${rv.followUpCount ? `q${rv.followUpCount} ` : ""}${rv.steeringState && rv.steeringState !== "none" ? "π " : ""}`;
1366
- const badge = `${this.mode === "select" ? `${this.isSelectedForBatch(rv.id) ? "◉" : "○"} ` : ""}${rv.pinned ? "★ " : ""}${rv.worktree ? "⌥ " : ""}${statusBadges}`;
1369
+ const statusBadges = `${rv.reviewReady ? "✓ " : ""}${rv.diagnosticStalled ? "⏳ " : ""}${rv.staleHost ? "⌁ " : ""}${rv.evidenceErrorCount || rv.diagnosticErrorCount ? "! " : ""}${rv.followUpCount ? `q${rv.followUpCount} ` : ""}${rv.steeringState && rv.steeringState !== "none" ? "π " : ""}`;
1370
+ const refsBadge = rv.refsBadge ? (rv.refsLowConfidence ? t.fg("dim", rv.refsBadge) : rv.refsBadge) : "";
1371
+ const badge = `${this.mode === "select" ? `${this.isSelectedForBatch(rv.id) ? "◉" : "○"} ` : ""}${rv.pinned ? "★ " : ""}${rv.worktree ? "⌥ " : ""}${statusBadges}${refsBadge ? `${refsBadge} ` : ""}`;
1367
1372
  const ageRaw = ` ${rv.age}`;
1368
1373
  const nameW = clamp(Math.floor(contentWidth * 0.34), 18, 30);
1369
1374
  const folderW = opts.showFolder !== false ? (contentWidth >= 72 ? clamp(Math.floor(contentWidth * 0.16), 10, 22) : contentWidth >= 56 ? 10 : 0) : 0;
@@ -1396,6 +1401,17 @@ export class DashboardComponent implements Component {
1396
1401
  out.push(t.fg("muted", "Auto-state"));
1397
1402
  out.push(clip(` ${auto.kind} · ${auto.confidence} · ${auto.source}${auto.reason ? ` — ${auto.reason}` : ""}`, width));
1398
1403
  }
1404
+ if (row.codeRefs && row.codeRefs.allRefs.length > 0) {
1405
+ const codeRefs = row.codeRefs;
1406
+ out.push("");
1407
+ out.push(t.fg("muted", "Refs"));
1408
+ if (codeRefs.provider) out.push(clip(` ${codeRefs.provider}`, width));
1409
+ for (const ref of codeRefs.allRefs) {
1410
+ const prefix = ref.kind === "pr" ? (codeRefs.prPrefix ?? "▸#") : (codeRefs.issuePrefix ?? "#");
1411
+ const line = ` ${ref.kind} ${prefix}${ref.number} · ${ref.confidence} · ${ref.source}${ref.url ? ` · ${ref.url}` : ""}`;
1412
+ out.push(clip(line, width));
1413
+ }
1414
+ }
1399
1415
  if (row.state?.question) {
1400
1416
  out.push("");
1401
1417
  out.push(t.fg("warning", "Question / blocker"));
@@ -8,6 +8,7 @@ import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@
8
8
  import { isProbablyEmptyPiInputLine } 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
+ import { evaluateAttachReconnect, shouldEscapeAttach } from "../core/pty-attach-reconnect.mjs";
11
12
  import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs";
12
13
  import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
13
14
  import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs";
@@ -28,7 +29,7 @@ export interface PtyAttachOptions {
28
29
  const require = createRequire(import.meta.url);
29
30
  const { Terminal } = require("@xterm/headless") as { Terminal: new (opts: Record<string, unknown>) => XtermLike };
30
31
 
31
- const DETACH_KEYS = new Set(["\x1d"]); // ctrl+]
32
+
32
33
  const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
33
34
  const MOUSE_DISABLE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";
34
35
  const XTSHIFTESCAPE_SELECT = "\x1b[>0s";
@@ -39,6 +40,8 @@ const LOADING_TICK_MS = 120;
39
40
  const ATTACH_SETTLE_MS = 250;
40
41
  /** Hard cap on the attach transition so a silent session can't stall the banner. */
41
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;
42
45
  /** How many tail bytes of the screen log to replay on attach. Read from the file tail
43
46
  * (not the whole file) so multi-MB logs don't block startup; ~60KB covers the last
44
47
  * handful of screens, which is all a fresh attach needs. */
@@ -133,6 +136,13 @@ export class PtyAttachComponent implements Component {
133
136
  private osc52Carry = "";
134
137
  private passthroughCarry = "";
135
138
  private readonly connectStartedAt = Date.now();
139
+ // Set once the first socket connect succeeds; drives the reconnect give-up
140
+ // policy (short window after a live session dies, long window during cold
141
+ // host start — see pty-attach-reconnect.mjs).
142
+ private everConnected = false;
143
+ // Timestamp of the first disconnect that started the current reconnect
144
+ // cycle; reset on every successful (re)connect.
145
+ private disconnectedAt: number | null = null;
136
146
  // Lines scrolled per mouse-wheel event; configurable via $AGENT_BOARD_WHEEL_LINES.
137
147
  private readonly mouseWheelLines = resolveWheelLines();
138
148
  private readonly term: XtermLike;
@@ -158,6 +168,7 @@ export class PtyAttachComponent implements Component {
158
168
  private attaching = true;
159
169
  private attachSettleTimer: ReturnType<typeof setTimeout> | null = null;
160
170
  private attachHardTimeout: ReturnType<typeof setTimeout> | null = null;
171
+ private gracefulSocketCloseTimer: ReturnType<typeof setTimeout> | null = null;
161
172
  // Force a single full-clear on the first paint so the prior session/dashboard can't
162
173
  // ghost behind this overlay; every later paint uses the TUI's coalesced, throttled,
163
174
  // differential renderer so wheel/output bursts don't each trigger a full repaint.
@@ -230,12 +241,12 @@ export class PtyAttachComponent implements Component {
230
241
  this.send({ type: "input", data });
231
242
  return;
232
243
  }
233
- if (
234
- DETACH_KEYS.has(data) ||
235
- matchesKey(data, Key.left) ||
236
- matchesKey(data, Key.ctrl("]"))
237
- ) {
238
- if (this.childInputLooksEmpty()) {
244
+ if (matchesKey(data, Key.left)) {
245
+ // While the socket is down the key can never reach the child, so
246
+ // escape unconditionally — the view must always be exitable, even
247
+ // after the host crashes mid-output (issue #48). Note: ctrl+] is NOT
248
+ // a detach key — it passes through to Pi (tui.editor.jumpForward).
249
+ if (shouldEscapeAttach(this.connected, this.childInputLooksEmpty())) {
239
250
  this.detach();
240
251
  return;
241
252
  }
@@ -257,7 +268,7 @@ export class PtyAttachComponent implements Component {
257
268
  const bodyHeight = Math.max(1, height - 2);
258
269
  let body: string[];
259
270
  if (!this.attaching && this.receivedOutput) {
260
- const projected = this.project(bodyHeight, width);
271
+ const projected = this.project(bodyHeight);
261
272
  body = projected.lines;
262
273
  while (body.length < bodyHeight) body.unshift("");
263
274
  } else {
@@ -265,7 +276,7 @@ export class PtyAttachComponent implements Component {
265
276
  }
266
277
  const header =
267
278
  this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
268
- this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · ← detach · ctrl+] detach`);
279
+ this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · ← detach`);
269
280
  return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
270
281
  }
271
282
 
@@ -281,14 +292,14 @@ export class PtyAttachComponent implements Component {
281
292
  out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
282
293
  out.push(center(this.theme.fg("muted", detail), width));
283
294
  out.push("");
284
- out.push(center(this.theme.fg("dim", "← or ctrl+] to detach"), width));
295
+ out.push(center(this.theme.fg("dim", "← to detach"), width));
285
296
  while (out.length < height) out.push("");
286
297
  return out.slice(0, height);
287
298
  }
288
299
 
289
300
  private loadingDetail(elapsedSeconds: number): string {
290
301
  if (this.status === "attached") return "Attached · waiting for the session to render…";
291
- if (this.status.startsWith("error") || this.status === "host exited") return this.status;
302
+ if (this.status.startsWith("error") || this.status === "host exited" || this.status === "host not reachable") return this.status;
292
303
  if (this.status === "disconnected") return `Reconnecting to the session host… ${elapsedSeconds}s`;
293
304
  return `Starting the session host… ${elapsedSeconds}s`;
294
305
  }
@@ -300,8 +311,12 @@ export class PtyAttachComponent implements Component {
300
311
  }
301
312
 
302
313
  private detach(): void {
314
+ // Restore a held PTY before ending the control socket. The runner closes
315
+ // the socket immediately after receiving detach, so sending detach first
316
+ // can drop the G3 restore resize (issue #42).
317
+ this.jiggleRetry.restoreAndStop();
303
318
  this.send({ type: "detach" });
304
- this.close();
319
+ this.close(true);
305
320
  this.done({ action: "detached" });
306
321
  }
307
322
 
@@ -315,16 +330,28 @@ export class PtyAttachComponent implements Component {
315
330
 
316
331
  private connect(): void {
317
332
  if (this.closed || this.connected || this.socket) return;
318
- if (!existsSync(this.opts.socketPath)) {
333
+ // On Windows the control socket is a named pipe — it never exists as a
334
+ // filesystem entry, so existsSync can't gate the connect; a missing pipe
335
+ // surfaces as an error event on the socket and retries via scheduleReconnect.
336
+ if (process.platform !== "win32" && !existsSync(this.opts.socketPath)) {
319
337
  this.status = `starting host… ${Math.ceil((Date.now() - this.connectStartedAt) / 1000)}s`;
320
338
  this.scheduleReconnect();
321
339
  return;
322
340
  }
323
341
  const socket = createConnection(this.opts.socketPath);
324
342
  this.socket = socket;
343
+ // A partial protocol line left over from a failed socket would prefix the
344
+ // replacement connection's first line; start each socket with a clean buffer.
345
+ this.parserBuffer = "";
325
346
  socket.on("connect", () => {
347
+ if (this.socket !== socket) {
348
+ try { socket.destroy(); } catch {}
349
+ return;
350
+ }
326
351
  this.clearRetry();
327
352
  this.connected = true;
353
+ this.everConnected = true;
354
+ this.disconnectedAt = null;
328
355
  this.status = "attached";
329
356
  this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
330
357
  this.jiggleRetry.start(this.cols, this.rows);
@@ -332,10 +359,17 @@ export class PtyAttachComponent implements Component {
332
359
  this.scheduleRender();
333
360
  this.startAttachSettle();
334
361
  });
335
- socket.on("data", (chunk) => this.onSocketData(chunk.toString("utf8")));
362
+ socket.on("data", (chunk) => {
363
+ if (this.socket !== socket) return;
364
+ this.onSocketData(chunk.toString("utf8"));
365
+ });
336
366
  socket.on("close", () => {
367
+ // A failed socket can close after a replacement connection has already
368
+ // succeeded. Never let that stale event clear the replacement state.
369
+ if (this.socket !== socket) return;
337
370
  this.socket = null;
338
371
  this.connected = false;
372
+ this.disconnectedAt ??= Date.now();
339
373
  if (!this.closed && this.status !== "host exited") {
340
374
  this.status = "disconnected";
341
375
  this.scheduleReconnect();
@@ -343,8 +377,16 @@ export class PtyAttachComponent implements Component {
343
377
  if (!this.closed) this.scheduleRender();
344
378
  });
345
379
  socket.on("error", (err) => {
380
+ // The error may belong to an old socket after reconnect. Dispose that
381
+ // socket, but do not alter the state of the current connection.
382
+ if (this.socket !== socket) {
383
+ try { socket.destroy(); } catch {}
384
+ return;
385
+ }
346
386
  this.socket = null;
347
387
  this.connected = false;
388
+ try { socket.destroy(); } catch {}
389
+ this.disconnectedAt ??= Date.now();
348
390
  if (this.closed) return;
349
391
  this.status = `waiting for host… ${err.message}`;
350
392
  this.scheduleReconnect();
@@ -365,6 +407,17 @@ export class PtyAttachComponent implements Component {
365
407
 
366
408
  private scheduleReconnect(): void {
367
409
  if (this.closed || this.retryTimer) return;
410
+ const verdict = evaluateAttachReconnect({
411
+ everConnected: this.everConnected,
412
+ disconnectedAt: this.disconnectedAt,
413
+ connectStartedAt: this.connectStartedAt,
414
+ now: Date.now(),
415
+ });
416
+ if (verdict.giveUp) {
417
+ this.status = verdict.status ?? "host exited";
418
+ this.scheduleRender();
419
+ return;
420
+ }
368
421
  this.retryTimer = setTimeout(() => {
369
422
  this.retryTimer = null;
370
423
  this.connect();
@@ -447,12 +500,20 @@ export class PtyAttachComponent implements Component {
447
500
  this.retryTimer = null;
448
501
  }
449
502
 
503
+ private clearGracefulSocketCloseTimer(): void {
504
+ if (!this.gracefulSocketCloseTimer) return;
505
+ clearTimeout(this.gracefulSocketCloseTimer);
506
+ this.gracefulSocketCloseTimer = null;
507
+ }
508
+
450
509
  private enableMouseScroll(): void {
451
510
  if (!this.mouseScrollEnabled()) return;
452
511
  try {
453
512
  this.tui.terminal.write(XTSHIFTESCAPE_SELECT);
454
513
  this.tui.terminal.write(MOUSE_ENABLE);
455
- } catch {}
514
+ } catch {
515
+ /* best-effort: some terminals reject these sequences; mouse reporting is optional */
516
+ }
456
517
  }
457
518
 
458
519
  private mouseScrollEnabled(): boolean {
@@ -478,7 +539,9 @@ export class PtyAttachComponent implements Component {
478
539
  private disableMouseScroll(): void {
479
540
  try {
480
541
  this.tui.terminal.write(MOUSE_DISABLE);
481
- } catch {}
542
+ } catch {
543
+ /* best-effort: terminal may already be gone at teardown */
544
+ }
482
545
  }
483
546
 
484
547
  private handleMouseInputChunk(events: Array<{ raw: string; mouse: { button: number; row: number; col: number; action: string } }>): boolean {
@@ -692,7 +755,9 @@ export class PtyAttachComponent implements Component {
692
755
  if (seq) {
693
756
  try {
694
757
  this.tui.terminal.write(seq);
695
- } catch {}
758
+ } catch {
759
+ /* best-effort: OSC52 clipboard support is optional */
760
+ }
696
761
  }
697
762
  // Also mirror the selection into the X11 PRIMARY selection so the rest of the
698
763
  // desktop can middle-click-paste it — closes the loop with pastePrimarySelection().
@@ -713,7 +778,9 @@ export class PtyAttachComponent implements Component {
713
778
  const timer = setTimeout(() => {
714
779
  try {
715
780
  child.kill("SIGKILL");
716
- } catch {}
781
+ } catch {
782
+ /* the child may have already exited before the timeout fired */
783
+ }
717
784
  }, 800);
718
785
  child.stdout?.on("data", (chunk: Buffer) => {
719
786
  out += chunk.toString("utf8");
@@ -723,7 +790,9 @@ export class PtyAttachComponent implements Component {
723
790
  clearTimeout(timer);
724
791
  if (!this.closed && out) this.send({ type: "input", data: out });
725
792
  });
726
- } catch {}
793
+ } catch {
794
+ /* silent no-op when xclip is absent — documented contract of this helper */
795
+ }
727
796
  }
728
797
 
729
798
  /** Write `text` to the X11 PRIMARY selection so other apps can middle-click-paste it. */
@@ -734,7 +803,9 @@ export class PtyAttachComponent implements Component {
734
803
  child.stdin?.on("error", () => {});
735
804
  child.on("error", () => {});
736
805
  child.stdin?.end(text);
737
- } catch {}
806
+ } catch {
807
+ /* silent no-op when xclip is absent */
808
+ }
738
809
  }
739
810
 
740
811
  private selectionText(): string {
@@ -763,6 +834,9 @@ export class PtyAttachComponent implements Component {
763
834
  }
764
835
 
765
836
  private currentSize(): { cols: number; rows: number } {
837
+ // SAFETY: duck-typed read — Pi TUI's Terminal type does not consistently expose
838
+ // cols/columns/rows across versions (see resizeIfNeeded below). Runtime
839
+ // fallbacks (120/24) keep this safe when the fields are absent.
766
840
  const term = this.tui.terminal as unknown as { cols?: number; columns?: number; rows?: number } | undefined;
767
841
  return {
768
842
  cols: Math.max(20, term?.cols ?? term?.columns ?? 120),
@@ -892,7 +966,9 @@ export class PtyAttachComponent implements Component {
892
966
  for (const seq of toWrite) {
893
967
  try {
894
968
  this.tui.terminal.write(seq);
895
- } catch {}
969
+ } catch {
970
+ /* best-effort: forwarded sequences are enhancements, never critical */
971
+ }
896
972
  }
897
973
  }
898
974
 
@@ -919,7 +995,9 @@ export class PtyAttachComponent implements Component {
919
995
  } finally {
920
996
  closeSync(fd);
921
997
  }
922
- } catch {}
998
+ } catch {
999
+ /* best-effort: a missing or racing screen.log must not block attach */
1000
+ }
923
1001
  }
924
1002
 
925
1003
  private pushOutput(data: string, opts: { forwardProtocols?: boolean } = {}): void {
@@ -938,7 +1016,7 @@ export class PtyAttachComponent implements Component {
938
1016
  });
939
1017
  }
940
1018
 
941
- private project(height: number, width: number): { lines: string[]; cursor: { row: number; col: number } | null } {
1019
+ private project(height: number): { lines: string[]; cursor: { row: number; col: number } | null } {
942
1020
  const out: string[] = [];
943
1021
  const buf = this.term.buffer.active;
944
1022
  const selection = normalizeSelection(this.selection);
@@ -957,7 +1035,13 @@ export class PtyAttachComponent implements Component {
957
1035
  return { lines: out.slice(-height), cursor };
958
1036
  }
959
1037
 
960
- private close(): void {
1038
+ private close(gracefulSocket = false): void {
1039
+ if (this.closed && !this.socket) {
1040
+ // Already closed (e.g. dispose() after detach()); a pending graceful-close
1041
+ // fallback timer stays armed on purpose — it self-clears on socket close
1042
+ // or destroys the socket after the grace window.
1043
+ return;
1044
+ }
961
1045
  this.closed = true;
962
1046
  this.imeCoalesceUninstall?.();
963
1047
  this.jiggleRetry.restoreAndStop();
@@ -976,11 +1060,24 @@ export class PtyAttachComponent implements Component {
976
1060
  clearTimeout(this.attachHardTimeout);
977
1061
  this.attachHardTimeout = null;
978
1062
  }
979
- try {
980
- this.socket?.destroy();
981
- } catch {}
1063
+ this.clearGracefulSocketCloseTimer();
1064
+ const socket = this.socket;
982
1065
  this.socket = null;
983
1066
  this.connected = false;
1067
+ if (!socket) return;
1068
+ if (!gracefulSocket) {
1069
+ try { socket.destroy(); } catch {}
1070
+ return;
1071
+ }
1072
+ // socket.end() preserves the ordering of the already-buffered restore and
1073
+ // detach packets. destroy() would discard buffered writes under backpressure.
1074
+ socket.once("close", () => this.clearGracefulSocketCloseTimer());
1075
+ try { socket.end(); } catch {}
1076
+ this.gracefulSocketCloseTimer = setTimeout(() => {
1077
+ this.gracefulSocketCloseTimer = null;
1078
+ try { socket.destroy(); } catch {}
1079
+ }, GRACEFUL_SOCKET_CLOSE_MS);
1080
+ this.gracefulSocketCloseTimer.unref?.();
984
1081
  }
985
1082
  }
986
1083