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,385 @@
1
+ import {
2
+ ActionContractsZ,
3
+ ApplicationShellProjectionInputV2SchemaZ,
4
+ DesktopApplicationShellTargetSchemaZ,
5
+ TerminalRuntimeInventoryProjectionV1SchemaZ,
6
+ TmuxServerScopeSchemaZ,
7
+ WorkspaceCatalogResourceV2SchemaZ,
8
+ WorkspaceMultiplexerIntentSchemaZ,
9
+ WorkspacePaneCreateArgumentsSchemaZ,
10
+ type ActionResult,
11
+ type DesktopApplicationShellTarget,
12
+ type TerminalRuntimeInventoryProjectionV1,
13
+ type TmuxServerScope,
14
+ } from "@tmux-ide/contracts";
15
+ import type { TerminalFirstDaemonTransport } from "./direct-application-shell-transport.ts";
16
+ import type { ApplicationShellEventHandlers } from "./application-shell-session.ts";
17
+ import type { PreparedTerminalRuntimeInventory } from "./workspace-event-supervisor.ts";
18
+ import type {
19
+ WorkspaceClientCatalogPort,
20
+ WorkspaceClientOwnerActionPort,
21
+ } from "./workspace-client-types.ts";
22
+ import { createTmuxServerClient, type TmuxServerClientOptions } from "./tmux-server-client.ts";
23
+
24
+ export interface ScopedTmuxServerTransportOptions {
25
+ readonly clientOptions: TmuxServerClientOptions;
26
+ readonly scope: TmuxServerScope;
27
+ readonly target: DesktopApplicationShellTarget;
28
+ readonly sessionName: string;
29
+ readonly liveSessionId: string;
30
+ }
31
+
32
+ function abortable<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
33
+ signal.throwIfAborted();
34
+ return new Promise((resolve, reject) => {
35
+ const abort = () => reject(signal.reason);
36
+ signal.addEventListener("abort", abort, { once: true });
37
+ void work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
38
+ });
39
+ }
40
+
41
+ /** One selected incarnation and live session. No endpoint in this adapter can fall back to default. */
42
+ export function createScopedTmuxServerTransport(
43
+ options: ScopedTmuxServerTransportOptions,
44
+ ): TerminalFirstDaemonTransport {
45
+ const scope = TmuxServerScopeSchemaZ.parse(options.scope);
46
+ const target = DesktopApplicationShellTargetSchemaZ.parse(options.target);
47
+ if (target.daemon.instanceId !== scope.generation)
48
+ throw new Error("Scoped owner generation mismatch");
49
+ const base = `/api/v1/tmux-servers/${scope.serverId}/${scope.generation}`;
50
+ const fetcher = options.clientOptions.fetch ?? fetch;
51
+ const lifetime = new AbortController();
52
+ let retired = false;
53
+ let revision = 0;
54
+ let ready: Promise<void> | null = null;
55
+ let sink: ((resource: TerminalRuntimeInventoryProjectionV1) => void) | null = null;
56
+ const handlers = new Set<ApplicationShellEventHandlers>();
57
+ const catalogListeners = new Set<() => void>();
58
+ let refreshing = false;
59
+ let refreshAgain = false;
60
+ const validateTarget = (input: unknown): DesktopApplicationShellTarget => {
61
+ const candidate = DesktopApplicationShellTargetSchemaZ.parse(input);
62
+ if (
63
+ retired ||
64
+ candidate.workspaceName !== target.workspaceName ||
65
+ candidate.daemon.instanceId !== scope.generation ||
66
+ candidate.daemon.protocolVersion !== target.daemon.protocolVersion ||
67
+ candidate.daemon.productVersion !== target.daemon.productVersion ||
68
+ candidate.daemon.startedAt !== target.daemon.startedAt
69
+ )
70
+ throw new Error("Retired or mismatched scoped target");
71
+ return candidate;
72
+ };
73
+ const checkEnvelope = (raw: unknown): Record<string, unknown> => {
74
+ if (
75
+ !raw ||
76
+ typeof raw !== "object" ||
77
+ !("version" in raw) ||
78
+ raw.version !== 1 ||
79
+ !("server" in raw)
80
+ )
81
+ throw new Error("Invalid scoped response");
82
+ const candidate = TmuxServerScopeSchemaZ.parse(raw.server);
83
+ if (
84
+ retired ||
85
+ candidate.serverId !== scope.serverId ||
86
+ candidate.generation !== scope.generation
87
+ )
88
+ throw new Error("Scoped response generation mismatch");
89
+ return raw as Record<string, unknown>;
90
+ };
91
+ const url = (path: string) => new URL(`${base}/${path}`, options.clientOptions.baseUrl);
92
+ const headers = { Authorization: `Bearer ${options.clientOptions.ownerToken}` };
93
+ const request = async (path: string, signal: AbortSignal) => {
94
+ validateTarget(target);
95
+ const response = await fetcher(url(path), {
96
+ headers,
97
+ redirect: "error",
98
+ cache: "no-store",
99
+ signal: AbortSignal.any([
100
+ signal,
101
+ lifetime.signal,
102
+ AbortSignal.timeout(options.clientOptions.timeoutMs ?? 5000),
103
+ ]),
104
+ });
105
+ if (!response.ok) throw new Error(`Scoped resource unavailable (${response.status})`);
106
+ return checkEnvelope(await response.json());
107
+ };
108
+ const workspacePath = (kind: string) =>
109
+ `${kind}/${encodeURIComponent(target.workspaceName)}?liveSessionId=${encodeURIComponent(options.liveSessionId)}`;
110
+ const readInventory = async (signal: AbortSignal) => {
111
+ const raw = await request(workspacePath("inventory"), signal);
112
+ const resource = TerminalRuntimeInventoryProjectionV1SchemaZ.parse(raw.resource);
113
+ if (resource.workspaceName !== target.workspaceName)
114
+ throw new Error("Scoped workspace mismatch");
115
+ return resource;
116
+ };
117
+ const refresh = () => {
118
+ if (!sink || retired) return;
119
+ if (refreshing) {
120
+ refreshAgain = true;
121
+ return;
122
+ }
123
+ refreshing = true;
124
+ void (async () => {
125
+ do {
126
+ refreshAgain = false;
127
+ const observed = revision;
128
+ const resource = await readInventory(lifetime.signal);
129
+ if (retired) return;
130
+ if (observed !== revision) {
131
+ refreshAgain = true;
132
+ continue;
133
+ }
134
+ sink?.({ ...resource, resourceRevision: revision });
135
+ } while (refreshAgain && !retired);
136
+ })()
137
+ .catch((error: unknown) => {
138
+ if (!retired) for (const listener of handlers) listener.onError(String(error));
139
+ })
140
+ .finally(() => {
141
+ refreshing = false;
142
+ });
143
+ };
144
+ const ensureEvents = (): Promise<void> => {
145
+ if (ready) return ready;
146
+ let resolveReady!: () => void;
147
+ let rejectReady!: (error: unknown) => void;
148
+ ready = new Promise<void>((resolve, reject) => {
149
+ resolveReady = resolve;
150
+ rejectReady = reject;
151
+ });
152
+ void ready.catch(() => undefined);
153
+ const deadline = setTimeout(() => {
154
+ rejectReady(new Error("Scoped subscription deadline exceeded"));
155
+ lifetime.abort();
156
+ }, options.clientOptions.timeoutMs ?? 5000);
157
+ void ready.then(
158
+ () => clearTimeout(deadline),
159
+ () => clearTimeout(deadline),
160
+ );
161
+ void (async () => {
162
+ const response = await fetcher(url(workspacePath("session-events")), {
163
+ headers,
164
+ redirect: "error",
165
+ cache: "no-store",
166
+ signal: lifetime.signal,
167
+ });
168
+ if (!response.ok || !response.body)
169
+ throw new Error(`Scoped event stream unavailable (${response.status})`);
170
+ const reader = response.body.getReader();
171
+ const decoder = new TextDecoder();
172
+ let buffer = "";
173
+ let opened = false;
174
+ try {
175
+ for (;;) {
176
+ const next = await reader.read();
177
+ if (next.done) throw new Error("Scoped event stream closed");
178
+ buffer += decoder.decode(next.value, { stream: true });
179
+ if (buffer.length > 65536) throw new Error("Scoped event frame too large");
180
+ for (;;) {
181
+ const end = buffer.indexOf("\n\n");
182
+ if (end < 0) break;
183
+ const event = buffer.slice(0, end);
184
+ buffer = buffer.slice(end + 2);
185
+ const data = event
186
+ .split("\n")
187
+ .filter((line) => line.startsWith("data:"))
188
+ .map((line) => line.slice(5).trimStart())
189
+ .join("\n");
190
+ if (!data) continue;
191
+ const frame = checkEnvelope(JSON.parse(data));
192
+ if (
193
+ !Number.isSafeInteger(frame.revision) ||
194
+ Number(frame.revision) < revision ||
195
+ !["ready", "invalidate", "heartbeat"].includes(String(frame.type))
196
+ )
197
+ throw new Error("Invalid scoped event frame");
198
+ if (!opened) {
199
+ if (frame.type !== "ready") throw new Error("Missing scoped subscription barrier");
200
+ opened = true;
201
+ revision = Number(frame.revision);
202
+ resolveReady();
203
+ } else if (frame.type === "ready")
204
+ throw new Error("Repeated scoped subscription barrier");
205
+ if (frame.type === "invalidate") {
206
+ revision = Number(frame.revision);
207
+ for (const listener of handlers) listener.onInvalidate();
208
+ for (const invalidate of catalogListeners) invalidate();
209
+ refresh();
210
+ }
211
+ }
212
+ }
213
+ } finally {
214
+ await reader.cancel().catch(() => undefined);
215
+ reader.releaseLock();
216
+ }
217
+ })().catch((error: unknown) => {
218
+ rejectReady(error);
219
+ if (retired) return;
220
+ retired = true;
221
+ sink = null;
222
+ lifetime.abort();
223
+ for (const listener of handlers) listener.onPeerMismatch(String(error));
224
+ });
225
+ return ready;
226
+ };
227
+ return {
228
+ validateTarget,
229
+ async fetchApplicationShell(candidate, signal) {
230
+ validateTarget(candidate);
231
+ await abortable(ensureEvents(), signal);
232
+ const raw = await request(workspacePath("application-shell"), signal);
233
+ return ApplicationShellProjectionInputV2SchemaZ.parse(raw.resource);
234
+ },
235
+ connectEvents(candidate, listener) {
236
+ validateTarget(candidate);
237
+ handlers.add(listener);
238
+ void ensureEvents().then(
239
+ () => {
240
+ if (handlers.has(listener) && !retired) listener.onVerifiedOpen();
241
+ },
242
+ () => undefined,
243
+ );
244
+ return {
245
+ close() {
246
+ handlers.delete(listener);
247
+ },
248
+ };
249
+ },
250
+ async prepareTerminalRuntimeInventory(candidate, signal) {
251
+ validateTarget(candidate);
252
+ await abortable(ensureEvents(), signal);
253
+ for (let attempt = 0; attempt < 4; attempt++) {
254
+ signal.throwIfAborted();
255
+ const observed = revision;
256
+ const resource = await readInventory(signal);
257
+ if (observed !== revision) continue;
258
+ let consumed = false;
259
+ return {
260
+ resource,
261
+ consume() {
262
+ if (consumed || retired || signal.aborted || observed !== revision) return null;
263
+ consumed = true;
264
+ return { ...resource, resourceRevision: revision };
265
+ },
266
+ dispose() {
267
+ consumed = true;
268
+ },
269
+ };
270
+ }
271
+ throw new Error("Scoped topology changed during preparation");
272
+ },
273
+ adoptTerminalRuntimeInventory(prepared: PreparedTerminalRuntimeInventory, onResource) {
274
+ const resource = prepared.consume();
275
+ if (resource) sink = onResource;
276
+ return resource;
277
+ },
278
+ refreshTerminalRuntimeInventory: refresh,
279
+ selectApplicationShellFallback() {
280
+ sink = null;
281
+ },
282
+ disposeEventSupervisor() {
283
+ retired = true;
284
+ sink = null;
285
+ handlers.clear();
286
+ catalogListeners.clear();
287
+ lifetime.abort();
288
+ },
289
+ connectWorkspaceCatalog(candidate, invalidate) {
290
+ validateTarget(candidate);
291
+ catalogListeners.add(invalidate);
292
+ return {
293
+ ready: ensureEvents(),
294
+ close() {
295
+ catalogListeners.delete(invalidate);
296
+ },
297
+ };
298
+ },
299
+ };
300
+ }
301
+
302
+ export function createScopedTmuxServerCatalogPort(
303
+ options: ScopedTmuxServerTransportOptions,
304
+ transport: TerminalFirstDaemonTransport,
305
+ ): WorkspaceClientCatalogPort {
306
+ return {
307
+ async read(target, signal) {
308
+ transport.validateTarget(target);
309
+ const barrier = transport.connectWorkspaceCatalog(target, () => undefined);
310
+ try {
311
+ await abortable(barrier.ready, signal);
312
+ } finally {
313
+ barrier.close();
314
+ }
315
+ const { scope, clientOptions } = options;
316
+ const response = await (clientOptions.fetch ?? fetch)(
317
+ new URL(
318
+ `/api/v1/tmux-servers/${scope.serverId}/${scope.generation}/catalog`,
319
+ clientOptions.baseUrl,
320
+ ),
321
+ {
322
+ headers: { Authorization: `Bearer ${clientOptions.ownerToken}` },
323
+ redirect: "error",
324
+ cache: "no-store",
325
+ signal,
326
+ },
327
+ );
328
+ if (!response.ok) throw new Error(`Scoped catalog unavailable (${response.status})`);
329
+ const envelope = (await response.json()) as {
330
+ version: number;
331
+ server: unknown;
332
+ resource: object;
333
+ };
334
+ const actual = TmuxServerScopeSchemaZ.parse(envelope.server);
335
+ if (
336
+ envelope.version !== 1 ||
337
+ actual.serverId !== scope.serverId ||
338
+ actual.generation !== scope.generation
339
+ )
340
+ throw new Error("Scoped catalog mismatch");
341
+ transport.validateTarget(target);
342
+ return WorkspaceCatalogResourceV2SchemaZ.parse({
343
+ ...envelope.resource,
344
+ version: 2,
345
+ daemon: target.daemon,
346
+ });
347
+ },
348
+ subscribe(target, invalidate) {
349
+ return transport.connectWorkspaceCatalog(target, invalidate);
350
+ },
351
+ };
352
+ }
353
+
354
+ export function createScopedTmuxServerOwnerActions(
355
+ options: ScopedTmuxServerTransportOptions,
356
+ transport: TerminalFirstDaemonTransport,
357
+ ): WorkspaceClientOwnerActionPort {
358
+ const client = createTmuxServerClient(options.clientOptions, options.scope);
359
+ return {
360
+ async dispatch(request) {
361
+ transport.validateTarget(request.target);
362
+ if (request.name === "workspace.pane.create") {
363
+ const intent = WorkspacePaneCreateArgumentsSchemaZ.parse(request.input);
364
+ if (intent.workspaceName !== options.target.workspaceName)
365
+ throw new Error("Scoped action workspace mismatch");
366
+ const result = await client.createPane(request.operationId, intent, options.liveSessionId);
367
+ transport.validateTarget(request.target);
368
+ transport.refreshTerminalRuntimeInventory();
369
+ return result as ActionResult<typeof request.name>;
370
+ }
371
+ const intent = WorkspaceMultiplexerIntentSchemaZ.parse({
372
+ ...request.input,
373
+ verb: request.name,
374
+ });
375
+ if (intent.workspaceName !== options.target.workspaceName)
376
+ throw new Error("Scoped action workspace mismatch");
377
+ const result = await client.mutate(request.operationId, intent, options.liveSessionId);
378
+ transport.validateTarget(request.target);
379
+ transport.refreshTerminalRuntimeInventory();
380
+ return ActionContractsZ[request.name].result.parse(result) as ActionResult<
381
+ typeof request.name
382
+ >;
383
+ },
384
+ };
385
+ }
@@ -0,0 +1,279 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import {
3
+ PANE_STREAM_PROTOCOL_VERSION,
4
+ PANE_STREAM_REDEEM_PATH,
5
+ PANE_STREAM_WEBSOCKET_SUBPROTOCOL,
6
+ tmuxServerPaneStreamPath,
7
+ } from "@tmux-ide/contracts";
8
+ import {
9
+ createTmuxServerClient,
10
+ listTmuxServers,
11
+ registerTmuxServer,
12
+ removeTmuxServer,
13
+ } from "./tmux-server-client.ts";
14
+ const scope = {
15
+ serverId: `tmux-server.${"a".repeat(32)}`,
16
+ generation: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
17
+ };
18
+ const other = { ...scope, serverId: `tmux-server.${"b".repeat(32)}` };
19
+ const requestId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
20
+ const base = {
21
+ baseUrl: "http://127.0.0.1:4000",
22
+ ownerToken: "owner",
23
+ hostClientId: "client",
24
+ origin: "http://localhost:3000",
25
+ };
26
+ const sessions = (server = scope) => ({
27
+ version: 1,
28
+ server,
29
+ sessions: [
30
+ {
31
+ liveSessionId: `live-session.${"a".repeat(20)}`,
32
+ sessionName: "main",
33
+ workspaceName: "alias",
34
+ paneCount: 1,
35
+ },
36
+ ],
37
+ });
38
+ const stream = {
39
+ protocolVersion: PANE_STREAM_PROTOCOL_VERSION,
40
+ workspaceName: "main",
41
+ panes: ["pane.one"],
42
+ viewerMode: "interactive" as const,
43
+ };
44
+ const issued = () => ({
45
+ status: "issued",
46
+ descriptor: {
47
+ protocolVersion: PANE_STREAM_PROTOCOL_VERSION,
48
+ webSocketUrl: `ws://127.0.0.1:4000${tmuxServerPaneStreamPath(scope)}`,
49
+ subprotocol: PANE_STREAM_WEBSOCKET_SUBPROTOCOL,
50
+ redemptionTicket: `ps2_${"a".repeat(43)}`,
51
+ daemonInstanceId: scope.generation,
52
+ requestId,
53
+ expiresAt: Date.now() + 10000,
54
+ panes: ["pane.one"],
55
+ effectiveViewerMode: "interactive",
56
+ },
57
+ });
58
+
59
+ describe("explicit tmux server SDK client", () => {
60
+ it("validates versioned server listing without fallback", async () => {
61
+ const fetcher = mock(async (url: string | URL | Request, init?: RequestInit) => {
62
+ expect(String(url)).toBe(`${base.baseUrl}/api/v1/tmux-servers`);
63
+ expect(init?.headers).toMatchObject({ Authorization: "Bearer owner" });
64
+ return Response.json({
65
+ version: 1,
66
+ servers: [{ ...scope, state: "online", label: "default" }],
67
+ });
68
+ });
69
+ expect(
70
+ (await listTmuxServers({ ...base, fetch: fetcher as typeof fetch })).servers,
71
+ ).toHaveLength(1);
72
+ await expect(
73
+ listTmuxServers({
74
+ ...base,
75
+ fetch: mock(async () => Response.json({ version: 2, servers: [] })) as typeof fetch,
76
+ }),
77
+ ).rejects.toThrow();
78
+ });
79
+
80
+ it("binds copied inputs and cache keys to an immutable exact scope", async () => {
81
+ const input = { ...scope };
82
+ const fetcher = mock(async (url: string | URL | Request) => {
83
+ expect(String(url)).toContain(`/${scope.serverId}/${scope.generation}/sessions`);
84
+ return Response.json(sessions());
85
+ });
86
+ const client = createTmuxServerClient({ ...base, fetch: fetcher as typeof fetch }, input);
87
+ input.serverId = other.serverId;
88
+ expect((await client.sessions()).server).toEqual(scope);
89
+ expect(Object.isFrozen(client.scope)).toBe(true);
90
+ expect(client.cacheKey("pane", "%0")).not.toBe(
91
+ createTmuxServerClient(base, other).cacheKey("pane", "%0"),
92
+ );
93
+ });
94
+
95
+ it("rejects scope mismatches and stale HTTP refusal without retries", async () => {
96
+ const fetcher = mock(async () => Response.json(sessions(other)));
97
+ await expect(
98
+ createTmuxServerClient({ ...base, fetch: fetcher as typeof fetch }, scope).sessions(),
99
+ ).rejects.toMatchObject({ code: "scope-mismatch" });
100
+ const refused = mock(async () =>
101
+ Response.json({ error: { code: "stale-generation" } }, { status: 409 }),
102
+ );
103
+ await expect(
104
+ createTmuxServerClient({ ...base, fetch: refused as typeof fetch }, scope).sessions(),
105
+ ).rejects.toMatchObject({ status: 409 });
106
+ expect(refused).toHaveBeenCalledTimes(1);
107
+ });
108
+
109
+ it("drops an old selection's late response after disposal", async () => {
110
+ let resolve!: (response: Response) => void;
111
+ const pending = new Promise<Response>((done) => {
112
+ resolve = done;
113
+ });
114
+ const fetcher = mock(async () => pending);
115
+ const client = createTmuxServerClient({ ...base, fetch: fetcher as typeof fetch }, scope);
116
+ const result = client.sessions();
117
+ client.dispose();
118
+ resolve(Response.json(sessions()));
119
+ await expect(result).rejects.toMatchObject({ code: "disposed" });
120
+ await expect(client.sessions()).rejects.toMatchObject({ code: "disposed" });
121
+ expect(fetcher).toHaveBeenCalledTimes(1);
122
+ });
123
+
124
+ it("uses explicit owner authority for passive fleet close without a controller principal", async () => {
125
+ const fetcher = mock(async (_url: RequestInfo | URL, init?: RequestInit) => {
126
+ expect(new Headers(init?.headers).has("X-Tmux-Ide-Host-Client-Id")).toBe(false);
127
+ expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer owner");
128
+ return Response.json({
129
+ operationId: requestId,
130
+ daemonInstanceId: scope.generation,
131
+ workspaceName: "same",
132
+ verb: "workspace.session.kill",
133
+ outcome: "applied",
134
+ });
135
+ });
136
+ await createTmuxServerClient({ ...base, fetch: fetcher as typeof fetch }, scope).mutate(
137
+ requestId,
138
+ {
139
+ verb: "workspace.session.kill",
140
+ workspaceName: "same",
141
+ fleetTarget: {
142
+ daemonInstanceId: scope.generation,
143
+ liveSessionId: `live-session.${"a".repeat(20)}`,
144
+ sessionName: "same",
145
+ },
146
+ },
147
+ );
148
+ expect(fetcher).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ it("sends owner-scoped mutations and verifies generation and operation echoes", async () => {
152
+ const fetcher = mock(async (_url: string | URL | Request, init?: RequestInit) => {
153
+ const body = JSON.parse(String(init?.body));
154
+ expect(body.expectedDaemonInstanceId).toBe(scope.generation);
155
+ return Response.json({
156
+ operationId: body.operationId,
157
+ daemonInstanceId: scope.generation,
158
+ outcome: "applied",
159
+ workspaceName: "main",
160
+ verb: "workspace.pane.select",
161
+ semanticPaneId: "pane.one",
162
+ });
163
+ });
164
+ const client = createTmuxServerClient({ ...base, fetch: fetcher as typeof fetch }, scope);
165
+ const result = await client.mutate(requestId, {
166
+ verb: "workspace.pane.select",
167
+ workspaceName: "main",
168
+ semanticPaneId: "pane.one",
169
+ });
170
+ expect(result.daemonInstanceId).toBe(scope.generation);
171
+ });
172
+
173
+ it("sends exact issue headers and validates scoped redemption", async () => {
174
+ const fetcher = mock(async (url: string | URL | Request, init?: RequestInit) => {
175
+ expect(String(url)).toEndWith(`/${scope.serverId}/${scope.generation}/pane-streams/issue`);
176
+ expect(init?.headers).toMatchObject({
177
+ Authorization: "Bearer owner",
178
+ Origin: base.origin,
179
+ "X-Tmux-Ide-Request-Id": requestId,
180
+ "X-Tmux-Ide-Expected-Daemon-Instance-Id": scope.generation,
181
+ "X-Tmux-Ide-Host-Client-Id": "client",
182
+ });
183
+ return Response.json(issued());
184
+ });
185
+ expect(
186
+ (
187
+ await createTmuxServerClient(
188
+ { ...base, fetch: fetcher as typeof fetch },
189
+ scope,
190
+ ).issuePaneStream(requestId, stream)
191
+ ).status,
192
+ ).toBe("issued");
193
+ const wrong = issued();
194
+ wrong.descriptor.webSocketUrl = `ws://127.0.0.1:4000${tmuxServerPaneStreamPath(other)}`;
195
+ await expect(
196
+ createTmuxServerClient(
197
+ { ...base, fetch: mock(async () => Response.json(wrong)) as typeof fetch },
198
+ scope,
199
+ ).issuePaneStream(requestId, stream),
200
+ ).rejects.toMatchObject({ code: "scope-mismatch" });
201
+ });
202
+
203
+ it("rejects inventories from the wrong workspace", async () => {
204
+ const fetcher = mock(async () =>
205
+ Response.json({
206
+ version: 1,
207
+ server: scope,
208
+ resource: {
209
+ workspaceName: "other",
210
+ workspaceId: "workspace.0123456789abcdefabcd",
211
+ sessionId: "session.0123456789abcdefabcd",
212
+ resourceRevision: 0,
213
+ semanticPaneIds: ["pane.one"],
214
+ },
215
+ }),
216
+ );
217
+ await expect(
218
+ createTmuxServerClient({ ...base, fetch: fetcher as typeof fetch }, scope).inventory("main"),
219
+ ).rejects.toMatchObject({ code: "scope-mismatch" });
220
+ });
221
+ });
222
+
223
+ describe("server registration and adopted default", () => {
224
+ it("passes remote selector intent unchanged and removes by opaque registration", async () => {
225
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
226
+ const fetcher = mock(async (url: string | URL | Request, init?: RequestInit) => {
227
+ calls.push({ url: String(url), init });
228
+ return Response.json(
229
+ init?.method === "DELETE"
230
+ ? { removed: true }
231
+ : { ...scope, label: "remote", state: "online" },
232
+ );
233
+ });
234
+ const options = { ...base, fetch: fetcher as typeof fetch };
235
+ await registerTmuxServer(options, {
236
+ label: "remote",
237
+ selector: { kind: "path", path: "/remote/socket" },
238
+ });
239
+ expect(JSON.parse(String(calls[0]?.init?.body)).selector).toEqual({
240
+ kind: "path",
241
+ path: "/remote/socket",
242
+ });
243
+ await removeTmuxServer(options, scope.serverId);
244
+ expect(calls[1]?.init?.method).toBe("DELETE");
245
+ expect(calls[1]?.url).toEndWith(`/${scope.serverId}`);
246
+ await expect(removeTmuxServer(options, "../default")).rejects.toThrow();
247
+ expect(fetcher).toHaveBeenCalledTimes(2);
248
+ });
249
+
250
+ it("accepts default redemption only with an exact generation and request echo", async () => {
251
+ const response = issued();
252
+ response.descriptor.webSocketUrl = `ws://127.0.0.1:4000${PANE_STREAM_REDEEM_PATH}`;
253
+ const fetcher = mock(async () => Response.json(response));
254
+ const client = createTmuxServerClient({ ...base, fetch: fetcher as typeof fetch }, scope);
255
+ expect((await client.issuePaneStream(requestId, stream)).status).toBe("issued");
256
+ response.descriptor.daemonInstanceId = requestId;
257
+ await expect(client.issuePaneStream(requestId, stream)).rejects.toMatchObject({
258
+ code: "scope-mismatch",
259
+ });
260
+ });
261
+
262
+ it("preserves authoritative workspace aliases and unavailable collisions", async () => {
263
+ const response = sessions();
264
+ const client = createTmuxServerClient(
265
+ { ...base, fetch: mock(async () => Response.json(response)) as typeof fetch },
266
+ scope,
267
+ );
268
+ expect((await client.sessions()).sessions[0]?.workspaceName).toBe("alias");
269
+ const unavailable = {
270
+ ...response,
271
+ sessions: response.sessions.map((session) => ({ ...session, workspaceName: null })),
272
+ };
273
+ const otherClient = createTmuxServerClient(
274
+ { ...base, fetch: mock(async () => Response.json(unavailable)) as typeof fetch },
275
+ scope,
276
+ );
277
+ expect((await otherClient.sessions()).sessions[0]?.workspaceName).toBeNull();
278
+ });
279
+ });