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
@@ -0,0 +1,55 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
3
+ import { WorkspaceCatalogLiveSessionIdSchemaZ } from "@tmux-ide/contracts";
4
+ import { liveSessionIdForNativeIdentity } from "../terminal/protocol/live-session-identity.js";
5
+ import { shellEscape } from "./shell.js";
6
+ /** Session identity travels with the mutation lane, never in global mutable selection. */
7
+ export function createTmuxSessionMutationFence() {
8
+ const context = new AsyncLocalStorage();
9
+ return {
10
+ wrap(run) {
11
+ return (args) => {
12
+ const selected = context.getStore();
13
+ if (!selected)
14
+ return run(args);
15
+ const sentinel = `tmux-session-stale.${randomUUID()}`;
16
+ const output = run([
17
+ "if-shell",
18
+ "-t",
19
+ `=${selected.name}`,
20
+ "-F",
21
+ `#{&&:#{==:#{session_id},${selected.id}},#{==:#{session_created},${selected.created}}}`,
22
+ args.map((arg) => (arg === ";" ? ";" : shellEscape(arg))).join(" "),
23
+ `display-message -p '${sentinel}'`,
24
+ ]);
25
+ if (output.trim() === sentinel)
26
+ throw new Error("Selected live tmux session changed before mutation");
27
+ return output;
28
+ };
29
+ },
30
+ async execute(options) {
31
+ WorkspaceCatalogLiveSessionIdSchemaZ.parse(options.liveSessionId);
32
+ const raw = await options.run([
33
+ "list-sessions",
34
+ "-F",
35
+ "#{pid}\t#{session_id}\t#{session_created}\t#{session_name}",
36
+ ]);
37
+ const rows = raw
38
+ .trim()
39
+ .split("\n")
40
+ .map((line) => line.split("\t"))
41
+ .filter(([pid, id, created, name]) => pid &&
42
+ /^\d+$/.test(pid) &&
43
+ id &&
44
+ /^\$\d+$/.test(id) &&
45
+ created &&
46
+ /^\d+$/.test(created) &&
47
+ name === options.sessionName &&
48
+ liveSessionIdForNativeIdentity(pid, id, created) === options.liveSessionId);
49
+ if (rows.length !== 1)
50
+ throw new Error("Selected live tmux session is no longer available");
51
+ const [, id, created] = rows[0];
52
+ return context.run({ id: id, created: created, name: options.sessionName }, options.mutate);
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,88 @@
1
+ export const WINDOW_LINK_GUARD_APPLIED = "link-guard.ok";
2
+ export const WINDOW_LINK_GUARD_STALE = "link-guard.stale";
3
+ /** if-shell -F and its first branch command do not yield in tmux's command queue.
4
+ * Keep the mutation first and synchronous. Do not insert run-shell/wait-for/UI.
5
+ * This checks a current tuple, not an immutable historical winlink (tmux has none).
6
+ * Use buildNativeWindowLinkPaneSelectGuard for guarded compound pane selection.
7
+ */
8
+ export function buildNativeWindowLinkGuard(address, action) {
9
+ const { sessionId, windowIndex, expectedWindowId } = address;
10
+ if (!/^\$(?:0|[1-9]\d{0,15})$/u.test(sessionId) ||
11
+ !/^@(?:0|[1-9]\d{0,15})$/u.test(expectedWindowId) ||
12
+ !Number.isSafeInteger(Number(sessionId.slice(1))) ||
13
+ !Number.isSafeInteger(Number(expectedWindowId.slice(1))) ||
14
+ !Number.isSafeInteger(windowIndex) ||
15
+ windowIndex < 0 ||
16
+ (address.expectedServerPid !== undefined &&
17
+ !/^[1-9][0-9]{0,15}$/u.test(address.expectedServerPid)) ||
18
+ (address.expectedSessionCreated !== undefined &&
19
+ !/^(?:0|[1-9][0-9]{0,15})$/u.test(address.expectedSessionCreated)) ||
20
+ (action !== "select" && action !== "unlink")) {
21
+ throw new Error("Invalid native window link guard address or action");
22
+ }
23
+ const target = `${sessionId}:${windowIndex}`;
24
+ // CANFAIL target lookup makes all three comparisons necessary for missing targets.
25
+ let condition = `#{&&:#{==:#{session_id},${sessionId}},#{&&:#{==:#{window_index},${windowIndex}},#{==:#{window_id},${expectedWindowId}}}}`;
26
+ if (address.expectedServerPid !== undefined)
27
+ condition = `#{&&:#{==:#{pid},${address.expectedServerPid}},${condition}}`;
28
+ if (address.expectedSessionCreated !== undefined)
29
+ condition = `#{&&:#{==:#{session_created},${address.expectedSessionCreated}},${condition}}`;
30
+ const verb = action === "select" ? "select-window" : "unlink-window";
31
+ // Validated native IDs/index contain no quote, separator, or format syntax.
32
+ return [
33
+ "if-shell",
34
+ "-F",
35
+ "-t",
36
+ target,
37
+ condition,
38
+ `${verb} -t '${target}' ; display-message -p '${WINDOW_LINK_GUARD_APPLIED}'`,
39
+ `display-message -p '${WINDOW_LINK_GUARD_STALE}'`,
40
+ ];
41
+ }
42
+ /** Select an exact link and pane while preserving native user hooks. The window
43
+ * selection hook may yield or move topology, so revalidate after it. A failed
44
+ * second check is indeterminate: the window selection may already have happened.
45
+ */
46
+ export function buildNativeWindowLinkPaneSelectGuard(address, paneId) {
47
+ const windowGuard = buildNativeWindowLinkGuard(address, "select");
48
+ if (!/^%(?:0|[1-9]\d{0,15})$/u.test(paneId) || !Number.isSafeInteger(Number(paneId.slice(1)))) {
49
+ throw new Error("Invalid native pane guard address");
50
+ }
51
+ const target = `${address.sessionId}:${address.windowIndex}`;
52
+ const paneCondition = `#{&&:#{==:#{pane_id},${paneId}},#{==:#{window_id},${address.expectedWindowId}}}`;
53
+ const stale = `display-message -p '${WINDOW_LINK_GUARD_STALE}'`;
54
+ const interrupted = "display-message -p 'link-guard.interrupted'";
55
+ // All interpolated IDs are canonical numeric native identities. Braces are
56
+ // tmux command groups, not a host shell or a caller-provided command language.
57
+ const linkCheck = (yes, no) => `if-shell -F -t '${target}' '${windowGuard[4]}' { ${yes} } { ${no} }`;
58
+ const paneCheck = (yes, no) => `if-shell -F -t '${paneId}' '${paneCondition}' { ${yes} } { ${no} }`;
59
+ const selectPane = `select-pane -t '${paneId}' ; display-message -p '${WINDOW_LINK_GUARD_APPLIED}'`;
60
+ const afterHook = paneCheck(linkCheck(selectPane, interrupted), interrupted);
61
+ return [
62
+ "if-shell",
63
+ "-F",
64
+ "-t",
65
+ paneId,
66
+ paneCondition,
67
+ linkCheck(`select-window -t '${target}' ; ${afterHook}`, stale),
68
+ stale,
69
+ ];
70
+ }
71
+ /** Consume only output associated with this request's own native command boundary.
72
+ * Nonzero completion reports native failure, including last-link unlink refusal.
73
+ * For compound selection this can follow partial mutation; it is not rollback proof.
74
+ * Missing/extra output or interrupted completion is indeterminate, never success;
75
+ * it must not trigger an automatic retry of a potentially completed mutation.
76
+ */
77
+ export function classifyNativeWindowLinkGuardResult(status, stdout) {
78
+ if (status === null)
79
+ return "indeterminate";
80
+ if (status !== 0)
81
+ return "native-refused";
82
+ const output = stdout.trim();
83
+ if (output === WINDOW_LINK_GUARD_APPLIED)
84
+ return "applied";
85
+ if (output === WINDOW_LINK_GUARD_STALE)
86
+ return "stale";
87
+ return "indeterminate";
88
+ }
@@ -1,3 +1,6 @@
1
+ // Consumers outside the daemon tsconfig need the bundled declaration as well.
2
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference
3
+ /// <reference path="../js-yaml.d.ts" />
1
4
  /**
2
5
  * Side-effect-free loader for repository-scoped WorkspaceConfigV1 files.
3
6
  *
@@ -26,6 +26,7 @@ import { getDefaultWorkspaceRegistry } from "./workspace-registry.js";
26
26
  import { internalInteractionOperationMarker } from "./tmux-external-interaction-observer.js";
27
27
  import { INTERNAL_READ_OPERATION_OPTION, INTERNAL_SEND_OPERATION_OPTION, } from "./tmux-interaction-options.js";
28
28
  import { memorablePaneName } from "../terminal/protocol/pane-display-name.js";
29
+ import { WindowLinkResolutionError } from "../terminal/mirror/window-link-authority.js";
29
30
  const CREATION_OPTION = "@tmux_ide_creation_id";
30
31
  const SEMANTIC_PANE_OPTION = "@tmux_ide_pane_id";
31
32
  const SEMANTIC_WINDOW_OPTION = "@tmux_ide_window_id";
@@ -47,6 +48,10 @@ const ERROR_MESSAGES = {
47
48
  workspace_unavailable: "The requested workspace is not available for multiplexer verbs.",
48
49
  pane_not_found: "No pane in this workspace carries the requested semantic identity.",
49
50
  window_not_found: "No window in this workspace carries the requested identity.",
51
+ window_link_stale: "The window link observation is no longer current.",
52
+ window_link_ambiguous: "Select an explicit window link for this pane.",
53
+ window_link_session_mismatch: "The window link belongs to another session generation.",
54
+ window_link_backing_mismatch: "The window link no longer names the expected window.",
50
55
  ambiguous_target: "The requested identity names more than one live tmux object.",
51
56
  last_window_refused: "This is the session's last window. Close the session instead if that is what you mean.",
52
57
  last_pane_refused: "This is the session's last pane. Close the session instead if that is what you mean.",
@@ -121,7 +126,19 @@ export function parseMultiplexerPaneRows(output) {
121
126
  creationId: creationId === "" ? null : creationId,
122
127
  });
123
128
  }
124
- return rows;
129
+ // list-panes -s repeats each backing for its session links. Identical native
130
+ // rows are one pane; conflicting observations fail closed.
131
+ const unique = new Map();
132
+ for (const row of rows) {
133
+ const previous = unique.get(row.paneId);
134
+ if (previous && JSON.stringify(previous) !== JSON.stringify(row)) {
135
+ throw new WorkspaceMultiplexerError("workspace_unavailable", {
136
+ reason: "conflicting_linked_pane_observation",
137
+ });
138
+ }
139
+ unique.set(row.paneId, row);
140
+ }
141
+ return [...unique.values()];
125
142
  }
126
143
  /** Distinct windows in a pane listing, in first-seen order. */
127
144
  export function windowIdsOf(rows) {
@@ -205,6 +222,61 @@ export class WorkspaceMultiplexerAuthority {
205
222
  mutate(raw, timing) {
206
223
  return this.#mutate(raw, timing);
207
224
  }
225
+ /** Runs only inside the existing semantic mutation lane; never rediscovers a stale link. */
226
+ async mutateWindowLink(raw, execute) {
227
+ if (this.#disposed)
228
+ throw new WorkspaceMultiplexerError("workspace_unavailable");
229
+ const request = WorkspaceMultiplexerMutationRequestSchemaZ.parse(raw);
230
+ if (request.expectedDaemonInstanceId !== this.#daemonInstanceId)
231
+ throw new WorkspaceMultiplexerError("daemon_instance_mismatch");
232
+ const intent = request.intent;
233
+ if (intent.verb !== "workspace.window.link.select" &&
234
+ intent.verb !== "workspace.window.link.unlink" &&
235
+ intent.verb !== "workspace.pane.select")
236
+ throw new TypeError("Expected a window-link selection or unlink intent");
237
+ const workspace = this.#registry.get(intent.workspaceName);
238
+ if (!workspace)
239
+ throw new WorkspaceMultiplexerError("workspace_not_found");
240
+ const envelope = {
241
+ operationId: request.operationId,
242
+ daemonInstanceId: this.#daemonInstanceId,
243
+ workspaceName: intent.workspaceName,
244
+ };
245
+ try {
246
+ const result = await execute(workspace.sessionName, intent.verb === "workspace.pane.select"
247
+ ? {
248
+ action: "select",
249
+ ...(intent.windowLink ? { target: intent.windowLink } : {}),
250
+ paneId: intent.semanticPaneId,
251
+ }
252
+ : {
253
+ action: intent.verb === "workspace.window.link.unlink" ? "unlink" : "select",
254
+ target: intent.target,
255
+ });
256
+ if (result.outcome !== "applied") {
257
+ throw new WorkspaceMultiplexerError(result.outcome === "stale"
258
+ ? "window_link_stale"
259
+ : result.outcome === "native-refused"
260
+ ? "mutation_failed"
261
+ : "mutation_unverified", { reason: result.outcome });
262
+ }
263
+ return WorkspaceMultiplexerMutationResultSchemaZ.parse(intent.verb === "workspace.pane.select"
264
+ ? {
265
+ ...envelope,
266
+ verb: intent.verb,
267
+ outcome: "applied",
268
+ semanticPaneId: intent.semanticPaneId,
269
+ }
270
+ : { ...envelope, verb: intent.verb, outcome: "applied", target: intent.target });
271
+ }
272
+ catch (error) {
273
+ if (error instanceof WorkspaceMultiplexerError)
274
+ throw error;
275
+ if (error instanceof WindowLinkResolutionError)
276
+ throw new WorkspaceMultiplexerError(error.reason, { operationId: request.operationId }, error);
277
+ throw new WorkspaceMultiplexerError("mutation_unverified", { operationId: request.operationId }, error);
278
+ }
279
+ }
208
280
  /**
209
281
  * Capture a semantically addressed pane for an authored read. The tmux
210
282
  * after-capture hook is the completion authority; this method only performs
@@ -436,6 +508,11 @@ export class WorkspaceMultiplexerAuthority {
436
508
  workspaceName: intent.workspaceName,
437
509
  };
438
510
  switch (intent.verb) {
511
+ case "workspace.window.link.select":
512
+ case "workspace.window.link.unlink":
513
+ throw new WorkspaceMultiplexerError("workspace_unavailable", {
514
+ reason: "window_link_authority_required",
515
+ });
439
516
  case "workspace.window.split":
440
517
  return this.#split(request, workspace, envelope);
441
518
  case "workspace.window.kill":
@@ -826,6 +903,10 @@ export class WorkspaceMultiplexerAuthority {
826
903
  };
827
904
  }
828
905
  #select(intent, sessionName, envelope, timing) {
906
+ if (intent.windowLink)
907
+ throw new WorkspaceMultiplexerError("workspace_unavailable", {
908
+ reason: "window_link_authority_required",
909
+ });
829
910
  const timed = (operation, run) => {
830
911
  if (!timing)
831
912
  return run();
@@ -1,10 +1,10 @@
1
1
  import { boundedTmuxRead } from "./bounded-tmux-read.js";
2
2
  import { accessSync, constants, realpathSync, statSync } from "node:fs";
3
- import { delimiter, isAbsolute, join, relative, sep } from "node:path";
3
+ import { isAbsolute, relative, sep } from "node:path";
4
4
  import { WorkspacePaneCreateMutationRequestSchemaZ, WorkspacePaneCreateMutationResultSchemaZ, } from "@tmux-ide/contracts";
5
5
  import { runTmuxBinary, TmuxError } from "@tmux-ide/tmux-bridge";
6
6
  import { createNamedSocketFence } from "./tmux-named-socket-fence.js";
7
- import { resolveBundledTmux } from "./bundled-tmux.js";
7
+ import { resolveTmuxExecutable as resolvePinnedTmuxExecutable, tmuxClientEnvironment, } from "./tmux-client-execution.js";
8
8
  import { probeProjectReadiness } from "./project-readiness-probe.js";
9
9
  import { agentHintForCommand } from "./agent-kind.js";
10
10
  import { loadWorkspaceConfig, WorkspaceConfigLoadError, } from "./workspace-config-loader.js";
@@ -12,7 +12,7 @@ import { getDefaultWorkspaceRegistry } from "./workspace-registry.js";
12
12
  import { memorablePaneName } from "../terminal/protocol/pane-display-name.js";
13
13
  import { shellEscape } from "./shell.js";
14
14
  import { MissionRepository } from "./mission-repository.js";
15
- import { resolveRuntimeNamespace } from "./runtime-namespace.js";
15
+ import { resolveRuntimeNamespace, resolveTmuxServerIntent } from "./runtime-namespace.js";
16
16
  import { prepareTmuxTruecolorEnvironment } from "./tmux-terminal-color.js";
17
17
  import { captureUnixSocketIdentity, revalidateUnixSocketIdentity, } from "./unix-socket-authority.js";
18
18
  const MAX_LIVE_OR_UNSAFE_OPERATIONS = 128;
@@ -102,61 +102,17 @@ function assertEffectiveConfigProvenance(workspace, canonicalRoot, source) {
102
102
  }
103
103
  }
104
104
  function resolveTmuxExecutable() {
105
+ // Preserve development artifact qualification errors instead of masking them.
105
106
  if (resolveRuntimeNamespace().development)
106
- return resolveBundledTmux();
107
- const configured = process.env.TMUX_IDE_TMUX_BIN;
108
- if (!configured) {
109
- const bundled = resolveBundledTmux();
110
- if (bundled)
111
- return bundled;
112
- }
113
- const candidates = configured
114
- ? [configured]
115
- : (process.env.PATH ?? "")
116
- .split(delimiter)
117
- // Empty and relative PATH entries mean the daemon/project cwd. They
118
- // are never executable authority for a privileged tmux mutation.
119
- .filter((entry) => entry.length > 0 && isAbsolute(entry))
120
- .map((entry) => join(entry, "tmux"));
121
- for (const candidate of candidates) {
122
- try {
123
- if (!isAbsolute(candidate))
124
- continue;
125
- accessSync(candidate, constants.X_OK);
126
- const canonical = realpathSync(candidate);
127
- if (statSync(canonical).isFile())
128
- return canonical;
129
- }
130
- catch {
131
- // Continue to the next daemon-start candidate.
132
- }
107
+ return resolvePinnedTmuxExecutable();
108
+ try {
109
+ return resolvePinnedTmuxExecutable();
133
110
  }
134
- throw new WorkspacePaneCreationError("workspace_unavailable", {
135
- reason: "tmux_executable_unavailable",
136
- });
137
- }
138
- const SAFE_TERMINAL_VALUE = /^(?:xterm|screen|tmux|rxvt|vt100|ansi)[A-Za-z0-9+._-]{0,58}$/u;
139
- const SAFE_LOCALE_VALUE = /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}$/u;
140
- /**
141
- * The pinned tmux client receives only presentation metadata. Everything else
142
- * is denied by omission: PATH/HOME/SHELL, TMUX/TMUX_PANE/TMUX_TMPDIR, shell
143
- * startup hooks, dynamic-loader variables, Node options, and project secrets
144
- * cannot redirect authority or inject code into this daemon-owned execution.
145
- */
146
- function tmuxClientEnvironment(source) {
147
- const environment = {
148
- TERM: SAFE_TERMINAL_VALUE.test(source.TERM ?? "") ? source.TERM : "xterm-256color",
149
- // A pinned runner may be the first tmux client and therefore create the
150
- // server. Never let a headless parent (`TERM=dumb`, `NO_COLOR=1`) become
151
- // the global environment inherited by every later interactive child.
152
- COLORTERM: "truecolor",
153
- };
154
- for (const name of ["LANG", "LC_ALL", "LC_CTYPE"]) {
155
- const value = source[name];
156
- if (value && SAFE_LOCALE_VALUE.test(value))
157
- environment[name] = value;
111
+ catch {
112
+ throw new WorkspacePaneCreationError("workspace_unavailable", {
113
+ reason: "tmux_executable_unavailable",
114
+ });
158
115
  }
159
- return environment;
160
116
  }
161
117
  function tmuxSocketFromEnvironment() {
162
118
  const match = /^(.*),[0-9]+,[0-9]+$/u.exec(process.env.TMUX ?? "");
@@ -165,7 +121,7 @@ function tmuxSocketFromEnvironment() {
165
121
  export function resolveWorkspacePaneTmuxAuthority() {
166
122
  const namespace = resolveRuntimeNamespace();
167
123
  const executablePath = resolveTmuxExecutable();
168
- if (namespace.development)
124
+ if (resolveTmuxServerIntent()?.source === "explicit")
169
125
  return Object.freeze({ executablePath, socketSelector: namespace.tmuxSocket });
170
126
  const environmentSocket = tmuxSocketFromEnvironment();
171
127
  if (environmentSocket) {
@@ -186,7 +142,7 @@ export function resolveWorkspacePaneTmuxAuthority() {
186
142
  // A sessionless daemon may start before the default tmux server exists. Pin
187
143
  // the daemon's default socket *name* and captured environment rather than
188
144
  // consulting a later request's TMUX/PATH.
189
- return Object.freeze({ executablePath, socketSelector: resolveRuntimeNamespace().tmuxSocket });
145
+ return Object.freeze({ executablePath, socketSelector: namespace.tmuxSocket });
190
146
  }
191
147
  /** Execute mutations only through the daemon-generation-pinned tmux authority. */
192
148
  export function createPinnedWorkspaceTmuxRunner(authority, options = {}) {
@@ -67,6 +67,7 @@ const PANE_VERIFY_FORMAT = [
67
67
  "#{pane_id}",
68
68
  `#{${SEMANTIC_PANE_OPTION}}`,
69
69
  `#{${SEMANTIC_WINDOW_OPTION}}`,
70
+ "#{window_index}",
70
71
  SENTINEL,
71
72
  ].join(FIELD);
72
73
  const ERROR_MESSAGES = {
@@ -223,8 +224,10 @@ function parseVerifyPanes(output) {
223
224
  const fields = line.split(FIELD);
224
225
  const windowPaneCount = positiveInteger(fields[3] ?? "");
225
226
  const sessionWindowCount = positiveInteger(fields[4] ?? "");
226
- if (fields.length !== 9 ||
227
- fields[8] !== SENTINEL ||
227
+ if (fields.length !== 10 ||
228
+ fields[9] !== SENTINEL ||
229
+ !/^(0|[1-9][0-9]*)$/u.test(fields[8]) ||
230
+ !Number.isSafeInteger(Number(fields[8])) ||
228
231
  !/^\$[0-9]+$/u.test(fields[0]) ||
229
232
  !/^@[0-9]+$/u.test(fields[1]) ||
230
233
  !/^%[0-9]+$/u.test(fields[5]) ||
@@ -243,6 +246,7 @@ function parseVerifyPanes(output) {
243
246
  paneId: fields[5],
244
247
  semanticPaneId: fields[6],
245
248
  semanticWindowId: fields[7],
249
+ windowIndex: Number(fields[8]),
246
250
  });
247
251
  }
248
252
  return panes;
@@ -368,9 +372,7 @@ export class WorkspacePromotionAuthority {
368
372
  // promotion is the app's only repair verb — so reconcile the stamps here
369
373
  // before resolving to a `replayed` outcome. Stamping is additive and never
370
374
  // overwrites a valid stamp, so a healthy session is untouched.
371
- const alreadyRegistered = this.#registry
372
- .list()
373
- .find((workspace) => workspace.sessionName === session.sessionName);
375
+ const alreadyRegistered = this.#registeredSession(session.sessionName);
374
376
  if (alreadyRegistered) {
375
377
  const registeredIdentity = {
376
378
  workspaceName: alreadyRegistered.name,
@@ -390,6 +392,39 @@ export class WorkspacePromotionAuthority {
390
392
  this.#assertActive(request.operationId);
391
393
  await this.#verifyPromotedInventory(session.sessionId, identity);
392
394
  await this.#publishFleetEnrollment(request, session, identity);
395
+ // Catalog discovery can admit this session while the native stamping
396
+ // and verification awaits run. Reuse that exact registry identity instead
397
+ // of publishing a second name for the same session at the commit point.
398
+ const discovered = this.#registeredSession(session.sessionName);
399
+ if (discovered) {
400
+ if (discovered.name !== identity.workspaceName) {
401
+ try {
402
+ await this.#io.runTmux([
403
+ "set-option",
404
+ "-t",
405
+ session.sessionId,
406
+ SESSION_WORKSPACE_OPTION,
407
+ discovered.name,
408
+ ]);
409
+ }
410
+ catch (error) {
411
+ throw new WorkspacePromotionError("stamp_failed", {
412
+ operationId: request.operationId,
413
+ workspaceName: discovered.name,
414
+ }, error);
415
+ }
416
+ }
417
+ this.#assertActive(request.operationId);
418
+ if (this.#registeredSession(session.sessionName)?.name !== discovered.name) {
419
+ throw new WorkspacePromotionError("workspace_conflict", {
420
+ operationId: request.operationId,
421
+ workspaceName: discovered.name,
422
+ });
423
+ }
424
+ return this.#succeed(request, fingerprint, discovered.name, session.sessionName, {
425
+ replayed: true,
426
+ });
427
+ }
393
428
  let registered;
394
429
  try {
395
430
  registered = this.#registry.add({
@@ -449,6 +484,15 @@ export class WorkspacePromotionAuthority {
449
484
  throw error;
450
485
  }
451
486
  }
487
+ #registeredSession(sessionName) {
488
+ const candidates = this.#registry
489
+ .list()
490
+ .filter((workspace) => workspace.sessionName === sessionName);
491
+ if (candidates.length > 1) {
492
+ throw new WorkspacePromotionError("workspace_conflict", { sessionName });
493
+ }
494
+ return candidates[0];
495
+ }
452
496
  #assertConflictFreeIdentity(identity) {
453
497
  for (const workspace of this.#registry.list()) {
454
498
  if (workspace.name === identity.workspaceName &&
@@ -531,6 +575,19 @@ export class WorkspacePromotionAuthority {
531
575
  reason: "empty_or_foreign_pane_inventory",
532
576
  });
533
577
  }
578
+ // list-panes -s repeats each backing pane once per window link. Stamp the
579
+ // backing only once, and reject contradictory observations before writes.
580
+ const uniqueScan = new Map();
581
+ for (const pane of scanned) {
582
+ const previous = uniqueScan.get(pane.paneId);
583
+ if (previous && JSON.stringify(previous) !== JSON.stringify(pane)) {
584
+ throw new WorkspacePromotionError("promotion_verification_failed", {
585
+ reason: "inconsistent_tmux_topology",
586
+ });
587
+ }
588
+ uniqueScan.set(pane.paneId, pane);
589
+ }
590
+ scanned = [...uniqueScan.values()];
534
591
  const nowSec = Math.floor(this.#io.now() / 1000);
535
592
  const reconciledWindows = new Set();
536
593
  try {
@@ -699,7 +756,29 @@ export class WorkspacePromotionAuthority {
699
756
  reason: "missing_window_stamp",
700
757
  });
701
758
  }
702
- const catalog = analyzeTrustedSemanticPaneCatalog(panes.map((pane) => ({
759
+ // Validate links independently from their shared backing. Repeated native
760
+ // panes are legal only across distinct links with identical backing facts.
761
+ const links = new Map();
762
+ const backingPanes = new Map();
763
+ const semanticWindows = new Map();
764
+ for (const pane of panes) {
765
+ const { windowIndex, ...backing } = pane;
766
+ const previous = backingPanes.get(pane.paneId);
767
+ const semanticOwner = semanticWindows.get(pane.semanticWindowId);
768
+ const rows = links.get(windowIndex) ?? [];
769
+ if ((previous && JSON.stringify(previous) !== JSON.stringify(backing)) ||
770
+ (semanticOwner && semanticOwner !== pane.windowId) ||
771
+ rows.some((row) => row.paneId === pane.paneId || row.windowId !== pane.windowId)) {
772
+ throw new WorkspacePromotionError("promotion_verification_failed", {
773
+ reason: "inconsistent_tmux_topology",
774
+ });
775
+ }
776
+ backingPanes.set(pane.paneId, backing);
777
+ semanticWindows.set(pane.semanticWindowId, pane.windowId);
778
+ rows.push(pane);
779
+ links.set(windowIndex, rows);
780
+ }
781
+ const catalog = analyzeTrustedSemanticPaneCatalog([...backingPanes.values()].map((pane) => ({
703
782
  workspaceName: identity.workspaceName,
704
783
  semanticPaneId: pane.semanticPaneId === "" ? null : pane.semanticPaneId,
705
784
  sessionId: pane.sessionId,
@@ -716,16 +795,16 @@ export class WorkspacePromotionAuthority {
716
795
  reason: "semantic_pane_catalog_rejected_inventory",
717
796
  });
718
797
  }
719
- const windows = new Map();
720
- for (const pane of panes) {
721
- const rows = windows.get(pane.windowId) ?? [];
798
+ const backings = new Map();
799
+ for (const pane of backingPanes.values()) {
800
+ const rows = backings.get(pane.windowId) ?? [];
722
801
  rows.push(pane);
723
- windows.set(pane.windowId, rows);
802
+ backings.set(pane.windowId, rows);
724
803
  }
725
804
  const sessionWindowCount = panes[0].sessionWindowCount;
726
- if (windows.size !== sessionWindowCount ||
805
+ if (links.size !== sessionWindowCount ||
727
806
  panes.some((pane) => pane.sessionWindowCount !== sessionWindowCount) ||
728
- [...windows.values()].some((rows) => rows.length !== rows[0].windowPaneCount ||
807
+ [...links.values(), ...backings.values()].some((rows) => rows.length !== rows[0].windowPaneCount ||
729
808
  rows.some((row) => row.windowPaneCount !== rows.length ||
730
809
  row.windowName !== rows[0].windowName ||
731
810
  row.semanticWindowId !== rows[0].semanticWindowId))) {
@@ -101,6 +101,10 @@ export class WorkspaceRegistry {
101
101
  has(name) {
102
102
  return this.workspaces.some((w) => w.name === name);
103
103
  }
104
+ /** Whether this entry is discovered live state, never durable user intent. */
105
+ isVolatile(name) {
106
+ return this.volatileNames.has(name);
107
+ }
104
108
  add(input) {
105
109
  if (this.has(input.name)) {
106
110
  throw new WorkspaceAlreadyExistsError(input.name);
@@ -20,9 +20,11 @@ function rejectUpgrade(socket, status) {
20
20
  ? "Forbidden"
21
21
  : status === 404
22
22
  ? "Not Found"
23
- : status === 426
24
- ? "Upgrade Required"
25
- : "Service Unavailable";
23
+ : status === 410
24
+ ? "Gone"
25
+ : status === 426
26
+ ? "Upgrade Required"
27
+ : "Service Unavailable";
26
28
  try {
27
29
  socket.end(`HTTP/1.1 ${status} ${phrase}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
28
30
  }
@@ -36,7 +38,7 @@ function rejectUpgrade(socket, status) {
36
38
  * Origin, and pre-auth capacity decisions happen before `handleUpgrade`, so
37
39
  * rejected requests never become WebSockets.
38
40
  */
39
- export function attachPaneStreamWebSocket(server, coordinator) {
41
+ export function attachPaneStreamWebSocket(server, coordinator, redemptionPath = PANE_STREAM_REDEEM_PATH, isCurrent = () => true) {
40
42
  const wss = new WebSocketServer({
41
43
  noServer: true,
42
44
  clientTracking: false,
@@ -52,9 +54,17 @@ export function attachPaneStreamWebSocket(server, coordinator) {
52
54
  const upgrade = (request, socket, head) => {
53
55
  const rawPath = request.url ?? "";
54
56
  const pathname = rawPath.split("?", 1)[0] ?? "";
55
- if (!pathname.startsWith("/v1/terminal/pane-streams/"))
57
+ if (redemptionPath === PANE_STREAM_REDEEM_PATH &&
58
+ pathname.startsWith("/v1/terminal/pane-streams/")) {
59
+ rejectUpgrade(socket, 426);
60
+ return;
61
+ }
62
+ if (redemptionPath !== PANE_STREAM_REDEEM_PATH && pathname !== redemptionPath)
56
63
  return;
57
- if (pathname !== PANE_STREAM_REDEEM_PATH) {
64
+ if (redemptionPath === PANE_STREAM_REDEEM_PATH &&
65
+ !pathname.startsWith("/v2/terminal/pane-streams/"))
66
+ return;
67
+ if (pathname !== redemptionPath) {
58
68
  rejectUpgrade(socket, 404);
59
69
  return;
60
70
  }
@@ -78,6 +88,10 @@ export function attachPaneStreamWebSocket(server, coordinator) {
78
88
  rejectUpgrade(socket, 403);
79
89
  return;
80
90
  }
91
+ if (!isCurrent()) {
92
+ rejectUpgrade(socket, 410);
93
+ return;
94
+ }
81
95
  const decision = coordinator.reserveUpgrade({
82
96
  path: rawPath,
83
97
  protocols: protocols(protocolHeaders[0]),