tmux-ide 2.9.4 → 2.9.5

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 (80) hide show
  1. package/bin/cli.js +4354 -3376
  2. package/package.json +7 -6
  3. package/packages/contracts/src/session-runtime.ts +2 -0
  4. package/packages/contracts/src/terminal-delivery.ts +4 -0
  5. package/packages/core/src/terminal-compact-capability.ts +31 -1
  6. package/packages/core/src/terminal-delivery.ts +234 -32
  7. package/packages/core/src/terminal-replica-hash-cache.ts +162 -21
  8. package/packages/core/src/terminal-replica-owned-row.ts +165 -10
  9. package/packages/core/src/terminal-replica.ts +43 -21
  10. package/packages/daemon/dist/lib/daemon-embed.js +19 -34
  11. package/packages/daemon/dist/lib/runtime-trace-writer.js +133 -0
  12. package/packages/daemon/dist/lib/tmux-client-execution.js +37 -2
  13. package/packages/daemon/dist/lib/tmux-server-owner.js +20 -4
  14. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  15. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  16. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  17. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  18. package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +5 -1
  19. package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
  20. package/packages/daemon/dist/native/tmux/darwin-x64/manifest.json +5 -1
  21. package/packages/daemon/dist/native/tmux/darwin-x64/tmux +0 -0
  22. package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
  23. package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
  24. package/packages/daemon/dist/native/tmux/linux-x64/licenses/libevent_core-2.1.so.7.txt +0 -0
  25. package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +5 -1
  26. package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
  27. package/packages/daemon/dist/terminal/attachments/native-runtime.js +6 -6
  28. package/packages/daemon/dist/terminal/mirror/control-channel.js +379 -75
  29. package/packages/daemon/dist/terminal/mirror/mirror-service.js +43 -3
  30. package/packages/daemon/dist/terminal/mirror/native-grid-projection.js +20 -11
  31. package/packages/daemon/dist/terminal/mirror/session-channel.js +639 -811
  32. package/packages/daemon/dist/terminal/mirror/stock-pane-snapshot.js +177 -0
  33. package/packages/daemon/dist/terminal/session-runtime/causal-cell-ledger.js +1 -1
  34. package/packages/daemon/dist/terminal/session-runtime/registry.js +145 -15
  35. package/packages/daemon/dist/terminal/session-runtime/stock-capture-fidelity.js +12 -0
  36. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +128 -10
  37. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +21 -4
  38. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +19 -2
  39. package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +17 -19
  40. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +64 -4
  41. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +10 -1
  42. package/packages/daemon/dist/tui/mirror/reference-performance-trace.js +117 -71
  43. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +27 -3
  44. package/packages/daemon/dist/tui/mirror/runtime/pane-scoped-terminal-surface.jsx +17 -4
  45. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +19 -0
  46. package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +10 -2
  47. package/packages/daemon/dist/tui/mirror/testing/styled-frame-oracle.js +156 -0
  48. package/packages/daemon/src/lib/daemon-embed.ts +22 -34
  49. package/packages/daemon/src/lib/runtime-trace-writer.ts +132 -0
  50. package/packages/daemon/src/lib/tmux-client-execution.ts +47 -2
  51. package/packages/daemon/src/lib/tmux-server-owner.ts +19 -3
  52. package/packages/daemon/src/terminal/attachments/native-runtime.ts +6 -0
  53. package/packages/daemon/src/terminal/mirror/control-channel.ts +428 -84
  54. package/packages/daemon/src/terminal/mirror/events.ts +7 -7
  55. package/packages/daemon/src/terminal/mirror/mirror-service.ts +49 -3
  56. package/packages/daemon/src/terminal/mirror/native-grid-projection.ts +21 -11
  57. package/packages/daemon/src/terminal/mirror/pane-feed.ts +9 -4
  58. package/packages/daemon/src/terminal/mirror/session-channel.ts +705 -859
  59. package/packages/daemon/src/terminal/mirror/stock-pane-snapshot.ts +228 -0
  60. package/packages/daemon/src/terminal/session-runtime/causal-cell-ledger.ts +1 -1
  61. package/packages/daemon/src/terminal/session-runtime/registry.ts +203 -32
  62. package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +7 -0
  63. package/packages/daemon/src/terminal/session-runtime/stock-capture-fidelity.ts +13 -0
  64. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +142 -8
  65. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +26 -3
  66. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +25 -1
  67. package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +26 -20
  68. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +63 -4
  69. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -1
  70. package/packages/daemon/src/tui/mirror/reference-performance-trace.ts +141 -90
  71. package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +27 -4
  72. package/packages/daemon/src/tui/mirror/runtime/pane-scoped-terminal-surface.tsx +61 -31
  73. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +31 -3
  74. package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +9 -6
  75. package/packages/daemon/src/tui/mirror/testing/styled-frame-oracle.ts +209 -0
  76. package/packages/daemon-client/src/terminal-fast-lane.ts +53 -4
  77. package/packages/daemon-client/src/workspace-client-types.ts +1 -0
  78. package/packages/daemon-client/src/workspace-client.ts +78 -5
  79. package/packages/tmux-bridge/src/index.ts +1 -0
  80. package/packages/tmux-bridge/src/runner.ts +25 -1
@@ -21,6 +21,7 @@ import { withBundledTmuxResources } from "../../lib/bundled-tmux.ts";
21
21
  * chance to detach us — killing the reader first can wedge the server.
