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

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 (173) hide show
  1. package/bin/cli.js +52253 -49070
  2. package/package.json +1 -1
  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/index.ts +2 -0
  15. package/packages/contracts/src/interaction-receipts.ts +39 -0
  16. package/packages/contracts/src/issue-error.ts +1 -1
  17. package/packages/contracts/src/multiplexer-verbs.ts +25 -1
  18. package/packages/contracts/src/pane-stream.ts +51 -8
  19. package/packages/contracts/src/tmux-server-scope.ts +172 -0
  20. package/packages/contracts/src/window-links.ts +76 -0
  21. package/packages/contracts/src/workspace-multiplexer.ts +54 -0
  22. package/packages/core/src/interaction-receipts.ts +4 -0
  23. package/packages/daemon/dist/command-center/actions/command-definitions.js +6 -0
  24. package/packages/daemon/dist/command-center/actions/handlers/workspace-multiplexer.js +2 -0
  25. package/packages/daemon/dist/command-center/actions/registry.js +13 -1
  26. package/packages/daemon/dist/command-center/discovery.js +2 -9
  27. package/packages/daemon/dist/command-center/server.js +21 -1
  28. package/packages/daemon/dist/command-center/tmux-server-native-backing.js +37 -0
  29. package/packages/daemon/dist/command-center/tmux-servers.js +345 -0
  30. package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +64 -8
  31. package/packages/daemon/dist/lib/canonical-daemon.js +5 -2
  32. package/packages/daemon/dist/lib/daemon-embed.js +198 -30
  33. package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +147 -0
  34. package/packages/daemon/dist/lib/fleet-lifecycle-authority.js +16 -10
  35. package/packages/daemon/dist/lib/headless-daemon.js +10 -1
  36. package/packages/daemon/dist/lib/runtime-namespace.js +17 -3
  37. package/packages/daemon/dist/lib/tmux-client-execution.js +59 -0
  38. package/packages/daemon/dist/lib/tmux-named-socket-fence.js +4 -0
  39. package/packages/daemon/dist/lib/tmux-server-generation-runner.js +62 -0
  40. package/packages/daemon/dist/lib/tmux-server-owner.js +285 -0
  41. package/packages/daemon/dist/lib/tmux-server-owners.js +192 -0
  42. package/packages/daemon/dist/lib/tmux-server-proof.js +82 -0
  43. package/packages/daemon/dist/lib/tmux-server-registration.js +85 -0
  44. package/packages/daemon/dist/lib/tmux-server-session-create.js +43 -0
  45. package/packages/daemon/dist/lib/tmux-server-session-open.js +101 -0
  46. package/packages/daemon/dist/lib/tmux-servers-cli.js +134 -0
  47. package/packages/daemon/dist/lib/tmux-session-mutation-fence.js +55 -0
  48. package/packages/daemon/dist/lib/tmux-window-link-guard.js +88 -0
  49. package/packages/daemon/dist/lib/workspace-config-loader.js +3 -0
  50. package/packages/daemon/dist/lib/workspace-multiplexer-verbs.js +82 -1
  51. package/packages/daemon/dist/lib/workspace-pane-creation.js +13 -57
  52. package/packages/daemon/dist/lib/workspace-promotion.js +91 -12
  53. package/packages/daemon/dist/lib/workspace-registry.js +4 -0
  54. package/packages/daemon/dist/server/pane-stream-upgrade.js +20 -6
  55. package/packages/daemon/dist/terminal/attachments/native-runtime.js +61 -17
  56. package/packages/daemon/dist/terminal/mirror/__tests__/scripted-channel.js +9 -1
  57. package/packages/daemon/dist/terminal/mirror/__tests__/simulated-channel.js +11 -2
  58. package/packages/daemon/dist/terminal/mirror/control-channel.js +65 -6
  59. package/packages/daemon/dist/terminal/mirror/mirror-service.js +44 -0
  60. package/packages/daemon/dist/terminal/mirror/session-channel.js +210 -50
  61. package/packages/daemon/dist/terminal/mirror/window-link-authority.js +160 -0
  62. package/packages/daemon/dist/terminal/pane-stream/lease-manager.js +3 -3
  63. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +40 -6
  64. package/packages/daemon/dist/terminal/protocol/live-session-identity.js +9 -0
  65. package/packages/daemon/dist/terminal/protocol/native-backing-client.js +2 -1
  66. package/packages/daemon/dist/terminal/session-runtime/interaction-receipt-facts.js +9 -0
  67. package/packages/daemon/dist/terminal/session-runtime/registry.js +6 -0
  68. package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-executor.js +1 -1
  69. package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-resource-changes.js +1 -0
  70. package/packages/daemon/dist/tui/mirror/application-shell-daemon-connection.js +7 -2
  71. package/packages/daemon/dist/tui/mirror/application-shell-server-connection.js +114 -0
  72. package/packages/daemon/dist/tui/mirror/menu-model.js +1 -1
  73. package/packages/daemon/dist/tui/mirror/open-tui-verified-routing.js +34 -4
  74. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +29 -8
  75. package/packages/daemon/dist/tui/mirror/runtime/application-home-catalog-owner.js +11 -5
  76. package/packages/daemon/dist/tui/mirror/runtime/application-home-catalog.js +140 -75
  77. package/packages/daemon/dist/tui/mirror/runtime/application-machine-catalog.js +12 -2
  78. package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +190 -31
  79. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +91 -37
  80. package/packages/daemon/dist/tui/mirror/runtime/application-root-configuration.js +9 -3
  81. package/packages/daemon/dist/tui/mirror/runtime/application-route-connection.js +95 -2
  82. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +158 -2
  83. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace-policy.js +43 -0
  84. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +78 -25
  85. package/packages/daemon/dist/tui/mirror/runtime/fleet-lifecycle-client.js +42 -1
  86. package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +1 -1
  87. package/packages/daemon/dist/tui/mirror/runtime/select-terminal-pane.js +2 -1
  88. package/packages/daemon/dist/tui/mirror/runtime/terminal-pane-input-router.js +6 -3
  89. package/packages/daemon/dist/tui/mirror/workspace/application-command-description.js +11 -2
  90. package/packages/daemon/dist/tui/mirror/workspace/terminal-window-strip.jsx +2 -1
  91. package/packages/daemon/src/command-center/actions/command-definitions.ts +6 -0
  92. package/packages/daemon/src/command-center/actions/errors.ts +4 -0
  93. package/packages/daemon/src/command-center/actions/handlers/workspace-multiplexer.ts +2 -0
  94. package/packages/daemon/src/command-center/actions/registry.ts +14 -0
  95. package/packages/daemon/src/command-center/discovery.ts +2 -10
  96. package/packages/daemon/src/command-center/server.ts +29 -1
  97. package/packages/daemon/src/command-center/tmux-server-native-backing.ts +49 -0
  98. package/packages/daemon/src/command-center/tmux-servers.ts +468 -0
  99. package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +111 -4
  100. package/packages/daemon/src/lib/canonical-daemon.ts +15 -3
  101. package/packages/daemon/src/lib/daemon-embed.ts +241 -28
  102. package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +190 -0
  103. package/packages/daemon/src/lib/fleet-lifecycle-authority.ts +16 -8
  104. package/packages/daemon/src/lib/headless-daemon.ts +18 -1
  105. package/packages/daemon/src/lib/runtime-namespace.ts +24 -3
  106. package/packages/daemon/src/lib/tmux-client-execution.ts +56 -0
  107. package/packages/daemon/src/lib/tmux-named-socket-fence.ts +5 -0
  108. package/packages/daemon/src/lib/tmux-server-generation-runner.ts +81 -0
  109. package/packages/daemon/src/lib/tmux-server-owner.ts +336 -0
  110. package/packages/daemon/src/lib/tmux-server-owners.ts +228 -0
  111. package/packages/daemon/src/lib/tmux-server-proof.ts +95 -0
  112. package/packages/daemon/src/lib/tmux-server-registration.ts +105 -0
  113. package/packages/daemon/src/lib/tmux-server-session-create.ts +52 -0
  114. package/packages/daemon/src/lib/tmux-server-session-open.ts +106 -0
  115. package/packages/daemon/src/lib/tmux-servers-cli.ts +186 -0
  116. package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +62 -0
  117. package/packages/daemon/src/lib/tmux-window-link-guard.ts +112 -0
  118. package/packages/daemon/src/lib/workspace-config-loader.ts +3 -0
  119. package/packages/daemon/src/lib/workspace-multiplexer-verbs.ts +121 -1
  120. package/packages/daemon/src/lib/workspace-pane-creation.ts +16 -55
  121. package/packages/daemon/src/lib/workspace-promotion.ts +98 -12
  122. package/packages/daemon/src/lib/workspace-registry.ts +5 -0
  123. package/packages/daemon/src/server/pane-stream-upgrade.ts +25 -5
  124. package/packages/daemon/src/terminal/attachments/native-runtime.ts +70 -16
  125. package/packages/daemon/src/terminal/mirror/control-channel.ts +64 -7
  126. package/packages/daemon/src/terminal/mirror/events.ts +2 -0
  127. package/packages/daemon/src/terminal/mirror/mirror-service.ts +56 -0
  128. package/packages/daemon/src/terminal/mirror/session-channel.ts +245 -50
  129. package/packages/daemon/src/terminal/mirror/window-link-authority.ts +203 -0
  130. package/packages/daemon/src/terminal/pane-stream/lease-manager.ts +3 -3
  131. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +50 -6
  132. package/packages/daemon/src/terminal/protocol/live-session-identity.ts +14 -0
  133. package/packages/daemon/src/terminal/protocol/native-backing-client.ts +4 -1
  134. package/packages/daemon/src/terminal/session-runtime/interaction-receipt-facts.ts +9 -0
  135. package/packages/daemon/src/terminal/session-runtime/registry.ts +9 -0
  136. package/packages/daemon/src/terminal/session-runtime/semantic-mutation-executor.ts +2 -2
  137. package/packages/daemon/src/terminal/session-runtime/semantic-mutation-resource-changes.ts +1 -0
  138. package/packages/daemon/src/tui/mirror/application-shell-daemon-connection.ts +28 -2
  139. package/packages/daemon/src/tui/mirror/application-shell-server-connection.ts +154 -0
  140. package/packages/daemon/src/tui/mirror/menu-model.ts +1 -1
  141. package/packages/daemon/src/tui/mirror/open-tui-verified-routing.ts +41 -3
  142. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +47 -6
  143. package/packages/daemon/src/tui/mirror/runtime/application-entry.ts +2 -1
  144. package/packages/daemon/src/tui/mirror/runtime/application-fleet-session-actions.tsx +10 -4
  145. package/packages/daemon/src/tui/mirror/runtime/application-fleet-tabs.ts +2 -0
  146. package/packages/daemon/src/tui/mirror/runtime/application-home-catalog-owner.ts +13 -3
  147. package/packages/daemon/src/tui/mirror/runtime/application-home-catalog.ts +172 -100
  148. package/packages/daemon/src/tui/mirror/runtime/application-machine-catalog.ts +19 -7
  149. package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +263 -36
  150. package/packages/daemon/src/tui/mirror/runtime/application-machine-overlays.tsx +1 -1
  151. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +124 -40
  152. package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +3 -0
  153. package/packages/daemon/src/tui/mirror/runtime/application-root-configuration.ts +12 -3
  154. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +24 -24
  155. package/packages/daemon/src/tui/mirror/runtime/application-route-connection.ts +93 -1
  156. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +4 -0
  157. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +177 -2
  158. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace-policy.ts +62 -0
  159. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +87 -30
  160. package/packages/daemon/src/tui/mirror/runtime/fleet-lifecycle-client.ts +52 -0
  161. package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +1 -1
  162. package/packages/daemon/src/tui/mirror/runtime/select-terminal-pane.ts +3 -0
  163. package/packages/daemon/src/tui/mirror/runtime/terminal-pane-input-router.ts +15 -4
  164. package/packages/daemon/src/tui/mirror/workspace/application-command-description.ts +13 -2
  165. package/packages/daemon/src/tui/mirror/workspace/terminal-window-strip.tsx +5 -1
  166. package/packages/daemon-client/package.json +3 -1
  167. package/packages/daemon-client/src/index.ts +2 -0
  168. package/packages/daemon-client/src/pane-stream-client.test.ts +108 -38
  169. package/packages/daemon-client/src/pane-stream-client.ts +26 -0
  170. package/packages/daemon-client/src/scoped-tmux-server-transport.test.ts +114 -0
  171. package/packages/daemon-client/src/scoped-tmux-server-transport.ts +385 -0
  172. package/packages/daemon-client/src/tmux-server-client.test.ts +279 -0
  173. package/packages/daemon-client/src/tmux-server-client.ts +287 -0
