tmux-ide 2.9.0-beta.21 → 2.9.0-beta.26

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 (209) hide show
  1. package/bin/cli.js +46791 -43589
  2. package/package.json +2 -2
  3. package/packages/contracts/src/__tests__/daemon-wire.test.ts +2 -2
  4. package/packages/contracts/src/__tests__/fixtures/daemon-wire-v3.json +35 -0
  5. package/packages/contracts/src/__tests__/issue-error.test.ts +1 -1
  6. package/packages/contracts/src/__tests__/multiplexer-verbs.test.ts +3 -1
  7. package/packages/contracts/src/__tests__/pane-stream.test.ts +17 -5
  8. package/packages/contracts/src/__tests__/tmux-server-scope.test.ts +166 -0
  9. package/packages/contracts/src/__tests__/window-links.test.ts +243 -0
  10. package/packages/contracts/src/actions-contract.ts +12 -0
  11. package/packages/contracts/src/daemon-resource-request.test.ts +1 -1
  12. package/packages/contracts/src/daemon-wire.ts +10 -5
  13. package/packages/contracts/src/fleet-client-state.ts +3 -0
  14. package/packages/contracts/src/fleet-lifecycle.ts +5 -0
  15. package/packages/contracts/src/index.ts +2 -0
  16. package/packages/contracts/src/interaction-receipts.ts +39 -0
  17. package/packages/contracts/src/issue-error.ts +1 -1
  18. package/packages/contracts/src/multiplexer-verbs.ts +25 -1
  19. package/packages/contracts/src/pane-stream.ts +51 -8
  20. package/packages/contracts/src/tmux-server-scope.ts +172 -0
  21. package/packages/contracts/src/window-links.ts +76 -0
  22. package/packages/contracts/src/workspace-multiplexer.ts +54 -0
  23. package/packages/core/src/interaction-receipts.ts +4 -0
  24. package/packages/daemon/dist/command-center/actions/command-definitions.js +6 -0
  25. package/packages/daemon/dist/command-center/actions/handlers/workspace-multiplexer.js +2 -0
  26. package/packages/daemon/dist/command-center/actions/registry.js +13 -1
  27. package/packages/daemon/dist/command-center/discovery.js +2 -9
  28. package/packages/daemon/dist/command-center/server.js +21 -1
  29. package/packages/daemon/dist/command-center/tmux-server-native-backing.js +37 -0
  30. package/packages/daemon/dist/command-center/tmux-servers.js +351 -0
  31. package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +64 -8
  32. package/packages/daemon/dist/lib/canonical-daemon.js +5 -2
  33. package/packages/daemon/dist/lib/daemon-embed.js +198 -30
  34. package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +147 -0
  35. package/packages/daemon/dist/lib/fleet-lifecycle-authority.js +31 -11
  36. package/packages/daemon/dist/lib/headless-daemon.js +10 -1
  37. package/packages/daemon/dist/lib/runtime-namespace.js +17 -3
  38. package/packages/daemon/dist/lib/tmux-client-execution.js +59 -0
  39. package/packages/daemon/dist/lib/tmux-named-socket-fence.js +4 -0
  40. package/packages/daemon/dist/lib/tmux-server-generation-runner.js +62 -0
  41. package/packages/daemon/dist/lib/tmux-server-owner.js +285 -0
  42. package/packages/daemon/dist/lib/tmux-server-owners.js +192 -0
  43. package/packages/daemon/dist/lib/tmux-server-proof.js +82 -0
  44. package/packages/daemon/dist/lib/tmux-server-registration.js +85 -0
  45. package/packages/daemon/dist/lib/tmux-server-session-create.js +43 -0
  46. package/packages/daemon/dist/lib/tmux-server-session-open.js +101 -0
  47. package/packages/daemon/dist/lib/tmux-servers-cli.js +134 -0
  48. package/packages/daemon/dist/lib/tmux-session-mutation-fence.js +55 -0
  49. package/packages/daemon/dist/lib/tmux-window-link-guard.js +88 -0
  50. package/packages/daemon/dist/lib/workspace-config-loader.js +3 -0
  51. package/packages/daemon/dist/lib/workspace-multiplexer-verbs.js +82 -1
  52. package/packages/daemon/dist/lib/workspace-pane-creation.js +13 -57
  53. package/packages/daemon/dist/lib/workspace-promotion.js +91 -12
  54. package/packages/daemon/dist/lib/workspace-registry.js +4 -0
  55. package/packages/daemon/dist/server/pane-stream-upgrade.js +20 -6
  56. package/packages/daemon/dist/terminal/attachments/native-runtime.js +61 -17
  57. package/packages/daemon/dist/terminal/mirror/__tests__/scripted-channel.js +9 -1
  58. package/packages/daemon/dist/terminal/mirror/__tests__/simulated-channel.js +11 -2
  59. package/packages/daemon/dist/terminal/mirror/control-channel.js +65 -6
  60. package/packages/daemon/dist/terminal/mirror/mirror-service.js +44 -0
  61. package/packages/daemon/dist/terminal/mirror/session-channel.js +210 -50
  62. package/packages/daemon/dist/terminal/mirror/window-link-authority.js +160 -0
  63. package/packages/daemon/dist/terminal/pane-stream/lease-manager.js +3 -3
  64. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +40 -6
  65. package/packages/daemon/dist/terminal/protocol/live-session-identity.js +9 -0
  66. package/packages/daemon/dist/terminal/protocol/native-backing-client.js +2 -1
  67. package/packages/daemon/dist/terminal/session-runtime/interaction-receipt-facts.js +9 -0
  68. package/packages/daemon/dist/terminal/session-runtime/registry.js +6 -0
  69. package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-executor.js +1 -1
  70. package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-resource-changes.js +1 -0
  71. package/packages/daemon/dist/tui/mirror/application-shell-daemon-connection.js +7 -2
  72. package/packages/daemon/dist/tui/mirror/application-shell-server-connection.js +114 -0
  73. package/packages/daemon/dist/tui/mirror/menu-model.js +1 -1
  74. package/packages/daemon/dist/tui/mirror/open-tui-verified-routing.js +34 -4
  75. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +29 -8
  76. package/packages/daemon/dist/tui/mirror/runtime/application-guided-tour-integration.jsx +119 -0
  77. package/packages/daemon/dist/tui/mirror/runtime/application-guided-tour-owner.js +132 -0
  78. package/packages/daemon/dist/tui/mirror/runtime/application-home-agent-roster.jsx +21 -9
  79. package/packages/daemon/dist/tui/mirror/runtime/application-home-agent-transport.js +63 -1
  80. package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +8 -2
  81. package/packages/daemon/dist/tui/mirror/runtime/application-home-agents.js +3 -1
  82. package/packages/daemon/dist/tui/mirror/runtime/application-home-catalog-owner.js +11 -5
  83. package/packages/daemon/dist/tui/mirror/runtime/application-home-catalog.js +140 -75
  84. package/packages/daemon/dist/tui/mirror/runtime/application-home-experience.js +40 -0
  85. package/packages/daemon/dist/tui/mirror/runtime/application-home-fleet.js +132 -0
  86. package/packages/daemon/dist/tui/mirror/runtime/application-machine-agents.js +9 -0
  87. package/packages/daemon/dist/tui/mirror/runtime/application-machine-catalog.js +12 -2
  88. package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +211 -31
  89. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +91 -37
  90. package/packages/daemon/dist/tui/mirror/runtime/application-performance-log.js +20 -7
  91. package/packages/daemon/dist/tui/mirror/runtime/application-root-configuration.js +9 -3
  92. package/packages/daemon/dist/tui/mirror/runtime/application-route-connection.js +95 -2
  93. package/packages/daemon/dist/tui/mirror/runtime/application-shell-home.jsx +6 -2
  94. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +158 -2
  95. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace-policy.js +43 -0
  96. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +78 -25
  97. package/packages/daemon/dist/tui/mirror/runtime/application-theme-repaint.js +9 -0
  98. package/packages/daemon/dist/tui/mirror/runtime/fleet-lifecycle-client.js +44 -1
  99. package/packages/daemon/dist/tui/mirror/runtime/guided-tour-coach.jsx +54 -0
  100. package/packages/daemon/dist/tui/mirror/runtime/guided-tour-storage.js +67 -0
  101. package/packages/daemon/dist/tui/mirror/runtime/guided-tour.js +113 -0
  102. package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +1 -1
  103. package/packages/daemon/dist/tui/mirror/runtime/resize-terminal-pane.js +4 -0
  104. package/packages/daemon/dist/tui/mirror/runtime/select-terminal-pane.js +2 -1
  105. package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +12 -4
  106. package/packages/daemon/dist/tui/mirror/runtime/terminal-pane-input-router.js +6 -3
  107. package/packages/daemon/dist/tui/mirror/workspace/application-command-description.js +11 -2
  108. package/packages/daemon/dist/tui/mirror/workspace/terminal-window-strip.jsx +2 -1
  109. package/packages/daemon/src/command-center/actions/command-definitions.ts +6 -0
  110. package/packages/daemon/src/command-center/actions/errors.ts +4 -0
  111. package/packages/daemon/src/command-center/actions/handlers/workspace-multiplexer.ts +2 -0
  112. package/packages/daemon/src/command-center/actions/registry.ts +14 -0
  113. package/packages/daemon/src/command-center/discovery.ts +2 -10
  114. package/packages/daemon/src/command-center/server.ts +29 -1
  115. package/packages/daemon/src/command-center/tmux-server-native-backing.ts +49 -0
  116. package/packages/daemon/src/command-center/tmux-servers.ts +479 -0
  117. package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +111 -4
  118. package/packages/daemon/src/lib/canonical-daemon.ts +15 -3
  119. package/packages/daemon/src/lib/daemon-embed.ts +241 -28
  120. package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +190 -0
  121. package/packages/daemon/src/lib/fleet-lifecycle-authority.ts +33 -9
  122. package/packages/daemon/src/lib/headless-daemon.ts +18 -1
  123. package/packages/daemon/src/lib/runtime-namespace.ts +24 -3
  124. package/packages/daemon/src/lib/tmux-client-execution.ts +56 -0
  125. package/packages/daemon/src/lib/tmux-named-socket-fence.ts +5 -0
  126. package/packages/daemon/src/lib/tmux-server-generation-runner.ts +81 -0
  127. package/packages/daemon/src/lib/tmux-server-owner.ts +336 -0
  128. package/packages/daemon/src/lib/tmux-server-owners.ts +228 -0
  129. package/packages/daemon/src/lib/tmux-server-proof.ts +95 -0
  130. package/packages/daemon/src/lib/tmux-server-registration.ts +105 -0
  131. package/packages/daemon/src/lib/tmux-server-session-create.ts +52 -0
  132. package/packages/daemon/src/lib/tmux-server-session-open.ts +106 -0
  133. package/packages/daemon/src/lib/tmux-servers-cli.ts +186 -0
  134. package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +62 -0
  135. package/packages/daemon/src/lib/tmux-window-link-guard.ts +112 -0
  136. package/packages/daemon/src/lib/workspace-config-loader.ts +3 -0
  137. package/packages/daemon/src/lib/workspace-multiplexer-verbs.ts +121 -1
  138. package/packages/daemon/src/lib/workspace-pane-creation.ts +16 -55
  139. package/packages/daemon/src/lib/workspace-promotion.ts +98 -12
  140. package/packages/daemon/src/lib/workspace-registry.ts +5 -0
  141. package/packages/daemon/src/server/pane-stream-upgrade.ts +25 -5
  142. package/packages/daemon/src/terminal/attachments/native-runtime.ts +70 -16
  143. package/packages/daemon/src/terminal/mirror/control-channel.ts +64 -7
  144. package/packages/daemon/src/terminal/mirror/events.ts +2 -0
  145. package/packages/daemon/src/terminal/mirror/mirror-service.ts +56 -0
  146. package/packages/daemon/src/terminal/mirror/session-channel.ts +245 -50
  147. package/packages/daemon/src/terminal/mirror/window-link-authority.ts +203 -0
  148. package/packages/daemon/src/terminal/pane-stream/lease-manager.ts +3 -3
  149. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +50 -6
  150. package/packages/daemon/src/terminal/protocol/live-session-identity.ts +14 -0
  151. package/packages/daemon/src/terminal/protocol/native-backing-client.ts +4 -1
  152. package/packages/daemon/src/terminal/session-runtime/interaction-receipt-facts.ts +9 -0
  153. package/packages/daemon/src/terminal/session-runtime/registry.ts +9 -0
  154. package/packages/daemon/src/terminal/session-runtime/semantic-mutation-executor.ts +2 -2
  155. package/packages/daemon/src/terminal/session-runtime/semantic-mutation-resource-changes.ts +1 -0
  156. package/packages/daemon/src/tui/mirror/application-shell-daemon-connection.ts +28 -2
  157. package/packages/daemon/src/tui/mirror/application-shell-server-connection.ts +154 -0
  158. package/packages/daemon/src/tui/mirror/menu-model.ts +1 -1
  159. package/packages/daemon/src/tui/mirror/open-tui-verified-routing.ts +41 -3
  160. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +47 -6
  161. package/packages/daemon/src/tui/mirror/runtime/application-entry.ts +2 -1
  162. package/packages/daemon/src/tui/mirror/runtime/application-fleet-session-actions.tsx +10 -4
  163. package/packages/daemon/src/tui/mirror/runtime/application-fleet-tabs.ts +2 -0
  164. package/packages/daemon/src/tui/mirror/runtime/application-guided-tour-integration.tsx +199 -0
  165. package/packages/daemon/src/tui/mirror/runtime/application-guided-tour-owner.ts +162 -0
  166. package/packages/daemon/src/tui/mirror/runtime/application-home-agent-roster.tsx +36 -10
  167. package/packages/daemon/src/tui/mirror/runtime/application-home-agent-transport.ts +69 -0
  168. package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +12 -2
  169. package/packages/daemon/src/tui/mirror/runtime/application-home-agents.ts +13 -2
  170. package/packages/daemon/src/tui/mirror/runtime/application-home-catalog-owner.ts +13 -3
  171. package/packages/daemon/src/tui/mirror/runtime/application-home-catalog.ts +172 -100
  172. package/packages/daemon/src/tui/mirror/runtime/application-home-experience.ts +59 -0
  173. package/packages/daemon/src/tui/mirror/runtime/application-home-fleet.ts +175 -0
  174. package/packages/daemon/src/tui/mirror/runtime/application-machine-agents.ts +9 -1
  175. package/packages/daemon/src/tui/mirror/runtime/application-machine-catalog.ts +19 -7
  176. package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +285 -36
  177. package/packages/daemon/src/tui/mirror/runtime/application-machine-overlays.tsx +1 -1
  178. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +124 -40
  179. package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +3 -0
  180. package/packages/daemon/src/tui/mirror/runtime/application-performance-log.ts +18 -6
  181. package/packages/daemon/src/tui/mirror/runtime/application-root-configuration.ts +12 -3
  182. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +40 -40
  183. package/packages/daemon/src/tui/mirror/runtime/application-route-connection.ts +93 -1
  184. package/packages/daemon/src/tui/mirror/runtime/application-shell-catalog.tsx +4 -0
  185. package/packages/daemon/src/tui/mirror/runtime/application-shell-home.tsx +20 -1
  186. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +10 -0
  187. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +177 -2
  188. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace-policy.ts +62 -0
  189. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +87 -30
  190. package/packages/daemon/src/tui/mirror/runtime/application-theme-repaint.ts +19 -0
  191. package/packages/daemon/src/tui/mirror/runtime/fleet-lifecycle-client.ts +55 -0
  192. package/packages/daemon/src/tui/mirror/runtime/guided-tour-coach.tsx +118 -0
  193. package/packages/daemon/src/tui/mirror/runtime/guided-tour-storage.ts +72 -0
  194. package/packages/daemon/src/tui/mirror/runtime/guided-tour.ts +147 -0
  195. package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +1 -1
  196. package/packages/daemon/src/tui/mirror/runtime/resize-terminal-pane.ts +3 -0
  197. package/packages/daemon/src/tui/mirror/runtime/select-terminal-pane.ts +3 -0
  198. package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +11 -4
  199. package/packages/daemon/src/tui/mirror/runtime/terminal-pane-input-router.ts +15 -4
  200. package/packages/daemon/src/tui/mirror/workspace/application-command-description.ts +13 -2
  201. package/packages/daemon/src/tui/mirror/workspace/terminal-window-strip.tsx +5 -1
  202. package/packages/daemon-client/package.json +3 -1
  203. package/packages/daemon-client/src/index.ts +2 -0
  204. package/packages/daemon-client/src/pane-stream-client.test.ts +108 -38
  205. package/packages/daemon-client/src/pane-stream-client.ts +26 -0
  206. package/packages/daemon-client/src/scoped-tmux-server-transport.test.ts +114 -0
  207. package/packages/daemon-client/src/scoped-tmux-server-transport.ts +385 -0
  208. package/packages/daemon-client/src/tmux-server-client.test.ts +279 -0
  209. package/packages/daemon-client/src/tmux-server-client.ts +287 -0
