tmux-ide 2.9.10 → 2.9.11

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 (42) hide show
  1. package/bin/cli.js +173 -61
  2. package/package.json +1 -1
  3. package/packages/contracts/src/pane-stream.ts +12 -0
  4. package/packages/core/src/terminal-replica-hash-cache.ts +8 -1
  5. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  6. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  7. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  8. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  9. package/packages/daemon/dist/terminal/mirror/mirror-service.js +12 -2
  10. package/packages/daemon/dist/terminal/mirror/session-channel.js +46 -23
  11. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +15 -4
  12. package/packages/daemon/dist/terminal/session-runtime/registry.js +17 -6
  13. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +95 -36
  14. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +6 -0
  15. package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +2 -2
  16. package/packages/daemon/dist/tui/mirror/runtime/application-generation-starter.js +16 -1
  17. package/packages/daemon/dist/tui/mirror/runtime/application-machine-agent-navigation.js +13 -0
  18. package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +12 -4
  19. package/packages/daemon/dist/tui/mirror/runtime/application-retained-agent.js +18 -0
  20. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +62 -2
  21. package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +2 -2
  22. package/packages/daemon/dist/tui/mirror/runtime/runtime-layout-presentation.js +6 -3
  23. package/packages/daemon/dist/tui/mirror/runtime/select-terminal-pane.js +7 -2
  24. package/packages/daemon/dist/tui/mirror/runtime/terminal-selection-waiter.js +36 -0
  25. package/packages/daemon/src/terminal/mirror/mirror-service.ts +19 -2
  26. package/packages/daemon/src/terminal/mirror/session-channel.ts +57 -9
  27. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +29 -1
  28. package/packages/daemon/src/terminal/session-runtime/registry.ts +23 -3
  29. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +102 -44
  30. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +7 -0
  31. package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +3 -0
  32. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +2 -0
  33. package/packages/daemon/src/tui/mirror/runtime/application-generation-starter.ts +17 -1
  34. package/packages/daemon/src/tui/mirror/runtime/application-machine-agent-navigation.ts +15 -0
  35. package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +18 -4
  36. package/packages/daemon/src/tui/mirror/runtime/application-retained-agent.ts +29 -0
  37. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +63 -2
  38. package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +2 -2
  39. package/packages/daemon/src/tui/mirror/runtime/runtime-layout-presentation.ts +7 -4
  40. package/packages/daemon/src/tui/mirror/runtime/select-terminal-pane.ts +9 -1
  41. package/packages/daemon/src/tui/mirror/runtime/terminal-selection-waiter.ts +36 -0
  42. package/packages/daemon-client/src/pane-stream-client.ts +17 -0
@@ -1,3 +1,4 @@
1
+ import { createTerminalSelectionWaiter } from "./terminal-selection-waiter.js";
1
2
  import { WorkspaceWindowLinkSelectResultSchemaZ } from "@tmux-ide/contracts";
2
3
  import { windowLinkForPane, windowLinkTarget } from "./application-terminal-workspace-policy.js";
3
4
  import { createHash, randomUUID } from "node:crypto";
