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
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Stock control-mode output may overtake a capture reply behind sibling output.
3
+ * An observed pause is the lower fence for a NOHOOKS capture/cursor/continue
4
+ * transaction: every subsequent target byte must follow snapshot metadata.
5
+ *
6
+ * This object owns only that attempt's delivery contract. It neither sends tmux
7
+ * commands nor authenticates the snapshot collector. Its caller must prove the
8
+ * uninterrupted command transaction and retain the connection's collector lease
9
+ * until cancelled wire work has drained. SessionChannel owns this lifecycle.
10
+ */
11
+ export class StockPaneSnapshot {
12
+ static MAX_BYTES = 1024 * 1024;
13
+ static MAX_CHUNKS = 512;
14
+ phase = "awaiting-pause";
15
+ failure = null;
16
+ context;
17
+ participants;
18
+ maxBytes;
19
+ maxChunks;
20
+ chunks = [];
21
+ bytes = 0;
22
+ constructor(context, limits = {}) {
23
+ if (!/^%(?:0|[1-9][0-9]*)$/u.test(context.paneId) ||
24
+ !Number.isSafeInteger(context.incarnation) ||
25
+ context.incarnation < 0 ||
26
+ !Number.isSafeInteger(context.layoutGeneration) ||
27
+ context.layoutGeneration < 0 ||
28
+ context.participants.length === 0 ||
29
+ new Set(context.participants).size !== context.participants.length ||
30
+ context.participants.some((participant) => participant === null || typeof participant !== "object"))
31
+ throw new TypeError("stock snapshot requires exact pane and participant identities");
32
+ this.maxBytes = limits.maxBytes ?? StockPaneSnapshot.MAX_BYTES;
33
+ this.maxChunks = limits.maxChunks ?? StockPaneSnapshot.MAX_CHUNKS;
34
+ if (!Number.isSafeInteger(this.maxBytes) ||
35
+ this.maxBytes < 1 ||
36
+ this.maxBytes > StockPaneSnapshot.MAX_BYTES ||
37
+ !Number.isSafeInteger(this.maxChunks) ||
38
+ this.maxChunks < 1 ||
39
+ this.maxChunks > StockPaneSnapshot.MAX_CHUNKS)
40
+ throw new RangeError("stock snapshot buffer limits must be positive and bounded");
41
+ this.context = { ...context, participants: [...context.participants] };
42
+ this.participants = new Set(this.context.participants);
43
+ }
44
+ get state() {
45
+ return this.phase;
46
+ }
47
+ get failureReason() {
48
+ return this.failure;
49
+ }
50
+ get bufferedBytes() {
51
+ return this.bytes;
52
+ }
53
+ get bufferedChunks() {
54
+ return this.chunks.length;
55
+ }
56
+ matchesContext(context) {
57
+ if (this.phase === "invalidated")
58
+ return false;
59
+ if (context.paneId !== this.context.paneId ||
60
+ context.incarnation !== this.context.incarnation ||
61
+ context.layoutGeneration !== this.context.layoutGeneration ||
62
+ context.participants.length !== this.participants.size ||
63
+ new Set(context.participants).size !== this.participants.size ||
64
+ context.participants.some((participant) => !this.participants.has(participant))) {
65
+ this.invalidate("context-changed");
66
+ return false;
67
+ }
68
+ return true;
69
+ }
70
+ current(context) {
71
+ return this.phase !== "committed" && this.matchesContext(context);
72
+ }
73
+ readCurrentContext(read) {
74
+ try {
75
+ return this.current(read());
76
+ }
77
+ catch {
78
+ this.invalidate("context-unavailable");
79
+ return false;
80
+ }
81
+ }
82
+ /** Only an actual current connection's pause notification establishes the fence. */
83
+ observePause(context) {
84
+ if (!this.current(context))
85
+ return false;
86
+ if (this.phase !== "awaiting-pause") {
87
+ this.invalidate("repaused");
88
+ return false;
89
+ }
90
+ this.phase = "buffering";
91
+ return true;
92
+ }
93
+ acceptOutput(paneId, data, context) {
94
+ if (paneId !== this.context.paneId)
95
+ return "unrelated";
96
+ if (!this.matchesContext(context))
97
+ return "invalid";
98
+ if (this.phase === "committed")
99
+ return "live";
100
+ // The caller fences all participating feeds before requesting pause. These
101
+ // bytes precede the observed pause and are represented by the future seed.
102
+ if (this.phase === "awaiting-pause")
103
+ return "discarded";
104
+ if (data.byteLength === 0)
105
+ return "held";
106
+ if (this.chunks.length >= this.maxChunks || this.bytes + data.byteLength > this.maxBytes) {
107
+ this.invalidate("overflow");
108
+ return "invalid";
109
+ }
110
+ this.chunks.push(data.slice());
111
+ this.bytes += data.byteLength;
112
+ return "held";
113
+ }
114
+ /** Revoke future routing even after commit; delivered events cannot be retracted. */
115
+ invalidate(reason = "cancelled") {
116
+ if (this.phase === "invalidated")
117
+ return;
118
+ this.phase = "invalidated";
119
+ this.failure = reason;
120
+ this.chunks = [];
121
+ this.bytes = 0;
122
+ }
123
+ /**
124
+ * Publish an authenticated snapshot, then replay each retained chunk to every
125
+ * exact participant. Context is rechecked around callbacks. A callback may
126
+ * append more output, which joins this same bounded replay before commit.
127
+ * Failure cannot retract already delivered events: the caller must retire or
128
+ * reseed participants when false is returned, never release their live feeds.
129
+ */
130
+ publish(batches, currentContext, emit) {
131
+ if (!this.readCurrentContext(currentContext))
132
+ return false;
133
+ if (this.phase !== "buffering") {
134
+ this.invalidate(this.phase === "awaiting-pause" ? "pause-not-observed" : "invalid-snapshot");
135
+ return false;
136
+ }
137
+ if (batches.length !== this.participants.size ||
138
+ new Set(batches.map((batch) => batch.participant)).size !== this.participants.size ||
139
+ batches.some(({ participant, events }) => !this.participants.has(participant) ||
140
+ events.length !== 3 ||
141
+ events[0]?.type !== "reset" ||
142
+ events[1]?.type !== "seed" ||
143
+ events[2]?.type !== "cursor")) {
144
+ this.invalidate("invalid-snapshot");
145
+ return false;
146
+ }
147
+ this.phase = "publishing";
148
+ const deliver = (participant, event) => {
149
+ if (!this.readCurrentContext(currentContext))
150
+ return false;
151
+ try {
152
+ emit(participant, event);
153
+ }
154
+ catch {
155
+ this.invalidate("consumer-error");
156
+ return false;
157
+ }
158
+ return this.readCurrentContext(currentContext);
159
+ };
160
+ for (const { participant, events } of batches)
161
+ for (const event of events)
162
+ if (!deliver(participant, event))
163
+ return false;
164
+ for (let index = 0; index < this.chunks.length; index += 1) {
165
+ const data = this.chunks[index];
166
+ for (const participant of this.context.participants) {
167
+ // A subscriber cannot mutate the bytes replayed to its siblings.
168
+ if (!deliver(participant, { type: "delta", data: data.slice() }))
169
+ return false;
170
+ }
171
+ }
172
+ this.chunks = [];
173
+ this.bytes = 0;
174
+ this.phase = "committed";
175
+ return true;
176
+ }
177
+ }
@@ -25,7 +25,7 @@ function colorsEqual(left, right) {
25
25
  (left.kind === "indexed" && right.kind === "indexed" && left.index === right.index) ||
26
26
  (left.kind === "rgb" && right.kind === "rgb" && left.value === right.value)));
