@zhuxixi/pi-agent-board 0.11.0 → 0.11.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ conventional commits by `scripts/release_helper.mjs`. Entries are
5
5
  forward-only: they begin with the first release after this file landed —
6
6
  for earlier history, see the git log and the pull-request list.
7
7
 
8
+ ## [0.11.1] - 2026-10-13
9
+
10
+ ### Fixes
11
+
12
+ - **attach**: defer color-scheme reports until child input is ready (#180)
13
+
14
+ [0.11.1]: https://github.com/zhuxixi/pi-agent-board/compare/v0.11.0...v0.11.1
15
+
8
16
  ## [0.11.0] - 2026-10-12
9
17
 
10
18
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -240,6 +240,12 @@ export class PtyAttachComponent implements Component {
240
240
  // null never matches the duplicate check, so a timeout-then-late sequence
241
241
  // still delivers. Single-shot per attach — settle runs at most once.
242
242
  private sentScheme: TerminalColorScheme | null = null;
243
+ // Pi enables bracketed paste AFTER entering raw mode and disables it at
244
+ // shutdown. Only current runner output/snapshots may establish readiness;
245
+ // the screen.log warm-start can belong to a previous child incarnation.
246
+ private childInputReady = false;
247
+ private childInputModeCarry = "";
248
+ private pendingSchemeReport: { scheme: TerminalColorScheme; api?: "colors" | "background" | "none"; late?: boolean } | null = null;
243
249
  // Issue #128 D2: unsubscriber for the color-scheme bridge registered at
244
250
  // attach settle; nulled on every terminal path so the TUI never keeps a
245
251
  // listener pointing at a dead attach surface.
@@ -469,7 +475,7 @@ export class PtyAttachComponent implements Component {
469
475
  this.parserBuffer = "";
470
476
  socket.on("connect", () => {
471
477
  if (this.socket !== socket) {
472
- try { socket.destroy(); } catch {}
478
+ try { socket.destroy(); } catch { /* best-effort stale-socket cleanup */ }
473
479
  return;
474
480
  }
475
481
  this.clearRetry();
@@ -520,7 +526,7 @@ export class PtyAttachComponent implements Component {
520
526
  // The error may belong to an old socket after reconnect. Dispose that
521
527
  // socket, but do not alter the state of the current connection.
522
528
  if (this.socket !== socket) {
523
- try { socket.destroy(); } catch {}
529
+ try { socket.destroy(); } catch { /* best-effort stale-socket cleanup */ }
524
530
  return;
525
531
  }
526
532
  this.socket = null;
@@ -529,7 +535,7 @@ export class PtyAttachComponent implements Component {
529
535
  // stale-socket guard (this.socket no longer matches) and must not be
530
536
  // the only path that cancels the probe deadline (CR R1 advisory).
531
537
  this.attachClient.onDisconnect();
532
- try { socket.destroy(); } catch {}
538
+ try { socket.destroy(); } catch { /* already handling socket failure */ }
533
539
  this.disconnectedAt ??= Date.now();
534
540
  if (this.closed) return;
535
541
  this.status = `waiting for host… ${err.message}`;
@@ -653,8 +659,9 @@ export class PtyAttachComponent implements Component {
653
659
  if (this.colorSchemeBridgeUnsubscribe) return;
654
660
  try {
655
661
  this.colorSchemeBridgeUnsubscribe = this.tui.onTerminalColorSchemeChange((scheme) => {
656
- const data = toColorSchemeReport(scheme);
657
- if (data) this.send({ type: "input", data });
662
+ if (!toColorSchemeReport(scheme)) return;
663
+ this.pendingSchemeReport = { scheme };
664
+ this.flushPendingSchemeReport();
658
665
  });
659
666
  } catch {
660
667
  /* best-effort: an unbridgeable TUI keeps pre-#128 behavior */
@@ -664,6 +671,8 @@ export class PtyAttachComponent implements Component {
664
671
  /** Called on every terminal path (close() covers detach/dispose; the exit
665
672
  * branch calls done() without close(), so it unsubscribes explicitly). */
666
673
  private detachColorSchemeBridge(): void {
674
+ this.pendingSchemeReport = null;
675
+ this.childInputReady = false;
667
676
  const unsubscribe = this.colorSchemeBridgeUnsubscribe;
668
677
  this.colorSchemeBridgeUnsubscribe = null;
669
678
  if (!unsubscribe) return;
@@ -728,7 +737,8 @@ export class PtyAttachComponent implements Component {
728
737
  const rgb = backgroundRgbFromTerminalColors(colors as never);
729
738
  const scheme = rgb ? colorSchemeForBackgroundRgb(rgb) ?? undefined : undefined;
730
739
  if (!scheme) return;
731
- if (this.sentScheme === scheme) {
740
+ if (this.sentScheme === scheme && this.childInputReady) {
741
+ this.pendingSchemeReport = null;
732
742
  this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: api, outcome: "duplicate_skipped" }));
733
743
  return;
734
744
  }
@@ -748,6 +758,12 @@ export class PtyAttachComponent implements Component {
748
758
  this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: api, outcome: "dropped_disconnected" }));
749
759
  return;
750
760
  }
761
+ if (!this.childInputReady) {
762
+ // Settle/probe completion is not a child-readiness handshake. Keep
763
+ // only the latest scheme, then send when the child enters raw mode.
764
+ this.pendingSchemeReport = { scheme, api, late };
765
+ return;
766
+ }
751
767
  this.sentScheme = scheme;
752
768
  this.send({ type: "input", data });
753
769
  const details: { probeApi: "colors" | "background" | "none"; outcome: "reported"; report: string; late?: boolean } = {
@@ -759,6 +775,26 @@ export class PtyAttachComponent implements Component {
759
775
  this.opts.onDiagnostic?.(buildSettleSchemePatch(details));
760
776
  }
761
777
 
778
+ private updateChildInputReadiness(data: string): void {
779
+ // Scan only LIVE bytes, not warm-start history. A reused xterm buffer
780
+ // can still have the old child's paste mode enabled during cold boot.
781
+ const input = this.childInputModeCarry + data;
782
+ for (const match of input.matchAll(/\x1b\[\?2004([hl])/g)) {
783
+ this.childInputReady = match[1] === "h";
784
+ }
785
+ // Keep at most an incomplete eight-byte mode sequence across chunks.
786
+ this.childInputModeCarry = input.slice(-7);
787
+ }
788
+
789
+ private flushPendingSchemeReport(): void {
790
+ if (this.closed || !this.connected || !this.childInputReady) return;
791
+ const pending = this.pendingSchemeReport;
792
+ this.pendingSchemeReport = null;
793
+ if (!pending) return;
794
+ if (pending.api) this.deliverSettleScheme(pending.scheme, pending.api, pending.late ?? false);
795
+ else this.send({ type: "input", data: toColorSchemeReport(pending.scheme) });
796
+ }
797
+
762
798
  private startDesyncProbe(): void {
763
799
  this.stopDesyncProbe();
764
800
  // The probe is deliberately NOT hooked into the render path: rendering is
@@ -1411,8 +1447,12 @@ export class PtyAttachComponent implements Component {
1411
1447
  // constructor's screen.log warm-start must not outlive the protocol
1412
1448
  // answer — a gated reset let the dead session's tail render as a
1413
1449
  // cold-start ghost once the banner lifted (final-review F1).
1414
- if (typeof snap.frame === "string") this.pushOutput(snap.frame);
1415
- else if (snap.empty || snap.resnapshot) this.term.reset();
1450
+ if (typeof snap.frame === "string") this.pushOutput(snap.frame, { authoritative: true });
1451
+ else if (snap.empty || snap.resnapshot) {
1452
+ this.childInputReady = false;
1453
+ this.childInputModeCarry = "";
1454
+ this.term.reset();
1455
+ }
1416
1456
  return;
1417
1457
  }
1418
1458
  if (event === "output") {
@@ -1422,6 +1462,10 @@ export class PtyAttachComponent implements Component {
1422
1462
  if (typeof payload === "string") this.pushOutput(payload, { forwardProtocols: true });
1423
1463
  return;
1424
1464
  }
1465
+ if (event === "epochReset") {
1466
+ this.childInputReady = false;
1467
+ this.childInputModeCarry = "";
1468
+ }
1425
1469
  // "resubscribing": recovery is client-internal (informational only).
1426
1470
  // "protocolError": observability only — recovery is automatic until the
1427
1471
  // legacy fallback, whose mode event the UI acts on above.
@@ -1500,11 +1544,19 @@ export class PtyAttachComponent implements Component {
1500
1544
  }
1501
1545
  }
1502
1546
 
1503
- private pushOutput(data: string, opts: { forwardProtocols?: boolean } = {}): void {
1547
+ private pushOutput(data: string, opts: { forwardProtocols?: boolean; authoritative?: boolean } = {}): void {
1504
1548
  if (data.length === 0) return;
1505
1549
  // Recorded synchronously BEFORE term.write (whose callback is async): the
1506
1550
  // desync quiet-window must measure when output ARRIVED, not when parsing finished.
1507
1551
  this.lastOutputAt = this.nowFn();
1552
+ // Current snapshots explicitly restore all modes, including paste mode.
1553
+ // Read their readiness synchronously so an older parse callback cannot
1554
+ // overwrite a newer live shutdown/reload signal.
1555
+ if (opts.authoritative) {
1556
+ this.childInputReady = false;
1557
+ this.childInputModeCarry = "";
1558
+ }
1559
+ if (opts.forwardProtocols || opts.authoritative) this.updateChildInputReadiness(data);
1508
1560
  if (opts.forwardProtocols) this.forwardTerminalProtocols(data);
1509
1561
  // @xterm/headless parses asynchronously; the buffer is only populated once this
1510
1562
  // callback fires. Mark the buffer as ready (so the project path can paint it), but
@@ -1513,6 +1565,7 @@ export class PtyAttachComponent implements Component {
1513
1565
  // chunk also defers the settle window, so the banner holds until output actually
1514
1566
  // stops arriving (i.e. the redraw finished), not just until the resize jiggle ends.
1515
1567
  this.term.write(data, () => {
1568
+ if (opts.forwardProtocols || opts.authoritative) this.flushPendingSchemeReport();
1516
1569
  this.receivedOutput = true;
1517
1570
  this.deferAttachSettle();
1518
1571
  this.outputRenderScheduler.request();
@@ -1573,16 +1626,16 @@ export class PtyAttachComponent implements Component {
1573
1626
  this.connected = false;
1574
1627
  if (!socket) return;
1575
1628
  if (!gracefulSocket) {
1576
- try { socket.destroy(); } catch {}
1629
+ try { socket.destroy(); } catch { /* best-effort teardown */ }
1577
1630
  return;
1578
1631
  }
1579
1632
  // socket.end() preserves the ordering of the already-buffered restore and
1580
1633
  // detach packets. destroy() would discard buffered writes under backpressure.
1581
1634
  socket.once("close", () => this.clearGracefulSocketCloseTimer());
1582
- try { socket.end(); } catch {}
1635
+ try { socket.end(); } catch { /* fallback timer below forces cleanup */ }
1583
1636
  this.gracefulSocketCloseTimer = setTimeout(() => {
1584
1637
  this.gracefulSocketCloseTimer = null;
1585
- try { socket.destroy(); } catch {}
1638
+ try { socket.destroy(); } catch { /* best-effort teardown */ }
1586
1639
  }, GRACEFUL_SOCKET_CLOSE_MS);
1587
1640
  this.gracefulSocketCloseTimer.unref?.();
1588
1641
  }