@@ -117,6 +118,10 @@ export function createApplicationTerminalInteractionController(options) {
117
118
  left.generation === right.generation &&
118
119
  left.workspace === right.workspace;
119
120
  let selectedControl = null;
121
+ const selectionWaiter = createTerminalSelectionWaiter();
122
+ let observedLayoutEpoch = null;
123
+ let selectionGeneration = null;
124
+ let selectionLayout = options.layout();
120
125
  const waitingInput = new Set();
121
126
  let waitingInputBytes = 0;
122
127
  let releasingInput = null;
@@ -522,8 +527,28 @@ export function createApplicationTerminalInteractionController(options) {
522
527
  const paneInput = new TerminalPaneInputRouter({
523
528
  select: async (paneId) => {
524
529
  finishLinkSelection(false);
530
+ const initial = options.generation();
531
+ const selectionControl = controlIdentity(initial);
532
+ const selectionVersion = paneInput.selectionVersion;
533
+ const selectionCurrent = () => paneInput.selectionVersion === selectionVersion &&
534
+ sameControl(selectionControl, controlIdentity(selectionGeneration));
535
+ const panePresent = () => selectionLayout.windows.some((window) => window.panes.some((pane) => pane.pane === paneId));
536
+ if (initial?.status === "rebinding") {
537
+ // Reserve the optimistic target now, but send its FIRST mutation only
538
+ // after this same control connection has a coherent terminal runtime.
539
+ const ready = await selectionWaiter.wait(() => {
540
+ if (!selectionCurrent())
541
+ return false;
542
+ const active = selectionGeneration;
543
+ if (active?.status !== "live" || observedLayoutEpoch !== active.rendererEpoch)
544
+ return "pending";
545
+ return panePresent();
546
+ });
547
+ if (!ready || !selectionCurrent())
548
+ return false;
549
+ }
525
550
  const expected = liveSelectionTarget();
526
- const selectionControl = controlIdentity(options.generation());
551
+ let dispatchedRendererEpoch;
527
552
  const selecting = pendingWindowSwitch;
528
553
  const operationId = selecting?.traceId;
529
554
  const selectionFailure = { current: null };
@@ -536,8 +561,36 @@ export function createApplicationTerminalInteractionController(options) {
536
561
  : null;
537
562
  }, paneId, operationId, (failure) => {
538
563
  selectionFailure.current = failure;
539
- }, windowLinkForPane(options.layout(), paneId) ?? undefined)
564
+ }, windowLinkForPane(options.layout(), paneId) ?? undefined, () => {
565
+ dispatchedRendererEpoch = options.generation()?.rendererEpoch;
566
+ })
540
567
  : null;
568
+ if (receipt === null &&
569
+ selectionFailure.current?.reason === "topology-changed" &&
570
+ selectionCurrent()) {
571
+ // The daemon may have selected the pane before retiring its stream.
572
+ // Observe the replacement runtime; never resubmit an uncertain command
573
+ // or manufacture an operation receipt. Input still waits on this promise.
574
+ const confirmed = await selectionWaiter.wait(() => {
575
+ if (!selectionCurrent())
576
+ return false;
577
+ const active = selectionGeneration;
578
+ if (active?.status !== "live" ||
579
+ active.rendererEpoch === dispatchedRendererEpoch ||
580
+ observedLayoutEpoch !== active.rendererEpoch)
581
+ return "pending";
582
+ if (!panePresent())
583
+ return false;
584
+ const current = selectionLayout.current;
585
+ const activePanes = current?.panes.filter((pane) => pane.active) ?? [];
586
+ if (!current?.currentWindow || activePanes.length !== 1)
587
+ return "pending";
588
+ return activePanes[0]?.pane === paneId ? true : "pending";
589
+ });
590
+ if (selecting && pendingWindowSwitch === selecting)
591
+ pendingWindowSwitch = null;
592
+ return confirmed && selectionCurrent();
593
+ }
541
594
  if (selecting && pendingWindowSwitch === selecting) {
542
595
  const receiptAtMicros = options.detailedWindowSwitchTiming ? diagnosticNowMicros() : null;
543
596
  if (selecting.timing)
@@ -689,18 +742,21 @@ export function createApplicationTerminalInteractionController(options) {
689
742
  });
690
743
  return {
691
744
  cancelPendingInput() {
745
+ selectionWaiter.cancel();
692
746
  finishLinkSelection(false);
693
747
  paneInput.invalidateSelection();
694
748
  for (const entry of waitingInput)
695
749
  entry.settle(false);
696
750
  },
697
751
  adoptGeneration(snapshot) {
752
+ selectionGeneration = snapshot;
698
753
  const control = controlIdentity(snapshot);
699
754
  if (!sameControl(selectedControl, control)) {
700
755
  finishLinkSelection(false);
701
756
  paneInput.invalidateSelection();
702
757
  }
703
758
  selectedControl = control;
759
+ selectionWaiter.reconcile();
704
760
  if (waitingInput.size > 0 && snapshot?.status === "live" && snapshot.fastLane) {
705
761
  // Old waiters resume first; input admitted synchronously by the next
706
762
  // layout publication must not overtake them in this microtask turn.
@@ -764,6 +820,9 @@ export function createApplicationTerminalInteractionController(options) {
764
820
  inputAuthorityIdentity = next;
765
821
  },
766
822
  adoptLayout(snapshot) {
823
+ selectionLayout = snapshot;
824
+ observedLayoutEpoch = snapshot.rendererEpoch ?? null;
825
+ selectionWaiter.reconcile();
767
826
  reconcileLinkSelection(snapshot);
768
827
  paneInput.adoptCanonicalPane(snapshot.current ? paneForWindow(snapshot.current) : null);
769
828
  const currentWindow = snapshot.current?.semanticWindowId ?? snapshot.current?.windowName;
@@ -850,6 +909,7 @@ export function createApplicationTerminalInteractionController(options) {
850
909
  selectPane: (paneId) => {
851
910
  finishLinkSelection(false);
852
911
  paneInput.selectPane(paneId);
912
+ selectionWaiter.reconcile();
853
913
  },
854
914
  async unlinkWindowLink(target) {
855
915
  const expected = liveSelectionTarget();
@@ -371,7 +371,7 @@ export function createOpenTuiGenerationHost(sessionName, presentation, overrides
371
371
  // desired state against the fresh socket on the following live publish.
372
372
  publish({ ...snapshot, status: "rebinding" });
373
373
  owner.stopPresentation?.();
374
- owner.stopPresentation = runtime ? presentation.adopt(runtime) : null;
374
+ owner.stopPresentation = runtime ? presentation.adopt(runtime, rendererEpoch + 1) : null;
375
375
  owner.runtime = runtime;
376
376
  owner.authorityClient = runtime ? runtimeAuthorityClient(owner.bundle, runtime) : null;
377
377
  if (!runtime)
@@ -394,7 +394,7 @@ export function createOpenTuiGenerationHost(sessionName, presentation, overrides
394
394
  if (candidate !== owner)
395
395
  return;
396
396
  if (runtime) {
397
- owner.stopPresentation = presentation.adopt(runtime);
397
+ owner.stopPresentation = presentation.adopt(runtime, rendererEpoch + 1);
398
398
  diagnose?.("host-internal-snapshot-publication", {
399
399
  publicationPhase: "presentation-adopted",
400
400
  daemonGeneration: owner.bundle.connection.target.daemon.instanceId,
@@ -52,7 +52,7 @@ export function createOpenTuiRuntimeLayoutPresentation() {
52
52
  listener(windows);
53
53
  return () => windowListeners.delete(listener);
54
54
  },
55
- adopt(port) {
55
+ adopt(port, rendererEpoch) {
56
56
  if (disposed)
57
57
  return () => undefined;
58
58
  let candidate = true;
@@ -60,15 +60,18 @@ export function createOpenTuiRuntimeLayoutPresentation() {
60
60
  candidateStop = port.onLayout((layout) => {
61
61
  if (disposed || !candidate || !layout.current)
62
62
  return;
63
+ // A port may reuse its snapshot object. Own the provenance wrapper so
64
+ // a preparing runtime cannot relabel the retained coherent frame.
65
+ const presented = Object.freeze({ ...layout, rendererEpoch });
63
66
  if (currentPort !== port) {
64
67
  const previousStop = stopLayout;
65
68
  currentPort = port;
66
69
  stopLayout = candidateStop;
67
- publish(layout);
70
+ publish(presented);
68
71
  previousStop?.();
69
72
  return;
70
73
  }
71
- publish(layout);
74
+ publish(presented);
72
75
  });
73
76
  // `onLayout` intentionally seeds synchronously. Capture the disposer
74
77
  // after that first callback has promoted this candidate.
@@ -1,5 +1,5 @@
1
1
  /** Acquire controller authority before issuing the canonical pane-select intent. */
2
- export async function selectTerminalPane(expected, current, semanticPaneId, operationId, onFailure, windowLink) {
2
+ export async function selectTerminalPane(expected, current, semanticPaneId, operationId, onFailure, windowLink, onDispatch) {
3
3
  const fail = (failure) => {
4
4
  try {
5
5
  onFailure?.(failure);
@@ -20,7 +20,11 @@ export async function selectTerminalPane(expected, current, semanticPaneId, oper
20
20
  : undefined;
21
21
  return fail({
22
22
  stage,
23
- reason: code === "operation-timeout" ? "operation-timeout" : "transport-rejected",
23
+ reason: code === "operation-timeout"
24
+ ? "operation-timeout"
25
+ : code === "topology-changed" && stage === "dispatch"
26
+ ? "topology-changed"
27
+ : "transport-rejected",
24
28
  ...(backendReason ? { backendReason } : {}),
25
29
  });
26
30
  };
@@ -50,6 +54,7 @@ export async function selectTerminalPane(expected, current, semanticPaneId, oper
50
54
  return fail({ stage: "pre-dispatch", reason: "generation-replaced" });
51
55
  let result;
52
56
  try {
57
+ onDispatch?.();
53
58
  result = await expected.client.dispatch({
54
59
  kind: "semantic-intent",
55
60
  ...(operationId ? { operationId } : {}),
@@ -0,0 +1,36 @@
1
+ /** One bounded selection observation; never submits or repeats a mutation. */
2
+ export function createTerminalSelectionWaiter() {
3
+ let pending = null;
4
+ const cancel = () => pending?.finish(false);
5
+ const reconcile = () => {
6
+ const waiting = pending;
7
+ if (!waiting)
8
+ return;
9
+ const result = waiting.check();
10
+ if (result !== "pending")
11
+ waiting.finish(result);
12
+ };
13
+ return {
14
+ cancel,
15
+ reconcile,
16
+ wait(check) {
17
+ cancel();
18
+ return new Promise((resolve) => {
19
+ const timer = setTimeout(() => waiting.finish(false), 5000);
20
+ timer.unref?.();
21
+ const waiting = {
22
+ check,
23
+ finish(selected) {
24
+ if (pending !== waiting)
25
+ return;
26
+ pending = null;
27
+ clearTimeout(timer);
28
+ resolve(selected);
29
+ },
30
+ };
31
+ pending = waiting;
32
+ reconcile();
33
+ });
34
+ },
35
+ };
36
+ }
@@ -237,14 +237,31 @@ export class MirrorService {
237
237
  }
238
238
  }
239
239
 
240
- async describeSessionAuthority(session: string): Promise<{
240
+ async describeSessionAuthority(
241
+ session: string,
242
+ expectedSemanticPaneIds?: readonly string[],
243
+ ): Promise<{
241
244
  readonly description: MirrorSessionDescription;
242
245
  readonly runtimeSessionId: string;
243
246
  }> {
244
247
  const entry = await this.acquire(session);
245
248
  try {
246
249
  const identity = await entry.channel.attachedSessionIdentity();
247
- return { description: entry.channel.describe(), runtimeSessionId: identity.runtimeSessionId };
250
+ let description = entry.channel.describe();
251
+ if (
252
+ expectedSemanticPaneIds?.some(
253
+ (id) => !description.panes.some((pane) => pane.semanticPaneId === id),
254
+ )
255
+ ) {
256
+ // A mutation receipt and fresh catalog can precede debounced mirror
257
+ // discovery. Join the verified native inventory before declaring a
258
+ // requested pane absent; never substitute receipt IDs for tmux truth.
259
+ await entry.channel.describeTrustedInventory(identity.runtimeSessionId, true);
260
+ description = entry.channel.describe();
261
+ }
262
+ if (this.disposed || entry.retired || this.channels.get(session) !== entry)
263
+ throw new Error("Mirror authority retired during description");
264
+ return { description, runtimeSessionId: identity.runtimeSessionId };
248
265
  } finally {
249
266
  this.release(session, entry);
250
267
  }
@@ -507,6 +507,8 @@ export class SessionChannel {
507
507
  private readonly fittedWindows = new Map<string, { cols: number; rows: number }>();
508
508
  private cancelSync: (() => void) | null = null;
509
509
  private syncOrdinal = 0;
510
+ private identityJoinOrdinal = 0;
511
+ private trustedInventoryJoinsMissing = false;
510
512
  private completedSyncOrdinal = 0;
511
513
  private lastDisplayNameSyncAtMs = 0;
512
514
  private disposed = false;
@@ -799,22 +801,36 @@ export class SessionChannel {
799
801
 
800
802
  /**
801
803
  * Strict daemon-internal inventory from this channel's current tmux truth.
802
- * Unlike the background discovery path, this query awaits descriptor
803
- * reconciliation before projecting and rejects incomplete identity rather
804
- * than returning the previous descriptor snapshot.
804
+ * Unlike background discovery, this query verifies current native membership
805
+ * before projecting and rejects incomplete identity. Stream admission may
806
+ * explicitly join missing pane records after that verification; ordinary
807
+ * inventory readers retain their existing fail-closed no-join contract.
805
808
  */
806
809
  describeTrustedInventory(
807
810
  expectedRuntimeSessionId: string,
811
+ joinMissingPanes = false,
808
812
  ): Promise<TrustedMirrorSessionInventory> {
809
813
  if (this.disposed) {
810
814
  return Promise.reject(new Error(`mirror session ${this.opts.session} is disposed`));
811
815
  }
812
816
  if (this.trustedInventoryFlight) {
817
+ // A default read can fail precisely because it does not join new panes.
818
+ // Preserve that failure for its caller, then qualify admission separately.
819
+ if (
820
+ joinMissingPanes &&
821
+ !this.trustedInventoryJoinsMissing &&
822
+ this.trustedInventoryFlightSessionId === expectedRuntimeSessionId
823
+ )
824
+ return this.trustedInventoryFlight.then(
825
+ () => this.describeTrustedInventory(expectedRuntimeSessionId, true),
826
+ () => this.describeTrustedInventory(expectedRuntimeSessionId, true),
827
+ );
813
828
  return this.trustedInventoryFlightSessionId === expectedRuntimeSessionId
814
829
  ? this.trustedInventoryFlight
815
830
  : Promise.reject(new Error(`trusted inventory identity changed for ${this.opts.session}`));
816
831
  }
817
- const flight = this.refreshTrustedInventory(expectedRuntimeSessionId)
832
+ this.trustedInventoryJoinsMissing = joinMissingPanes;
833
+ const flight = this.refreshTrustedInventory(expectedRuntimeSessionId, 0, joinMissingPanes)
818
834
  .catch((error) => {
819
835
  this.windowLinkAuthority?.invalidate();
820
836
  this.latestWindowStage = null;
@@ -3387,6 +3403,7 @@ export class SessionChannel {
3387
3403
  private async refreshTrustedInventory(
3388
3404
  expectedRuntimeSessionId: string,
3389
3405
  attempt = 0,
3406
+ joinMissingPanes = false,
3390
3407
  ): Promise<TrustedMirrorSessionInventory> {
3391
3408
  const authorityOrdinal = this.windowIdentityOrdinal;
3392
3409
  const beforeLines = await this.io.request(
@@ -3486,7 +3503,11 @@ export class SessionChannel {
3486
3503
  ) {
3487
3504
  if (attempt >= 1)
3488
3505
  throw new Error(`trusted inventory for ${this.opts.session} did not settle`);
3489
- return await this.refreshTrustedInventory(expectedRuntimeSessionId, attempt + 1);
3506
+ return await this.refreshTrustedInventory(
3507
+ expectedRuntimeSessionId,
3508
+ attempt + 1,
3509
+ joinMissingPanes,
3510
+ );
3490
3511
  }
3491
3512
  if (this.disposed) throw new Error(`mirror session ${this.opts.session} is disposed`);
3492
3513
  const activeWindowId = activeWindowIds.values().next().value;
@@ -3521,7 +3542,28 @@ export class SessionChannel {
3521
3542
  stagedPaneIds.size !== descriptors.length ||
3522
3543
  [...computedWindowCounts.keys()].some(
3523
3544
  (runtimeWindowId) => !confirmedWindowStage.windows.has(runtimeWindowId),
3524
- ) ||
3545
+ )
3546
+ )
3547
+ throw new Error(`trusted inventory for ${this.opts.session} lacks verified identity`);
3548
+ // Fresh native inventory can beat background discovery after window creation.
3549
+ // Join verified stable membership before projecting semantic records.
3550
+ if (
3551
+ joinMissingPanes &&
3552
+ descriptors.some(
3553
+ (pane) => this.panesByRuntime.get(pane.runtimePaneId)?.semanticId !== pane.semanticPaneId,
3554
+ )
3555
+ ) {
3556
+ this.discovery.invalidate();
3557
+ await this.reconcileIdentity(
3558
+ descriptors,
3559
+ runtimePaneIds,
3560
+ () => authorityOrdinal === this.windowIdentityOrdinal,
3561
+ false,
3562
+ );
3563
+ }
3564
+ if (this.disposed || authorityOrdinal !== this.windowIdentityOrdinal)
3565
+ throw new Error(`trusted inventory for ${this.opts.session} changed during identity join`);
3566
+ if (
3525
3567
  descriptors.some((descriptor) => {
3526
3568
  const pane = this.panesByRuntime.get(descriptor.runtimePaneId);
3527
3569
  const window = confirmedWindowStage.windows.get(descriptor.windowId!);
@@ -3934,7 +3976,10 @@ export class SessionChannel {
3934
3976
  private async reconcileIdentity(
3935
3977
  descriptors: readonly SessionPaneDescriptor[],
3936
3978
  listed: ReadonlySet<string>,
3979
+ current: () => boolean = () => true,
3980
+ publish = true,
3937
3981
  ): Promise<boolean> {
3982
+ const joinOrdinal = ++this.identityJoinOrdinal;
3938
3983
  if (this.disposed) return false;
3939
3984
  if (this.opts.hasSharedWindowConflict?.()) {
3940
3985
  this.settleFirstJoin();
@@ -3973,7 +4018,8 @@ export class SessionChannel {
3973
4018
  ),
3974
4019
  ),
3975
4020
  );
3976
- if (this.disposed) return plan.stampEffects.length > 0;
4021
+ if (this.disposed || !current() || joinOrdinal !== this.identityJoinOrdinal)
4022
+ return plan.stampEffects.length > 0;
3977
4023
  const reconciliation = finalizeWorkspaceTmuxReconciliation(plan, outcomes);
3978
4024
  const descriptorByRuntime = new Map(descriptors.map((d) => [d.runtimePaneId, d]));
3979
4025
  for (const verified of reconciliation.panes) {
@@ -4080,8 +4126,10 @@ export class SessionChannel {
4080
4126
  * without this the new pane stays invisible until something else happens to
4081
4127
  * move a layout, and a split looks like it did not reach the view.
4082
4128
  */
4083
- for (const runtimeId of this.layoutByWindow.keys()) this.emitLayout(runtimeId);
4084
- this.emitLayoutAuthority();
4129
+ if (publish) {
4130
+ for (const runtimeId of this.layoutByWindow.keys()) this.emitLayout(runtimeId);
4131
+ this.emitLayoutAuthority();
4132
+ }
4085
4133
  this.settleFirstJoin();
4086
4134
  return plan.stampEffects.length > 0;
4087
4135
  }
@@ -1,3 +1,7 @@
1
+ import {
2
+ PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION,
3
+ type PaneStreamInputLifetime,
4
+ } from "@tmux-ide/contracts";
1
5
  import { inflateSync } from "node:zlib";
2
6
  import { PaneStreamLayoutSnapshotFrameSchemaZ, type WindowLinkTopology } from "@tmux-ide/contracts";
3
7
  import type { TerminalDeliveryGraphics } from "../session-runtime/terminal-delivery-hub.ts";
@@ -149,6 +153,7 @@ export interface PaneStreamMirror {
149
153
  describeSession(session: string): Promise<MirrorSessionDescription>;
150
154
  describeSessionAuthority?(
151
155
  session: string,
156
+ expectedSemanticPaneIds?: readonly string[],
152
157
  ): Promise<{ readonly description: MirrorSessionDescription; readonly runtimeSessionId: string }>;
153
158
  subscribe(request: MirrorSubscribeRequest): Promise<MirrorSubscription>;
154
159
  subscribeLayout?(
@@ -304,6 +309,7 @@ export interface SessionRuntimePaneStreamTransportBinding {
304
309
  performanceTraceId?: string,
305
310
  causalProbe?: CausalCellProbeV1,
306
311
  onCausalResult?: (result: CausalCellLedgerResult) => void,
312
+ expectedLifetime?: PaneStreamInputLifetime,
307
313
  ): void;
308
314
  fitViewport(
309
315
  lease: SessionRuntimeAuthorityLease,
@@ -493,7 +499,10 @@ export class PaneStreamAdmissionCoordinator {
493
499
  try {
494
500
  const describeStartedAtMicros = trace ? this.#observability!.nowMicros() : 0;
495
501
  if (this.#mirror.describeSessionAuthority) {
496
- const authority = await this.#mirror.describeSessionAuthority(context.sessionName);
502
+ const authority = await this.#mirror.describeSessionAuthority(
503
+ context.sessionName,
504
+ request.panes,
505
+ );
497
506
  described = authority.description;
498
507
  runtimeSessionId = authority.runtimeSessionId;
499
508
  } else {
@@ -1279,6 +1288,12 @@ export class PaneStreamLiveConnection {
1279
1288
  try {
1280
1289
  sendControl(this.#socket, {
1281
1290
  type: "ready",
1291
+ ...(this.#sessionRuntimeBinding &&
1292
+ this.#descriptor.terminalDelivery?.protocolVersions.includes(
1293
+ PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION,
1294
+ )
1295
+ ? { inputLifetime: true as const }
1296
+ : {}),
1282
1297
  protocolVersion: PANE_STREAM_PROTOCOL_VERSION,
1283
1298
  daemonInstanceId: this.#binding.daemonInstanceId,
1284
1299
  requestId: this.#binding.requestId,
@@ -2560,6 +2575,7 @@ export class PaneStreamLiveConnection {
2560
2575
  ingressAtMicros,
2561
2576
  callbackAtSharedMicros,
2562
2577
  ingressAtSharedMicros,
2578
+ frame.expectedLifetime,
2563
2579
  );
2564
2580
  };
2565
2581
 
@@ -2712,7 +2728,18 @@ export class PaneStreamLiveConnection {
2712
2728
  ingressAtMicros: number | null = null,
2713
2729
  callbackAtSharedMicros: number | null = null,
2714
2730
  ingressAtSharedMicros: number | null = null,
2731
+ expectedLifetime?: PaneStreamInputLifetime,
2715
2732
  ): void {
2733
+ if (
2734
+ expectedLifetime &&
2735
+ (!this.#sessionRuntimeBinding ||
2736
+ !this.#descriptor.terminalDelivery?.protocolVersions.includes(
2737
+ PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION,
2738
+ ))
2739
+ ) {
2740
+ this.#failProtocol("input-rejected");
2741
+ return;
2742
+ }
2716
2743
  if (this.#descriptor.viewerMode !== "interactive") {
2717
2744
  this.#failProtocol("input-rejected");
2718
2745
  return;
@@ -2832,6 +2859,7 @@ export class PaneStreamLiveConnection {
2832
2859
  }
2833
2860
  }
2834
2861
  : undefined,
2862
+ expectedLifetime,
2835
2863
  );
2836
2864
  else if (kind === "text") channel.sub!.sendText(data);
2837
2865
  else channel.sub!.sendKey(data);
@@ -30,6 +30,7 @@ import {
30
30
  type CanonicalTerminalReplicaUpdate,
31
31
  type CausalCellFailureReasonV1,
32
32
  type CausalCellProbeV1,
33
+ type PaneStreamInputLifetime,
33
34
  type TerminalDeliveryAck,
34
35
  type TerminalDeliveryNack,
35
36
  type TerminalDeliveryOffer,
@@ -244,6 +245,7 @@ export interface SessionRuntimeConsumer {
244
245
  performanceTraceId?: string,
245
246
  causalProbe?: CausalCellProbeV1,
246
247
  onCausalResult?: (result: CausalCellLedgerResult) => void,
248
+ expectedLifetime?: PaneStreamInputLifetime,
247
249
  ): void;
248
250
  failCausalCellProbe(
249
251
  semanticPaneId: string,
@@ -896,13 +898,16 @@ export class SessionRuntimeRegistry implements PaneStreamMirror {
896
898
  return result;
897
899
  }
898
900
 
899
- async describeSessionAuthority(session: string): Promise<{
901
+ async describeSessionAuthority(
902
+ session: string,
903
+ expectedSemanticPaneIds?: readonly string[],
904
+ ): Promise<{
900
905
  readonly description: MirrorSessionDescription;
901
906
  readonly runtimeSessionId: string;
902
907
  }> {
903
908
  const runtime = this.#runtime(session);
904
909
  await runtime.whenReady();
905
- const authority = await this.#mirror.describeSessionAuthority(session);
910
+ const authority = await this.#mirror.describeSessionAuthority(session, expectedSemanticPaneIds);
906
911
  if (this.#disposed || this.#sessions.get(session) !== runtime) {
907
912
  const error = new Error(`session runtime ${session} changed topology`);
908
913
  error.name = "MirrorTopologyChangedError";
@@ -1589,6 +1594,7 @@ class SessionRuntime {
1589
1594
  performanceTraceId?: string,
1590
1595
  rawCausalProbe?: CausalCellProbeV1,
1591
1596
  onCausalResult?: (result: CausalCellLedgerResult) => void,
1597
+ expectedLifetime?: PaneStreamInputLifetime,
1592
1598
  ): void {
1593
1599
  this.assertController(lease, clientId);
1594
1600
  const inputLease = this.#authority.leaseFor(clientId, "input");
@@ -1626,7 +1632,10 @@ class SessionRuntime {
1626
1632
  })
1627
1633
  : null;
1628
1634
  const started = this.#observability.enabled ? this.#observability.nowMicros() : 0;
1629
- const replicaOwner = this.#terminalReplicaOwner(semanticPaneId);
1635
+ const replicaOwner = expectedLifetime
1636
+ ? this.#terminalReplicas.get(semanticPaneId)
1637
+ : this.#terminalReplicaOwner(semanticPaneId);
1638
+ if (!replicaOwner) throw new Error("Input target lifetime unavailable");
1630
1639
  if (causalProbe) {
1631
1640
  this.#activeCausalCellProbes += 1;
1632
1641
  let settled = false;
@@ -1649,6 +1658,15 @@ class SessionRuntime {
1649
1658
  this.#outputTraces.arm(semanticPaneId, trace);
1650
1659
  }
1651
1660
  try {
1661
+ // Synchronous final admission: never create a new owner to satisfy a stale
1662
+ // held-input fence, and never await between this check and mirror dispatch.
1663
+ if (
1664
+ expectedLifetime &&
1665
+ (expectedLifetime.generation !== this.generation ||
1666
+ this.#terminalReplicas.get(semanticPaneId) !== replicaOwner ||
1667
+ replicaOwner.inputAdmissionIncarnation() !== expectedLifetime.incarnation)
1668
+ )
1669
+ throw new Error("Input target lifetime changed");
1652
1670
  if (input.kind === "text")
1653
1671
  this.#mirror.sendText(
1654
1672
  this.session,
@@ -2323,6 +2341,7 @@ class SessionRuntimeConsumerImpl implements SessionRuntimeConsumer {
2323
2341
  performanceTraceId?: string,
2324
2342
  causalProbe?: CausalCellProbeV1,
2325
2343
  onCausalResult?: (result: CausalCellLedgerResult) => void,
2344
+ expectedLifetime?: PaneStreamInputLifetime,
2326
2345
  ): void {
2327
2346
  this.#assertOpen();
2328
2347
  this.#runtime.sendInput(
@@ -2333,6 +2352,7 @@ class SessionRuntimeConsumerImpl implements SessionRuntimeConsumer {
2333
2352
  performanceTraceId,
2334
2353
  causalProbe,
2335
2354
  onCausalResult,
2355
+ expectedLifetime,
2336
2356
  );
2337
2357
  }
2338
2358