@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 +8 -0
- package/package.json +1 -1
- package/src/ui/pty-attach.ts +65 -12
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
package/src/ui/pty-attach.ts
CHANGED
|
@@ -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
|
-
|
|
657
|
-
|
|
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)
|
|
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
|
}
|