tmux-ide 2.9.9 → 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
@@ -53,6 +53,23 @@ import {
53
53
  } from "../graphics/kitty-graphics.ts";
54
54
  import type { TerminalGraphicsState } from "@tmux-ide/contracts";
55
55
 
56
+ // Bound parser work per turn without changing byte-stream semantics. This is a
57
+ // batch bound, not a producer queue cap: upstream pressure must preserve bytes.
58
+ const MAX_INGEST_BATCH_BYTES = 256 * 1024;
59
+ type PendingWrite = {
60
+ data: Uint8Array;
61
+ offset: number;
62
+ resolve: () => void;
63
+ reject: (error: unknown) => void;
64
+ };
65
+ type WriteGroup = {
66
+ entries: PendingWrite[];
67
+ bytes: number;
68
+ scheduled: boolean;
69
+ flushQueued: boolean;
70
+ trace: SessionRuntimeTraceContext | null;
71
+ };
72
+
56
73
  type CloseReason = "pane-closed" | "session-restarted" | "runtime-disposed";
57
74
  export type TerminalReplicaInterpreterOperation =
58
75
  | {
@@ -167,13 +184,7 @@ export class TerminalReplicaInterpreter {
167
184
  #needsSeed = true;
168
185
  #closed = false;
169
186
  #walkCount = 0;
170
- #pendingWrites: Array<{
171
- data: Uint8Array;
172
- trace: SessionRuntimeTraceContext | null;
173
- resolve: () => void;
174
- reject: (error: unknown) => void;
175
- }> = [];
176
- #writeFlushScheduled = false;
187
+ #pendingWrites: WriteGroup | null = null;
177
188
  #widgetGate = false;
178
189
  // kitty graphics: images outlive reseeds; the scanner owns split APCs.
179
190
  readonly #kittyScanner = new KittyApcScanner();
@@ -252,18 +263,27 @@ export class TerminalReplicaInterpreter {
252
263
  if (operation.type === "write") {
253
264
  const data = operation.data.slice();
254
265
  const trace = operation.trace ?? null;
255
- const pendingTrace = this.#pendingWrites[0]?.trace ?? null;
256
266
  if (
257
- this.#pendingWrites.length > 0 &&
258
- (pendingTrace?.traceId ?? null) !== (trace?.traceId ?? null)
267
+ this.#pendingWrites &&
268
+ (this.#pendingWrites.trace?.traceId ?? null) !== (trace?.traceId ?? null)
259
269
  )
260
270
  this.#flushWrites();
271
+ const group = (this.#pendingWrites ??= {
272
+ entries: [],
273
+ bytes: 0,
274
+ scheduled: false,
275
+ flushQueued: false,
276
+ trace,
277
+ });
261
278
  const promise = new Promise<void>((resolve, reject) => {
262
- this.#pendingWrites.push({ data, trace, resolve, reject });
279
+ group.entries.push({ data, offset: 0, resolve, reject });
280
+ group.bytes += data.length;
263
281
  });
264
- if (!this.#writeFlushScheduled) {
265
- this.#writeFlushScheduled = true;
266
- this.#scheduler.microtask(() => this.#flushWrites());
282
+ // Leave this group open while the parser awaits its callback. Scheduling
283
+ // it once on the FIFO lets later arrivals join the next bounded batch.
284
+ if (!group.scheduled && !group.flushQueued) {
285
+ group.flushQueued = true;
286
+ this.#scheduler.microtask(() => this.#scheduleWriteGroup(group));
267
287
  }
268
288
  return promise;
269
289
  }
@@ -528,7 +548,27 @@ export class TerminalReplicaInterpreter {
528
548
  // turn, so a frame split into dozens of %output chunks would otherwise
529
549
  // serialize dozens of turns and fall behind a 30 fps image stream.
530
550
  if (scanned.forward.byteLength === 0) return;
531
- await this.#writeToBackend(this.#backend, scanned.forward);
551
+ // Scanning can prepend an escape retained from the previous chunk. Bound
552
+ // the actual parser calls too, and union damage across every fragment.
553
+ for (let offset = 0; offset < scanned.forward.length; offset += MAX_INGEST_BATCH_BYTES) {
554
+ await this.#writeToBackend(
555
+ this.#backend,
556
+ scanned.forward.subarray(offset, offset + MAX_INGEST_BATCH_BYTES),
557
+ );
558
+ // The parser resets its dirty tracker per write. Synchronized output and
559
+ // bounded fragments can both span writes before canonical publication.
560
+ const dirty = this.#backend.dirtyRange();
561
+ const pending = this.#pendingWriteDirty;
562
+ this.#pendingWriteDirty =
563
+ pending === null
564
+ ? dirty
565
+ : pending === undefined || dirty === undefined
566
+ ? undefined
567
+ : {
568
+ start: Math.min(pending.start, dirty.start),
569
+ end: Math.max(pending.end, dirty.end),
570
+ };
571
+ }
532
572
  if (this.#observability.enabled)
533
573
  this.#observability.recordSpan(
534
574
  "parse",
@@ -537,19 +577,6 @@ export class TerminalReplicaInterpreter {
537
577
  this.#observability.nowMicros(),
538
578
  trace,
539
579
  );
540
- // The parser resets its dirty tracker per write. Synchronized output may
541
- // span many writes before publication, so retain their union until commit.
542
- const dirty = this.#backend.dirtyRange();
543
- const pending = this.#pendingWriteDirty;
544
- this.#pendingWriteDirty =
545
- pending === null
546
- ? dirty
547
- : pending === undefined || dirty === undefined
548
- ? undefined
549
- : {
550
- start: Math.min(pending.start, dirty.start),
551
- end: Math.max(pending.end, dirty.end),
552
- };
553
580
  // DEC synchronized-output is atomic: no intermediate frame leaks.
554
581
  if (this.#backend.requiresNativeReseed()) {
555
582
  this.#commit(false);
@@ -574,6 +601,7 @@ export class TerminalReplicaInterpreter {
574
601
  if (this.#syncRecovery || this.#closed) return;
575
602
  this.#syncRecovery = this.#scheduler.timer(() => {
576
603
  this.#syncRecovery = null;
604
+ this.#flushWrites();
577
605
  const run = this.#tail.then(async () => {
578
606
  if (this.#closed || !this.#backend.modes().synchronizedOutput) return;
579
607
  // Recovery is synthetic parser maintenance, not pane output caused by
@@ -859,24 +887,54 @@ export class TerminalReplicaInterpreter {
859
887
  }
860
888
  }
861
889
 
890
+ /** Seal the current group at every trace, control and idle barrier. */
862
891
  #flushWrites(): void {
863
- this.#writeFlushScheduled = false;
864
- if (this.#pendingWrites.length === 0) return;
865
- const pending = this.#pendingWrites.splice(0);
866
- const length = pending.reduce((total, entry) => total + entry.data.length, 0);
867
- const data = new Uint8Array(length);
868
- let offset = 0;
869
- for (const entry of pending) {
870
- data.set(entry.data, offset);
871
- offset += entry.data.length;
872
- }
873
- const trace = pending.find(({ trace }) => trace !== null)?.trace ?? null;
874
- const run = this.#tail.then(() => this.#write(data, trace));
892
+ const group = this.#pendingWrites;
893
+ this.#pendingWrites = null;
894
+ if (group) this.#scheduleWriteGroup(group);
895
+ }
896
+
897
+ #scheduleWriteGroup(group: WriteGroup): void {
898
+ if (group.scheduled) return;
899
+ group.scheduled = true;
900
+ const run = this.#tail.then(async () => {
901
+ try {
902
+ while (group.entries.length > 0) {
903
+ const length = Math.min(group.bytes, MAX_INGEST_BATCH_BYTES);
904
+ const data = new Uint8Array(length);
905
+ const completed: PendingWrite[] = [];
906
+ let offset = 0;
907
+ for (const entry of group.entries) {
908
+ const take = Math.min(entry.data.length - entry.offset, length - offset);
909
+ data.set(entry.data.subarray(entry.offset, entry.offset + take), offset);
910
+ offset += take;
911
+ entry.offset += take;
912
+ if (entry.offset === entry.data.length) completed.push(entry);
913
+ else break;
914
+ if (offset === length && length > 0) break;
915
+ }
916
+ group.entries.splice(0, completed.length);
917
+ group.bytes -= length;
918
+ try {
919
+ await this.#write(data, group.trace);
920
+ } catch (error) {
921
+ for (const entry of completed) entry.reject(error);
922
+ throw error;
923
+ }
924
+ for (const entry of completed) entry.resolve();
925
+ }
926
+ } catch (error) {
927
+ // Reject every remaining fragment in the failed group; later FIFO
928
+ // controls (including recovery reseeds and close) still execute.
929
+ for (const entry of group.entries) entry.reject(error);
930
+ group.entries.length = 0;
931
+ group.bytes = 0;
932
+ throw error;
933
+ } finally {
934
+ if (this.#pendingWrites === group) this.#pendingWrites = null;
935
+ }
936
+ });
875
937
  this.#tail = run.catch(() => undefined);
876
- void run.then(
877
- () => pending.forEach((entry) => entry.resolve()),
878
- (error) => pending.forEach((entry) => entry.reject(error)),
879
- );
880
938
  }
881
939
 
882
940
  #observeMarkerBytes(data: Uint8Array): void {
@@ -259,6 +259,13 @@ export class SessionRuntimeTerminalReplicaOwner {
259
259
  return this.#disposed ? null : this.#publishedIncarnation;
260
260
  }
261
261
 
262
+ /** Scalar admission fence; unavailable while canonical replacement is pending. */
263
+ inputAdmissionIncarnation(): string | null {
264
+ return this.#disposed || this.#reseed || this.#waitingForGeometryCapture
265
+ ? null
266
+ : this.#publishedIncarnation;
267
+ }
268
+
262
269
  qualificationSnapshot(): TerminalReplicaQualificationSnapshot {
263
270
  const seed = this.#interpreter.currentSeed();
264
271
  return Object.freeze({
@@ -13,6 +13,7 @@ import {
13
13
  type SessionRuntimeSemanticIntent,
14
14
  type SessionRuntimeTerminalInput,
15
15
  type CausalCellProbeV1,
16
+ type PaneStreamInputLifetime,
16
17
  type TerminalDeliveryOffer,
17
18
  type TerminalDeliveryServerMessage,
18
19
  } from "@tmux-ide/contracts";
@@ -428,6 +429,7 @@ export class SessionRuntimeTransportBinding {
428
429
  performanceTraceId?: string,
429
430
  causalProbe?: CausalCellProbeV1,
430
431
  onCausalResult?: (result: CausalCellLedgerResult) => void,
432
+ expectedLifetime?: PaneStreamInputLifetime,
431
433
  ): void {
432
434
  this.assertController(semanticPaneId);
433
435
  const lease = this.#shared.lease;
@@ -451,6 +453,7 @@ export class SessionRuntimeTransportBinding {
451
453
  onCausalResult?.(result);
452
454
  }
453
455
  : onCausalResult,
456
+ expectedLifetime,
454
457
  );
455
458
  } catch (error) {
456
459
  if (causalProbe) this.#causalCellProbes.delete(causalProbe.traceId);
@@ -144,6 +144,8 @@ export const OPEN_TUI_HOST_CLIENT_ID = `opentui:${process.pid}`;
144
144
  export type OpenTuiWorkspaceLayout = Extract<PaneStreamServerFrame, { type: "layout" }>;
145
145
 
146
146
  export interface OpenTuiWorkspaceLayoutSnapshot {
147
+ /** Renderer-local provenance, stamped only by the host presentation adapter. */
148
+ readonly rendererEpoch?: number;
147
149
  /** Live link authority; absent until an atomic stream snapshot arrives. */
148
150
  readonly windowLinks?: WindowLinkTopology | null;
149
151
  readonly current: OpenTuiWorkspaceLayout | null;
@@ -104,9 +104,25 @@ export function createApplicationAgentNavigator(options: {
104
104
  return async (sessionName: string, paneId: string, source: Source = "mouse") => {
105
105
  const token = ++navigationToken;
106
106
  const currentOwner = options.sessionOwner();
107
+ const snapshot = currentOwner.snapshot();
108
+ // A renderer rebind keeps its semantic control connection. Reserve this
109
+ // exact target synchronously; the controller fences and bounds first send.
110
+ const retainedControl = (() => {
111
+ try {
112
+ return (
113
+ snapshot?.status === "rebinding" &&
114
+ snapshot.connection?.workspaceName === sessionName &&
115
+ snapshot.daemonGeneration &&
116
+ snapshot.client &&
117
+ Number.isSafeInteger(snapshot.client.getSnapshot().generation)
118
+ );
119
+ } catch {
120
+ return false;
121
+ }
122
+ })();
107
123
  if (
108
124
  currentOwner.sessionName() === sessionName &&
109
- applicationGenerationNavigationKey(currentOwner.snapshot()) !== null
125
+ (applicationGenerationNavigationKey(snapshot) !== null || retainedControl)
110
126
  ) {
111
127
  // Sidebar agents belong to the already-live workspace. Select their pane
112
128
  // before returning control to OpenTUI so the very next key cannot leak to
@@ -1,3 +1,4 @@
1
+ import { retainsAgentControl } from "./application-retained-agent.ts";
1
2
  import {
2
3
  createApplicationHomeAgentNavigator,
3
4
  type HomeAgentNavigationSource,
@@ -39,6 +40,20 @@ export function createApplicationMachineAgentNavigator(options: {
39
40
  const selected = () => options.selectedMachineId() === machineId;
40
41
  const generation = () =>
41
42
  selected() && options.generationMachineId() === machineId ? options.generation() : null;
43
+ if (
44
+ selected() &&
45
+ options.isCurrentTarget(machineId, target) &&
46
+ options.sessionName() === target.sessionName &&
47
+ retainsAgentControl(generation(), target)
48
+ ) {
49
+ // Reserve before the next key; the terminal controller waits for a fresh
50
+ // coherent runtime and remains the only owner of physical input.
51
+ options.selectPane(target.paneId!, source);
52
+ options.showTerminals(source);
53
+ options.setNote(null);
54
+ opening = false;
55
+ return Promise.resolve({ opened: true });
56
+ }
42
57
  current = createApplicationHomeAgentNavigator({
43
58
  isCurrentTarget: (row) => selected() && options.isCurrentTarget(machineId, row),
44
59
  currentGeneration() {
@@ -1,3 +1,4 @@
1
+ import { retainsAgentControl } from "./application-retained-agent.ts";
1
2
  import type { OpenTuiGenerationHostSnapshot } from "./open-tui-generation-host.ts";
2
3
  import type { TmuxServerScope } from "@tmux-ide/contracts";
3
4
  import { fleetHostColor } from "./fleet-presentation.ts";
@@ -90,7 +91,12 @@ export function createApplicationMachineNavigation(options: {
90
91
  const [tabRevision, setTabRevision] = createSignal(0);
91
92
  const history: string[] = [...saved().recent].reverse();
92
93
  let historyIndex = history.length - 1;
93
- const select = (id: string, expectedLiveSessionId?: string, server?: TmuxServerScope) => {
94
+ const select = (
95
+ id: string,
96
+ expectedLiveSessionId?: string,
97
+ server?: TmuxServerScope,
98
+ retainedAgent?: ApplicationMachineAgent,
99
+ ) => {
94
100
  if (!manager.getMachine(id)) return false;
95
101
  if (manager.snapshot().selectedMachineId !== id) {
96
102
  navigation++;
@@ -100,9 +106,17 @@ export function createApplicationMachineNavigation(options: {
100
106
  else options.resetWorkspace(id, expectedLiveSessionId);
101
107
  manager.select(id);
102
108
  } else if (expectedLiveSessionId) {
109
+ const active = options.attachedGeneration?.() ?? null;
110
+ const retained =
111
+ retainedAgent && retainsAgentControl(active, retainedAgent)
112
+ ? {
113
+ liveSessionId: active!.connection!.liveSessionId,
114
+ daemonGeneration: active!.daemonGeneration,
115
+ ...(active!.connection!.server ? { server: active!.connection!.server } : {}),
116
+ }
117
+ : null;
103
118
  const attached =
104
- options.attachedTarget?.() ??
105
- attachedMachineNavigationTarget(options.attachedGeneration?.() ?? null);
119
+ options.attachedTarget?.() ?? attachedMachineNavigationTarget(active) ?? retained;
106
120
  const sameAuthority =
107
121
  attached?.liveSessionId === expectedLiveSessionId &&
108
122
  (server
@@ -357,7 +371,7 @@ export function createApplicationMachineNavigation(options: {
357
371
  return;
358
372
  }
359
373
  options.cancelOpen();
360
- if (!select(id, selectedSession?.liveSessionId, selectedSession?.server)) return;
374
+ if (!select(id, selectedSession?.liveSessionId, selectedSession?.server, row)) return;
361
375
  if (selectedSession) setActiveSessionKey(selectedSession.id);
362
376
  navigation++;
363
377
  setFocused(false);
@@ -0,0 +1,29 @@
1
+ import type { OpenTuiGenerationHostSnapshot } from "./open-tui-generation-host.ts";
2
+ import type { HomeAgentNavigationTarget } from "./application-home-agent-navigation.ts";
3
+
4
+ /** Selection intent may wait on this exact retained control; input cannot use it yet. */
5
+ export function retainsAgentControl(
6
+ active: OpenTuiGenerationHostSnapshot | null,
7
+ target: HomeAgentNavigationTarget,
8
+ ): boolean {
9
+ if (
10
+ active?.status !== "rebinding" ||
11
+ !active.client ||
12
+ !target.paneId ||
13
+ active.daemonGeneration !== target.daemonInstanceId ||
14
+ active.connection?.workspaceName !== target.sessionName ||
15
+ active.connection.liveSessionId !== target.liveSessionId
16
+ )
17
+ return false;
18
+ try {
19
+ const state = active.client.getSnapshot();
20
+ return (
21
+ Number.isSafeInteger(state.generation) &&
22
+ state.semantic?.sidebar.agents.some(
23
+ (agent) => agent.id === target.agentId && agent.paneId === target.paneId,
24
+ ) === true
25
+ );
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
@@ -1,3 +1,4 @@
1
+ import { createTerminalSelectionWaiter } from "./terminal-selection-waiter.ts";
1
2
  import { WorkspaceWindowLinkSelectResultSchemaZ } from "@tmux-ide/contracts";
2
3
  import { windowLinkForPane, windowLinkTarget } from "./application-terminal-workspace-policy.ts";
3
4
  import { createHash, randomUUID } from "node:crypto";
@@ -350,6 +351,10 @@ export function createApplicationTerminalInteractionController(
350
351
  left.generation === right.generation &&
351
352
  left.workspace === right.workspace;
352
353
  let selectedControl: ControlIdentity | null = null;
354
+ const selectionWaiter = createTerminalSelectionWaiter();
355
+ let observedLayoutEpoch: number | null = null;
356
+ let selectionGeneration: OpenTuiGenerationHostSnapshot | null = null;
357
+ let selectionLayout = options.layout();
353
358
  const waitingInput = new Set<{
354
359
  control: ControlIdentity;
355
360
  weight: number;
@@ -823,10 +828,30 @@ export function createApplicationTerminalInteractionController(
823
828
  "semanticPaneId" | "generation" | "incarnation" | "revision" | "stateHash"
824
829
  >;
825
830
  }>({
826
- select: async (paneId) => {
831
+ select: async (paneId): Promise<boolean> => {
827
832
  finishLinkSelection(false);
833
+ const initial = options.generation();
834
+ const selectionControl = controlIdentity(initial);
835
+ const selectionVersion = paneInput.selectionVersion;
836
+ const selectionCurrent = (): boolean =>
837
+ paneInput.selectionVersion === selectionVersion &&
838
+ sameControl(selectionControl, controlIdentity(selectionGeneration));
839
+ const panePresent = () =>
840
+ selectionLayout.windows.some((window) => window.panes.some((pane) => pane.pane === paneId));
841
+ if (initial?.status === "rebinding") {
842
+ // Reserve the optimistic target now, but send its FIRST mutation only
843
+ // after this same control connection has a coherent terminal runtime.
844
+ const ready = await selectionWaiter.wait(() => {
845
+ if (!selectionCurrent()) return false;
846
+ const active = selectionGeneration;
847
+ if (active?.status !== "live" || observedLayoutEpoch !== active.rendererEpoch)
848
+ return "pending";
849
+ return panePresent();
850
+ });
851
+ if (!ready || !selectionCurrent()) return false;
852
+ }
828
853
  const expected = liveSelectionTarget();
829
- const selectionControl = controlIdentity(options.generation());
854
+ let dispatchedRendererEpoch: number | undefined;
830
855
  const selecting = pendingWindowSwitch;
831
856
  const operationId = selecting?.traceId;
832
857
  const selectionFailure: { current: PaneSelectionFailure | null } = { current: null };
@@ -845,8 +870,37 @@ export function createApplicationTerminalInteractionController(
845
870
  selectionFailure.current = failure;
846
871
  },
847
872
  windowLinkForPane(options.layout(), paneId) ?? undefined,
873
+ () => {
874
+ dispatchedRendererEpoch = options.generation()?.rendererEpoch;
875
+ },
848
876
  )
849
877
  : null;
878
+ if (
879
+ receipt === null &&
880
+ selectionFailure.current?.reason === "topology-changed" &&
881
+ selectionCurrent()
882
+ ) {
883
+ // The daemon may have selected the pane before retiring its stream.
884
+ // Observe the replacement runtime; never resubmit an uncertain command
885
+ // or manufacture an operation receipt. Input still waits on this promise.
886
+ const confirmed = await selectionWaiter.wait(() => {
887
+ if (!selectionCurrent()) return false;
888
+ const active = selectionGeneration;
889
+ if (
890
+ active?.status !== "live" ||
891
+ active.rendererEpoch === dispatchedRendererEpoch ||
892
+ observedLayoutEpoch !== active.rendererEpoch
893
+ )
894
+ return "pending";
895
+ if (!panePresent()) return false;
896
+ const current = selectionLayout.current;
897
+ const activePanes = current?.panes.filter((pane) => pane.active) ?? [];
898
+ if (!current?.currentWindow || activePanes.length !== 1) return "pending";
899
+ return activePanes[0]?.pane === paneId ? true : "pending";
900
+ });
901
+ if (selecting && pendingWindowSwitch === selecting) pendingWindowSwitch = null;
902
+ return confirmed && selectionCurrent();
903
+ }
850
904
  if (selecting && pendingWindowSwitch === selecting) {
851
905
  const receiptAtMicros = options.detailedWindowSwitchTiming ? diagnosticNowMicros() : null;
852
906
  if (selecting.timing) selecting.timing.receiptAtMicros = receiptAtMicros;
@@ -1001,17 +1055,20 @@ export function createApplicationTerminalInteractionController(
1001
1055
 
1002
1056
  return {
1003
1057
  cancelPendingInput() {
1058
+ selectionWaiter.cancel();
1004
1059
  finishLinkSelection(false);
1005
1060
  paneInput.invalidateSelection();
1006
1061
  for (const entry of waitingInput) entry.settle(false);
1007
1062
  },
1008
1063
  adoptGeneration(snapshot) {
1064
+ selectionGeneration = snapshot;
1009
1065
  const control = controlIdentity(snapshot);
1010
1066
  if (!sameControl(selectedControl, control)) {
1011
1067
  finishLinkSelection(false);
1012
1068
  paneInput.invalidateSelection();
1013
1069
  }
1014
1070
  selectedControl = control;
1071
+ selectionWaiter.reconcile();
1015
1072
  if (waitingInput.size > 0 && snapshot?.status === "live" && snapshot.fastLane) {
1016
1073
  // Old waiters resume first; input admitted synchronously by the next
1017
1074
  // layout publication must not overtake them in this microtask turn.
@@ -1074,6 +1131,9 @@ export function createApplicationTerminalInteractionController(
1074
1131
  inputAuthorityIdentity = next;
1075
1132
  },
1076
1133
  adoptLayout(snapshot) {
1134
+ selectionLayout = snapshot;
1135
+ observedLayoutEpoch = snapshot.rendererEpoch ?? null;
1136
+ selectionWaiter.reconcile();
1077
1137
  reconcileLinkSelection(snapshot);
1078
1138
  paneInput.adoptCanonicalPane(snapshot.current ? paneForWindow(snapshot.current) : null);
1079
1139
  const currentWindow = snapshot.current?.semanticWindowId ?? snapshot.current?.windowName;
@@ -1165,6 +1225,7 @@ export function createApplicationTerminalInteractionController(
1165
1225
  selectPane: (paneId) => {
1166
1226
  finishLinkSelection(false);
1167
1227
  paneInput.selectPane(paneId);
1228
+ selectionWaiter.reconcile();
1168
1229
  },
1169
1230
  async unlinkWindowLink(target) {
1170
1231
  const expected = liveSelectionTarget();
@@ -560,7 +560,7 @@ export function createOpenTuiGenerationHost(
560
560
  // desired state against the fresh socket on the following live publish.
561
561
  publish({ ...snapshot, status: "rebinding" });
562
562
  owner.stopPresentation?.();
563
- owner.stopPresentation = runtime ? presentation.adopt(runtime) : null;
563
+ owner.stopPresentation = runtime ? presentation.adopt(runtime, rendererEpoch + 1) : null;
564
564
  owner.runtime = runtime;
565
565
  owner.authorityClient = runtime ? runtimeAuthorityClient(owner.bundle, runtime) : null;
566
566
  if (!runtime) presentation.clear();
@@ -580,7 +580,7 @@ export function createOpenTuiGenerationHost(
580
580
  }
581
581
  if (candidate !== owner) return;
582
582
  if (runtime) {
583
- owner.stopPresentation = presentation.adopt(runtime);
583
+ owner.stopPresentation = presentation.adopt(runtime, rendererEpoch + 1);
584
584
  diagnose?.("host-internal-snapshot-publication", {
585
585
  publicationPhase: "presentation-adopted",
586
586
  daemonGeneration: owner.bundle.connection.target.daemon.instanceId,
@@ -11,7 +11,7 @@ export interface OpenTuiRuntimeLayoutPresentation {
11
11
  getWindowSnapshot(): OpenTuiWorkspaceLayoutSnapshot;
12
12
  subscribe(listener: (layout: OpenTuiTerminalLayout | null) => void): () => void;
13
13
  subscribeWindows(listener: (layout: OpenTuiWorkspaceLayoutSnapshot) => void): () => void;
14
- adopt(port: OpenTuiLayoutPort): () => void;
14
+ adopt(port: OpenTuiLayoutPort, rendererEpoch: number): () => void;
15
15
  clear(): void;
16
16
  dispose(): void;
17
17
  }
@@ -66,21 +66,24 @@ export function createOpenTuiRuntimeLayoutPresentation(): OpenTuiRuntimeLayoutPr
66
66
  listener(windows);
67
67
  return () => windowListeners.delete(listener);
68
68
  },
69
- adopt(port) {
69
+ adopt(port, rendererEpoch) {
70
70
  if (disposed) return () => undefined;
71
71
  let candidate = true;
72
72
  let candidateStop: (() => void) | null = null;
73
73
  candidateStop = port.onLayout((layout) => {
74
74
  if (disposed || !candidate || !layout.current) return;
75
+ // A port may reuse its snapshot object. Own the provenance wrapper so
76
+ // a preparing runtime cannot relabel the retained coherent frame.
77
+ const presented = Object.freeze({ ...layout, rendererEpoch });
75
78
  if (currentPort !== port) {
76
79
  const previousStop = stopLayout;
77
80
  currentPort = port;
78
81
  stopLayout = candidateStop;
79
- publish(layout);
82
+ publish(presented);
80
83
  previousStop?.();
81
84
  return;
82
85
  }
83
- publish(layout);
86
+ publish(presented);
84
87
  });
85
88
  // `onLayout` intentionally seeds synchronously. Capture the disposer
86
89
  // after that first callback has promoted this candidate.
@@ -20,6 +20,7 @@ export type PaneSelectionFailure = Readonly<{
20
20
  | "generation-replaced"
21
21
  | "operation-timeout"
22
22
  | "transport-rejected"
23
+ | "topology-changed"
23
24
  | "receipt-invalid";
24
25
  backendReason?:
25
26
  | "pane_inventory_not_ready"
@@ -42,6 +43,7 @@ export async function selectTerminalPane(
42
43
  operationId?: string,
43
44
  onFailure?: (failure: PaneSelectionFailure) => void,
44
45
  windowLink?: WindowLinkTarget,
46
+ onDispatch?: () => void,
45
47
  ): Promise<PaneSelectionReceipt | null> {
46
48
  const fail = (failure: PaneSelectionFailure): null => {
47
49
  try {
@@ -62,7 +64,12 @@ export async function selectTerminalPane(
62
64
  : undefined;
63
65
  return fail({
64
66
  stage,
65
- reason: code === "operation-timeout" ? "operation-timeout" : "transport-rejected",
67
+ reason:
68
+ code === "operation-timeout"
69
+ ? "operation-timeout"
70
+ : code === "topology-changed" && stage === "dispatch"
71
+ ? "topology-changed"
72
+ : "transport-rejected",
66
73
  ...(backendReason ? { backendReason } : {}),
67
74
  });
68
75
  };
@@ -90,6 +97,7 @@ export async function selectTerminalPane(
90
97
  if (!isCurrent()) return fail({ stage: "pre-dispatch", reason: "generation-replaced" });
91
98
  let result: unknown;
92
99
  try {
100
+ onDispatch?.();
93
101
  result = await expected.client.dispatch({
94
102
  kind: "semantic-intent",
95
103
  ...(operationId ? { operationId } : {}),
@@ -0,0 +1,36 @@
1
+ /** One bounded selection observation; never submits or repeats a mutation. */
2
+ export function createTerminalSelectionWaiter() {
3
+ let pending: {
4
+ check: () => boolean | "pending";
5
+ finish: (selected: boolean) => void;
6
+ } | null = null;
7
+ const cancel = () => pending?.finish(false);
8
+ const reconcile = () => {
9
+ const waiting = pending;
10
+ if (!waiting) return;
11
+ const result = waiting.check();
12
+ if (result !== "pending") waiting.finish(result);
13
+ };
14
+ return {
15
+ cancel,
16
+ reconcile,
17
+ wait(check: () => boolean | "pending"): Promise<boolean> {
18
+ cancel();
19
+ return new Promise((resolve) => {
20
+ const timer = setTimeout(() => waiting.finish(false), 5000);
21
+ timer.unref?.();
22
+ const waiting = {
23
+ check,
24
+ finish(selected: boolean) {
25
+ if (pending !== waiting) return;
26
+ pending = null;
27
+ clearTimeout(timer);
28
+ resolve(selected);
29
+ },
30
+ };
31
+ pending = waiting;
32
+ reconcile();
33
+ });
34
+ },
35
+ };
36
+ }