tmux-ide 2.9.2 → 2.9.4

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 (267) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +121 -140
  3. package/bin/cli.js +2457 -727
  4. package/package.json +23 -8
  5. package/packages/contracts/src/desktop-host.ts +3 -0
  6. package/packages/contracts/src/saved-machines.ts +19 -0
  7. package/packages/contracts/src/terminal-replica.ts +1 -1
  8. package/packages/core/package.json +3 -2
  9. package/packages/core/src/terminal-delivery.ts +51 -3
  10. package/packages/core/src/terminal-fnv64-wasm.ts +4 -3
  11. package/packages/core/src/terminal-replica-hash-cache.ts +129 -31
  12. package/packages/core/src/terminal-replica-owned-row.ts +27 -0
  13. package/packages/core/src/terminal-replica.ts +13 -28
  14. package/packages/daemon/dist/command-center/daemon-fleet-facts-observer.js +96 -10
  15. package/packages/daemon/dist/command-center/resources/saved-machine-route.js +40 -1
  16. package/packages/daemon/dist/command-center/ws-events.js +5 -24
  17. package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +8 -0
  18. package/packages/daemon/dist/lib/canonical-daemon.js +28 -2
  19. package/packages/daemon/dist/lib/daemon-embed.js +1 -1
  20. package/packages/daemon/dist/lib/daemon-service-manager.js +122 -0
  21. package/packages/daemon/dist/lib/daemon-service-plan.js +127 -0
  22. package/packages/daemon/dist/lib/daemon-service.js +259 -0
  23. package/packages/daemon/dist/lib/install-origin.js +2 -0
  24. package/packages/daemon/dist/lib/local-fleet-request.js +9 -3
  25. package/packages/daemon/dist/lib/log.js +6 -10
  26. package/packages/daemon/dist/lib/managed-installer-update.js +67 -0
  27. package/packages/daemon/dist/lib/{tui-download-lock.js → private-operation-lock.js} +11 -11
  28. package/packages/daemon/dist/lib/remote-daemon-info.js +3 -2
  29. package/packages/daemon/dist/lib/remote-daemon-lifecycle.js +4 -4
  30. package/packages/daemon/dist/lib/remote-tmux-command.js +10 -0
  31. package/packages/daemon/dist/lib/runtime-namespace.js +28 -16
  32. package/packages/daemon/dist/lib/saved-machines.js +40 -2
  33. package/packages/daemon/dist/lib/ssh-daemon-transport.js +24 -9
  34. package/packages/daemon/dist/lib/tmux-server-owner.js +1 -1
  35. package/packages/daemon/dist/lib/tmux-server-registration.js +21 -3
  36. package/packages/daemon/dist/lib/tui-binary.js +2 -2
  37. package/packages/daemon/dist/lib/update.js +18 -0
  38. package/packages/daemon/dist/lib/workspace-multiplexer-verbs.js +24 -1
  39. package/packages/daemon/dist/machines.js +40 -4
  40. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  41. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  42. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  43. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  44. package/packages/daemon/dist/terminal/attachments/direct-websocket.js +4 -1
  45. package/packages/daemon/dist/terminal/attachments/native-runtime.js +22 -13
  46. package/packages/daemon/dist/terminal/mirror/mirror-service.js +101 -3
  47. package/packages/daemon/dist/terminal/mirror/registered-window-guard.js +62 -0
  48. package/packages/daemon/dist/terminal/mirror/session-channel.js +75 -4
  49. package/packages/daemon/dist/terminal/mirror/shared-window-index.js +45 -0
  50. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +11 -1
  51. package/packages/daemon/dist/terminal/session-runtime/multiplexer-backend.js +1 -0
  52. package/packages/daemon/dist/terminal/session-runtime/registry.js +56 -7
  53. package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-executor.js +3 -1
  54. package/packages/daemon/dist/tui/integrations/claude-agent-teams.js +264 -0
  55. package/packages/daemon/dist/tui/mirror/dialog-model.js +2 -35
  56. package/packages/daemon/dist/tui/mirror/runtime/application-daemon-authority-owner.js +8 -0
  57. package/packages/daemon/dist/tui/mirror/runtime/application-home-fleet.js +20 -2
  58. package/packages/daemon/dist/tui/mirror/runtime/application-machine-agents.js +7 -1
  59. package/packages/daemon/dist/tui/mirror/runtime/application-machine-authority.js +59 -9
  60. package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +27 -19
  61. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +23 -18
  62. package/packages/daemon/dist/tui/mirror/runtime/application-machine-startup.js +31 -2
  63. package/packages/daemon/dist/tui/mirror/runtime/application-palette-search-owner.js +7 -3
  64. package/packages/daemon/dist/tui/mirror/runtime/application-performance-log.js +3 -0
  65. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +146 -2
  66. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +201 -50
  67. package/packages/daemon/dist/tui/mirror/runtime/bounded-performance-record-writer.js +50 -10
  68. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +6 -5
  69. package/packages/daemon/dist/tui/mirror/runtime/terminal-pane-input-router.js +3 -0
  70. package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +8 -2
  71. package/packages/daemon/dist/tui/mirror/terminal-text.js +35 -0
  72. package/packages/daemon/dist/tui/mirror/workspace/connection-feedback.js +5 -3
  73. package/packages/daemon/src/command-center/daemon-fleet-facts-observer.ts +100 -10
  74. package/packages/daemon/src/command-center/resources/saved-machine-route.ts +48 -1
  75. package/packages/daemon/src/command-center/ws-events.ts +5 -25
  76. package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +12 -0
  77. package/packages/daemon/src/lib/canonical-daemon.ts +44 -2
  78. package/packages/daemon/src/lib/daemon-embed.ts +1 -0
  79. package/packages/daemon/src/lib/daemon-service-manager.ts +152 -0
  80. package/packages/daemon/src/lib/daemon-service-plan.ts +153 -0
  81. package/packages/daemon/src/lib/daemon-service.ts +301 -0
  82. package/packages/daemon/src/lib/install-origin.ts +5 -0
  83. package/packages/daemon/src/lib/local-fleet-request.ts +19 -3
  84. package/packages/daemon/src/lib/log.ts +13 -9
  85. package/packages/daemon/src/lib/managed-installer-update.ts +79 -0
  86. package/packages/daemon/src/lib/{tui-download-lock.ts → private-operation-lock.ts} +15 -11
  87. package/packages/daemon/src/lib/remote-daemon-info.ts +3 -3
  88. package/packages/daemon/src/lib/remote-daemon-lifecycle.ts +4 -4
  89. package/packages/daemon/src/lib/remote-tmux-command.ts +12 -0
  90. package/packages/daemon/src/lib/runtime-namespace.ts +27 -18
  91. package/packages/daemon/src/lib/saved-machines.ts +40 -1
  92. package/packages/daemon/src/lib/ssh-daemon-transport.ts +25 -7
  93. package/packages/daemon/src/lib/tmux-server-owner.ts +1 -0
  94. package/packages/daemon/src/lib/tmux-server-registration.ts +21 -3
  95. package/packages/daemon/src/lib/tui-binary.ts +2 -2
  96. package/packages/daemon/src/lib/update.ts +24 -0
  97. package/packages/daemon/src/lib/workspace-multiplexer-verbs.ts +22 -0
  98. package/packages/daemon/src/machines.ts +53 -5
  99. package/packages/daemon/src/terminal/attachments/direct-websocket.ts +8 -2
  100. package/packages/daemon/src/terminal/attachments/native-runtime.ts +28 -13
  101. package/packages/daemon/src/terminal/mirror/mirror-service.ts +105 -3
  102. package/packages/daemon/src/terminal/mirror/registered-window-guard.ts +66 -0
  103. package/packages/daemon/src/terminal/mirror/session-channel.ts +92 -4
  104. package/packages/daemon/src/terminal/mirror/shared-window-index.ts +42 -0
  105. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +12 -1
  106. package/packages/daemon/src/terminal/session-runtime/multiplexer-backend.ts +2 -0
  107. package/packages/daemon/src/terminal/session-runtime/registry.ts +70 -8
  108. package/packages/daemon/src/terminal/session-runtime/semantic-mutation-executor.ts +3 -1
  109. package/packages/daemon/src/tui/integrations/claude-agent-teams.ts +334 -0
  110. package/packages/daemon/src/tui/mirror/dialog-model.ts +2 -32
  111. package/packages/daemon/src/tui/mirror/runtime/application-daemon-authority-owner.ts +8 -0
  112. package/packages/daemon/src/tui/mirror/runtime/application-home-fleet.ts +19 -2
  113. package/packages/daemon/src/tui/mirror/runtime/application-machine-agents.ts +6 -1
  114. package/packages/daemon/src/tui/mirror/runtime/application-machine-authority.ts +60 -11
  115. package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +39 -33
  116. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +31 -23
  117. package/packages/daemon/src/tui/mirror/runtime/application-machine-startup.ts +38 -2
  118. package/packages/daemon/src/tui/mirror/runtime/application-palette-search-owner.ts +7 -4
  119. package/packages/daemon/src/tui/mirror/runtime/application-performance-log.ts +3 -0
  120. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +1 -1
  121. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +47 -46
  122. package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +165 -2
  123. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +207 -53
  124. package/packages/daemon/src/tui/mirror/runtime/bounded-performance-record-writer.ts +49 -9
  125. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +6 -5
  126. package/packages/daemon/src/tui/mirror/runtime/terminal-pane-input-router.ts +4 -0
  127. package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +8 -3
  128. package/packages/daemon/src/tui/mirror/terminal-text.ts +32 -0
  129. package/packages/daemon/src/tui/mirror/workspace/connection-feedback.ts +6 -3
  130. package/packages/daemon-client/src/fleet-connection-status.ts +10 -1
  131. package/packages/daemon-client/src/workspace-client.ts +4 -1
  132. package/skill/SKILL.md +30 -30
  133. package/packages/contracts/src/__tests__/actions-contract.test.ts +0 -119
  134. package/packages/contracts/src/__tests__/agent-graph-overlay.test.ts +0 -428
  135. package/packages/contracts/src/__tests__/app-window-mutation.test.ts +0 -51
  136. package/packages/contracts/src/__tests__/app-window-state.test.ts +0 -34
  137. package/packages/contracts/src/__tests__/application-shell.test.ts +0 -645
  138. package/packages/contracts/src/__tests__/automation-operations.test.ts +0 -78
  139. package/packages/contracts/src/__tests__/causal-cell.test.ts +0 -47
  140. package/packages/contracts/src/__tests__/client-view-state.test.ts +0 -115
  141. package/packages/contracts/src/__tests__/cohesion-fixture.test.ts +0 -106
  142. package/packages/contracts/src/__tests__/commands.test.ts +0 -86
  143. package/packages/contracts/src/__tests__/control.test.ts +0 -154
  144. package/packages/contracts/src/__tests__/daemon-events.test.ts +0 -405
  145. package/packages/contracts/src/__tests__/daemon-resources.test.ts +0 -143
  146. package/packages/contracts/src/__tests__/daemon-wire.test.ts +0 -149
  147. package/packages/contracts/src/__tests__/desktop-host.test.ts +0 -269
  148. package/packages/contracts/src/__tests__/desktop-missions.test.ts +0 -215
  149. package/packages/contracts/src/__tests__/experience-conformance.test.ts +0 -153
  150. package/packages/contracts/src/__tests__/experience-kernel-boundary.test.ts +0 -113
  151. package/packages/contracts/src/__tests__/experience-shell.test.ts +0 -65
  152. package/packages/contracts/src/__tests__/fixtures/daemon-wire-v2.json +0 -35
  153. package/packages/contracts/src/__tests__/fixtures/daemon-wire-v3.json +0 -35
  154. package/packages/contracts/src/__tests__/fixtures/daemon-wire-v4.json +0 -35
  155. package/packages/contracts/src/__tests__/fixtures/scoped-receipt.ts +0 -62
  156. package/packages/contracts/src/__tests__/fleet-agent-graph.test.ts +0 -231
  157. package/packages/contracts/src/__tests__/fleet-catalog.test.ts +0 -270
  158. package/packages/contracts/src/__tests__/fleet-lifecycle.test.ts +0 -62
  159. package/packages/contracts/src/__tests__/focus-overlay.test.ts +0 -110
  160. package/packages/contracts/src/__tests__/ide-config.test.ts +0 -64
  161. package/packages/contracts/src/__tests__/interaction-evidence.test.ts +0 -288
  162. package/packages/contracts/src/__tests__/interaction-receipt-v2.test.ts +0 -72
  163. package/packages/contracts/src/__tests__/issue-error.test.ts +0 -78
  164. package/packages/contracts/src/__tests__/mission-projections.test.ts +0 -186
  165. package/packages/contracts/src/__tests__/missions.test.ts +0 -272
  166. package/packages/contracts/src/__tests__/multiplexer-verbs.test.ts +0 -242
  167. package/packages/contracts/src/__tests__/pane-appearance.test.ts +0 -146
  168. package/packages/contracts/src/__tests__/pane-read-result.test.ts +0 -55
  169. package/packages/contracts/src/__tests__/pane-stream.test.ts +0 -536
  170. package/packages/contracts/src/__tests__/pane-widget-marker.test.ts +0 -327
  171. package/packages/contracts/src/__tests__/performance-metrics.test.ts +0 -73
  172. package/packages/contracts/src/__tests__/performance-qualification.test.ts +0 -137
  173. package/packages/contracts/src/__tests__/rich-card-widget.test.ts +0 -29
  174. package/packages/contracts/src/__tests__/session-runtime.test.ts +0 -512
  175. package/packages/contracts/src/__tests__/startup-readiness.test.ts +0 -409
  176. package/packages/contracts/src/__tests__/terminal-attachment-stream.test.ts +0 -72
  177. package/packages/contracts/src/__tests__/terminal-attachments.test.ts +0 -329
  178. package/packages/contracts/src/__tests__/terminal-delivery.test.ts +0 -88
  179. package/packages/contracts/src/__tests__/terminal-runtime-inventory.test.ts +0 -68
  180. package/packages/contracts/src/__tests__/terminals.test.ts +0 -87
  181. package/packages/contracts/src/__tests__/tmux-server-scope.test.ts +0 -166
  182. package/packages/contracts/src/__tests__/transport-contract-loading.test.ts +0 -91
  183. package/packages/contracts/src/__tests__/visual-tokens.test.ts +0 -224
  184. package/packages/contracts/src/__tests__/window-links.test.ts +0 -243
  185. package/packages/contracts/src/__tests__/workspace-changes-resource.test.ts +0 -317
  186. package/packages/contracts/src/__tests__/workspace-changes-view.test.ts +0 -122
  187. package/packages/contracts/src/__tests__/workspace-config.test.ts +0 -294
  188. package/packages/contracts/src/__tests__/workspace-files-resource.test.ts +0 -261
  189. package/packages/contracts/src/__tests__/workspace-files-tree.test.ts +0 -147
  190. package/packages/contracts/src/__tests__/workspace-pane-creation.test.ts +0 -249
  191. package/packages/contracts/src/__tests__/workspace-promotion.test.ts +0 -94
  192. package/packages/contracts/src/__tests__/workspace-resource-identity.test.ts +0 -90
  193. package/packages/contracts/src/daemon-resource-request.test.ts +0 -192
  194. package/packages/contracts/src/native-operation-identity.test.ts +0 -23
  195. package/packages/contracts/src/workspace-admission.test.ts +0 -21
  196. package/packages/contracts/src/workspace-catalog-resource.test.ts +0 -146
  197. package/packages/core/src/agent-provisioning.test.ts +0 -42
  198. package/packages/core/src/application-shell-session.test.ts +0 -105
  199. package/packages/core/src/interaction-evidence.test.ts +0 -134
  200. package/packages/core/src/interaction-flow.test.ts +0 -66
  201. package/packages/core/src/interaction-receipts.test.ts +0 -671
  202. package/packages/core/src/navigator.test.ts +0 -34
  203. package/packages/core/src/optimistic-projection-conformance.test.ts +0 -75
  204. package/packages/core/src/optimistic-projection.test.ts +0 -118
  205. package/packages/core/src/performance-metrics.test.ts +0 -121
  206. package/packages/core/src/performance-qualification.test.ts +0 -283
  207. package/packages/core/src/saved-machines.test.ts +0 -80
  208. package/packages/core/src/terminal-compact-cold.test.ts +0 -87
  209. package/packages/core/src/terminal-conformance.test.ts +0 -48
  210. package/packages/core/src/terminal-delivery-cache-lifetime.test.ts +0 -167
  211. package/packages/core/src/terminal-delivery-encode-cooperative.test.ts +0 -140
  212. package/packages/core/src/terminal-delivery-representation-hash.test.ts +0 -77
  213. package/packages/core/src/terminal-delivery.test.ts +0 -1471
  214. package/packages/core/src/terminal-fnv64-wasm.test.ts +0 -61
  215. package/packages/core/src/terminal-replica-cold.test.ts +0 -31
  216. package/packages/core/src/terminal-replica-cooperative.test.ts +0 -170
  217. package/packages/core/src/terminal-replica-hash-cache.test.ts +0 -283
  218. package/packages/core/src/terminal-replica.test.ts +0 -347
  219. package/packages/core/src/workspace-selection.test.ts +0 -63
  220. package/packages/daemon/dist/__tests__/support.js +0 -33
  221. package/packages/daemon/dist/lib/__tests__/fixtures/workspace-state-writer.js +0 -58
  222. package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +0 -400
  223. package/packages/daemon/dist/terminal/__tests__/MockPtyAdapter.js +0 -182
  224. package/packages/daemon/dist/terminal/mirror/__tests__/scripted-channel.js +0 -98
  225. package/packages/daemon/dist/terminal/mirror/__tests__/simulated-channel.js +0 -156
  226. package/packages/daemon/src/tui/mirror/__snapshots__/activity-surface-renderer.test.tsx.snap +0 -42
  227. package/packages/daemon/src/tui/mirror/__snapshots__/changes-terminal-surface-renderer.test.tsx.snap +0 -267
  228. package/packages/daemon/src/tui/mirror/__snapshots__/home-files-surface-renderer.test.tsx.snap +0 -267
  229. package/packages/daemon/src/tui/mirror/__snapshots__/missions-surface-renderer.test.tsx.snap +0 -134
  230. package/packages/daemon/src/tui/mirror/__snapshots__/recipes-gallery-renderer.test.tsx.snap +0 -141
  231. package/packages/daemon/src/tui/mirror/__snapshots__/shell-chrome-renderer.test.tsx.snap +0 -134
  232. package/packages/daemon/src/tui/mirror/runtime/__snapshots__/application-shell-view-renderer.test.tsx.snap +0 -87
  233. package/packages/daemon/src/tui/mirror/workspace/__snapshots__/agent-terminal-canvas-renderer.test.tsx.snap +0 -348
  234. package/packages/daemon/src/tui/mirror/workspace/__snapshots__/application-shell-renderer.test.tsx.snap +0 -134
  235. package/packages/daemon/src/tui/mirror/workspace/__snapshots__/command-palette-surface-renderer.test.tsx.snap +0 -255
  236. package/packages/daemon/src/tui/mirror/workspace/__snapshots__/pane-frame-renderer.test.tsx.snap +0 -525
  237. package/packages/daemon/src/tui/mirror/workspace/__snapshots__/terminal-pane-header-polish-renderer.test.tsx.snap +0 -31
  238. package/packages/daemon/src/tui/mirror/workspace/__snapshots__/workbench-shell-renderer.test.tsx.snap +0 -134
  239. package/packages/daemon-client/src/application-shell-session.test.ts +0 -196
  240. package/packages/daemon-client/src/automation-client.test.ts +0 -214
  241. package/packages/daemon-client/src/bootstrap-coordinator.test.ts +0 -145
  242. package/packages/daemon-client/src/connection-supervisor.test.ts +0 -109
  243. package/packages/daemon-client/src/direct-application-shell-transport.test.ts +0 -941
  244. package/packages/daemon-client/src/first-latest-coordinator.test.ts +0 -175
  245. package/packages/daemon-client/src/fleet-dial-scheduler.test.ts +0 -114
  246. package/packages/daemon-client/src/fleet-environments.test.ts +0 -40
  247. package/packages/daemon-client/src/generation-bound-store-timers.test.ts +0 -74
  248. package/packages/daemon-client/src/owner-action-client.test.ts +0 -245
  249. package/packages/daemon-client/src/pane-stream-client.test.ts +0 -2841
  250. package/packages/daemon-client/src/pane-stream-clock-calibration.test.ts +0 -207
  251. package/packages/daemon-client/src/pane-stream-loopback-benchmark.test.ts +0 -51
  252. package/packages/daemon-client/src/push-resource-session.test.ts +0 -509
  253. package/packages/daemon-client/src/resource-replica.test.ts +0 -186
  254. package/packages/daemon-client/src/runtime-resource-ledger.test.ts +0 -18
  255. package/packages/daemon-client/src/scoped-tmux-server-transport.test.ts +0 -114
  256. package/packages/daemon-client/src/terminal-fast-lane.test.ts +0 -835
  257. package/packages/daemon-client/src/tmux-server-client.test.ts +0 -279
  258. package/packages/daemon-client/src/tmux-server-interaction-events.test.ts +0 -230
  259. package/packages/daemon-client/src/workspace-catalog-v2.test.ts +0 -109
  260. package/packages/daemon-client/src/workspace-client-boundaries.test.ts +0 -59
  261. package/packages/daemon-client/src/workspace-client-operations.test.ts +0 -96
  262. package/packages/daemon-client/src/workspace-client.test.ts +0 -2912
  263. package/packages/daemon-client/src/workspace-event-supervisor.test.ts +0 -1086
  264. package/packages/daemon-client/src/workspace-pane-client.test.ts +0 -80
  265. package/packages/presentation/src/presentation-contract.test.ts +0 -63
  266. package/packages/tmux-bridge/src/runner.test.ts +0 -614
  267. package/packages/tmux-bridge/src/targeting.test.ts +0 -107