27
27
  }
28
- function cellsEqual(left, right) {
28
+ export function cellsEqual(left, right) {
29
29
  return (left === right ||
30
30
  (left !== undefined &&
31
31
  right !== undefined &&
@@ -1,3 +1,4 @@
1
+ import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.js";
1
2
  import { SessionRuntimeClientIdSchemaZ, SessionRuntimeAuthorityLeaseSchemaZ, SessionRuntimeControllerLeaseSchemaZ, SessionRuntimeGenerationSchemaZ, SessionRuntimeSemanticIntentSchemaZ, SessionRuntimeTerminalInputSchemaZ, CausalCellProbeV1SchemaZ, } from "@tmux-ide/contracts";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { z } from "zod";
@@ -19,6 +20,21 @@ async function abortable(promise, signal) {
19
20
  void promise.then(resolve, reject).then(() => signal.removeEventListener("abort", onAbort), () => signal.removeEventListener("abort", onAbort));
20
21
  });
21
22
  }
23
+ const emptyListenerDisposal = () => ({
24
+ sessions: 0,
25
+ owners: 0,
26
+ failures: 0,
27
+ canonical: 0,
28
+ raw: 0,
29
+ upstream: 0,
30
+ authority: 0,
31
+ consumerHandles: 0,
32
+ deliveryConnections: 0,
33
+ deliverySources: 0,
34
+ pendingDeliverySources: 0,
35
+ pendingSourceCloses: 0,
36
+ pendingDeliveryClients: 0,
37
+ });
22
38
  function authoredOriginForSurface(surface) {
23
39
  if (surface === "opentui")
24
40
  return "tui";
@@ -56,6 +72,7 @@ export class SessionRuntimeRegistry {
56
72
  #observability;
57
73
  #createTraceCorrelator;
58
74
  #sessions = new Map();
75
+ #listenerDisposal = null;
59
76
  #proofPrewarmOwnership = new Map();
60
77
  #trustedInventoryTokens = new WeakMap();
61
78
  #executionHandles = new WeakMap();
@@ -81,14 +98,30 @@ export class SessionRuntimeRegistry {
81
98
  ...(this.#observability.enabled
82
99
  ? {
83
100
  nowMicros: () => this.#observability.nowMicros(),
84
- onInputWrite: (_session, action, startedAtMicros, endedAtMicros, pendingBeforeSend) => {
85
- for (const traceId of action.traceIds ?? []) {
86
- const trace = this.#observability.beginTrace("terminal-input-to-paint", { generation: this.generation, incarnation: null }, traceId);
87
- this.#observability.recordSpan("tmux", "control-write", startedAtMicros, endedAtMicros, trace);
88
- this.#observability.recordSpan("tmux", pendingBeforeSend === 0
89
- ? "control-queue-empty-at-send"
90
- : "control-queue-nonempty-at-send", endedAtMicros, endedAtMicros, trace);
91
- }
101
+ captureInputWrite: (session, action, semanticPaneId) => {
102
+ const runtime = this.#sessions.get(session);
103
+ const binding = runtime?.captureInputDispatch(semanticPaneId);
104
+ return (startedAtMicros, endedAtMicros, pendingBeforeSend, paneCurrent) => {
105
+ const incarnation = paneCurrent && this.#sessions.get(session) === runtime
106
+ ? (binding?.() ?? null)
107
+ : null;
108
+ for (const traceId of action.traceIds ?? []) {
109
+ const trace = this.#observability.beginTrace("terminal-input-to-paint", {
110
+ generation: this.generation,
111
+ incarnation,
112
+ controlDispatch: Object.freeze({
113
+ workspaceName: session,
114
+ nativePaneId: action.pane,
115
+ semanticPaneId: paneCurrent ? semanticPaneId : null,
116
+ scope: "dispatch-time",
117
+ }),
118
+ }, traceId);
119
+ this.#observability.recordSpan("tmux", "control-write", startedAtMicros, endedAtMicros, trace);
120
+ this.#observability.recordSpan("tmux", pendingBeforeSend === 0
121
+ ? "control-queue-empty-at-send"
122
+ : "control-queue-nonempty-at-send", endedAtMicros, endedAtMicros, trace);
123
+ }
124
+ };
92
125
  },
93
126
  }
94
127
  : {}),
@@ -572,6 +605,8 @@ export class SessionRuntimeRegistry {
572
605
  return Object.freeze({
573
606
  generation: this.generation,
574
607
  controlChannels: this.activeControlChannelCount(),
608
+ mirrorListeners: this.#mirror.qualificationListeners(),
609
+ listenerDisposal: this.#listenerDisposal,
575
610
  controllerLeases: this.activeControllerLeaseCount(),
576
611
  sessions: Object.freeze([...this.#sessions.values()].map((runtime) => runtime.qualificationSnapshot())),
577
612
  mutations: this.#semanticMutations?.metrics() ?? null,
@@ -584,7 +619,31 @@ export class SessionRuntimeRegistry {
584
619
  this.#stopExitObserver();
585
620
  this.#disposePromise = (async () => {
586
621
  await this.#semanticMutations?.dispose();
587
- await Promise.allSettled([...this.#sessions.values()].map((runtime) => runtime.dispose()));
622
+ const runtimes = [...this.#sessions.values()];
623
+ const results = await Promise.allSettled(runtimes.map((runtime) => runtime.dispose()));
624
+ const final = { ...emptyListenerDisposal(), sessions: runtimes.length };
625
+ runtimes.forEach((runtime, index) => {
626
+ const counts = runtime.listenerDisposal;
627
+ if (!counts || results[index]?.status === "rejected")
628
+ final.failures += 1;
629
+ if (counts)
630
+ for (const key of [
631
+ "owners",
632
+ "failures",
633
+ "canonical",
634
+ "raw",
635
+ "upstream",
636
+ "authority",
637
+ "consumerHandles",
638
+ "deliveryConnections",
639
+ "deliverySources",
640
+ "pendingDeliverySources",
641
+ "pendingSourceCloses",
642
+ "pendingDeliveryClients",
643
+ ])
644
+ final[key] += counts[key];
645
+ });
646
+ this.#listenerDisposal = Object.freeze(final);
588
647
  this.#sessions.clear();
589
648
  this.#proofPrewarmOwnership.clear();
590
649
  await this.#mirror.dispose();
@@ -661,6 +720,7 @@ class SessionRuntime {
661
720
  session;
662
721
  #mirror;
663
722
  #consumers = new Set();
723
+ listenerDisposal = null;
664
724
  #consumersByClientId = new Map();
665
725
  #terminalReplicas = new Map();
666
726
  #terminalReplicaClocks = new Map();
@@ -786,11 +846,40 @@ class SessionRuntime {
786
846
  hasConsumers() {
787
847
  return this.#consumers.size > 0;
788
848
  }
849
+ listenerCounts() {
850
+ const counts = {
851
+ authority: this.#authorityListeners.size,
852
+ mirrorSubscriptions: 0,
853
+ replicaSubscriptions: 0,
854
+ deliveryConnections: 0,
855
+ };
856
+ for (const consumer of this.#consumers) {
857
+ const handles = consumer.qualificationListeners();
858
+ counts.mirrorSubscriptions += handles.mirrorSubscriptions;
859
+ counts.replicaSubscriptions += handles.replicaSubscriptions;
860
+ counts.deliveryConnections += handles.deliveryConnections;
861
+ }
862
+ return Object.freeze(counts);
863
+ }
864
+ captureInputDispatch(semanticPaneId) {
865
+ const owner = this.#disposed || semanticPaneId === null
866
+ ? undefined
867
+ : this.#terminalReplicas.get(semanticPaneId);
868
+ const incarnation = owner?.inputDispatchIncarnation() ?? null;
869
+ return () => !this.#disposed &&
870
+ semanticPaneId !== null &&
871
+ owner !== undefined &&
872
+ this.#terminalReplicas.get(semanticPaneId) === owner &&
873
+ owner.inputDispatchIncarnation() === incarnation
874
+ ? incarnation
875
+ : null;
876
+ }
789
877
  qualificationSnapshot() {
790
878
  return Object.freeze({
791
879
  session: this.session,
792
880
  consumers: this.#consumers.size,
793
881
  retained: this.#retention !== null,
882
+ listeners: this.listenerCounts(),
794
883
  delivery: this.#terminalDeliveryHub.metrics(),
795
884
  convergence: this.#terminalDeliveryHub.convergenceSnapshot(),
796
885
  replicas: Object.freeze(Object.fromEntries([...this.#terminalReplicas.entries()].map(([paneId, owner]) => [
@@ -1148,9 +1237,11 @@ class SessionRuntime {
1148
1237
  current.epoch += 1;
1149
1238
  this.#terminalReplicaClocks.set(semanticPaneId, current);
1150
1239
  },
1151
- onFault: () => {
1240
+ onFault: (error) => {
1152
1241
  if (this.#terminalReplicas.get(semanticPaneId) !== candidate)
1153
1242
  return;
1243
+ if (error instanceof StockCaptureTabUnavailableError)
1244
+ this.#terminalDeliveryHub.failPaneCapture(semanticPaneId, error);
1154
1245
  this.#outputTraces?.clearPane(semanticPaneId);
1155
1246
  this.#terminalReplicas.delete(semanticPaneId);
1156
1247
  this.#restartBarrier = this.#restartBarrier.then(async () => {
@@ -1248,20 +1339,49 @@ class SessionRuntime {
1248
1339
  this.#trustedInventoryRuntimeSessionId = null;
1249
1340
  this.#trustedInventoryEpoch += 1;
1250
1341
  const consumers = [...this.#consumers];
1251
- await Promise.allSettled(consumers.map((consumer) => consumer.close()));
1342
+ const consumerResults = await Promise.allSettled(consumers.map((consumer) => consumer.close()));
1252
1343
  this.#clearController();
1253
1344
  this.#authority.dispose();
1254
1345
  this.#authorityListeners.clear();
1255
1346
  this.#completedHandoffs.clear();
1256
1347
  this.#releasedLeases.clear();
1257
1348
  this.#outputTraces?.clear();
1258
- await Promise.allSettled([...this.#terminalReplicas.values()].map((owner) => owner.dispose("runtime-disposed")));
1349
+ const owners = [...this.#terminalReplicas.values()];
1350
+ const results = await Promise.allSettled(owners.map((owner) => owner.dispose("runtime-disposed")));
1351
+ const final = {
1352
+ ...emptyListenerDisposal(),
1353
+ sessions: 1,
1354
+ owners: owners.length,
1355
+ failures: [...results, ...consumerResults].filter((result) => result.status === "rejected")
1356
+ .length,
1357
+ };
1358
+ for (const owner of owners) {
1359
+ const counts = owner.qualificationSnapshot().listeners;
1360
+ final.canonical += counts.canonical;
1361
+ final.raw += counts.raw;
1362
+ final.upstream += counts.upstream;
1363
+ }
1364
+ for (const consumer of consumers) {
1365
+ const counts = consumer.qualificationListeners();
1366
+ final.failures += counts.closeFailures;
1367
+ final.consumerHandles +=
1368
+ counts.mirrorSubscriptions + counts.replicaSubscriptions + counts.deliveryConnections;
1369
+ }
1370
+ final.authority = this.#authorityListeners.size;
1259
1371
  this.#terminalReplicas.clear();
1260
1372
  await this.#terminalDeliveryHub.close();
1261
1373
  await this.#startPromise?.catch(() => undefined);
1262
1374
  await this.#restartBarrier;
1263
1375
  await this.#retention?.close();
1264
1376
  this.#retention = null;
1377
+ const delivery = this.#terminalDeliveryHub.metrics();
1378
+ final.deliveryConnections = delivery.connections;
1379
+ final.deliverySources = delivery.sourceSubscriptions;
1380
+ final.pendingDeliverySources = delivery.pendingSourceSubscriptions;
1381
+ final.pendingSourceCloses = delivery.pendingSourceCloses;
1382
+ final.pendingDeliveryClients = delivery.pendingClients;
1383
+ final.failures += delivery.sourceCloseFailures;
1384
+ this.listenerDisposal = Object.freeze(final);
1265
1385
  }
1266
1386
  #assertConnected(clientId) {
1267
1387
  if (!this.#consumersByClientId.has(clientId)) {
@@ -1472,6 +1592,15 @@ class SessionRuntimeConsumerImpl {
1472
1592
  this.#deliveryConnections.add(connection);
1473
1593
  return connection;
1474
1594
  }
1595
+ #closeFailures = 0;
1596
+ qualificationListeners() {
1597
+ return Object.freeze({
1598
+ closeFailures: this.#closeFailures,
1599
+ mirrorSubscriptions: this.#subscriptions.size,
1600
+ replicaSubscriptions: this.#replicaSubscriptions.size,
1601
+ deliveryConnections: this.#deliveryConnections.size,
1602
+ });
1603
+ }
1475
1604
  async close() {
1476
1605
  if (this.#closed)
1477
1606
  return;
@@ -1485,9 +1614,10 @@ class SessionRuntimeConsumerImpl {
1485
1614
  // Authority retirement is synchronous. A slow/frozen mirror subscription
1486
1615
  // must never keep controller leases or previously issued handles alive.
1487
1616
  this.#runtime.release(this);
1488
- await Promise.allSettled(subscriptions.map((subscription) => subscription.close()));
1489
- await Promise.allSettled(replicaSubscriptions.map((subscription) => subscription.close()));
1490
- await Promise.allSettled(deliveryConnections.map((connection) => connection.close()));
1617
+ const mirrorResults = await Promise.allSettled(subscriptions.map((subscription) => subscription.close()));
1618
+ const replicaResults = await Promise.allSettled(replicaSubscriptions.map((subscription) => subscription.close()));
1619
+ const deliveryResults = await Promise.allSettled(deliveryConnections.map((connection) => connection.close()));
1620
+ this.#closeFailures += [...mirrorResults, ...replicaResults, ...deliveryResults].filter((result) => result.status === "rejected").length;
1491
1621
  }
1492
1622
  #assertOpen() {
1493
1623
  if (this.#closed)
@@ -0,0 +1,12 @@
1
+ import { STOCK_CAPTURE_TAB_UNAVAILABLE } from "@tmux-ide/contracts";
2
+ export class StockCaptureTabUnavailableError extends Error {
3
+ constructor() {
4
+ super(STOCK_CAPTURE_TAB_UNAVAILABLE);
5
+ this.name = "StockCaptureTabUnavailableError";
6
+ }
7
+ }
8
+ /** ANSI capture drops stored tab widths. Live HT is cursor motion, not capture paint. */
9
+ export function assertStockCaptureRepresentable(chunks) {
10
+ if (chunks.some((chunk) => chunk.includes(9)))
11
+ throw new StockCaptureTabUnavailableError();
12
+ }