@@ -35,10 +35,13 @@ import {
35
35
  import { createHash, randomBytes } from "node:crypto";
36
36
  import { hostname } from "node:os";
37
37
  import {
38
+ WINDOW_LINK_MAX_LINKS,
38
39
  WORKSPACE_SEMANTIC_PANE_OPTION,
39
40
  WORKSPACE_SEMANTIC_WINDOW_OPTION,
40
41
  WorkspaceIdSchemaZ,
41
42
  type WorkspacePaneRect,
43
+ type WindowLinkTarget,
44
+ type WindowLinkTopology,
42
45
  } from "@tmux-ide/contracts";
43
46
  import { textToHexKeys } from "../protocol/control.ts";
44
47
  import { InputCoalescer } from "../protocol/input-coalescer.ts";
@@ -81,6 +84,13 @@ import type {
81
84
  MirrorPaneEvent,
82
85
  MirrorSessionDescription,
83
86
  } from "./events.ts";
87
+ import { liveSessionIdForNativeIdentity } from "../protocol/live-session-identity.ts";
88
+ import { WindowLinkAuthority, WindowLinkResolutionError } from "./window-link-authority.ts";
89
+ import {
90
+ buildNativeWindowLinkGuard,
91
+ buildNativeWindowLinkPaneSelectGuard,
92
+ classifyNativeWindowLinkGuardResult,
93
+ } from "../../lib/tmux-window-link-guard.ts";
84
94
  import { FlowLedger } from "./flow-ledger.ts";
85
95
  import { PaneFeed } from "./pane-feed.ts";
86
96
  import type {
@@ -217,6 +227,7 @@ export interface MirrorFlowRecoveryObservation {
217
227
  }
218
228
 
219
229
  export interface SessionChannelOptions {
230
+ executeWindowLinkGuard?: (args: string[]) => Promise<{ status: number | null; stdout: string }>;
220
231
  session: string;
221
232
  createIo: (handlers: MirrorChannelHandlers) => MirrorChannelIo;
222
233
  /** Explicit capture tail override. Omitted captures all retained native history. */
@@ -279,6 +290,11 @@ interface SubRecord {
279
290
  closed: boolean;
280
291
  }
281
292
 
293
+ interface PlainReseedLease {
294
+ readonly sub: SubRecord;
295
+ cancelRecipe: (() => void) | null;
296
+ }
297
+
282
298
  interface PaneRecord {
283
299
  historySize?: number;
284
300
  scrollOnClear?: boolean;
@@ -395,6 +411,12 @@ interface WindowSyncStage {
395
411
  readonly layouts: Map<string, ParsedLayout & { zoomed: boolean; unzoomed?: ParsedLayout }>;
396
412
  readonly currentWindow: string;
397
413
  readonly repairedIdentity: boolean;
414
+ readonly links: readonly {
415
+ index: number;
416
+ runtimeWindowId: string;
417
+ semanticWindowId: string;
418
+ active: boolean;
419
+ }[];
398
420
  }
399
421
 
400
422
  export function defaultMirrorPaneId(): string {
@@ -429,6 +451,9 @@ export class SessionChannel {
429
451
  private readonly truthActive = new Map<string, boolean>();
430
452
  private readonly truthWindow = new Map<string, string>();
431
453
  private currentWindow = "";
454
+ private windowLinkAuthority: WindowLinkAuthority | null = null;
455
+ private latestWindowStage: WindowSyncStage | null = null;
456
+ private attachedServerGeneration: { serverPid: string; sessionCreated: string } | null = null;
432
457
  private diagnostics: MirrorDiagnostic[] = [];
433
458
  private degraded = false;
434
459
  private readonly ageByRuntime = new Map<string, number>();
@@ -443,7 +468,7 @@ export class SessionChannel {
443
468
  private windowAuthorityOrdinal = 0;
444
469
  private paneIncarnation = 0;
445
470
  private recoveryOrdinal = 0;
446
- private plainReseedActive: SubRecord | null = null;
471
+ private plainReseedActive: PlainReseedLease | null = null;
447
472
  private readonly plainReseedQueue = new Map<SubRecord, "requested" | null>();
448
473
  private readonly outputOrdinals = new Map<string, number>();
449
474
  private readonly pendingLayoutOutput = new Map<
@@ -629,12 +654,18 @@ export class SessionChannel {
629
654
  ? this.trustedInventoryFlight
630
655
  : Promise.reject(new Error(`trusted inventory identity changed for ${this.opts.session}`));
631
656
  }
632
- const flight = this.refreshTrustedInventory(expectedRuntimeSessionId).finally(() => {
633
- if (this.trustedInventoryFlight === flight) {
634
- this.trustedInventoryFlight = null;
635
- this.trustedInventoryFlightSessionId = null;
636
- }
637
- });
657
+ const flight = this.refreshTrustedInventory(expectedRuntimeSessionId)
658
+ .catch((error) => {
659
+ this.windowLinkAuthority?.invalidate();
660
+ this.latestWindowStage = null;
661
+ throw error;
662
+ })
663
+ .finally(() => {
664
+ if (this.trustedInventoryFlight === flight) {
665
+ this.trustedInventoryFlight = null;
666
+ this.trustedInventoryFlightSessionId = null;
667
+ }
668
+ });
638
669
  this.trustedInventoryFlight = flight;
639
670
  this.trustedInventoryFlightSessionId = expectedRuntimeSessionId;
640
671
  return flight;
@@ -649,23 +680,103 @@ export class SessionChannel {
649
680
  }
650
681
 
651
682
  private async captureAttachedSessionIdentity(): Promise<void> {
652
- const lines = await this.io.request(`display-message -p "#{qa:session_name}\t#{session_id}"`);
683
+ const lines = await this.io.request(
684
+ `display-message -p "#{qa:session_name}\t#{session_id}\t#{pid}\t#{session_created}"`,
685
+ );
653
686
  if (lines.length !== 1)
654
687
  throw new Error(`mirror session ${this.opts.session} identity is absent`);
655
688
  const decodedLine = decodeControlReplyUtf8(lines[0]!);
656
689
  if (decodedLine === null)
657
690
  throw new Error(`mirror session ${this.opts.session} identity is malformed`);
658
- const [encodedName = "", runtimeSessionId = ""] = decodedLine.split("\t");
691
+ const [encodedName = "", runtimeSessionId = "", serverPid = "", sessionCreated = ""] =
692
+ decodedLine.split("\t");
659
693
  const sessionName = decodeTmuxArgument(encodedName);
660
694
  if (
661
695
  sessionName.length === 0 ||
662
696
  sessionName.length > 160 ||
663
697
  !/^\$(?:0|[1-9][0-9]*)$/u.test(runtimeSessionId) ||
664
- runtimeSessionId.length > 32
698
+ runtimeSessionId.length > 32 ||
699
+ !/^[1-9][0-9]*$/u.test(serverPid) ||
700
+ !/^[0-9]+$/u.test(sessionCreated)
665
701
  ) {
666
702
  throw new Error(`mirror session ${this.opts.session} identity is malformed`);
667
703
  }
668
704
  this.attachedIdentity = Object.freeze({ sessionName, runtimeSessionId });
705
+ this.attachedServerGeneration = { serverPid, sessionCreated };
706
+ if (this.disposed) return;
707
+ this.bindWindowLinkAuthority(
708
+ liveSessionIdForNativeIdentity(serverPid, runtimeSessionId, sessionCreated),
709
+ );
710
+ }
711
+
712
+ private bindWindowLinkAuthority(liveSessionId: string): void {
713
+ const identity = this.attachedIdentity;
714
+ if (!identity || this.disposed) throw new WindowLinkResolutionError("window_link_stale");
715
+ if (this.windowLinkAuthority?.liveSessionId === liveSessionId) return;
716
+ this.windowLinkAuthority?.dispose();
717
+ this.windowLinkAuthority = new WindowLinkAuthority(liveSessionId, identity.runtimeSessionId);
718
+ if (this.latestWindowStage) this.windowLinkAuthority.reconcile(this.latestWindowStage.links);
719
+ }
720
+
721
+ async executeWindowLinkAction(request: {
722
+ action: "select" | "unlink";
723
+ target?: WindowLinkTarget;
724
+ paneId?: string;
725
+ }): Promise<{
726
+ outcome: "applied" | "stale" | "native-refused" | "indeterminate";
727
+ windowLinks: WindowLinkTopology | null;
728
+ }> {
729
+ const authority = this.windowLinkAuthority;
730
+ if (!authority || this.disposed) throw new WindowLinkResolutionError("window_link_stale");
731
+ const pane = request.paneId ? this.panesBySemantic.get(request.paneId) : undefined;
732
+ if (request.paneId && !pane)
733
+ throw new WindowLinkResolutionError("window_link_backing_mismatch");
734
+ const semanticWindow = pane?.windowRuntimeId
735
+ ? this.windowsByRuntime.get(pane.windowRuntimeId)?.semanticId
736
+ : null;
737
+ const target =
738
+ request.target ?? (semanticWindow ? authority.uniqueTargetForBacking(semanticWindow) : null);
739
+ if (!target) throw new WindowLinkResolutionError("window_link_stale");
740
+ const resolved = authority.resolve(target);
741
+ if (pane && (pane.windowRuntimeId !== resolved.runtimeWindowId || request.action !== "select"))
742
+ throw new WindowLinkResolutionError("window_link_backing_mismatch");
743
+ const address = {
744
+ sessionId: resolved.runtimeSessionId,
745
+ windowIndex: resolved.index,
746
+ expectedWindowId: resolved.runtimeWindowId,
747
+ expectedServerPid: this.attachedServerGeneration!.serverPid,
748
+ expectedSessionCreated: this.attachedServerGeneration!.sessionCreated,
749
+ };
750
+ const args = pane
751
+ ? buildNativeWindowLinkPaneSelectGuard(address, pane.runtimeId)
752
+ : buildNativeWindowLinkGuard(address, request.action);
753
+ let outcome: "applied" | "stale" | "native-refused" | "indeterminate";
754
+ try {
755
+ if (!this.opts.executeWindowLinkGuard) throw new Error("Window link executor unavailable");
756
+ const result = await this.opts.executeWindowLinkGuard(args);
757
+ outcome = classifyNativeWindowLinkGuardResult(result.status, result.stdout);
758
+ } catch {
759
+ // A lost command boundary can follow a mutation; never retry automatically.
760
+ outcome = "indeterminate";
761
+ }
762
+ let timer: ReturnType<typeof setTimeout> | undefined;
763
+ const reconciled = await Promise.race([
764
+ this.syncNow().then(
765
+ () => true,
766
+ () => false,
767
+ ),
768
+ new Promise<false>((resolve) => {
769
+ timer = setTimeout(() => resolve(false), 2000);
770
+ }),
771
+ ]).finally(() => {
772
+ if (timer !== undefined) clearTimeout(timer);
773
+ });
774
+ if (!reconciled) {
775
+ authority.invalidate();
776
+ this.latestWindowStage = null;
777
+ return { outcome, windowLinks: null };
778
+ }
779
+ return { outcome, windowLinks: authority.snapshot() };
669
780
  }
670
781
 
671
782
  /** Optional native backing, addressed through the verified semantic binding. */
@@ -829,7 +940,7 @@ export class SessionChannel {
829
940
  }
830
941
  }
831
942
  if (
832
- expectedByWindow.size !== inventory.panes[0]!.sessionWindowCount ||
943
+ this.latestWindowStage?.links.length !== inventory.panes[0]!.sessionWindowCount ||
833
944
  expectedByWindow.size !== this.windowsByRuntime.size
834
945
  ) {
835
946
  throw new Error(`authoritative layout for ${this.opts.session} has incomplete windows`);
@@ -981,6 +1092,7 @@ export class SessionChannel {
981
1092
  async dispose(): Promise<void> {
982
1093
  if (this.disposed) return;
983
1094
  this.disposed = true;
1095
+ this.windowLinkAuthority?.dispose();
984
1096
  this.nativeGrid.dispose();
985
1097
  this.settleFirstJoin();
986
1098
  this.cancelSync?.();
@@ -1052,16 +1164,17 @@ export class SessionChannel {
1052
1164
  // ── Seed / reseed (the atomic recipe) ────────────────────────────────────
1053
1165
 
1054
1166
  private reseed(
1055
- sub: SubRecord,
1167
+ lease: PlainReseedLease,
1056
1168
  onSettled?: (result: ReseedResult) => void,
1057
1169
  deferPublish = false,
1058
1170
  deadlineAt = this.recoveryNowMs() + RECOVERY_ABSOLUTE_DEADLINE_MS,
1059
1171
  ): void {
1172
+ const { sub } = lease;
1060
1173
  if (sub.closed || sub.frozen || this.disposed) {
1061
1174
  onSettled?.(FAILED_RESEED_RESULT);
1062
1175
  return;
1063
1176
  }
1064
- sub.cancelCapture?.();
1177
+ lease.cancelRecipe?.();
1065
1178
  const runtime = sub.pane.runtimeId;
1066
1179
  const epoch = sub.feed.beginReseed();
1067
1180
  let settled = false;
@@ -1073,7 +1186,7 @@ export class SessionChannel {
1073
1186
  if (settled) return;
1074
1187
  settled = true;
1075
1188
  cancelDeadline?.();
1076
- sub.cancelCapture = null;
1189
+ lease.cancelRecipe = null;
1077
1190
  onSettled?.(result);
1078
1191
  };
1079
1192
  // Keystroke ordering: pending coalesced input leaves before the probes.
@@ -1085,11 +1198,11 @@ export class SessionChannel {
1085
1198
  markerRetired = true;
1086
1199
  this.retireInternalReadMarker(runtime, internalReadMarker);
1087
1200
  };
1088
- sub.cancelCapture = () => {
1201
+ lease.cancelRecipe = () => {
1089
1202
  if (settled) return;
1090
1203
  settled = true;
1091
1204
  cancelDeadline?.();
1092
- sub.cancelCapture = null;
1205
+ lease.cancelRecipe = null;
1093
1206
  sub.feed.abort(epoch);
1094
1207
  if (!captureSucceeded) retireMarker();
1095
1208
  };
@@ -1130,9 +1243,9 @@ export class SessionChannel {
1130
1243
  settled = true;
1131
1244
  sub.feed.abort(epoch);
1132
1245
  cancelDeadline?.();
1133
- sub.cancelCapture = null;
1246
+ lease.cancelRecipe = null;
1134
1247
  retireMarker();
1135
- this.reseed(sub, onSettled, deferPublish, deadlineAt);
1248
+ this.reseed(lease, onSettled, deferPublish, deadlineAt);
1136
1249
  return;
1137
1250
  }
1138
1251
  sub.feed.abort(epoch);
@@ -1147,10 +1260,10 @@ export class SessionChannel {
1147
1260
  retireMarker();
1148
1261
  sub.feed.abort(epoch);
1149
1262
  cancelDeadline?.();
1150
- sub.cancelCapture = null;
1263
+ lease.cancelRecipe = null;
1151
1264
  sub.nativeBootstrap = false;
1152
1265
  this.nativeBootstrapUnavailable = true;
1153
- this.reseed(sub, onSettled, deferPublish, deadlineAt);
1266
+ this.reseed(lease, onSettled, deferPublish, deadlineAt);
1154
1267
  return;
1155
1268
  }
1156
1269
  if (!reply.ok) {
@@ -1219,13 +1332,13 @@ export class SessionChannel {
1219
1332
 
1220
1333
  private reseedPlain(sub: SubRecord, resumeReason: "requested" | null = null): void {
1221
1334
  if (sub.closed || sub.frozen || this.disposed) return;
1222
- if (this.plainReseedActive === sub) sub.cancelCapture?.();
1335
+ if (this.plainReseedActive?.sub === sub) sub.cancelCapture?.();
1223
1336
  if (!this.plainReseedQueue.has(sub) && this.plainReseedQueue.size >= MAX_QUEUED_PLAIN_RESEEDS) {
1224
1337
  this.failPlainReseed(sub);
1225
1338
  return;
1226
1339
  }
1227
1340
  this.plainReseedQueue.set(sub, resumeReason);
1228
- if (this.plainReseedActive !== sub) {
1341
+ if (this.plainReseedActive?.sub !== sub) {
1229
1342
  // Output before this queued recipe starts is already in its future
1230
1343
  // capture. Do not publish it using the prior geometry in the meantime.
1231
1344
  sub.feed.beginReseed();
@@ -1260,14 +1373,26 @@ export class SessionChannel {
1260
1373
  this.drainPlainReseeds();
1261
1374
  return;
1262
1375
  }
1263
- this.plainReseedActive = sub;
1376
+ sub.cancelCapture?.();
1377
+ const lease: PlainReseedLease = { sub, cancelRecipe: null };
1378
+ this.plainReseedActive = lease;
1379
+ // The queue lease outlives individual native capability probes. Retrying
1380
+ // or falling back replaces only cancelRecipe, never this queue owner.
1381
+ sub.cancelCapture = () => {
1382
+ if (this.plainReseedActive !== lease) return;
1383
+ lease.cancelRecipe?.();
1384
+ sub.cancelCapture = null;
1385
+ this.plainReseedActive = null;
1386
+ this.drainPlainReseeds();
1387
+ };
1264
1388
  // Only the recipe actually submitted to the control FIFO owns a five
1265
1389
  // second deadline. A large sibling projection cannot consume the waiting
1266
1390
  // panes' entire capture budgets before their commands have been written.
1267
1391
  this.reseed(
1268
- sub,
1392
+ lease,
1269
1393
  (result) => {
1270
- if (this.plainReseedActive !== sub) return;
1394
+ if (this.plainReseedActive !== lease) return;
1395
+ sub.cancelCapture = null;
1271
1396
  this.plainReseedActive = null;
1272
1397
  if (!result.ok) {
1273
1398
  // A stalled FIFO cannot make progress for queued recipes either. Retire
@@ -1287,15 +1412,6 @@ export class SessionChannel {
1287
1412
  },
1288
1413
  true,
1289
1414
  );
1290
- if (this.plainReseedActive === sub) {
1291
- const cancel = sub.cancelCapture;
1292
- sub.cancelCapture = () => {
1293
- cancel?.();
1294
- this.plainReseedQueue.delete(sub);
1295
- if (this.plainReseedActive === sub) this.plainReseedActive = null;
1296
- this.drainPlainReseeds();
1297
- };
1298
- }
1299
1415
  }
1300
1416
 
1301
1417
  private retireInternalReadMarker(runtime: string, marker: string): void {
@@ -2492,6 +2608,11 @@ export class SessionChannel {
2492
2608
  return;
2493
2609
  }
2494
2610
  if (name === "session-window-changed") {
2611
+ // The notification carries backing identity only; duplicate links require an index read.
2612
+ if (this.windowLinkAuthority) {
2613
+ this.scheduleSync();
2614
+ return;
2615
+ }
2495
2616
  const change = parseSessionWindowChanged(rest);
2496
2617
  if (!change) return;
2497
2618
  const previous = this.currentWindow;
@@ -2574,6 +2695,8 @@ export class SessionChannel {
2574
2695
  subscriber: (snapshot: MirrorLayoutAuthoritySnapshot) => void,
2575
2696
  runtimeSessionId: string,
2576
2697
  ): void {
2698
+ const windowLinks = this.windowLinkAuthority?.snapshot();
2699
+ if (!windowLinks) return;
2577
2700
  const layouts = [...this.layoutByWindow.keys()]
2578
2701
  .map((runtimeId) => this.layoutEventFor(runtimeId))
2579
2702
  .filter((event): event is MirrorLayoutEvent => event !== null);
@@ -2581,6 +2704,7 @@ export class SessionChannel {
2581
2704
  session: this.opts.session,
2582
2705
  runtimeSessionId,
2583
2706
  topologyEpoch: this.layoutTopologyEpoch,
2707
+ windowLinks,
2584
2708
  layouts,
2585
2709
  });
2586
2710
  }
@@ -2694,8 +2818,9 @@ export class SessionChannel {
2694
2818
  windowActive: windowActive === "1",
2695
2819
  });
2696
2820
  }
2821
+ const previousCurrentWindow = this.currentWindow;
2697
2822
  const { listed, movedWindowRuntimeIds } = this.applyPaneTruth(truth);
2698
- await this.syncWindows(this.opts.session, movedWindowRuntimeIds);
2823
+ await this.syncWindows(this.opts.session, movedWindowRuntimeIds, previousCurrentWindow);
2699
2824
  this.discovery.discover(listed);
2700
2825
  }
2701
2826
 
@@ -2766,7 +2891,18 @@ export class SessionChannel {
2766
2891
  ) {
2767
2892
  throw new Error(`trusted inventory for ${this.opts.session} is malformed`);
2768
2893
  }
2769
- const descriptors = parsed.descriptors;
2894
+ const descriptorByPane = new Map<string, SessionPaneDescriptor>();
2895
+ for (const row of parsed.descriptors) {
2896
+ const previous = descriptorByPane.get(row.runtimePaneId);
2897
+ if (previous) {
2898
+ const comparable = (value: SessionPaneDescriptor) =>
2899
+ JSON.stringify({ ...value, windowActive: false, windowIndex: null });
2900
+ if (comparable(previous) !== comparable(row))
2901
+ throw new Error("Inconsistent linked pane observation");
2902
+ if (row.windowActive) descriptorByPane.set(row.runtimePaneId, row);
2903
+ } else descriptorByPane.set(row.runtimePaneId, row);
2904
+ }
2905
+ const descriptors = [...descriptorByPane.values()];
2770
2906
  const runtimePaneIds = new Set(descriptors.map((pane) => pane.runtimePaneId));
2771
2907
  const runtimeSessionIds = new Set(descriptors.map((pane) => pane.runtimeSessionId));
2772
2908
  const activeWindowIds = new Set(
@@ -2796,12 +2932,32 @@ export class SessionChannel {
2796
2932
  descriptors.some(
2797
2933
  (pane) =>
2798
2934
  pane.windowPaneCount !== computedWindowCounts.get(pane.windowId!) ||
2799
- pane.sessionWindowCount !== computedWindowCounts.size,
2935
+ pane.sessionWindowCount !== descriptors[0]!.sessionWindowCount,
2800
2936
  )
2801
2937
  ) {
2802
2938
  throw new Error(`trusted inventory for ${this.opts.session} has incomplete counts`);
2803
2939
  }
2804
2940
  const windowStage = await this.stageWindows(expectedRuntimeSessionId);
2941
+ const descriptorKeys = new Set<string>();
2942
+ for (const row of parsed.descriptors) {
2943
+ const link = windowStage.links.find((link) => link.index === row.windowIndex);
2944
+ const key = `${row.windowIndex}:${row.runtimePaneId}`;
2945
+ if (
2946
+ !link ||
2947
+ link.runtimeWindowId !== row.windowId ||
2948
+ link.active !== row.windowActive ||
2949
+ descriptorKeys.has(key)
2950
+ )
2951
+ throw new Error(
2952
+ `trusted inventory for ${this.opts.session} lacks verified identity: inconsistent linked pane membership`,
2953
+ );
2954
+ descriptorKeys.add(key);
2955
+ }
2956
+ for (const link of windowStage.links) {
2957
+ const count = computedWindowCounts.get(link.runtimeWindowId)!;
2958
+ if (parsed.descriptors.filter((row) => row.windowIndex === link.index).length !== count)
2959
+ throw new Error("Incomplete linked pane membership");
2960
+ }
2805
2961
  const repairedPanes = await this.repairTrustedPaneIdentity(descriptors, windowStage.layouts);
2806
2962
  const afterLines = await this.io.request(
2807
2963
  `list-panes -s -t "${expectedRuntimeSessionId}" -F "${SESSION_PANE_DESCRIPTOR_FORMAT}"`,
@@ -2848,6 +3004,7 @@ export class SessionChannel {
2848
3004
  }
2849
3005
  if (
2850
3006
  confirmedWindowStage.currentWindow !== activeWindowId ||
3007
+ confirmedWindowStage.links.length !== descriptors[0]!.sessionWindowCount ||
2851
3008
  confirmedWindowStage.windows.size !== computedWindowCounts.size ||
2852
3009
  !stagedPaneMembershipExact ||
2853
3010
  stagedPaneCount !== descriptors.length ||
@@ -2896,7 +3053,7 @@ export class SessionChannel {
2896
3053
  throw new Error(`trusted inventory for ${this.opts.session} is degraded`);
2897
3054
  }
2898
3055
  const windowCounts = computedWindowCounts;
2899
- const sessionWindowCount = computedWindowCounts.size;
3056
+ const sessionWindowCount = confirmedWindowStage.links.length;
2900
3057
  const panes: TrustedMirrorPaneInventory[] = descriptors.map((descriptor) => {
2901
3058
  const record = this.panesByRuntime.get(descriptor.runtimePaneId);
2902
3059
  const runtimeWindowId = descriptor.windowId!;
@@ -2941,9 +3098,14 @@ export class SessionChannel {
2941
3098
  private async syncWindows(
2942
3099
  target = this.opts.session,
2943
3100
  requiredLayoutEmits: ReadonlySet<string> = new Set(),
3101
+ previousCurrentWindow = this.currentWindow,
2944
3102
  ): Promise<boolean> {
2945
- const stage = await this.stageWindows(target);
2946
- this.commitWindowStage(stage, requiredLayoutEmits);
3103
+ const stage = await this.stageWindows(target).catch((error) => {
3104
+ this.windowLinkAuthority?.invalidate();
3105
+ this.latestWindowStage = null;
3106
+ throw error;
3107
+ });
3108
+ this.commitWindowStage(stage, requiredLayoutEmits, previousCurrentWindow);
2947
3109
  return stage.repairedIdentity;
2948
3110
  }
2949
3111
 
@@ -2952,6 +3114,8 @@ export class SessionChannel {
2952
3114
  requiredLayoutEmits: ReadonlySet<string> = new Set(),
2953
3115
  previousCurrentWindow = this.currentWindow,
2954
3116
  ): void {
3117
+ this.windowLinkAuthority?.reconcile(stage.links);
3118
+ this.latestWindowStage = stage;
2955
3119
  const changedWindows = new Set<string>();
2956
3120
  for (const [runtimeId, record] of stage.windows) {
2957
3121
  const previous = this.windowsByRuntime.get(runtimeId);
@@ -3011,6 +3175,7 @@ export class SessionChannel {
3011
3175
 
3012
3176
  private windowStagesEqual(left: WindowSyncStage, right: WindowSyncStage): boolean {
3013
3177
  if (
3178
+ JSON.stringify(left.links) !== JSON.stringify(right.links) ||
3014
3179
  left.currentWindow !== right.currentWindow ||
3015
3180
  left.windows.size !== right.windows.size ||
3016
3181
  left.layouts.size !== right.layouts.size
@@ -3054,9 +3219,12 @@ export class SessionChannel {
3054
3219
 
3055
3220
  private async stageWindows(target = this.opts.session): Promise<WindowSyncStage> {
3056
3221
  const lines = await this.io.request(
3057
- `list-windows -t "${target}" -F "#{window_id}\t#{qa:@tmux_ide_window_id}\t#{qa:window_name}\t#{window_active}\t#{window_visible_layout}\t#{?window_zoomed_flag,1,0}\t#{pane-border-status}\t#{window_layout}\t#{mode-keys}"`,
3222
+ `list-windows -t "${target}" -F "#{window_id}\t#{qa:@tmux_ide_window_id}\t#{qa:window_name}\t#{window_active}\t#{window_visible_layout}\t#{?window_zoomed_flag,1,0}\t#{pane-border-status}\t#{window_layout}\t#{mode-keys}\t#{window_index}"`,
3058
3223
  );
3224
+ if (lines.length > WINDOW_LINK_MAX_LINKS)
3225
+ throw new Error("Window link observation exceeds bound");
3059
3226
  interface Row {
3227
+ index: number;
3060
3228
  runtimeId: string;
3061
3229
  stamp: string | null;
3062
3230
  name: string | null;
@@ -3068,12 +3236,12 @@ export class SessionChannel {
3068
3236
  modeKeys?: "emacs" | "vi";
3069
3237
  }
3070
3238
  const rows: Row[] = [];
3071
- const seenRuntimeIds = new Set<string>();
3239
+ const seenIndexes = new Set<number>();
3072
3240
  for (const raw of lines) {
3073
3241
  // Replies are latin1 byte strings; recover UTF-8 window names first.
3074
3242
  const line = Buffer.from(raw, "latin1").toString("utf8");
3075
3243
  const parts = line.split("\t");
3076
- if (parts.length !== 7 && parts.length !== 8 && parts.length !== 9) {
3244
+ if (parts.length !== 10) {
3077
3245
  throw new Error(`window layout truth for ${this.opts.session} is malformed`);
3078
3246
  }
3079
3247
  const [
@@ -3086,10 +3254,14 @@ export class SessionChannel {
3086
3254
  borderStatus = "off",
3087
3255
  full = visible,
3088
3256
  modeKeys,
3257
+ indexRaw = "",
3089
3258
  ] = parts;
3259
+ const index = Number(indexRaw);
3090
3260
  if (
3091
3261
  !/^@[0-9]+$/u.test(runtimeId) ||
3092
- seenRuntimeIds.has(runtimeId) ||
3262
+ !/^(?:0|[1-9][0-9]*)$/u.test(indexRaw) ||
3263
+ !Number.isSafeInteger(index) ||
3264
+ seenIndexes.has(index) ||
3093
3265
  (active !== "0" && active !== "1") ||
3094
3266
  (zoomed !== "0" && zoomed !== "1") ||
3095
3267
  (borderStatus !== "top" && borderStatus !== "bottom" && borderStatus !== "off") ||
@@ -3097,10 +3269,11 @@ export class SessionChannel {
3097
3269
  ) {
3098
3270
  throw new Error(`window layout truth for ${this.opts.session} is malformed`);
3099
3271
  }
3100
- seenRuntimeIds.add(runtimeId);
3272
+ seenIndexes.add(index);
3101
3273
  const stamp = decodeTmuxArgument(stampRaw);
3102
3274
  const name = decodeTmuxArgument(nameRaw);
3103
3275
  rows.push({
3276
+ index,
3104
3277
  runtimeId,
3105
3278
  stamp: stamp.length > 0 ? stamp : null,
3106
3279
  name: name.length > 0 ? name : null,
@@ -3121,11 +3294,27 @@ export class SessionChannel {
3121
3294
  `window layout truth for ${this.opts.session} has inconsistent active window`,
3122
3295
  );
3123
3296
  }
3297
+ const backingRows = new Map<string, Row>();
3298
+ for (const row of rows) {
3299
+ const previous = backingRows.get(row.runtimeId);
3300
+ if (
3301
+ previous &&
3302
+ (previous.stamp !== row.stamp ||
3303
+ previous.name !== row.name ||
3304
+ previous.visible !== row.visible ||
3305
+ previous.full !== row.full ||
3306
+ previous.zoomed !== row.zoomed ||
3307
+ previous.paneBorderStatus !== row.paneBorderStatus ||
3308
+ previous.modeKeys !== row.modeKeys)
3309
+ )
3310
+ throw new Error("Inconsistent linked backing observation");
3311
+ backingRows.set(row.runtimeId, row);
3312
+ }
3124
3313
  const nextLayoutByWindow = new Map<
3125
3314
  string,
3126
3315
  ParsedLayout & { zoomed: boolean; unzoomed?: ParsedLayout }
3127
3316
  >();
3128
- for (const row of rows) {
3317
+ for (const row of backingRows.values()) {
3129
3318
  const parsed = parseLayout(row.visible);
3130
3319
  if (!parsed) {
3131
3320
  throw new Error(`window layout truth for ${this.opts.session} is malformed`);
@@ -3137,7 +3326,7 @@ export class SessionChannel {
3137
3326
  // Valid unique stamps are identity; missing/invalid/duplicated stamps are
3138
3327
  // ALL regenerated and stamped back (the pane policy, applied to windows).
3139
3328
  const stampCounts = new Map<string, number>();
3140
- for (const row of rows) {
3329
+ for (const row of backingRows.values()) {
3141
3330
  if (row.stamp && WorkspaceIdSchemaZ.safeParse(row.stamp).success) {
3142
3331
  stampCounts.set(row.stamp, (stampCounts.get(row.stamp) ?? 0) + 1);
3143
3332
  }
@@ -3146,9 +3335,8 @@ export class SessionChannel {
3146
3335
  const generateWindowId = this.opts.generateWindowId ?? defaultMirrorWindowId;
3147
3336
  let repairedIdentity = false;
3148
3337
  const next = new Map<string, WindowRecord>();
3149
- let nextCurrentWindow = "";
3150
- for (const row of rows) {
3151
- if (row.active) nextCurrentWindow = row.runtimeId;
3338
+ const nextCurrentWindow = activeRows[0]!.runtimeId;
3339
+ for (const row of backingRows.values()) {
3152
3340
  let semanticId: string | null = null;
3153
3341
  if (row.stamp && stampCounts.get(row.stamp) === 1) {
3154
3342
  semanticId = row.stamp;
@@ -3194,6 +3382,12 @@ export class SessionChannel {
3194
3382
  layouts: nextLayoutByWindow,
3195
3383
  currentWindow: nextCurrentWindow,
3196
3384
  repairedIdentity,
3385
+ links: rows.map((row) => ({
3386
+ index: row.index,
3387
+ runtimeWindowId: row.runtimeId,
3388
+ semanticWindowId: next.get(row.runtimeId)!.semanticId ?? "",
3389
+ active: row.active,
3390
+ })),
3197
3391
  };
3198
3392
  }
3199
3393
 
@@ -3410,6 +3604,7 @@ export class SessionChannel {
3410
3604
 
3411
3605
  private onChannelExit(): void {
3412
3606
  if (this.disposed) return;
3607
+ this.windowLinkAuthority?.dispose();
3413
3608
  this.nativeGrid.dispose();
3414
3609
  this.settleFirstJoin();
3415
3610
  for (const runtime of [...this.recoveries.keys()]) this.cancelRecovery(runtime);