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,228 @@
1
+ import type { MirrorPaneEvent } from "./events.ts";
2
+
3
+ /** Identity of one pane-wide, observed-pause snapshot attempt. */
4
+ export interface StockPaneSnapshotContext {
5
+ readonly paneId: string;
6
+ readonly incarnation: number;
7
+ readonly layoutGeneration: number;
8
+ readonly participants: readonly object[];
9
+ }
10
+
11
+ export interface StockPaneSnapshotBatch {
12
+ readonly participant: object;
13
+ /** Capture-time metadata only. Live bytes are owned by the transaction. */
14
+ readonly events: readonly MirrorPaneEvent[];
15
+ }
16
+
17
+ export type StockPaneSnapshotFailure =
18
+ | "cancelled"
19
+ | "context-changed"
20
+ | "context-unavailable"
21
+ | "pause-not-observed"
22
+ | "repaused"
23
+ | "overflow"
24
+ | "invalid-snapshot"
25
+ | "consumer-error";
26
+
27
+ export type StockPaneSnapshotOutput = "unrelated" | "discarded" | "held" | "invalid" | "live";
28
+
29
+ /**
30
+ * Stock control-mode output may overtake a capture reply behind sibling output.
31
+ * An observed pause is the lower fence for a NOHOOKS capture/cursor/continue
32
+ * transaction: every subsequent target byte must follow snapshot metadata.
33
+ *
34
+ * This object owns only that attempt's delivery contract. It neither sends tmux
35
+ * commands nor authenticates the snapshot collector. Its caller must prove the
36
+ * uninterrupted command transaction and retain the connection's collector lease
37
+ * until cancelled wire work has drained. SessionChannel owns this lifecycle.
38
+ */
39
+ export class StockPaneSnapshot {
40
+ static readonly MAX_BYTES = 1024 * 1024;
41
+ static readonly MAX_CHUNKS = 512;
42
+
43
+ private phase: "awaiting-pause" | "buffering" | "publishing" | "committed" | "invalidated" =
44
+ "awaiting-pause";
45
+ private failure: StockPaneSnapshotFailure | null = null;
46
+ private readonly context: StockPaneSnapshotContext;
47
+ private readonly participants: ReadonlySet<object>;
48
+ private readonly maxBytes: number;
49
+ private readonly maxChunks: number;
50
+ private chunks: Uint8Array[] = [];
51
+ private bytes = 0;
52
+
53
+ constructor(
54
+ context: StockPaneSnapshotContext,
55
+ limits: { readonly maxBytes?: number; readonly maxChunks?: number } = {},
56
+ ) {
57
+ if (
58
+ !/^%(?:0|[1-9][0-9]*)$/u.test(context.paneId) ||
59
+ !Number.isSafeInteger(context.incarnation) ||
60
+ context.incarnation < 0 ||
61
+ !Number.isSafeInteger(context.layoutGeneration) ||
62
+ context.layoutGeneration < 0 ||
63
+ context.participants.length === 0 ||
64
+ new Set(context.participants).size !== context.participants.length ||
65
+ context.participants.some(
66
+ (participant) => participant === null || typeof participant !== "object",
67
+ )
68
+ )
69
+ throw new TypeError("stock snapshot requires exact pane and participant identities");
70
+ this.maxBytes = limits.maxBytes ?? StockPaneSnapshot.MAX_BYTES;
71
+ this.maxChunks = limits.maxChunks ?? StockPaneSnapshot.MAX_CHUNKS;
72
+ if (
73
+ !Number.isSafeInteger(this.maxBytes) ||
74
+ this.maxBytes < 1 ||
75
+ this.maxBytes > StockPaneSnapshot.MAX_BYTES ||
76
+ !Number.isSafeInteger(this.maxChunks) ||
77
+ this.maxChunks < 1 ||
78
+ this.maxChunks > StockPaneSnapshot.MAX_CHUNKS
79
+ )
80
+ throw new RangeError("stock snapshot buffer limits must be positive and bounded");
81
+ this.context = { ...context, participants: [...context.participants] };
82
+ this.participants = new Set(this.context.participants);
83
+ }
84
+
85
+ get state() {
86
+ return this.phase;
87
+ }
88
+
89
+ get failureReason(): StockPaneSnapshotFailure | null {
90
+ return this.failure;
91
+ }
92
+
93
+ get bufferedBytes(): number {
94
+ return this.bytes;
95
+ }
96
+
97
+ get bufferedChunks(): number {
98
+ return this.chunks.length;
99
+ }
100
+
101
+ private matchesContext(context: StockPaneSnapshotContext): boolean {
102
+ if (this.phase === "invalidated") return false;
103
+ if (
104
+ context.paneId !== this.context.paneId ||
105
+ context.incarnation !== this.context.incarnation ||
106
+ context.layoutGeneration !== this.context.layoutGeneration ||
107
+ context.participants.length !== this.participants.size ||
108
+ new Set(context.participants).size !== this.participants.size ||
109
+ context.participants.some((participant) => !this.participants.has(participant))
110
+ ) {
111
+ this.invalidate("context-changed");
112
+ return false;
113
+ }
114
+ return true;
115
+ }
116
+
117
+ private current(context: StockPaneSnapshotContext): boolean {
118
+ return this.phase !== "committed" && this.matchesContext(context);
119
+ }
120
+
121
+ private readCurrentContext(read: () => StockPaneSnapshotContext): boolean {
122
+ try {
123
+ return this.current(read());
124
+ } catch {
125
+ this.invalidate("context-unavailable");
126
+ return false;
127
+ }
128
+ }
129
+
130
+ /** Only an actual current connection's pause notification establishes the fence. */
131
+ observePause(context: StockPaneSnapshotContext): boolean {
132
+ if (!this.current(context)) return false;
133
+ if (this.phase !== "awaiting-pause") {
134
+ this.invalidate("repaused");
135
+ return false;
136
+ }
137
+ this.phase = "buffering";
138
+ return true;
139
+ }
140
+
141
+ acceptOutput(
142
+ paneId: string,
143
+ data: Uint8Array,
144
+ context: StockPaneSnapshotContext,
145
+ ): StockPaneSnapshotOutput {
146
+ if (paneId !== this.context.paneId) return "unrelated";
147
+ if (!this.matchesContext(context)) return "invalid";
148
+ if (this.phase === "committed") return "live";
149
+ // The caller fences all participating feeds before requesting pause. These
150
+ // bytes precede the observed pause and are represented by the future seed.
151
+ if (this.phase === "awaiting-pause") return "discarded";
152
+ if (data.byteLength === 0) return "held";
153
+ if (this.chunks.length >= this.maxChunks || this.bytes + data.byteLength > this.maxBytes) {
154
+ this.invalidate("overflow");
155
+ return "invalid";
156
+ }
157
+ this.chunks.push(data.slice());
158
+ this.bytes += data.byteLength;
159
+ return "held";
160
+ }
161
+
162
+ /** Revoke future routing even after commit; delivered events cannot be retracted. */
163
+ invalidate(reason: StockPaneSnapshotFailure = "cancelled"): void {
164
+ if (this.phase === "invalidated") return;
165
+ this.phase = "invalidated";
166
+ this.failure = reason;
167
+ this.chunks = [];
168
+ this.bytes = 0;
169
+ }
170
+
171
+ /**
172
+ * Publish an authenticated snapshot, then replay each retained chunk to every
173
+ * exact participant. Context is rechecked around callbacks. A callback may
174
+ * append more output, which joins this same bounded replay before commit.
175
+ * Failure cannot retract already delivered events: the caller must retire or
176
+ * reseed participants when false is returned, never release their live feeds.
177
+ */
178
+ publish(
179
+ batches: readonly StockPaneSnapshotBatch[],
180
+ currentContext: () => StockPaneSnapshotContext,
181
+ emit: (participant: object, event: MirrorPaneEvent) => void,
182
+ ): boolean {
183
+ if (!this.readCurrentContext(currentContext)) return false;
184
+ if (this.phase !== "buffering") {
185
+ this.invalidate(this.phase === "awaiting-pause" ? "pause-not-observed" : "invalid-snapshot");
186
+ return false;
187
+ }
188
+ if (
189
+ batches.length !== this.participants.size ||
190
+ new Set(batches.map((batch) => batch.participant)).size !== this.participants.size ||
191
+ batches.some(
192
+ ({ participant, events }) =>
193
+ !this.participants.has(participant) ||
194
+ events.length !== 3 ||
195
+ events[0]?.type !== "reset" ||
196
+ events[1]?.type !== "seed" ||
197
+ events[2]?.type !== "cursor",
198
+ )
199
+ ) {
200
+ this.invalidate("invalid-snapshot");
201
+ return false;
202
+ }
203
+ this.phase = "publishing";
204
+ const deliver = (participant: object, event: MirrorPaneEvent): boolean => {
205
+ if (!this.readCurrentContext(currentContext)) return false;
206
+ try {
207
+ emit(participant, event);
208
+ } catch {
209
+ this.invalidate("consumer-error");
210
+ return false;
211
+ }
212
+ return this.readCurrentContext(currentContext);
213
+ };
214
+ for (const { participant, events } of batches)
215
+ for (const event of events) if (!deliver(participant, event)) return false;
216
+ for (let index = 0; index < this.chunks.length; index += 1) {
217
+ const data = this.chunks[index]!;
218
+ for (const participant of this.context.participants) {
219
+ // A subscriber cannot mutate the bytes replayed to its siblings.
220
+ if (!deliver(participant, { type: "delta", data: data.slice() })) return false;
221
+ }
222
+ }
223
+ this.chunks = [];
224
+ this.bytes = 0;
225
+ this.phase = "committed";
226
+ return true;
227
+ }
228
+ }
@@ -63,7 +63,7 @@ function colorsEqual(
63
63
  );