@@ -1,3 +1,5 @@
1
+ import { SharedWindowIndex } from "./shared-window-index.ts";
2
+ import { RegisteredWindowGuard, type RegisteredWindowReader } from "./registered-window-guard.ts";
1
3
  import type { OwnedViewerAdapter } from "./owned-viewer-adapter.ts";
2
4
  import { accessSync, constants, realpathSync, statSync } from "node:fs";
3
5
  import { isAbsolute } from "node:path";
@@ -47,6 +49,7 @@ import {
47
49
  } from "./control-mode-ownership.ts";
48
50
 
49
51
  export interface MirrorServiceOptions {
52
+ onSharedWindowConflict?: (session: string, conflicted: boolean) => void;
50
53
  createOwnedViewerAdapter?: (session: string) => OwnedViewerAdapter | undefined;
51
54
  nativeServerIdentity?: import("../../lib/tmux-server-generation-runner.ts").NativeTmuxServerIdentity;
52
55
  resolveSocketPath?: () => string;
@@ -148,6 +151,47 @@ interface ChannelEntry {
148
151
  export class MirrorService {
149
152
  private readonly opts: MirrorServiceOptions;
150
153
  private readonly channels = new Map<string, ChannelEntry>();
154
+ private readonly sharedWindows = new SharedWindowIndex<SessionChannel>();
155
+ private readonly channelSessions = new Map<SessionChannel, string>();
156
+ private registeredWindows: RegisteredWindowGuard | null = null;
157
+ private readonly registeredConflicts = new Set<string>();
158
+
159
+ setRegisteredWindowReader(reader: RegisteredWindowReader): {
160
+ invalidate(): void;
161
+ dispose(): void;
162
+ } {
163
+ this.registeredWindows?.dispose();
164
+ const guard = new RegisteredWindowGuard(reader);
165
+ this.registeredWindows = guard;
166
+ this.invalidateRegisteredWindows();
167
+ return {
168
+ invalidate: () => {
169
+ if (this.registeredWindows === guard) this.invalidateRegisteredWindows();
170
+ },
171
+ dispose: () => guard.dispose(),
172
+ };
173
+ }
174
+
175
+ private invalidateRegisteredWindows(): void {
176
+ this.registeredWindows?.invalidate();
177
+ }
178
+
179
+ async verifyRegisteredWindows(): Promise<void> {
180
+ const guard = this.registeredWindows;
181
+ if (!guard) return;
182
+ await guard.verify();
183
+ if (guard !== this.registeredWindows || this.disposed)
184
+ throw new Error("Window ownership changed during discovery");
185
+ for (const [channel, session] of this.channelSessions) {
186
+ if (guard.blocked(session)) {
187
+ channel.setGeometryParticipation(false);
188
+ if (!this.registeredConflicts.has(session)) {
189
+ this.registeredConflicts.add(session);
190
+ this.opts.onSharedWindowConflict?.(session, true);
191
+ }
192
+ } else this.registeredConflicts.delete(session);
193
+ }
194
+ }
151
195
  private readonly pendingDisposals = new Set<Promise<void>>();
152
196
  private readonly drainingChannels = new Map<string, Promise<void>>();
153
197
  private readonly sessionExitListeners = new Set<(session: string) => void>();
@@ -239,8 +283,14 @@ export class MirrorService {
239
283
  reseed: () => handle.reseed(),
240
284
  readHistorySize: () => handle.readHistorySize(),
241
285
  captureNativeBacking: () => handle.captureNativeBacking(),
242
- sendText: (text) => handle.sendText(text),
243
- sendKey: (key) => handle.sendKey(key),
286
+ sendText: (text) => {
287
+ this.assertWindowOwnership(request.session);
288
+ handle.sendText(text);
289
+ },
290
+ sendKey: (key) => {
291
+ this.assertWindowOwnership(request.session);
292
+ handle.sendKey(key);
293
+ },
244
294
  close: async () => {
245
295
  if (closed) return;
246
296
  closed = true;
@@ -303,6 +353,7 @@ export class MirrorService {
303
353
  return (args: readonly string[]) => {
304
354
  if (entry.retired || this.channels.get(session) !== entry)
305
355
  return Promise.reject(new Error("Resize mirror session retired"));
356
+ this.assertWindowOwnership(session);
306
357
  return run(args);
307
358
  };
308
359
  }
@@ -313,6 +364,7 @@ export class MirrorService {
313
364
  ) {
314
365
  const entry = this.channels.get(session);
315
366
  if (!entry || entry.retired) throw new Error(`Mirror session ${session} is unavailable`);
367
+ this.assertWindowOwnership(session);
316
368
  return entry.channel.executeWindowLinkAction(request);
317
369
  }
318
370
 
@@ -326,6 +378,7 @@ export class MirrorService {
326
378
  ): void {
327
379
  const entry = this.channels.get(session);
328
380
  if (!entry || entry.retired) throw new Error(`Mirror session ${session} is unavailable`);
381
+ this.assertWindowOwnership(session);
329
382
  entry.channel.sendText(semanticPaneId, text, performanceTraceId, isolated);
330
383
  }
331
384
 
@@ -337,24 +390,61 @@ export class MirrorService {
337
390
  ): void {
338
391
  const entry = this.channels.get(session);
339
392
  if (!entry || entry.retired) throw new Error(`Mirror session ${session} is unavailable`);
393
+ this.assertWindowOwnership(session);
340
394
  entry.channel.sendBytes(semanticPaneId, data, performanceTraceId);
341
395
  }
342
396
 
343
397
  sendKey(session: string, semanticPaneId: string, key: string, performanceTraceId?: string): void {
344
398
  const entry = this.channels.get(session);
345
399
  if (!entry || entry.retired) throw new Error(`Mirror session ${session} is unavailable`);
400
+ this.assertWindowOwnership(session);
346
401
  entry.channel.sendKey(semanticPaneId, key, performanceTraceId);
347
402
  }
348
403
 
404
+ private assertWindowOwnership(session: string): void {
405
+ const message = this.windowOwnershipMessage(session);
406
+ if (message) throw new Error(message);
407
+ }
408
+
409
+ windowOwnershipMessage(session: string): string | null {
410
+ if (this.registeredWindows?.pending())
411
+ return "Window ownership is being verified. Retry after session discovery completes.";
412
+ if (this.hasSharedWindowConflict(session))
413
+ return "Linked windows across registered sessions are unsupported. Unlink the shared window before controlling this session.";
414
+ return null;
415
+ }
416
+
417
+ hasSharedWindowConflict(session: string): boolean {
418
+ const entry = this.channels.get(session);
419
+ return (
420
+ (this.registeredWindows?.blocked(session) ?? false) ||
421
+ (!!entry && this.sharedWindows.conflicted(entry.channel))
422
+ );
423
+ }
424
+
425
+ private updateWindowMembership(channel: SessionChannel, windows: readonly string[]): void {
426
+ // A topology read may finish after retirement. It must not resurrect the
427
+ // old channel's membership or conflict with its replacement.
428
+ if (!this.channelSessions.has(channel)) return;
429
+ for (const [peer, conflict] of this.sharedWindows.update(channel, windows)) {
430
+ const session = this.channelSessions.get(peer);
431
+ if (session === undefined) continue;
432
+ if (conflict) peer.setGeometryParticipation(false);
433
+ this.opts.onSharedWindowConflict?.(session, conflict);
434
+ }
435
+ }
436
+
349
437
  fitViewport(session: string, cols: number, rows: number): void {
350
438
  const entry = this.channels.get(session);
351
439
  if (!entry || entry.retired) throw new Error(`Mirror session ${session} is unavailable`);
440
+ this.assertWindowOwnership(session);
352
441
  entry.channel.fitViewport(cols, rows);
353
442
  }
354
443
 
355
444
  fitWindowViewport(session: string, semanticWindowId: string, cols: number, rows: number): void {
356
445
  const entry = this.channels.get(session);
357
446
  if (!entry || entry.retired) throw new Error(`Mirror session ${session} is unavailable`);
447
+ this.assertWindowOwnership(session);
358
448
  entry.channel.fitWindowViewport(semanticWindowId, cols, rows);
359
449
  }
360
450
 
@@ -367,7 +457,7 @@ export class MirrorService {
367
457
  setGeometryParticipation(session: string, active: boolean): void {
368
458
  const entry = this.channels.get(session);
369
459
  if (!entry || entry.retired) return;
370
- entry.channel.setGeometryParticipation(active);
460
+ entry.channel.setGeometryParticipation(active && !this.hasSharedWindowConflict(session));
371
461
  }
372
462
 
373
463
  /**
@@ -412,12 +502,15 @@ export class MirrorService {
412
502
 
413
503
  async dispose(): Promise<void> {
414
504
  this.disposed = true;
505
+ this.registeredWindows?.dispose();
415
506
  this.sessionExitListeners.clear();
416
507
  const entries = [...this.channels.values()];
417
508
  this.channels.clear();
418
509
  await Promise.allSettled(
419
510
  entries.map(async (entry) => {
420
511
  entry.retired = true;
512
+ this.updateWindowMembership(entry.channel, []);
513
+ this.channelSessions.delete(entry.channel);
421
514
  try {
422
515
  await entry.channel.dispose();
423
516
  } finally {
@@ -468,6 +561,12 @@ export class MirrorService {
468
561
  );
469
562
  const channelOptions: SessionChannelOptions = {
470
563
  session,
564
+ onWindowMembership: (windows) => this.updateWindowMembership(channel, windows),
565
+ hasSharedWindowConflict: () => this.hasSharedWindowConflict(session),
566
+ beforeIdentityRepair: () => this.verifyRegisteredWindows(),
567
+ onWindowTopologyChanged: () => {
568
+ if (this.channelSessions.has(channel)) this.invalidateRegisteredWindows();
569
+ },
471
570
  ownedViewer,
472
571
  createIo: (handlers) =>
473
572
  this.opts.createIo?.(session, handlers) ??
@@ -547,6 +646,7 @@ export class MirrorService {
547
646
  releaseAuthority();
548
647
  throw cause;
549
648
  }
649
+ this.channelSessions.set(channel, session);
550
650
  entry = {
551
651
  channel,
552
652
  started: channel.start(),
@@ -583,6 +683,8 @@ export class MirrorService {
583
683
  private retire(session: string, entry: ChannelEntry): void {
584
684
  if (entry.retired) return;
585
685
  entry.retired = true;
686
+ this.updateWindowMembership(entry.channel, []);
687
+ this.channelSessions.delete(entry.channel);
586
688
  if (this.channels.get(session) === entry) this.channels.delete(session);
587
689
  const disposal = entry.channel
588
690
  .dispose()
@@ -0,0 +1,66 @@
1
+ export type RegisteredWindowReader = (
2
+ signal: AbortSignal,
3
+ ) => Promise<readonly { session: string; window: string }[]>;
4
+
5
+ /** A bounded registration/topology proof, shared by all retained channels. */
6
+ export class RegisteredWindowGuard {
7
+ #epoch = 0;
8
+ #current = false;
9
+ #disposed = false;
10
+ #conflicts = new Set<string>();
11
+ #read: { abort: AbortController; promise: Promise<void> } | null = null;
12
+
13
+ constructor(private readonly reader: RegisteredWindowReader) {}
14
+
15
+ blocked(session: string): boolean {
16
+ return !this.#current || this.#conflicts.has(session);
17
+ }
18
+
19
+ pending(): boolean {
20
+ return !this.#current;
21
+ }
22
+
23
+ invalidate(): void {
24
+ this.#epoch++;
25
+ this.#current = false;
26
+ this.#read?.abort.abort();
27
+ this.#read = null;
28
+ }
29
+
30
+ dispose(): void {
31
+ this.#disposed = true;
32
+ this.invalidate();
33
+ }
34
+
35
+ async verify(): Promise<void> {
36
+ if (this.#disposed) throw new Error("Registered window inventory is disposed");
37
+ if (this.#current) return;
38
+ if (this.#read) return this.#read.promise;
39
+ const epoch = this.#epoch;
40
+ const abort = new AbortController();
41
+ const promise = Promise.resolve()
42
+ .then(async () => {
43
+ abort.signal.throwIfAborted();
44
+ const rows = await this.reader(abort.signal);
45
+ abort.signal.throwIfAborted();
46
+ if (epoch !== this.#epoch) throw new Error("Window ownership changed during discovery");
47
+ const owners = new Map<string, Set<string>>();
48
+ for (const row of rows) {
49
+ const sessions = owners.get(row.window) ?? new Set<string>();
50
+ sessions.add(row.session);
51
+ owners.set(row.window, sessions);
52
+ }
53
+ this.#conflicts = new Set(
54
+ [...owners.values()]
55
+ .filter((sessions) => sessions.size > 1)
56
+ .flatMap((sessions) => [...sessions]),
57
+ );
58
+ this.#current = true;
59
+ })
60
+ .finally(() => {
61
+ if (this.#read?.abort === abort) this.#read = null;
62
+ });
63
+ this.#read = { abort, promise };
64
+ return promise;
65
+ }
66
+ }
@@ -123,7 +123,10 @@ const STRUCTURAL_NOTIFICATIONS = new Set([
123
123
  "window-add",
124
124
  "window-close",
125
125
  "window-renamed",
126
+ "unlinked-window-add",
126
127
  "unlinked-window-close",
128
+ "session-renamed",
129
+ "sessions-changed",
127
130
  ]);
128
131
  const NATIVE_CLIENT_NOTIFICATIONS = new Set([
129
132
  "client-attached",
@@ -239,6 +242,10 @@ export interface MirrorFlowRecoveryObservation {
239
242
  }
240
243
 
241
244
  export interface SessionChannelOptions {
245
+ onWindowMembership?: (windows: readonly string[]) => void;
246
+ hasSharedWindowConflict?: () => boolean;
247
+ beforeIdentityRepair?: () => Promise<void>;
248
+ onWindowTopologyChanged?: () => void;
242
249
  ownedViewer?: Pick<OwnedViewerAdapter, "bindIo" | "tryDispatch" | "dispose"> &
243
250
  Partial<Pick<OwnedViewerAdapter, "atomicSnapshotEpoch">>;
244
251
  executeWindowLinkGuard?: (args: string[]) => Promise<{ status: number | null; stdout: string }>;
@@ -441,6 +448,7 @@ function layoutIdentitiesEqual(left: WindowLayout, right: WindowLayout): boolean
441
448
  }
442
449
 
443
450
  interface WindowSyncStage {
451
+ readonly observedAuthorityOrdinal: number;
444
452
  readonly windows: Map<string, WindowRecord>;
445
453
  readonly layouts: Map<string, ParsedLayout & { zoomed: boolean; unzoomed?: ParsedLayout }>;
446
454
  readonly currentWindow: string;
@@ -502,6 +510,7 @@ export class SessionChannel {
502
510
  private nativeClientProbePending = false;
503
511
  // Native captures fence every geometry event; inventory fences identity only.
504
512
  private windowAuthorityOrdinal = 0;
513
+ private readonly layoutNotificationOrdinals = new Map<string, number>();
505
514
  private windowIdentityOrdinal = 0;
506
515
  private paneIncarnation = 0;
507
516
  private recoveryOrdinal = 0;
@@ -1186,9 +1195,31 @@ export class SessionChannel {
1186
1195
  }
1187
1196
  this.input.flush();
1188
1197
  this.clearWindowViewports();
1198
+ this.restoreWindowSizing(this.attachedIdentity?.runtimeSessionId ?? `=${this.opts.session}`);
1189
1199
  this.io.send(`refresh-client -C ${cols}x${rows}`);
1190
1200
  }
1191
1201
 
1202
+ private restoreWindowSizing(target: string): void {
1203
+ if (!this.geometryParticipating) return;
1204
+ // resize-window pins a window (including through inherited session options).
1205
+ // Evaluate inside the existing control connection: no shell, polling, or extra
1206
+ // request/response round trip. The shell also pre-fits hidden windows;
1207
+ // only the active window may have its manual policy repaired.
1208
+ const quoted = tmuxSingleQuote(target);
1209
+ // if-shell emits its own reply and the selected branch emits another.
1210
+ // Keep both branches at one command so either policy consumes exactly two
1211
+ // FIFO slots; a discard-only send would misroute the next capture replies.
1212
+ this.io.commandListInline(
1213
+ `if-shell -F -t ${quoted} '#{&&:#{window_active},#{==:#{window-size},manual}}' ` +
1214
+ tmuxSingleQuote(`set-option -w -t ${quoted} window-size latest`) +
1215
+ " " +
1216
+ tmuxSingleQuote(`display-message -p -t ${quoted} ''`),
1217
+ 2,
1218
+ 1,
1219
+ () => {},
1220
+ );
1221
+ }
1222
+
1192
1223
  /**
1193
1224
  * Experimental: window-specific overrides stay private to this control client.
1194
1225
  * Native tmux still falls back to the client size for unscoped neighbours;
@@ -1214,6 +1245,7 @@ export class SessionChannel {
1214
1245
  const previous = this.fittedWindows.get(window.runtimeId);
1215
1246
  if (previous?.cols === cols && previous.rows === rows) return;
1216
1247
  this.input.flush();
1248
+ this.restoreWindowSizing(window.runtimeId);
1217
1249
  this.io.send(`refresh-client -C ${window.runtimeId}:${cols}x${rows}`);
1218
1250
  this.fittedWindows.set(window.runtimeId, { cols, rows });
1219
1251
  }
@@ -1288,6 +1320,7 @@ export class SessionChannel {
1288
1320
  pane.subs.clear();
1289
1321
  }
1290
1322
  this.pendingLayoutOutput.clear();
1323
+ this.layoutNotificationOrdinals.clear();
1291
1324
  this.layoutSubscribers.clear();
1292
1325
  this.layoutAuthoritySubscribers.clear();
1293
1326
  await this.io.dispose();
@@ -2851,6 +2884,15 @@ export class SessionChannel {
2851
2884
  }
2852
2885
 
2853
2886
  private onNotify(name: string, rest: string): void {
2887
+ if (
2888
+ name === "window-add" ||
2889
+ name === "window-close" ||
2890
+ name === "unlinked-window-add" ||
2891
+ name === "unlinked-window-close" ||
2892
+ name === "session-renamed" ||
2893
+ name === "sessions-changed"
2894
+ )
2895
+ this.opts.onWindowTopologyChanged?.();
2854
2896
  if (name === "subscription-changed") {
2855
2897
  const policy =
2856
2898
  /^tmux-ide-scroll-on-clear\s+\$[0-9]+\s+@[0-9]+\s+[0-9]+\s+(%[0-9]+)\s+:\s+([01])\s*$/u.exec(
@@ -2976,6 +3018,7 @@ export class SessionChannel {
2976
3018
  this.scheduleSync(); // never guess from a failed parse
2977
3019
  return;
2978
3020
  }
3021
+ this.layoutNotificationOrdinals.set(change.windowId, this.windowAuthorityOrdinal);
2979
3022
  const pendingLayout = {
2980
3023
  ...parsed,
2981
3024
  zoomed: change.zoomed,
@@ -3294,8 +3337,10 @@ export class SessionChannel {
3294
3337
  previousCurrentWindow,
3295
3338
  syncOrdinal,
3296
3339
  );
3297
- for (const pane of this.panesByRuntime.values())
3340
+ for (const pane of this.panesByRuntime.values()) {
3341
+ if (pane.windowRuntimeId && this.pendingLayoutOutput.has(pane.windowRuntimeId)) continue;
3298
3342
  for (const sub of pane.subs) sub.resumeLayoutCapture?.(syncOrdinal);
3343
+ }
3299
3344
  this.discovery.discover(listed);
3300
3345
  }
3301
3346
 
@@ -3593,6 +3638,29 @@ export class SessionChannel {
3593
3638
  previousCurrentWindow = this.currentWindow,
3594
3639
  syncOrdinal?: number,
3595
3640
  ): void {
3641
+ // Control notifications in the same read chunk run before a promise-based
3642
+ // inventory read resumes. Preserve newer geometry (and its pending border
3643
+ // query) rather than letting that older inventory erase the notification.
3644
+ const newerLayouts = new Set(
3645
+ [...this.layoutNotificationOrdinals]
3646
+ .filter(([, ordinal]) => ordinal > stage.observedAuthorityOrdinal)
3647
+ .map(([runtimeId]) => runtimeId),
3648
+ );
3649
+ if (newerLayouts.size > 0) {
3650
+ stage = { ...stage, windows: new Map(stage.windows), layouts: new Map(stage.layouts) };
3651
+ for (const runtimeId of newerLayouts) {
3652
+ const currentLayout = this.layoutByWindow.get(runtimeId);
3653
+ const currentWindow = this.windowsByRuntime.get(runtimeId);
3654
+ const stagedWindow = stage.windows.get(runtimeId);
3655
+ if (currentLayout && currentWindow && stagedWindow) {
3656
+ stage.layouts.set(runtimeId, currentLayout);
3657
+ stage.windows.set(runtimeId, {
3658
+ ...stagedWindow,
3659
+ paneBorderStatus: currentWindow.paneBorderStatus,
3660
+ });
3661
+ }
3662
+ }
3663
+ }
3596
3664
  this.windowLinkAuthority?.reconcile(stage.links);
3597
3665
  this.latestWindowStage = stage;
3598
3666
  const changedWindows = new Set<string>();
@@ -3647,8 +3715,14 @@ export class SessionChannel {
3647
3715
  ),
3648
3716
  );
3649
3717
  for (const runtimeId of this.pendingLayoutOutput.keys())
3650
- if (!stage.windows.has(runtimeId)) this.pendingLayoutOutput.delete(runtimeId);
3651
- for (const runtimeId of layoutEmits) this.releasePendingLayout(runtimeId, syncOrdinal);
3718
+ if (!stage.windows.has(runtimeId) && !newerLayouts.has(runtimeId))
3719
+ this.pendingLayoutOutput.delete(runtimeId);
3720
+ for (const runtimeId of this.layoutNotificationOrdinals.keys())
3721
+ if (!stage.windows.has(runtimeId) && !newerLayouts.has(runtimeId))
3722
+ this.layoutNotificationOrdinals.delete(runtimeId);
3723
+ for (const runtimeId of layoutEmits)
3724
+ if (!newerLayouts.has(runtimeId) || !this.pendingLayoutOutput.has(runtimeId))
3725
+ this.releasePendingLayout(runtimeId, syncOrdinal);
3652
3726
  this.emitLayoutAuthority();
3653
3727
  }
3654
3728
 
@@ -3686,6 +3760,7 @@ export class SessionChannel {
3686
3760
  }
3687
3761
 
3688
3762
  private async stageWindows(target = this.opts.session): Promise<WindowSyncStage> {
3763
+ const observedAuthorityOrdinal = this.windowAuthorityOrdinal;
3689
3764
  const lines = await this.io.request(
3690
3765
  `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}"`,
3691
3766
  );
@@ -3791,6 +3866,10 @@ export class SessionChannel {
3791
3866
  if (!unzoomed) throw new Error(`full window layout for ${this.opts.session} is malformed`);
3792
3867
  nextLayoutByWindow.set(row.runtimeId, { ...parsed, zoomed: row.zoomed, unzoomed });
3793
3868
  }
3869
+ // Publish validated physical membership before any identity repair writes.
3870
+ // The receiving session may introduce duplicate stamps by linking a window.
3871
+ this.opts.onWindowMembership?.([...backingRows.keys()]);
3872
+ await this.opts.beforeIdentityRepair?.();
3794
3873
  // Valid unique stamps are identity; missing/invalid/duplicated stamps are
3795
3874
  // ALL regenerated and stamped back (the pane policy, applied to windows).
3796
3875
  const stampCounts = new Map<string, number>();
@@ -3808,7 +3887,7 @@ export class SessionChannel {
3808
3887
  let semanticId: string | null = null;
3809
3888
  if (row.stamp && stampCounts.get(row.stamp) === 1) {
3810
3889
  semanticId = row.stamp;
3811
- } else {
3890
+ } else if (!this.opts.hasSharedWindowConflict?.()) {
3812
3891
  repairedIdentity = true;
3813
3892
  let candidate: string | null = null;
3814
3893
  for (let attempt = 0; attempt < 32 && !candidate; attempt += 1) {
@@ -3846,6 +3925,7 @@ export class SessionChannel {
3846
3925
  });
3847
3926
  }
3848
3927
  return {
3928
+ observedAuthorityOrdinal,
3849
3929
  windows: next,
3850
3930
  layouts: nextLayoutByWindow,
3851
3931
  currentWindow: nextCurrentWindow,
@@ -3864,6 +3944,10 @@ export class SessionChannel {
3864
3944
  listed: ReadonlySet<string>,
3865
3945
  ): Promise<boolean> {
3866
3946
  if (this.disposed) return false;
3947
+ if (this.opts.hasSharedWindowConflict?.()) {
3948
+ this.settleFirstJoin();
3949
+ return false;
3950
+ }
3867
3951
  const snapshots: WorkspaceTmuxPaneSnapshot[] = descriptors
3868
3952
  .filter((descriptor) => listed.has(descriptor.runtimePaneId))
3869
3953
  .map((descriptor) => ({
@@ -4005,6 +4089,10 @@ export class SessionChannel {
4005
4089
  descriptors: readonly SessionPaneDescriptor[],
4006
4090
  layouts: ReadonlyMap<string, ParsedLayout & { zoomed: boolean; unzoomed?: ParsedLayout }>,
4007
4091
  ): Promise<boolean> {
4092
+ if (this.opts.hasSharedWindowConflict?.())
4093
+ throw new Error(
4094
+ "Linked windows across controlled sessions are unsupported. Unlink the shared window before controlling this session.",
4095
+ );
4008
4096
  const rectForRuntime = (runtimePaneId: string): WorkspacePaneRect => {
4009
4097
  for (const layout of layouts.values()) {
4010
4098
  const leaf = layout.leaves.find(({ id }) => id === runtimePaneId);
@@ -0,0 +1,42 @@
1
+ /** Event-fed physical window membership for one pinned tmux server. */
2
+ export class SharedWindowIndex<Owner> {
3
+ readonly #windows = new Map<string, Set<Owner>>();
4
+ readonly #owners = new Map<Owner, Set<string>>();
5
+ readonly #conflicts = new Set<Owner>();
6
+
7
+ conflicted(owner: Owner): boolean {
8
+ return this.#conflicts.has(owner);
9
+ }
10
+
11
+ update(owner: Owner, windows: readonly string[]): Map<Owner, boolean> {
12
+ const previous = this.#owners.get(owner) ?? new Set<string>();
13
+ const next = new Set(windows);
14
+ const affected = new Set<Owner>([owner]);
15
+ for (const window of new Set([...previous, ...next])) {
16
+ for (const peer of this.#windows.get(window) ?? []) affected.add(peer);
17
+ if (previous.has(window) && !next.has(window)) {
18
+ const peers = this.#windows.get(window)!;
19
+ peers.delete(owner);
20
+ if (!peers.size) this.#windows.delete(window);
21
+ }
22
+ if (next.has(window)) {
23
+ const peers = this.#windows.get(window) ?? new Set<Owner>();
24
+ peers.add(owner);
25
+ this.#windows.set(window, peers);
26
+ }
27
+ }
28
+ if (next.size) this.#owners.set(owner, next);
29
+ else this.#owners.delete(owner);
30
+ const changed = new Map<Owner, boolean>();
31
+ for (const peer of affected) {
32
+ const conflict = [...(this.#owners.get(peer) ?? [])].some(
33
+ (window) => (this.#windows.get(window)?.size ?? 0) > 1,
34
+ );
35
+ if (conflict === this.#conflicts.has(peer)) continue;
36
+ if (conflict) this.#conflicts.add(peer);
37
+ else this.#conflicts.delete(peer);
38
+ changed.set(peer, conflict);
39
+ }
40
+ return changed;
41
+ }
42
+ }
@@ -2219,7 +2219,18 @@ export class PaneStreamLiveConnection {
2219
2219
  !this.#sessionRuntimeBinding.authoritySnapshot
2220
2220
  )
2221
2221
  return this.#failProtocol("protocol-error");
2222
- const lease = this.#sessionRuntimeBinding.requestAuthority(frame.authority);
2222
+ let lease: SessionRuntimeAuthorityLease | null = null;
2223
+ try {
2224
+ lease = this.#sessionRuntimeBinding.requestAuthority(frame.authority);
2225
+ } catch (error) {
2226
+ // Topology changes can temporarily prevent ownership. Reject this
2227
+ // request without letting an expected conflict terminate the daemon.
2228
+ if (
2229
+ !(error instanceof SessionRuntimeControllerLeaseError) ||
2230
+ error.code !== "controller-conflict"
2231
+ )
2232
+ return this.#failProtocol("input-rejected");
2233
+ }
2223
2234
  if (lease) this.#requestedAuthorities.add(frame.authority);
2224
2235
  this.#sendFrame(null, {
2225
2236
  type: "authority-receipt",
@@ -26,6 +26,7 @@ export interface SessionRuntimeMultiplexerBackendOptions {
26
26
  | "bindExecutionSource"
27
27
  | "assertExecutionHandle"
28
28
  | "submitPaneCredentialIntent"
29
+ | "verifyWindowOwnership"
29
30
  >;
30
31
  readonly resolveSession: (workspaceName: string) => string | null;
31
32
  readonly resolvePaneSourceBinding?: (
@@ -192,6 +193,7 @@ export function createSessionRuntimeMultiplexerBackend(
192
193
  if (!ownerAuthorized) {
193
194
  throw new Error("Semantic mutation requires a live host, pane, or owner principal");
194
195
  }
196
+ await options.registry.verifyWindowOwnership();
195
197
  const owner = acquireOwner(session);
196
198
  try {
197
199
  let lease: ReturnType<SessionRuntimeConsumer["acquireController"]>;