@@ -1,3 +1,7 @@
1
+ import {
2
+ fenceNativeTmuxCommand,
3
+ type NativeTmuxServerIdentity,
4
+ } from "../../lib/tmux-server-generation-runner.ts";
1
5
  import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
6
  import { execFile } from "node:child_process";
3
7
  import type { createNamedSocketFence } from "../../lib/tmux-named-socket-fence.ts";
@@ -119,6 +123,7 @@ export function getNativeTerminalAttachmentRuntimeConstructionCount(): number {
119
123
  }
120
124
 
121
125
  export interface NativeTerminalAttachmentTmuxAuthority {
126
+ readonly nativeServerIdentity?: NativeTmuxServerIdentity;
122
127
  /** Shared with the canonical daemon's named command runners. */
123
128
  readonly namedSocketFence?: ReturnType<typeof createNamedSocketFence>;
124
129
  readonly executablePath: string;
@@ -163,6 +168,7 @@ export interface NativeTerminalInventoryReadRunner {
163
168
  }
164
169
 
165
170
  interface CanonicalTmuxAuthority {
171
+ readonly nativeServerIdentity?: NativeTmuxServerIdentity;
166
172
  readonly namedSocketFence: ReturnType<typeof createNamedSocketFence> | null;
167
173
  readonly executablePath: string;
168
174
  readonly socketSelector: DaemonTmuxSocketSelector;
@@ -208,6 +214,7 @@ function canonicalAuthority(input: NativeTerminalAttachmentTmuxAuthority): Canon
208
214
  socketArgv = ["-L", input.socketSelector.name];
209
215
  }
210
216
  return Object.freeze({
217
+ nativeServerIdentity: input.nativeServerIdentity,
211
218
  namedSocketFence: socketSelector.kind === "name" ? (input.namedSocketFence ?? null) : null,
212
219
  executablePath,
213
220
  socketSelector: Object.freeze(socketSelector),
@@ -288,9 +295,12 @@ function pinnedRunner(
288
295
  run(command: TmuxArgvPlan): TmuxAttachmentCommandResult {
289
296
  if (command.executable !== "tmux") return { status: "failed" };
290
297
  try {
298
+ const guarded = authority.nativeServerIdentity
299
+ ? fenceNativeTmuxCommand(command.argv, authority.nativeServerIdentity)
300
+ : null;
291
301
  const stdout = execute(
292
302
  authority.executablePath,
293
- [...currentSocketArgv(authority), ...command.argv],
303
+ [...currentSocketArgv(authority), ...(guarded?.argv ?? command.argv)],
294
304
  {
295
305
  cwd: authority.trustedCwd,
296
306
  env: authority.environment,
@@ -298,7 +308,7 @@ function pinnedRunner(
298
308
  timeoutMs: TERMINAL_ATTACHMENT_TMUX_COMMAND_TIMEOUT_MS,
299
309
  },
300
310
  );
301
- const value = String(stdout);
311
+ const value = guarded ? guarded.verify(String(stdout)) : String(stdout);
302
312
  if (authority.namedSocketFence && !authority.namedSocketFence.isPinned())
303
313
  authority.namedSocketFence.resolve();
304
314
  if (value.includes("\0") || Buffer.byteLength(value, "utf8") > MAX_TMUX_OUTPUT_BYTES) {
@@ -368,13 +378,16 @@ function pinnedReadRunner(
368
378
  async run(command: TmuxArgvPlan, signal?: AbortSignal): Promise<TmuxAttachmentCommandResult> {
369
379
  if (command.executable !== "tmux" || signal?.aborted) return { status: "failed" };
370
380
  try {
381
+ const guarded = authority.nativeServerIdentity
382
+ ? fenceNativeTmuxCommand(command.argv, authority.nativeServerIdentity)
383
+ : null;
371
384
  const stdout = await execute(
372
385
  authority.executablePath,
373
386
  [
374
387
  ...(authority.namedSocketFence
375
388
  ? await authority.namedSocketFence.resolveAsync(signal)
376
389
  : currentSocketArgv(authority)),
377
- ...command.argv,
390
+ ...(guarded?.argv ?? command.argv),
378
391
  ],
379
392
  {
380
393
  cwd: authority.trustedCwd,
@@ -384,7 +397,7 @@ function pinnedReadRunner(
384
397
  ...(signal ? { signal } : {}),
385
398
  },
386
399
  );
387
- const value = String(stdout);
400
+ const value = guarded ? guarded.verify(String(stdout)) : String(stdout);
388
401
  if (authority.namedSocketFence && !authority.namedSocketFence.isPinned())
389
402
  await authority.namedSocketFence.resolveAsync(signal);
390
403
  if (value.includes("\0") || Buffer.byteLength(value, "utf8") > MAX_TMUX_OUTPUT_BYTES)
@@ -690,6 +703,7 @@ const PANE_FORMAT = [
690
703
  // window reports the same value; the catalog requires it before a multi-pane
691
704
  // window is attachable.
692
705
  "#{@tmux_ide_window_id}",
706
+ "#{window_index}",
693
707
  PANE_WIRE_SENTINEL,
694
708
  ].join(WIRE_SEPARATOR);
695
709
 
@@ -742,12 +756,13 @@ type LivePaneFacts = Omit<NativeTerminalInventoryPaneSnapshot, "workspaceName">;
742
756
  function parsePaneSnapshot(
743
757
  stdout: string,
744
758
  expected: LiveSessionIdentity,
745
- ): readonly LivePaneFacts[] {
759
+ ): { panes: readonly LivePaneFacts[]; linksProof: string } {
746
760
  const panes: LivePaneFacts[] = [];
747
- const runtimeIds = new Set<string>();
761
+ const runtimeIds = new Map<string, { facts: LivePaneFacts; proof: string }>();
762
+ const links = new Map<number, { windowId: string; active: boolean; panes: Set<string> }>();
748
763
  for (const line of strictLines(stdout, MAX_DISCOVERED_PANES)) {
749
764
  const fields = line.split(WIRE_SEPARATOR);
750
- if (fields.length !== 19 || fields[18] !== PANE_WIRE_SENTINEL) {
765
+ if (fields.length !== 20 || fields[19] !== PANE_WIRE_SENTINEL) {
751
766
  throw new NativeTerminalAttachmentRuntimeError("invalid-tmux-output");
752
767
  }
753
768
  const [
@@ -769,6 +784,7 @@ function parsePaneSnapshot(
769
784
  missionStamp,
770
785
  dir,
771
786
  windowStampValue,
787
+ windowIndexValue,
772
788
  ] = fields as [
773
789
  string,
774
790
  string,
@@ -789,22 +805,21 @@ function parsePaneSnapshot(
789
805
  string,
790
806
  string,
791
807
  string,
808
+ string,
792
809
  ];
793
810
  if (
794
811
  sessionName !== expected.name ||
795
812
  sessionId !== expected.id ||
796
813
  !RUNTIME_WINDOW_ID.test(windowId) ||
797
814
  !RUNTIME_PANE_ID.test(runtimePaneId) ||
798
- runtimeIds.has(runtimePaneId) ||
799
815
  !["0", "1"].includes(windowActive) ||
800
816
  !["0", "1"].includes(paneActive)
801
817
  ) {
802
818
  throw new NativeTerminalAttachmentRuntimeError("invalid-tmux-output");
803
819
  }
804
- runtimeIds.add(runtimePaneId);
805
820
  const nullable = (value: string): string | null =>
806
821
  boundedWireValue(value, 256).length === 0 ? null : value;
807
- panes.push({
822
+ const facts: LivePaneFacts = {
808
823
  sessionName,
809
824
  sessionId,
810
825
  windowId,
@@ -826,22 +841,58 @@ function parsePaneSnapshot(
826
841
  // registered workspace remains the trusted application-shell root, so
827
842
  // keep discovery available and carry the empty presentation value.
828
843
  dir: boundedWireValue(dir, 4_096),
829
- });
844
+ };
845
+ const windowIndex = nonnegativeInteger(windowIndexValue);
846
+ const link = links.get(windowIndex) ?? {
847
+ windowId,
848
+ active: windowActive === "1",
849
+ panes: new Set<string>(),
850
+ };
851
+ if (
852
+ link.windowId !== windowId ||
853
+ link.active !== (windowActive === "1") ||
854
+ link.panes.has(runtimePaneId)
855
+ ) {
856
+ throw new NativeTerminalAttachmentRuntimeError("invalid-tmux-output");
857
+ }
858
+ link.panes.add(runtimePaneId);
859
+ links.set(windowIndex, link);
860
+ // Only link index and current-link state may differ for a shared backing.
861
+ const proof = JSON.stringify(fields.filter((_value, index) => index !== 10 && index !== 18));
862
+ const previous = runtimeIds.get(runtimePaneId);
863
+ if (previous) {
864
+ if (previous.proof !== proof)
865
+ throw new NativeTerminalAttachmentRuntimeError("invalid-tmux-output");
866
+ if (facts.active && !previous.facts.active) {
867
+ const merged = { ...previous.facts, active: true };
868
+ panes[panes.indexOf(previous.facts)] = merged;
869
+ previous.facts = merged;
870
+ }
871
+ } else {
872
+ runtimeIds.set(runtimePaneId, { facts, proof });
873
+ panes.push(facts);
874
+ }
830
875
  }
831
876
  const counts = new Map<string, number>();
832
877
  for (const pane of panes) counts.set(pane.windowId, (counts.get(pane.windowId) ?? 0) + 1);
833
- const windows = new Set(panes.map((pane) => pane.windowId));
834
878
  if (
835
879
  panes.some(
836
880
  (pane) =>
837
881
  counts.get(pane.windowId) !== pane.windowPaneCount ||
838
- windows.size !== pane.sessionWindowCount,
882
+ links.size !== pane.sessionWindowCount,
839
883
  ) ||
884
+ [...links.values()].some((link) => link.panes.size !== counts.get(link.windowId)) ||
885
+ [...links.values()].filter((link) => link.active).length > 1 ||
840
886
  panes.filter((pane) => pane.active).length > 1
841
887
  ) {
842
888
  throw new NativeTerminalAttachmentRuntimeError("invalid-tmux-output");
843
889
  }
844
- return panes;
890
+ return {
891
+ panes,
892
+ linksProof: JSON.stringify(
893
+ [...links].map(([index, link]) => [index, link.windowId, [...link.panes]]),
894
+ ),
895
+ };
845
896
  }
846
897
 
847
898
  /**
@@ -872,10 +923,12 @@ export async function discoverWorkspaceRegistryTerminalInventory(
872
923
  const argv = ["list-panes", "-s", "-t", identity.id, "-F", PANE_FORMAT] as const;
873
924
  const before = await requiredTmuxResult(runner, argv, signal);
874
925
  if (before === null) continue;
875
- const panes = parsePaneSnapshot(before, identity);
926
+ const first = parsePaneSnapshot(before, identity);
927
+ const panes = first.panes;
876
928
  const after = await requiredTmuxResult(runner, argv, signal);
877
929
  if (after === null) throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
878
- const latest = parsePaneSnapshot(after, identity);
930
+ const second = parsePaneSnapshot(after, identity);
931
+ const latest = second.panes;
879
932
  // Foreground commands, titles, focus and paths can change while the same
880
933
  // pane remains valid. Fence binding/topology only, then publish one complete
881
934
  // latest snapshot rather than mixing presentation values across reads.
@@ -891,6 +944,7 @@ export async function discoverWorkspaceRegistryTerminalInventory(
891
944
  "index",
892
945
  ] as const;
893
946
  if (
947
+ first.linksProof !== second.linksProof ||
894
948
  panes.length !== latest.length ||
895
949
  panes.some((pane, index) => proofKeys.some((key) => pane[key] !== latest[index]![key]))
896
950
  ) {
@@ -20,6 +20,8 @@
20
20
  * chance to detach us — killing the reader first can wedge the server.
21
21
  */
22
22
  import { spawn, type ChildProcess } from "node:child_process";
23
+ import { shellEscape } from "../../lib/shell.ts";
24
+ import type { NativeTmuxServerIdentity } from "../../lib/tmux-server-generation-runner.ts";
23
25
  import { parseControlLine } from "../protocol/control.ts";
24
26
 
25
27
  export interface ControlReply {
@@ -752,6 +754,7 @@ export class ControlChannelCore {
752
754
  export interface MirrorControlChannelOptions {
753
755
  /** Resolve through the owning daemon's generation fence before each spawn. */
754
756
  resolveSocketPath?: () => string;
757
+ nativeServerIdentity?: NativeTmuxServerIdentity;
755
758
  session: string;
756
759
  handlers: MirrorChannelHandlers;
757
760
  /** `tmux -L <name>` — isolated servers in tests; omit for the default. */
@@ -772,10 +775,23 @@ export interface MirrorControlChannelOptions {
772
775
  export function mirrorControlAttachArgs(
773
776
  options: Pick<
774
777
  MirrorControlChannelOptions,
775
- "session" | "socketName" | "socketPath" | "configFile"
778
+ "session" | "socketName" | "socketPath" | "configFile" | "nativeServerIdentity"
776
779
  >,
777
780
  pauseAfterSeconds = DEFAULT_PAUSE_AFTER_SECONDS,
778
781
  ): string[] {
782
+ const attach = [
783
+ "attach",
784
+ "-t",
785
+ options.session,
786
+ "-f",
787
+ `ignore-size,pause-after=${pauseAfterSeconds},active-pane`,
788
+ ];
789
+ const expected = options.nativeServerIdentity;
790
+ if (
791
+ expected &&
792
+ (!/^[1-9][0-9]*$/u.test(expected.pid) || !/^[1-9][0-9]*$/u.test(expected.startTime))
793
+ )
794
+ throw new Error("Invalid expected native server identity");
779
795
  return [
780
796
  ...(options.socketPath
781
797
  ? ["-S", options.socketPath]
@@ -784,11 +800,15 @@ export function mirrorControlAttachArgs(
784
800
  : []),
785
801
  ...(options.configFile ? ["-f", options.configFile] : []),
786
802
  "-C",
787
- "attach",
788
- "-t",
789
- options.session,
790
- "-f",
791
- `ignore-size,pause-after=${pauseAfterSeconds},active-pane`,
803
+ ...(expected
804
+ ? [
805
+ "if-shell",
806
+ "-F",
807
+ `#{&&:#{==:#{pid},${expected.pid}},#{==:#{start_time},${expected.startTime}}}`,
808
+ attach.map(shellEscape).join(" "),
809
+ "display-message -p 'tmux-server-generation-refused'",
810
+ ]
811
+ : attach),
792
812
  ];
793
813
  }
794
814
 
@@ -801,6 +821,8 @@ export class MirrorControlChannel implements MirrorChannelIo {
801
821
  private readonly core: ControlChannelCore;
802
822
  private readonly opts: MirrorControlChannelOptions;
803
823
  private exited = false;
824
+ private verifiedAttach = false;
825
+ private guardedStart: { resolve: () => void; reject: (error: Error) => void } | null = null;
804
826
  private atomicCollectorTimer: ReturnType<typeof setTimeout> | null = null;
805
827
 
806
828
  constructor(opts: MirrorControlChannelOptions) {
@@ -808,6 +830,16 @@ export class MirrorControlChannel implements MirrorChannelIo {
808
830
  this.core = new ControlChannelCore(
809
831
  {
810
832
  ...opts.handlers,
833
+ onOutput: (...args) => {
834
+ if (!opts.nativeServerIdentity || this.verifiedAttach) opts.handlers.onOutput(...args);
835
+ },
836
+ onNotify: (name, rest) => {
837
+ if (opts.nativeServerIdentity && name === "session-changed") {
838
+ this.verifiedAttach = true;
839
+ this.guardedStart?.resolve();
840
+ }
841
+ if (!opts.nativeServerIdentity || this.verifiedAttach) opts.handlers.onNotify(name, rest);
842
+ },
811
843
  onExit: (reason) => this.noteExit(reason),
812
844
  },
813
845
  opts.nowMicros,
@@ -851,7 +883,31 @@ export class MirrorControlChannel implements MirrorChannelIo {
851
883
  // tmux opens with an unsolicited %begin/%end greeting block; a queued
852
884
  // resolver makes start() settle when the protocol is actually live.
853
885
  return new Promise((resolve, reject) => {
854
- this.core.push({ kind: "promise", resolve: () => resolve(), reject, lines: [] });
886
+ let timer: ReturnType<typeof setTimeout> | undefined;
887
+ const settle = (error?: Error) => {
888
+ if (timer) clearTimeout(timer);
889
+ this.guardedStart = null;
890
+ if (error) reject(error);
891
+ else resolve();
892
+ };
893
+ if (this.opts.nativeServerIdentity) {
894
+ this.guardedStart = { resolve: () => settle(), reject: (error) => settle(error) };
895
+ timer = setTimeout(() => {
896
+ settle(new Error("Tmux generation-guarded attach timed out"));
897
+ void this.dispose();
898
+ }, 5_000);
899
+ timer.unref?.();
900
+ }
901
+ // The outer if-shell greeting precedes its attach branch. Only native
902
+ // session-changed proves that the guarded attach actually succeeded.
903
+ this.core.push({
904
+ kind: "promise",
905
+ resolve: () => {
906
+ if (!this.opts.nativeServerIdentity) settle();
907
+ },
908
+ reject: (error) => settle(error),
909
+ lines: [],
910
+ });
855
911
  proc.on("error", (err) => {
856
912
  this.core.fail(String(err));
857
913
  reject(err);
@@ -1010,6 +1066,7 @@ export class MirrorControlChannel implements MirrorChannelIo {
1010
1066
  private noteExit(reason: string | null): void {
1011
1067
  if (this.exited) return;
1012
1068
  this.exited = true;
1069
+ this.guardedStart?.reject(new Error(reason ?? "Control channel exited before guarded attach"));
1013
1070
  this.opts.handlers.onExit(reason);
1014
1071
  }
1015
1072
  }
@@ -1,3 +1,4 @@
1
+ import type { WindowLinkTopology } from "@tmux-ide/contracts";
1
2
  import type { NativeGridCapture } from "./native-grid-capture.ts";
2
3
  /**
3
4
  * Public event contract of the daemon MirrorService (m43 card 1).
@@ -131,6 +132,7 @@ export interface MirrorLayoutAuthoritySnapshot {
131
132
  readonly session: string;
132
133
  readonly runtimeSessionId: string;
133
134
  readonly topologyEpoch: number;
135
+ readonly windowLinks: WindowLinkTopology;
134
136
  readonly layouts: readonly MirrorLayoutEvent[];
135
137
  }
136
138
 
@@ -1,3 +1,12 @@
1
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute } from "node:path";
3
+ import { resolveTmuxExecutable, tmuxClientEnvironment } from "../../lib/tmux-client-execution.ts";
4
+ import {
5
+ captureUnixSocketIdentity,
6
+ revalidateUnixSocketIdentity,
7
+ } from "../../lib/unix-socket-authority.ts";
8
+ import { execFile } from "node:child_process";
9
+ import type { WindowLinkTarget } from "@tmux-ide/contracts";
1
10
  /**
2
11
  * MirrorService — the daemon-side shared mirror layer (m43 card 1).
3
12
  *
@@ -37,6 +46,7 @@ import {
37
46
  } from "./control-mode-ownership.ts";
38
47
 
39
48
  export interface MirrorServiceOptions {
49
+ nativeServerIdentity?: import("../../lib/tmux-server-generation-runner.ts").NativeTmuxServerIdentity;
40
50
  resolveSocketPath?: () => string;
41
51
  /** `tmux -L <name>` for every channel — isolated servers in tests. */
42
52
  socketName?: string;
@@ -284,6 +294,15 @@ export class MirrorService {
284
294
  };
285
295
  }
286
296
 
297
+ async executeWindowLinkAction(
298
+ session: string,
299
+ request: { action: "select" | "unlink"; target?: WindowLinkTarget; paneId?: string },
300
+ ) {
301
+ const entry = this.channels.get(session);
302
+ if (!entry || entry.retired) throw new Error(`Mirror session ${session} is unavailable`);
303
+ return entry.channel.executeWindowLinkAction(request);
304
+ }
305
+
287
306
  /** Synchronous hot input on an already-retained SessionRuntime channel. */
288
307
  sendText(
289
308
  session: string,
@@ -413,6 +432,21 @@ export class MirrorService {
413
432
  );
414
433
  let channel: SessionChannel;
415
434
  try {
435
+ const initialSocket = this.opts.resolveSocketPath
436
+ ? this.opts.resolveSocketPath()
437
+ : this.opts.socketPath;
438
+ const mutationSocketIdentity = initialSocket
439
+ ? captureUnixSocketIdentity(initialSocket)
440
+ : null;
441
+ const executable = mutationSocketIdentity
442
+ ? realpathSync(this.opts.executable ?? resolveTmuxExecutable())
443
+ : null;
444
+ if (executable) {
445
+ accessSync(executable, constants.X_OK);
446
+ if (!isAbsolute(executable) || !statSync(executable).isFile())
447
+ throw new Error("Invalid pinned tmux executable");
448
+ }
449
+ const environment = Object.freeze(tmuxClientEnvironment(process.env));
416
450
  const channelOptions: SessionChannelOptions = {
417
451
  session,
418
452
  createIo: (handlers) =>
@@ -424,10 +458,32 @@ export class MirrorService {
424
458
  socketPath: this.opts.socketPath,
425
459
  resolveSocketPath: this.opts.resolveSocketPath,
426
460
  executable: this.opts.executable,
461
+ nativeServerIdentity: this.opts.nativeServerIdentity,
427
462
  configFile: this.opts.configFile,
428
463
  pauseAfterSeconds: this.opts.pauseAfterSeconds,
429
464
  nowMicros: this.opts.nowMicros,
430
465
  }),
466
+ executeWindowLinkGuard: (args) => {
467
+ if (!mutationSocketIdentity || !executable)
468
+ throw new Error("Window link mutation requires a pinned socket authority");
469
+ if (this.opts.resolveSocketPath) this.opts.resolveSocketPath();
470
+ const socket = revalidateUnixSocketIdentity(mutationSocketIdentity);
471
+ return new Promise((resolve) => {
472
+ execFile(
473
+ executable,
474
+ ["-N", "-u", "-S", socket, ...args],
475
+ { timeout: 5000, maxBuffer: 65536, encoding: "utf8", env: environment },
476
+ (error, stdout) => {
477
+ const status = !error
478
+ ? 0
479
+ : typeof error.code === "number" && !error.killed
480
+ ? error.code
481
+ : null;
482
+ resolve({ status, stdout });
483
+ },
484
+ );
485
+ });
486
+ },
431
487
  historyLines: this.opts.historyLines,
432
488
  internalReadHookEmission: this.opts.internalReadHookEmission,
433
489
  generatePaneId: this.opts.generatePaneId,