64
64
  }
65
65
 
66
- function cellsEqual(
66
+ export function cellsEqual(
67
67
  left: TerminalReplicaSnapshot["grid"][number]["cells"][number] | undefined,
68
68
  right: TerminalReplicaSnapshot["grid"][number]["cells"][number] | undefined,
69
69
  ): boolean {
@@ -1,3 +1,4 @@
1
+ import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.ts";
1
2
  import type { OwnedNativePlanCompletion } from "../../lib/owned-native-interaction-bindings.ts";
2
3
  import type {
3
4
  NativeBackingIdentity,
@@ -119,14 +120,56 @@ export interface TrustedSessionInventoryCandidate {
119
120
  readonly token: object;
120
121
  }
121
122
 
123
+ interface RuntimeListenerCounts {
124
+ readonly authority: number;
125
+ readonly mirrorSubscriptions: number;
126
+ readonly replicaSubscriptions: number;
127
+ readonly deliveryConnections: number;
128
+ }
129
+ // Final-disposal observations for owners still held by this runtime/registry.
130
+ // This is not a historical count of owners retired by earlier incarnation changes.
131
+ interface ListenerDisposalSummary {
132
+ readonly sessions: number;
133
+ readonly owners: number;
134
+ readonly failures: number;
135
+ readonly canonical: number;
136
+ readonly raw: number;
137
+ readonly upstream: number;
138
+ readonly authority: number;
139
+ readonly consumerHandles: number;
140
+ readonly deliveryConnections: number;
141
+ readonly deliverySources: number;
142
+ readonly pendingDeliverySources: number;
143
+ readonly pendingSourceCloses: number;
144
+ readonly pendingDeliveryClients: number;
145
+ }
146
+ const emptyListenerDisposal = (): ListenerDisposalSummary => ({
147
+ sessions: 0,
148
+ owners: 0,
149
+ failures: 0,
150
+ canonical: 0,
151
+ raw: 0,
152
+ upstream: 0,
153
+ authority: 0,
154
+ consumerHandles: 0,
155
+ deliveryConnections: 0,
156
+ deliverySources: 0,
157
+ pendingDeliverySources: 0,
158
+ pendingSourceCloses: 0,
159
+ pendingDeliveryClients: 0,
160
+ });
161
+
122
162
  export interface SessionRuntimeQualificationSnapshot {
123
163
  readonly generation: SessionRuntimeGeneration;
124
164
  readonly controlChannels: number;
165
+ readonly mirrorListeners: ReturnType<MirrorService["qualificationListeners"]>;
166
+ readonly listenerDisposal: ListenerDisposalSummary | null;
125
167
  readonly controllerLeases: number;
126
168
  readonly sessions: readonly {
127
169
  readonly session: string;
128
170
  readonly consumers: number;
129
171
  readonly retained: boolean;
172
+ readonly listeners: RuntimeListenerCounts;
130
173
  readonly delivery: TerminalDeliveryMetrics;
131
174
  readonly convergence: TerminalDeliveryConvergenceSnapshot;
132
175
  readonly replicas: Readonly<Record<string, TerminalReplicaQualificationSnapshot>>;
@@ -272,6 +315,7 @@ export class SessionRuntimeRegistry implements PaneStreamMirror {
272
315
  readonly #observability: SessionRuntimeObservability;
273
316
  readonly #createTraceCorrelator: (scheduler: SessionRuntimeScheduler) => RuntimeTraceCorrelator;
274
317
  readonly #sessions = new Map<string, SessionRuntime>();
318
+ #listenerDisposal: ListenerDisposalSummary | null = null;
275
319
  readonly #proofPrewarmOwnership = new Map<SessionRuntime, { owned: boolean; claims: number }>();
276
320
  readonly #trustedInventoryTokens = new WeakMap<
277
321
  object,
@@ -310,30 +354,47 @@ export class SessionRuntimeRegistry implements PaneStreamMirror {
310
354
  ...(this.#observability.enabled
311
355
  ? {
312
356
  nowMicros: () => this.#observability.nowMicros(),
313
- onInputWrite: (_session, action, startedAtMicros, endedAtMicros, pendingBeforeSend) => {
314
- for (const traceId of action.traceIds ?? []) {
315
- const trace = this.#observability.beginTrace(
316
- "terminal-input-to-paint",
317
- { generation: this.generation, incarnation: null },
318
- traceId,
319
- );
320
- this.#observability.recordSpan(
321
- "tmux",
322
- "control-write",
323
- startedAtMicros,
324
- endedAtMicros,
325
- trace,
326
- );
327
- this.#observability.recordSpan(
328
- "tmux",
329
- pendingBeforeSend === 0
330
- ? "control-queue-empty-at-send"
331
- : "control-queue-nonempty-at-send",
332
- endedAtMicros,
333
- endedAtMicros,
334
- trace,
335
- );
336
- }
357
+ captureInputWrite: (session, action, semanticPaneId) => {
358
+ const runtime = this.#sessions.get(session);
359
+ const binding = runtime?.captureInputDispatch(semanticPaneId);
360
+ return (startedAtMicros, endedAtMicros, pendingBeforeSend, paneCurrent) => {
361
+ const incarnation =
362
+ paneCurrent && this.#sessions.get(session) === runtime
363
+ ? (binding?.() ?? null)
364
+ : null;
365
+ for (const traceId of action.traceIds ?? []) {
366
+ const trace = this.#observability.beginTrace(
367
+ "terminal-input-to-paint",
368
+ {
369
+ generation: this.generation,
370
+ incarnation,
371
+ controlDispatch: Object.freeze({
372
+ workspaceName: session,
373
+ nativePaneId: action.pane,
374
+ semanticPaneId: paneCurrent ? semanticPaneId : null,
375
+ scope: "dispatch-time" as const,
376
+ }),
377
+ },
378
+ traceId,
379
+ );
380
+ this.#observability.recordSpan(
381
+ "tmux",
382
+ "control-write",
383
+ startedAtMicros,
384
+ endedAtMicros,
385
+ trace,
386
+ );
387
+ this.#observability.recordSpan(
388
+ "tmux",
389
+ pendingBeforeSend === 0
390
+ ? "control-queue-empty-at-send"
391
+ : "control-queue-nonempty-at-send",
392
+ endedAtMicros,
393
+ endedAtMicros,
394
+ trace,
395
+ );
396
+ }
397
+ };
337
398
  },
338
399
  }
339
400
  : {}),
@@ -981,6 +1042,8 @@ export class SessionRuntimeRegistry implements PaneStreamMirror {
981
1042
  return Object.freeze({
982
1043
  generation: this.generation,
983
1044
  controlChannels: this.activeControlChannelCount(),
1045
+ mirrorListeners: this.#mirror.qualificationListeners(),
1046
+ listenerDisposal: this.#listenerDisposal,
984
1047
  controllerLeases: this.activeControllerLeaseCount(),
985
1048
  sessions: Object.freeze(
986
1049
  [...this.#sessions.values()].map((runtime) => runtime.qualificationSnapshot()),
@@ -996,7 +1059,30 @@ export class SessionRuntimeRegistry implements PaneStreamMirror {
996
1059
  this.#stopExitObserver();
997
1060
  this.#disposePromise = (async () => {
998
1061
  await this.#semanticMutations?.dispose();
999
- await Promise.allSettled([...this.#sessions.values()].map((runtime) => runtime.dispose()));
1062
+ const runtimes = [...this.#sessions.values()];
1063
+ const results = await Promise.allSettled(runtimes.map((runtime) => runtime.dispose()));
1064
+ const final = { ...emptyListenerDisposal(), sessions: runtimes.length };
1065
+ runtimes.forEach((runtime, index) => {
1066
+ const counts = runtime.listenerDisposal;
1067
+ if (!counts || results[index]?.status === "rejected") final.failures += 1;
1068
+ if (counts)
1069
+ for (const key of [
1070
+ "owners",
1071
+ "failures",
1072
+ "canonical",
1073
+ "raw",
1074
+ "upstream",
1075
+ "authority",
1076
+ "consumerHandles",
1077
+ "deliveryConnections",
1078
+ "deliverySources",
1079
+ "pendingDeliverySources",
1080
+ "pendingSourceCloses",
1081
+ "pendingDeliveryClients",
1082
+ ] as const)
1083
+ final[key] += counts[key];
1084
+ });
1085
+ this.#listenerDisposal = Object.freeze(final);
1000
1086
  this.#sessions.clear();
1001
1087
  this.#proofPrewarmOwnership.clear();
1002
1088
  await this.#mirror.dispose();
@@ -1099,6 +1185,7 @@ export class SessionRuntimeRegistry implements PaneStreamMirror {
1099
1185
  class SessionRuntime {
1100
1186
  readonly #mirror: MirrorService;
1101
1187
  readonly #consumers = new Set<SessionRuntimeConsumerImpl>();
1188
+ listenerDisposal: ListenerDisposalSummary | null = null;
1102
1189
  readonly #consumersByClientId = new Map<string, SessionRuntimeConsumerImpl>();
1103
1190
  readonly #terminalReplicas = new Map<string, SessionRuntimeTerminalReplicaOwner>();
1104
1191
  readonly #terminalReplicaClocks = new Map<string, { epoch: number; revision: number }>();
@@ -1275,11 +1362,44 @@ class SessionRuntime {
1275
1362
  return this.#consumers.size > 0;
1276
1363
  }
1277
1364
 
1365
+ private listenerCounts(): RuntimeListenerCounts {
1366
+ const counts = {
1367
+ authority: this.#authorityListeners.size,
1368
+ mirrorSubscriptions: 0,
1369
+ replicaSubscriptions: 0,
1370
+ deliveryConnections: 0,
1371
+ };
1372
+ for (const consumer of this.#consumers) {
1373
+ const handles = consumer.qualificationListeners();
1374
+ counts.mirrorSubscriptions += handles.mirrorSubscriptions;
1375
+ counts.replicaSubscriptions += handles.replicaSubscriptions;
1376
+ counts.deliveryConnections += handles.deliveryConnections;
1377
+ }
1378
+ return Object.freeze(counts);
1379
+ }
1380
+
1381
+ captureInputDispatch(semanticPaneId: string | null): () => string | null {
1382
+ const owner =
1383
+ this.#disposed || semanticPaneId === null
1384
+ ? undefined
1385
+ : this.#terminalReplicas.get(semanticPaneId);
1386
+ const incarnation = owner?.inputDispatchIncarnation() ?? null;
1387
+ return () =>
1388
+ !this.#disposed &&
1389
+ semanticPaneId !== null &&
1390
+ owner !== undefined &&
1391
+ this.#terminalReplicas.get(semanticPaneId) === owner &&
1392
+ owner.inputDispatchIncarnation() === incarnation
1393
+ ? incarnation
1394
+ : null;
1395
+ }
1396
+
1278
1397
  qualificationSnapshot(): SessionRuntimeQualificationSnapshot["sessions"][number] {
1279
1398
  return Object.freeze({
1280
1399
  session: this.session,
1281
1400
  consumers: this.#consumers.size,
1282
1401
  retained: this.#retention !== null,
1402
+ listeners: this.listenerCounts(),
1283
1403
  delivery: this.#terminalDeliveryHub.metrics(),
1284
1404
  convergence: this.#terminalDeliveryHub.convergenceSnapshot(),
1285
1405
  replicas: Object.freeze(
@@ -1777,8 +1897,10 @@ class SessionRuntime {
1777
1897
  current.epoch += 1;
1778
1898
  this.#terminalReplicaClocks.set(semanticPaneId, current);
1779
1899
  },
1780
- onFault: () => {
1900
+ onFault: (error) => {
1781
1901
  if (this.#terminalReplicas.get(semanticPaneId) !== candidate) return;
1902
+ if (error instanceof StockCaptureTabUnavailableError)
1903
+ this.#terminalDeliveryHub.failPaneCapture(semanticPaneId, error);
1782
1904
  this.#outputTraces?.clearPane(semanticPaneId);
1783
1905
  this.#terminalReplicas.delete(semanticPaneId);
1784
1906
  this.#restartBarrier = this.#restartBarrier.then(async () => {
@@ -1896,22 +2018,51 @@ class SessionRuntime {
1896
2018
  this.#trustedInventoryRuntimeSessionId = null;
1897
2019
  this.#trustedInventoryEpoch += 1;
1898
2020
  const consumers = [...this.#consumers];
1899
- await Promise.allSettled(consumers.map((consumer) => consumer.close()));
2021
+ const consumerResults = await Promise.allSettled(consumers.map((consumer) => consumer.close()));
1900
2022
  this.#clearController();
1901
2023
  this.#authority.dispose();
1902
2024
  this.#authorityListeners.clear();
1903
2025
  this.#completedHandoffs.clear();
1904
2026
  this.#releasedLeases.clear();
1905
2027
  this.#outputTraces?.clear();
1906
- await Promise.allSettled(
1907
- [...this.#terminalReplicas.values()].map((owner) => owner.dispose("runtime-disposed")),
2028
+ const owners = [...this.#terminalReplicas.values()];
2029
+ const results = await Promise.allSettled(
2030
+ owners.map((owner) => owner.dispose("runtime-disposed")),
1908
2031
  );
2032
+ const final = {
2033
+ ...emptyListenerDisposal(),
2034
+ sessions: 1,
2035
+ owners: owners.length,
2036
+ failures: [...results, ...consumerResults].filter((result) => result.status === "rejected")
2037
+ .length,
2038
+ };
2039
+ for (const owner of owners) {
2040
+ const counts = owner.qualificationSnapshot().listeners;
2041
+ final.canonical += counts.canonical;
2042
+ final.raw += counts.raw;
2043
+ final.upstream += counts.upstream;
2044
+ }
2045
+ for (const consumer of consumers) {
2046
+ const counts = consumer.qualificationListeners();
2047
+ final.failures += counts.closeFailures;
2048
+ final.consumerHandles +=
2049
+ counts.mirrorSubscriptions + counts.replicaSubscriptions + counts.deliveryConnections;
2050
+ }
2051
+ final.authority = this.#authorityListeners.size;
1909
2052
  this.#terminalReplicas.clear();
1910
2053
  await this.#terminalDeliveryHub.close();
1911
2054
  await this.#startPromise?.catch(() => undefined);
1912
2055
  await this.#restartBarrier;
1913
2056
  await this.#retention?.close();
1914
2057
  this.#retention = null;
2058
+ const delivery = this.#terminalDeliveryHub.metrics();
2059
+ final.deliveryConnections = delivery.connections;
2060
+ final.deliverySources = delivery.sourceSubscriptions;
2061
+ final.pendingDeliverySources = delivery.pendingSourceSubscriptions;
2062
+ final.pendingSourceCloses = delivery.pendingSourceCloses;
2063
+ final.pendingDeliveryClients = delivery.pendingClients;
2064
+ final.failures += delivery.sourceCloseFailures;
2065
+ this.listenerDisposal = Object.freeze(final);
1915
2066
  }
1916
2067
 
1917
2068
  #assertConnected(clientId: string): void {
@@ -2218,6 +2369,17 @@ class SessionRuntimeConsumerImpl implements SessionRuntimeConsumer {
2218
2369
  return connection;
2219
2370
  }
2220
2371
 
2372
+ #closeFailures = 0;
2373
+
2374
+ qualificationListeners() {
2375
+ return Object.freeze({
2376
+ closeFailures: this.#closeFailures,
2377
+ mirrorSubscriptions: this.#subscriptions.size,
2378
+ replicaSubscriptions: this.#replicaSubscriptions.size,
2379
+ deliveryConnections: this.#deliveryConnections.size,
2380
+ });
2381
+ }
2382
+
2221
2383
  async close(): Promise<void> {
2222
2384
  if (this.#closed) return;
2223
2385
  this.#closed = true;
@@ -2230,9 +2392,18 @@ class SessionRuntimeConsumerImpl implements SessionRuntimeConsumer {
2230
2392
  // Authority retirement is synchronous. A slow/frozen mirror subscription
2231
2393
  // must never keep controller leases or previously issued handles alive.
2232
2394
  this.#runtime.release(this);
2233
- await Promise.allSettled(subscriptions.map((subscription) => subscription.close()));
2234
- await Promise.allSettled(replicaSubscriptions.map((subscription) => subscription.close()));
2235
- await Promise.allSettled(deliveryConnections.map((connection) => connection.close()));
2395
+ const mirrorResults = await Promise.allSettled(
2396
+ subscriptions.map((subscription) => subscription.close()),
2397
+ );
2398
+ const replicaResults = await Promise.allSettled(
2399
+ replicaSubscriptions.map((subscription) => subscription.close()),
2400
+ );
2401
+ const deliveryResults = await Promise.allSettled(
2402
+ deliveryConnections.map((connection) => connection.close()),
2403
+ );
2404
+ this.#closeFailures += [...mirrorResults, ...replicaResults, ...deliveryResults].filter(
2405
+ (result) => result.status === "rejected",
2406
+ ).length;
2236
2407
  }
2237
2408
 
2238
2409
  #assertOpen(): void {
@@ -9,6 +9,13 @@ import { z } from "zod";
9
9
  export interface SessionRuntimeTraceAuthority {
10
10
  readonly generation: SessionRuntimeGeneration;
11
11
  readonly incarnation: string | null;
12
+ /** Current dispatch target only; neither admission authority nor native acceptance. */
13
+ readonly controlDispatch?: Readonly<{
14
+ workspaceName: string;
15
+ nativePaneId: string;
16
+ semanticPaneId: string | null;
17
+ scope: "dispatch-time";
18
+ }>;
12
19
  }
13
20
 
14
21
  export interface SessionRuntimeTraceContext {
@@ -0,0 +1,13 @@
1
+ import { STOCK_CAPTURE_TAB_UNAVAILABLE } from "@tmux-ide/contracts";
2
+
3
+ export class StockCaptureTabUnavailableError extends Error {
4
+ constructor() {
5
+ super(STOCK_CAPTURE_TAB_UNAVAILABLE);
6
+ this.name = "StockCaptureTabUnavailableError";
7
+ }
8
+ }
9
+
10
+ /** ANSI capture drops stored tab widths. Live HT is cursor motion, not capture paint. */
11
+ export function assertStockCaptureRepresentable(chunks: readonly Uint8Array[]): void {
12
+ if (chunks.some((chunk) => chunk.includes(9))) throw new StockCaptureTabUnavailableError();
13
+ }