22
22
  */
23
23
  import { spawn, type ChildProcess } from "node:child_process";
24
+ import { randomBytes } from "node:crypto";
24
25
  import { shellEscape } from "../../lib/shell.ts";
25
26
  import type { NativeTmuxServerIdentity } from "../../lib/tmux-server-generation-runner.ts";
26
27
  import {
@@ -116,8 +117,13 @@ export type AtomicPaneSnapshotFailureReason =
116
117
  | "retired";
117
118
 
118
119
  export interface AtomicPaneSnapshotResult {
120
+ /** Present only for an owned pause hook; an already-paused target emits no new notification.
121
+ * The caller must authenticate the live target and exact nonwaiting hook body. */
122
+ readonly pauseObserved?: boolean;
119
123
  readonly ok: boolean;
120
124
  readonly captureLines: readonly string[];
125
+ /** Optional second representation; bounded together with captureLines. */
126
+ readonly ansiCaptureLines?: readonly string[];
121
127
  readonly cursorLine: string | null;
122
128
  readonly continueObserved: boolean;
123
129
  readonly statusObserved: boolean;
@@ -140,6 +146,10 @@ export interface AtomicPaneSnapshotProgress {
140
146
  }
141
147
 
142
148
  export interface AtomicPaneSnapshotCollector {
149
+ /** Pause uses start / refresh-client :pause / complete, sharing this sole wire owner. */
150
+ readonly kind?: "snapshot" | "pause";
151
+ /** Adds ANSI capture / ansi-capture-end after capture-end, before cursor. */
152
+ readonly dualCapture?: boolean;
143
153
  readonly nonce: string;
144
154
  readonly runtimePaneId: string;
145
155
  readonly maxCaptureBytes: number;
@@ -151,6 +161,8 @@ export interface AtomicPaneSnapshotCollector {
151
161
  /** Authenticated, bounded forward progress for the currently armed owner. */
152
162
  readonly onProgress?: (progress: AtomicPaneSnapshotProgress) => void;
153
163
  readonly onSettled: (result: AtomicPaneSnapshotResult) => void;
164
+ /** Sole wire-ownership release signal; failed/retired work requires a FIFO fence or exit. */
165
+ readonly onDrained?: (reason: "complete" | "fence" | "channel-exit") => void;
154
166
  }
155
167
 
156
168
  const ATOMIC_CAPTURE_BYTE_HARD_CAP = 16 * 1024 * 1024;
@@ -288,15 +300,19 @@ export class ControlChannelCore {
288
300
  private failed = false;
289
301
  private atomicCollector: {
290
302
  spec: AtomicPaneSnapshotCollector;
303
+ retired: boolean;
304
+ settled: boolean;
291
305
  started: boolean;
292
306
  blockOrdinal: number;
293
307
  blockContentCount: number;
294
308
  captureLines: string[];
309
+ ansiCaptureLines: string[];
295
310
  captureBytes: number;
296
311
  totalLines: number;
297
312
  totalBytes: number;
298
313
  cursorLine: string | null;
299
314
  continueObserved: boolean;
315
+ pauseObserved: boolean;
300
316
  statusObserved: boolean;
301
317
  observerEmissionObserved: boolean;
302
318
  lastCompletedOrdinal: number;
@@ -437,6 +453,10 @@ export class ControlChannelCore {
437
453
 
438
454
  armAtomicPaneSnapshotCollector(spec: AtomicPaneSnapshotCollector): boolean {
439
455
  if (this.failed || this.atomicCollector) return false;
456
+ if (spec.kind !== undefined && spec.kind !== "snapshot" && spec.kind !== "pause") return false;
457
+ if (spec.kind === "pause" && (spec.observerCommandCount !== 0 || spec.dualCapture))
458
+ return false;
459
+ if (spec.dualCapture !== undefined && typeof spec.dualCapture !== "boolean") return false;
440
460
  if (!/^[0-9a-f]{32,128}$/.test(spec.nonce) || !/^%\d+$/.test(spec.runtimePaneId)) return false;
441
461
  if (
442
462
  !Number.isSafeInteger(spec.maxCaptureBytes) ||
@@ -455,15 +475,19 @@ export class ControlChannelCore {
455
475
  return false;
456
476
  this.atomicCollector = {
457
477
  spec,
478
+ retired: false,
479
+ settled: false,
458
480
  started: false,
459
481
  blockOrdinal: -1,
460
482
  blockContentCount: 0,
461
483
  captureLines: [],
484
+ ansiCaptureLines: [],
462
485
  captureBytes: 0,
463
486
  totalLines: 0,
464
487
  totalBytes: 0,
465
488
  cursorLine: null,
466
489
  continueObserved: false,
490
+ pauseObserved: false,
467
491
  statusObserved: false,
468
492
  observerEmissionObserved: false,
469
493
  lastCompletedOrdinal: -1,
@@ -477,27 +501,120 @@ export class ControlChannelCore {
477
501
  reason: AtomicPaneSnapshotFailureReason = "retired",
478
502
  ): boolean {
479
503
  const collector = this.atomicCollector;
480
- if (!collector || collector.spec.nonce !== nonce) return false;
504
+ if (!collector || collector.spec.nonce !== nonce || collector.settled) return false;
505
+ collector.retired = true;
506
+ collector.settled = true;
507
+ const result: AtomicPaneSnapshotResult = Object.freeze({
508
+ ok: false,
509
+ captureLines: Object.freeze([]),
510
+ ...(collector.spec.dualCapture ? { ansiCaptureLines: Object.freeze([]) } : {}),
511
+ cursorLine: null,
512
+ continueObserved: collector.continueObserved,
513
+ statusObserved: collector.statusObserved,
514
+ observerEmissionObserved: collector.observerEmissionObserved,
515
+ started: collector.started,
516
+ lastCompletedOrdinal: collector.lastCompletedOrdinal,
517
+ captureLineCount: collector.captureLines.length + collector.ansiCaptureLines.length,
518
+ captureByteCount: collector.captureBytes,
519
+ failureReason: collector.failureReason ?? reason,
520
+ ...(collector.spec.kind === "pause" ? { pauseObserved: collector.pauseObserved } : {}),
521
+ });
522
+ // Release payload memory before callbacks can throw or reenter retirement.
523
+ collector.captureLines.length = 0;
524
+ collector.ansiCaptureLines.length = 0;
525
+ collector.cursorLine = null;
526
+ collector.totalBytes = 0;
527
+ collector.totalLines = 0;
528
+ collector.spec.onSettled(result);
529
+ return true;
530
+ }
531
+
532
+ /** Caller has validated the separate ordinary command's exact fence reply. */
533
+ releaseRetiredCollector(nonce: string): boolean {
534
+ const collector = this.atomicCollector;
535
+ if (!collector?.retired || collector.spec.nonce !== nonce) return false;
481
536
  this.atomicCollector = null;
482
- collector.spec.onSettled(
483
- Object.freeze({
484
- ok: false,
485
- captureLines: Object.freeze([]),
486
- cursorLine: null,
487
- continueObserved: collector.continueObserved,
488
- statusObserved: collector.statusObserved,
489
- observerEmissionObserved: collector.observerEmissionObserved,
490
- started: collector.started,
491
- lastCompletedOrdinal: collector.lastCompletedOrdinal,
492
- captureLineCount: collector.captureLines.length,
493
- captureByteCount: collector.captureBytes,
494
- failureReason: collector.failureReason ?? reason,
495
- }),
496
- );
537
+ this.notifyAtomicDrained(collector, "fence");
497
538
  return true;
498
539
  }
499
540
 
541
+ private notifyAtomicDrained(
542
+ collector: NonNullable<ControlChannelCore["atomicCollector"]>,
543
+ reason: "complete" | "fence" | "channel-exit",
544
+ ): void {
545
+ try {
546
+ collector.spec.onDrained?.(reason);
547
+ } catch {
548
+ this.failRetiredDrain("snapshot drain callback failed");
549
+ }
550
+ }
551
+
552
+ private failRetiredDrain(reason: string): void {
553
+ try {
554
+ this.fail(reason);
555
+ } finally {
556
+ this.handlers.onExit(reason);
557
+ }
558
+ }
559
+
560
+ private consumeRetiredSnapshotLine(line: string): boolean {
561
+ const collector = this.atomicCollector;
562
+ if (!collector?.retired) return false;
563
+ const event = parseControlLine(line, this.inReply);
564
+ if (this.inReply && this.currentReplyFlags === 0) {
565
+ collector.totalBytes += Buffer.byteLength(line, "latin1") + 1;
566
+ collector.totalLines += 1;
567
+ if (
568
+ collector.totalBytes > collector.spec.maxCaptureBytes + 64 * 1024 ||
569
+ collector.totalLines > collector.spec.maxCaptureLines + 64
570
+ ) {
571
+ this.failRetiredDrain("retired snapshot drain exceeded bounds");
572
+ return true;
573
+ }
574
+ // Raw rows may look like %begin, a sentinel, or the fence payload. Only
575
+ // the matching raw terminator changes framing; none can redeem a FIFO slot.
576
+ if (event.kind === "end" || event.kind === "error") {
577
+ if (event.flags !== 0 || event.num !== this.currentReplyNum) {
578
+ this.failRetiredDrain("invalid retired snapshot framing");
579
+ return true;
580
+ }
581
+ this.inReply = false;
582
+ this.currentReplyNum = null;
583
+ this.currentReplyFlags = null;
584
+ this.currentReplyConsumesPending = false;
585
+ }
586
+ return true;
587
+ }
588
+ if (
589
+ event.kind === "begin" &&
590
+ (!Number.isSafeInteger(event.num) || event.num < 0 || ![0, 1].includes(event.flags))
591
+ ) {
592
+ this.failRetiredDrain("invalid retired snapshot opening frame");
593
+ return true;
594
+ }
595
+ if (event.kind === "begin" && event.flags === 0) {
596
+ this.inReply = true;
597
+ this.currentReplyNum = event.num;
598
+ this.currentReplyFlags = 0;
599
+ this.currentReplyConsumesPending = false;
600
+ return true;
601
+ }
602
+ if (event.kind === "end" || event.kind === "error") {
603
+ if (
604
+ !this.inReply ||
605
+ event.num !== this.currentReplyNum ||
606
+ event.flags !== this.currentReplyFlags
607
+ ) {
608
+ this.failRetiredDrain("invalid retired snapshot fence framing");
609
+ return true;
610
+ }
611
+ }
612
+ // Ordinary flags=1 responses still own their existing pending FIFO slots.
613
+ return false;
614
+ }
615
+
500
616
  feed(chunk: string, receivedAtMicros?: number): void {
617
+ if (this.failed) return;
501
618
  if (this.droppingOversizedReplyLine) {
502
619
  const newline = chunk.indexOf("\n");
503
620
  if (newline < 0) return;
@@ -515,6 +632,18 @@ export class ControlChannelCore {
515
632
  const lineReceivedAtMicros = this.bufferReceivedAtMicros;
516
633
  this.bufferReceivedAtMicros = this.buffer.length > 0 ? (receivedAtMicros ?? null) : null;
517
634
  this.handleLine(line, lineReceivedAtMicros);
635
+ if (this.failed) {
636
+ this.buffer = "";
637
+ return;
638
+ }
639
+ }
640
+ if (
641
+ this.atomicCollector?.retired &&
642
+ this.buffer.length > this.atomicCollector.spec.maxCaptureBytes + 64 * 1024
643
+ ) {
644
+ this.buffer = "";
645
+ this.failRetiredDrain("retired snapshot partial line exceeded bounds");
646
+ return;
518
647
  }
519
648
  const head = this.currentBudget();
520
649
  // Protocol terminators may themselves exceed a tiny payload budget and
@@ -535,23 +664,39 @@ export class ControlChannelCore {
535
664
  fail(reason: string): void {
536
665
  if (this.failed) return;
537
666
  this.failed = true;
538
- if (this.atomicCollector)
539
- this.retireAtomicPaneSnapshotCollector(this.atomicCollector.spec.nonce, "channel-exit");
667
+ if (this.atomicCollector) {
668
+ const collector = this.atomicCollector;
669
+ try {
670
+ this.retireAtomicPaneSnapshotCollector(collector.spec.nonce, "channel-exit");
671
+ } catch {
672
+ // A failed owner cannot prevent sibling requests from settling on exit.
673
+ }
674
+ this.atomicCollector = null;
675
+ try {
676
+ this.notifyAtomicDrained(collector, "channel-exit");
677
+ } catch {
678
+ // Exit notification callbacks may also fail; the pending FIFO still drains.
679
+ }
680
+ }
540
681
  for (const sink of this.pending.splice(0)) {
541
- if (sink.kind === "promise") sink.reject(new Error(reason));
542
- else if (sink.kind === "inline" || sink.kind === "bounded")
543
- sink.onReply({ ok: false, lines: [reason] });
544
- else if (sink.kind === "native-wrapper" && !sink.state.settled) {
545
- sink.state.settled = true;
546
- notifyNativeWrapper(sink.state, {
547
- ok: false,
548
- lines: [],
549
- metadataStatus: sink.state.metadataStatus,
550
- acknowledgement: sink.state.acknowledgement,
551
- });
552
- } else if (sink.kind === "command-list" && !sink.state.settled) {
553
- sink.state.settled = true;
554
- sink.state.onReply({ ok: false, lines: [reason] });
682
+ try {
683
+ if (sink.kind === "promise") sink.reject(new Error(reason));
684
+ else if (sink.kind === "inline" || sink.kind === "bounded")
685
+ sink.onReply({ ok: false, lines: [reason] });
686
+ else if (sink.kind === "native-wrapper" && !sink.state.settled) {
687
+ sink.state.settled = true;
688
+ notifyNativeWrapper(sink.state, {
689
+ ok: false,
690
+ lines: [],
691
+ metadataStatus: sink.state.metadataStatus,
692
+ acknowledgement: sink.state.acknowledgement,
693
+ });
694
+ } else if (sink.kind === "command-list" && !sink.state.settled) {
695
+ sink.state.settled = true;
696
+ sink.state.onReply({ ok: false, lines: [reason] });
697
+ }
698
+ } catch {
699
+ // Keep failure fan-out independent of any one request callback.
555
700
  }
556
701
  }
557
702
  }
@@ -709,6 +854,7 @@ export class ControlChannelCore {
709
854
  this.handlers.onOutput(event.pane, event.data, event.ageMs, timing);
710
855
  break;
711
856
  case "exit":
857
+ this.fail(event.reason ?? "control channel exited");
712
858
  this.handlers.onExit(event.reason);
713
859
  break;
714
860
  case "notify":
@@ -720,6 +866,7 @@ export class ControlChannelCore {
720
866
  private consumeAtomicPaneSnapshotLine(line: string): boolean {
721
867
  const collector = this.atomicCollector;
722
868
  if (!collector) return false;
869
+ if (collector.retired) return this.consumeRetiredSnapshotLine(line);
723
870
  const prefix = "%tmux-ide-atomic-v1 ";
724
871
  const ownPrefix = `${prefix}${collector.spec.nonce} `;
725
872
  if (line.startsWith(prefix) && !line.startsWith(ownPrefix)) {
@@ -733,7 +880,9 @@ export class ControlChannelCore {
733
880
  token !== "start" ||
734
881
  !this.inReply ||
735
882
  this.currentReplyFlags !== 0 ||
736
- this.currentReplyNum === null
883
+ this.currentReplyNum === null ||
884
+ (collector.spec.kind === "pause" &&
885
+ (!Number.isSafeInteger(this.currentReplyNum) || this.currentReplyNum < 0))
737
886
  ) {
738
887
  collector.failureReason ??= "sentinel-order";
739
888
  return true;
@@ -751,6 +900,9 @@ export class ControlChannelCore {
751
900
  if (collector.totalBytes > collector.spec.maxCaptureBytes + 64 * 1024)
752
901
  collector.failureReason ??= "capture-byte-cap";
753
902
 
903
+ if (collector.spec.kind === "pause") return this.consumeOwnedPauseLine(line, token);
904
+
905
+ const shift = collector.spec.dualCapture ? 2 : 0;
754
906
  const guard = parseControlLine(line, this.inReply);
755
907
  if (guard.kind === "begin") {
756
908
  if (guard.flags !== 0 || this.inReply) collector.failureReason ??= "sentinel-order";
@@ -772,12 +924,21 @@ export class ControlChannelCore {
772
924
  // The ownership compare-and-unset is an if-shell command plus exactly
773
925
  // one selected branch command. Both commands retain the control client
774
926
  // and therefore each has its own flags=0 guard block.
775
- const markerBranchOrdinal = 7 + collector.spec.observerCommandCount;
776
- const statusOrdinal = 8 + collector.spec.observerCommandCount;
777
- const completeOrdinal = 10 + collector.spec.observerCommandCount;
778
- const contentRequired = new Set([0, 2, 3, 4, statusOrdinal, completeOrdinal]);
927
+ const markerBranchOrdinal = 7 + shift + collector.spec.observerCommandCount;
928
+ const statusOrdinal = 8 + shift + collector.spec.observerCommandCount;
929
+ const completeOrdinal = 10 + shift + collector.spec.observerCommandCount;
930
+ const contentRequired = new Set([
931
+ 0,
932
+ 2,
933
+ 3 + shift,
934
+ 4 + shift,
935
+ ...(shift ? [4] : []),
936
+ statusOrdinal,
937
+ completeOrdinal,
938
+ ]);
779
939
  const contentSilent =
780
940
  collector.blockOrdinal !== 1 &&
941
+ !(shift && collector.blockOrdinal === 3) &&
781
942
  collector.blockOrdinal !== markerBranchOrdinal &&
782
943
  !contentRequired.has(collector.blockOrdinal);
783
944
  const markerBranchValid =
@@ -792,7 +953,7 @@ export class ControlChannelCore {
792
953
  guard.kind === "end" &&
793
954
  !guardInvalid &&
794
955
  collector.spec.observerCommandCount > 0 &&
795
- collector.blockOrdinal === 5 + collector.spec.observerCommandCount
956
+ collector.blockOrdinal === 5 + shift + collector.spec.observerCommandCount
796
957
  )
797
958
  collector.observerEmissionObserved = true;
798
959
  if (!guardInvalid && guard.kind === "end") {
@@ -803,31 +964,19 @@ export class ControlChannelCore {
803
964
  this.currentReplyNum = null;
804
965
  this.currentReplyFlags = null;
805
966
  if (collector.blockOrdinal === completeOrdinal) {
806
- this.atomicCollector = null;
807
967
  const ok =
808
968
  collector.failureReason === null &&
809
969
  collector.cursorLine !== null &&
810
970
  collector.statusObserved;
811
- collector.spec.onSettled(
812
- Object.freeze({
813
- ok,
814
- captureLines: Object.freeze(ok ? [...collector.captureLines] : []),
815
- cursorLine: ok ? collector.cursorLine : null,
816
- continueObserved: collector.continueObserved,
817
- statusObserved: collector.statusObserved,
818
- observerEmissionObserved: collector.observerEmissionObserved,
819
- started: collector.started,
820
- lastCompletedOrdinal: collector.lastCompletedOrdinal,
821
- captureLineCount: collector.captureLines.length,
822
- captureByteCount: collector.captureBytes,
823
- failureReason: ok ? null : (collector.failureReason ?? "sentinel-order"),
824
- }),
825
- );
971
+ this.completeAtomicCollector(collector, ok);
826
972
  }
827
973
  return true;
828
974
  }
829
975
 
830
- if (line === `%continue ${collector.spec.runtimePaneId}` && collector.blockOrdinal === 5) {
976
+ if (
977
+ line === `%continue ${collector.spec.runtimePaneId}` &&
978
+ collector.blockOrdinal === 5 + shift
979
+ ) {
831
980
  if (collector.continueObserved) collector.failureReason ??= "duplicate-sentinel";
832
981
  collector.continueObserved = true;
833
982
  return true;
@@ -842,21 +991,27 @@ export class ControlChannelCore {
842
991
  return true;
843
992
  }
844
993
 
845
- const markerBranchOrdinal = 7 + collector.spec.observerCommandCount;
846
- const statusOrdinal = 8 + collector.spec.observerCommandCount;
847
- const completeOrdinal = 10 + collector.spec.observerCommandCount;
994
+ const markerBranchOrdinal = 7 + shift + collector.spec.observerCommandCount;
995
+ const statusOrdinal = 8 + shift + collector.spec.observerCommandCount;
996
+ const completeOrdinal = 10 + shift + collector.spec.observerCommandCount;
848
997
  collector.blockContentCount += 1;
849
- if (collector.blockOrdinal === 1) {
998
+ if (collector.blockOrdinal === 1 || (shift && collector.blockOrdinal === 3)) {
850
999
  collector.captureBytes += Buffer.byteLength(line, "latin1") + 1;
851
1000
  if (collector.captureBytes > collector.spec.maxCaptureBytes)
852
1001
  collector.failureReason ??= "capture-byte-cap";
853
- if (collector.captureLines.length >= collector.spec.maxCaptureLines)
1002
+ if (
1003
+ collector.captureLines.length + collector.ansiCaptureLines.length >=
1004
+ collector.spec.maxCaptureLines
1005
+ )
854
1006
  collector.failureReason ??= "capture-line-cap";
855
- else collector.captureLines.push(line);
1007
+ else
1008
+ (collector.blockOrdinal === 1 ? collector.captureLines : collector.ansiCaptureLines).push(
1009
+ line,
1010
+ );
856
1011
  this.reportAtomicPaneSnapshotProgress(collector);
857
1012
  return true;
858
1013
  }
859
- if (collector.blockOrdinal === 3) {
1014
+ if (collector.blockOrdinal === 3 + shift) {
860
1015
  if (collector.cursorLine !== null) collector.failureReason ??= "cursor-cardinality";
861
1016
  else if (Buffer.byteLength(line, "latin1") > collector.spec.maxCursorBytes)
862
1017
  collector.failureReason ??= "cursor-byte-cap";
@@ -871,13 +1026,15 @@ export class ControlChannelCore {
871
1026
  const expectedToken =
872
1027
  collector.blockOrdinal === 2
873
1028
  ? "capture-end"
874
- : collector.blockOrdinal === 4
875
- ? "cursor-end"
876
- : collector.blockOrdinal === statusOrdinal
877
- ? "status-ok"
878
- : collector.blockOrdinal === completeOrdinal
879
- ? "complete"
880
- : null;
1029
+ : shift && collector.blockOrdinal === 4
1030
+ ? "ansi-capture-end"
1031
+ : collector.blockOrdinal === 4 + shift
1032
+ ? "cursor-end"
1033
+ : collector.blockOrdinal === statusOrdinal
1034
+ ? "status-ok"
1035
+ : collector.blockOrdinal === completeOrdinal
1036
+ ? "complete"
1037
+ : null;
881
1038
  if (expectedToken === null || token !== expectedToken)
882
1039
  collector.failureReason ??= token === "start" ? "duplicate-sentinel" : "sentinel-order";
883
1040
  if (collector.blockOrdinal === statusOrdinal && token === "status-ok") {
@@ -887,6 +1044,106 @@ export class ControlChannelCore {
887
1044
  return true;
888
1045
  }
889
1046
 
1047
+ private completeAtomicCollector(
1048
+ collector: NonNullable<ControlChannelCore["atomicCollector"]>,
1049
+ ok: boolean,
1050
+ ): void {
1051
+ if (!ok) {
1052
+ this.retireAtomicPaneSnapshotCollector(
1053
+ collector.spec.nonce,
1054
+ collector.failureReason ?? "sentinel-order",
1055
+ );
1056
+ return;
1057
+ }
1058
+ const result: AtomicPaneSnapshotResult = Object.freeze({
1059
+ ok: true,
1060
+ captureLines: Object.freeze([...collector.captureLines]),
1061
+ ...(collector.spec.dualCapture
1062
+ ? { ansiCaptureLines: Object.freeze([...collector.ansiCaptureLines]) }
1063
+ : {}),
1064
+ cursorLine: collector.cursorLine,
1065
+ continueObserved: collector.continueObserved,
1066
+ statusObserved: collector.statusObserved,
1067
+ observerEmissionObserved: collector.observerEmissionObserved,
1068
+ started: collector.started,
1069
+ lastCompletedOrdinal: collector.lastCompletedOrdinal,
1070
+ captureLineCount: collector.captureLines.length + collector.ansiCaptureLines.length,
1071
+ captureByteCount: collector.captureBytes,
1072
+ failureReason: null,
1073
+ ...(collector.spec.kind === "pause" ? { pauseObserved: collector.pauseObserved } : {}),
1074
+ });
1075
+ // Settlement cannot admit another collector before terminal framing drains.
1076
+ collector.settled = true;
1077
+ try {
1078
+ collector.spec.onSettled(result);
1079
+ } catch {
1080
+ this.failRetiredDrain("snapshot settlement callback failed");
1081
+ return;
1082
+ }
1083
+ if (this.atomicCollector === collector) {
1084
+ this.atomicCollector = null;
1085
+ this.notifyAtomicDrained(collector, "complete");
1086
+ }
1087
+ }
1088
+
1089
+ private consumeOwnedPauseLine(line: string, token: string | null): boolean {
1090
+ const collector = this.atomicCollector!;
1091
+ const guard = parseControlLine(line, this.inReply);
1092
+ if (guard.kind === "begin") {
1093
+ if (guard.flags !== 0 || this.inReply || !Number.isSafeInteger(guard.num) || guard.num < 0)
1094
+ collector.failureReason ??= "sentinel-order";
1095
+ this.inReply = true;
1096
+ this.currentReplyNum = guard.num;
1097
+ this.currentReplyFlags = guard.flags;
1098
+ collector.blockOrdinal += 1;
1099
+ collector.blockContentCount = 0;
1100
+ return true;
1101
+ }
1102
+ if (guard.kind === "end" || guard.kind === "error") {
1103
+ const valid =
1104
+ this.inReply &&
1105
+ guard.kind === "end" &&
1106
+ guard.flags === 0 &&
1107
+ this.currentReplyFlags === 0 &&
1108
+ guard.num === this.currentReplyNum;
1109
+ if (
1110
+ !valid ||
1111
+ (collector.blockOrdinal === 1
1112
+ ? collector.blockContentCount > 1
1113
+ : collector.blockContentCount !== 1)
1114
+ )
1115
+ collector.failureReason ??= "sentinel-order";
1116
+ if (valid) collector.lastCompletedOrdinal = collector.blockOrdinal;
1117
+ this.inReply = false;
1118
+ this.currentReplyNum = null;
1119
+ this.currentReplyFlags = null;
1120
+ this.currentReplyConsumesPending = false;
1121
+ if (collector.blockOrdinal === 2)
1122
+ this.completeAtomicCollector(
1123
+ collector,
1124
+ collector.failureReason === null && collector.statusObserved,
1125
+ );
1126
+ return true;
1127
+ }
1128
+ if (!this.inReply && (line === "%exit" || line.startsWith("%exit "))) {
1129
+ this.retireAtomicPaneSnapshotCollector(collector.spec.nonce, "channel-exit");
1130
+ return false;
1131
+ }
1132
+ if (!this.inReply) {
1133
+ collector.failureReason ??= "unexpected-post-line";
1134
+ return true;
1135
+ }
1136
+ collector.blockContentCount += 1;
1137
+ if (collector.blockOrdinal === 1 && line === `%pause ${collector.spec.runtimePaneId}`) {
1138
+ if (collector.pauseObserved) collector.failureReason ??= "duplicate-sentinel";
1139
+ collector.pauseObserved = true;
1140
+ } else if (collector.blockOrdinal === 2 && token === "complete") {
1141
+ if (collector.statusObserved) collector.failureReason ??= "duplicate-sentinel";
1142
+ collector.statusObserved = true;
1143
+ } else collector.failureReason ??= "sentinel-order";
1144
+ return true;
1145
+ }
1146
+
890
1147
  private reportAtomicPaneSnapshotProgress(
891
1148
  collector: NonNullable<ControlChannelCore["atomicCollector"]>,
892
1149
  ): void {
@@ -895,7 +1152,10 @@ export class ControlChannelCore {
895
1152
  Object.freeze({
896
1153
  started: collector.started,
897
1154
  lastCompletedOrdinal: collector.lastCompletedOrdinal,
898
- captureLineCount: Math.min(collector.captureLines.length, ATOMIC_CAPTURE_LINE_HARD_CAP),
1155
+ captureLineCount: Math.min(
1156
+ collector.captureLines.length + collector.ansiCaptureLines.length,
1157
+ ATOMIC_CAPTURE_LINE_HARD_CAP,
1158
+ ),
899
1159
  captureByteCount: Math.min(collector.captureBytes, ATOMIC_CAPTURE_BYTE_HARD_CAP),
900
1160
  continueObserved: collector.continueObserved,
901
1161
  statusObserved: collector.statusObserved,
@@ -985,7 +1245,13 @@ export class MirrorControlChannel implements MirrorChannelIo {
985
1245
  private viewerPending = 0;
986
1246
  private verifiedAttach = false;
987
1247
  private guardedStart: { resolve: () => void; reject: (error: Error) => void } | null = null;
988
- private atomicCollectorTimer: ReturnType<typeof setTimeout> | null = null;
1248
+ private atomicCollectorOwner: {
1249
+ nonce: string;
1250
+ timer: ReturnType<typeof setTimeout> | null;
1251
+ drainTimer: ReturnType<typeof setTimeout> | null;
1252
+ retiring: boolean;
1253
+ completed: boolean;
1254
+ } | null = null;
989
1255
 
990
1256
  constructor(opts: MirrorControlChannelOptions) {
991
1257
  this.opts = opts;
@@ -1002,7 +1268,13 @@ export class MirrorControlChannel implements MirrorChannelIo {
1002
1268
  }
1003
1269
  if (!opts.nativeServerIdentity || this.verifiedAttach) opts.handlers.onNotify(name, rest);
1004
1270
  },
1005
- onExit: (reason) => this.noteExit(reason),
1271
+ onExit: (reason) => {
1272
+ try {
1273
+ this.noteExit(reason);
1274
+ } finally {
1275
+ void this.dispose();
1276
+ }
1277
+ },
1006
1278
  },
1007
1279
  opts.nowMicros,
1008
1280
  );
@@ -1303,21 +1575,47 @@ export class MirrorControlChannel implements MirrorChannelIo {
1303
1575
  }
1304
1576
 
1305
1577
  armAtomicPaneSnapshotCollector(spec: AtomicPaneSnapshotCollector, timeoutMs: number): boolean {
1578
+ if (this.exited || !this.proc?.stdin?.writable) return false;
1306
1579
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 5_000) return false;
1580
+ const owner = {
1581
+ nonce: spec.nonce,
1582
+ timer: null as ReturnType<typeof setTimeout> | null,
1583
+ drainTimer: null as ReturnType<typeof setTimeout> | null,
1584
+ retiring: false,
1585
+ completed: false,
1586
+ };
1587
+ const clear = () => {
1588
+ if (owner.timer) clearTimeout(owner.timer);
1589
+ if (owner.drainTimer) clearTimeout(owner.drainTimer);
1590
+ owner.timer = owner.drainTimer = null;
1591
+ if (this.atomicCollectorOwner === owner) this.atomicCollectorOwner = null;
1592
+ };
1307
1593
  const wrapped = {
1308
1594
  ...spec,
1309
1595
  onSettled: (result: AtomicPaneSnapshotResult): void => {
1310
- if (this.atomicCollectorTimer) clearTimeout(this.atomicCollectorTimer);
1311
- this.atomicCollectorTimer = null;
1596
+ if (owner.timer) clearTimeout(owner.timer);
1597
+ owner.timer = null;
1598
+ owner.completed = result.ok;
1599
+ if (!result.ok && result.failureReason !== "channel-exit" && !owner.retiring) {
1600
+ this.beginCollectorDrain(spec.nonce, result.failureReason ?? "retired", () =>
1601
+ spec.onSettled(result),
1602
+ );
1603
+ return;
1604
+ }
1312
1605
  spec.onSettled(result);
1313
1606
  },
1607
+ onDrained: (reason: "complete" | "fence" | "channel-exit"): void => {
1608
+ clear();
1609
+ spec.onDrained?.(reason);
1610
+ },
1314
1611
  };
1315
1612
  if (!this.core.armAtomicPaneSnapshotCollector(wrapped)) return false;
1316
- this.atomicCollectorTimer = setTimeout(() => {
1317
- this.atomicCollectorTimer = null;
1318
- this.core.retireAtomicPaneSnapshotCollector(spec.nonce, "timeout");
1319
- }, timeoutMs);
1320
- this.atomicCollectorTimer.unref?.();
1613
+ this.atomicCollectorOwner = owner;
1614
+ owner.timer = setTimeout(
1615
+ () => this.retireAtomicPaneSnapshotCollector(spec.nonce, "timeout"),
1616
+ timeoutMs,
1617
+ );
1618
+ owner.timer.unref?.();
1321
1619
  return true;
1322
1620
  }
1323
1621
 
@@ -1325,9 +1623,54 @@ export class MirrorControlChannel implements MirrorChannelIo {
1325
1623
  nonce: string,
1326
1624
  reason: AtomicPaneSnapshotFailureReason = "retired",
1327
1625
  ): void {
1328
- if (!this.core.retireAtomicPaneSnapshotCollector(nonce, reason)) return;
1329
- if (this.atomicCollectorTimer) clearTimeout(this.atomicCollectorTimer);
1330
- this.atomicCollectorTimer = null;
1626
+ this.beginCollectorDrain(nonce, reason);
1627
+ }
1628
+
1629
+ private beginCollectorDrain(
1630
+ nonce: string,
1631
+ reason: AtomicPaneSnapshotFailureReason,
1632
+ settle?: () => void,
1633
+ ): void {
1634
+ const owner = this.atomicCollectorOwner;
1635
+ if (!owner || owner.nonce !== nonce || owner.retiring || owner.completed) return;
1636
+ owner.retiring = true;
1637
+ if (owner.timer) clearTimeout(owner.timer);
1638
+ owner.timer = null;
1639
+ const abort = (message: string) => {
1640
+ if (this.atomicCollectorOwner !== owner) return;
1641
+ try {
1642
+ this.core.fail(message);
1643
+ this.noteExit(message);
1644
+ } finally {
1645
+ void this.dispose();
1646
+ }
1647
+ };
1648
+ // Establish drain ownership before onSettled can reenter and try another arm.
1649
+ owner.drainTimer = setTimeout(() => abort("retired snapshot drain timed out"), 5_000);
1650
+ owner.drainTimer.unref?.();
1651
+ try {
1652
+ this.core.retireAtomicPaneSnapshotCollector(nonce, reason);
1653
+ // Natural failed completion already settled the core tombstone. Retire
1654
+ // application ownership before queuing a fence behind dispatched work.
1655
+ settle?.();
1656
+ } catch {
1657
+ abort("retired snapshot settlement callback failed");
1658
+ return;
1659
+ }
1660
+ if (this.atomicCollectorOwner !== owner) return;
1661
+ const fence = `tmux-ide-collector-drain-v1:${randomBytes(24).toString("hex")}`;
1662
+ this.commandBoundedInline(
1663
+ `display-message -p -l ${fence}`,
1664
+ { maxBytes: 256, maxLines: 1 },
1665
+ (reply) => {
1666
+ if (this.atomicCollectorOwner !== owner) return;
1667
+ if (!reply.ok || reply.lines.length !== 1 || reply.lines[0] !== fence) {
1668
+ abort("invalid retired snapshot drain fence");
1669
+ return;
1670
+ }
1671
+ if (!this.core.releaseRetiredCollector(nonce)) abort("retired snapshot owner changed");
1672
+ },
1673
+ );
1331
1674
  }
1332
1675
 
1333
1676
  send(cmd: string, onReply?: (reply: ControlReply) => void): void {
@@ -1352,8 +1695,9 @@ export class MirrorControlChannel implements MirrorChannelIo {
1352
1695
  */
1353
1696
  async dispose(): Promise<void> {
1354
1697
  this.retireNativeViewer();
1355
- if (this.atomicCollectorTimer) clearTimeout(this.atomicCollectorTimer);
1356
- this.atomicCollectorTimer = null;
1698
+ const collectorOwner = this.atomicCollectorOwner;
1699
+ if (collectorOwner?.timer) clearTimeout(collectorOwner.timer);
1700
+ if (collectorOwner?.drainTimer) clearTimeout(collectorOwner.drainTimer);
1357
1701
  const proc = this.proc;
1358
1702
  this.proc = null;
1359
1703
  if (!proc) return;