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,4 @@
1
+ import { mountTmuxServerRoutes } from "./tmux-servers.js";
1
2
  import { streamBoundedLogs } from "./log-stream.js";
2
3
  import { mountWorkspaceAdmissionRoute } from "./resources/workspace-admission-route.js";
3
4
  import { mountFleetPreviewRoute } from "./resources/fleet-preview-route.js";
@@ -317,6 +318,11 @@ export function createApp(options = {}) {
317
318
  const app = new Hono();
318
319
  // Allow cross-origin (Next.js dashboard, Tailscale, etc.)
319
320
  app.use("/*", cors());
321
+ if (options.tmuxServerOwners)
322
+ mountTmuxServerRoutes(app, {
323
+ ownerToken: options.remoteAccess?.ownerToken ?? null,
324
+ owners: options.tmuxServerOwners,
325
+ });
320
326
  // Owner-only reads use the same early routing boundary as issuance below:
321
327
  // remote/project credentials neither authorize nor block the owner bearer.
322
328
  mountDiagnosticsRoute(app, {
@@ -506,10 +512,24 @@ export function createApp(options = {}) {
506
512
  // Credential-free endpoint binding. A desktop host reads the nonce from the
507
513
  // owner-only canonical record, probes this endpoint, and compares before it
508
514
  // sends any remote-access or local-bypass credential.
509
- app.get("/identity", (c) => {
515
+ // Coalesce concurrent probes, but never reuse a completed server proof.
516
+ let pendingTmuxProof = null;
517
+ app.get("/identity", async (c) => {
518
+ const wantsTmuxProof = c.req.query("tmuxServerProof") === "1";
519
+ let tmuxServerProof = null;
520
+ if (wantsTmuxProof && options.tmuxServerProof) {
521
+ pendingTmuxProof ??= Promise.resolve()
522
+ .then(options.tmuxServerProof)
523
+ .catch(() => null)
524
+ .finally(() => {
525
+ pendingTmuxProof = null;
526
+ });
527
+ tmuxServerProof = await pendingTmuxProof;
528
+ }
510
529
  return c.json({
511
530
  ok: true,
512
531
  pid: process.pid,
532
+ ...(wantsTmuxProof && options.tmuxServerProof ? { tmuxServerProof } : {}),
513
533
  protocolVersion: DAEMON_WIRE_PROTOCOL_VERSION,
514
534
  productVersion: daemonIdentity.productVersion,
515
535
  instanceId: daemonIdentity.instanceId,
@@ -0,0 +1,37 @@
1
+ import { Hono } from "hono";
2
+ import { TmuxServerScopeSchemaZ } from "@tmux-ide/contracts";
3
+ import { TmuxServerScopeError } from "../lib/tmux-server-owners.js";
4
+ import { mountTerminalNativeBackingRoute } from "./resources/terminal-native-backing-route.js";
5
+ /** Retain admission/coalescing limits for the lifetime of each independent owner. */
6
+ export function mountTmuxServerNativeBackingRoute(app, options) {
7
+ const routers = new WeakMap();
8
+ app.get(`${options.scopedPath}/native-backing/:workspaceName/:pane`, async (c) => {
9
+ const scope = TmuxServerScopeSchemaZ.safeParse({
10
+ serverId: c.req.param("serverId"),
11
+ generation: c.req.param("generation"),
12
+ });
13
+ if (!scope.success)
14
+ return c.json({ status: "invalid" }, 400);
15
+ try {
16
+ return await options.owners.withOwner(scope.data, async (owner) => {
17
+ let router = routers.get(owner);
18
+ if (!router) {
19
+ router = new Hono();
20
+ mountTerminalNativeBackingRoute(router, {
21
+ ownerToken: options.ownerToken,
22
+ generation: owner.generation,
23
+ resolveSession: (workspace) => owner.workspaceRegistry.get(workspace)?.sessionName ?? null,
24
+ capture: (session, pane, expected) => owner.sessionRuntimeRegistry.captureNativeBacking(session, pane, expected),
25
+ });
26
+ routers.set(owner, router);
27
+ }
28
+ const url = new URL(c.req.url);
29
+ url.pathname = `/api/project/${encodeURIComponent(c.req.param("workspaceName"))}/terminal-native-backing/${encodeURIComponent(c.req.param("pane"))}`;
30
+ return await router.request(new Request(url, c.req.raw));
31
+ });
32
+ }
33
+ catch (error) {
34
+ return c.json({ status: error instanceof TmuxServerScopeError ? "retired" : "unavailable" }, error instanceof TmuxServerScopeError ? 409 : 503);
35
+ }
36
+ });
37
+ }
@@ -0,0 +1,345 @@
1
+ import { mountTmuxServerNativeBackingRoute } from "./tmux-server-native-backing.js";
2
+ import { Hono } from "hono";
3
+ import { streamSSE } from "hono/streaming";
4
+ import { projectApplicationShellResource } from "./resources/application-shell.js";
5
+ import { fleetSessionIdForName } from "./resources/fleet-catalog.js";
6
+ import { z } from "zod";
7
+ import { TMUX_SERVERS_API_PATH, PANE_STREAM_ISSUE_PATH, TmuxServerIdSchemaZ, TmuxServerScopeSchemaZ, TmuxServersResourceSchemaZ, TmuxServerSessionsResourceSchemaZ, WorkspaceMultiplexerMutationRequestSchemaZ, WorkspacePaneCreateMutationRequestSchemaZ, WorkspaceSessionCreateArgumentsSchemaZ, } from "@tmux-ide/contracts";
8
+ import { TmuxServerRegistrationSchemaZ, TmuxServerScopeError, } from "../lib/tmux-server-owners.js";
9
+ import { WorkspaceMultiplexerError } from "../lib/workspace-multiplexer-verbs.js";
10
+ import { ownerBearerMatches } from "./owner-authority.js";
11
+ import { mountPaneStreamIssueRoute } from "./pane-stream-issue.js";
12
+ import { projectTerminalRuntimeInventory } from "./resources/terminal-runtime-inventory.js";
13
+ const registrationSchema = TmuxServerRegistrationSchemaZ.omit({ serverId: true }).strict();
14
+ const workspaceSchema = z
15
+ .string()
16
+ .min(1)
17
+ .max(160)
18
+ .refine((value) => !/[\0\r\n]/u.test(value));
19
+ async function boundedJson(request) {
20
+ if (!request.body)
21
+ throw new TypeError("Missing body");
22
+ const reader = request.body.getReader();
23
+ const chunks = [];
24
+ let size = 0;
25
+ try {
26
+ for (;;) {
27
+ const next = await reader.read();
28
+ if (next.done)
29
+ break;
30
+ size += next.value.byteLength;
31
+ if (size > 64 * 1024) {
32
+ void reader.cancel().catch(() => undefined);
33
+ throw new TypeError("Body too large");
34
+ }
35
+ chunks.push(next.value);
36
+ }
37
+ }
38
+ finally {
39
+ reader.releaseLock();
40
+ }
41
+ const bytes = new Uint8Array(size);
42
+ let offset = 0;
43
+ for (const chunk of chunks) {
44
+ bytes.set(chunk, offset);
45
+ offset += chunk.byteLength;
46
+ }
47
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
48
+ }
49
+ function failure(c, error) {
50
+ if (error instanceof TmuxServerScopeError)
51
+ return c.json({ error: { code: error.code } }, error.code === "not-found" ? 404 : error.code === "capacity" ? 429 : 409);
52
+ if (error instanceof WorkspaceMultiplexerError)
53
+ return c.json({ error: { code: error.code } }, 409);
54
+ if (error instanceof z.ZodError || error instanceof SyntaxError || error instanceof TypeError)
55
+ return c.json({ error: { code: "invalid-request" } }, 400);
56
+ return c.json({ error: { code: "server-unavailable" } }, 503);
57
+ }
58
+ /** Owner capability is mandatory even when the ordinary local HTTP surface is open. */
59
+ export function mountTmuxServerRoutes(app, options) {
60
+ const base = TMUX_SERVERS_API_PATH;
61
+ const authorize = async (c, next) => {
62
+ c.header("Cache-Control", "no-store");
63
+ if (!ownerBearerMatches(c.req.header("Authorization"), options.ownerToken))
64
+ return c.json({ error: { code: "owner-required" } }, 401);
65
+ await next();
66
+ };
67
+ app.use(base, authorize);
68
+ app.use(`${base}/*`, authorize);
69
+ const route = (work) => async (c) => {
70
+ try {
71
+ return await work(c);
72
+ }
73
+ catch (error) {
74
+ return failure(c, error);
75
+ }
76
+ };
77
+ const scope = (c) => TmuxServerScopeSchemaZ.parse({
78
+ serverId: c.req.param("serverId"),
79
+ generation: c.req.param("generation"),
80
+ });
81
+ app.get(base, route(async (c) => c.json(TmuxServersResourceSchemaZ.parse({ version: 1, servers: await options.owners.refresh() }))));
82
+ app.post(base, route(async (c) => c.json(await options.owners.register(registrationSchema.parse(await boundedJson(c.req.raw))), 201)));
83
+ app.delete(`${base}/:serverId`, route(async (c) => {
84
+ await options.owners.remove(TmuxServerIdSchemaZ.parse(c.req.param("serverId")));
85
+ return c.json({ removed: true });
86
+ }));
87
+ const scoped = `${base}/:serverId/:generation`;
88
+ mountTmuxServerNativeBackingRoute(app, {
89
+ scopedPath: scoped,
90
+ ownerToken: options.ownerToken,
91
+ owners: options.owners,
92
+ });
93
+ app.post(`${scoped}/sessions/create`, route(async (c) => {
94
+ const server = scope(c);
95
+ const request = z
96
+ .object({
97
+ operationId: z.uuid(),
98
+ expectedDaemonInstanceId: z.uuid(),
99
+ intent: WorkspaceSessionCreateArgumentsSchemaZ,
100
+ })
101
+ .strict()
102
+ .parse(await boundedJson(c.req.raw));
103
+ if (request.expectedDaemonInstanceId !== server.generation ||
104
+ (request.intent.expectedDaemonInstanceId &&
105
+ request.intent.expectedDaemonInstanceId !== server.generation))
106
+ return c.json({ error: { code: "stale-generation" } }, 409);
107
+ return c.json(await options.owners.withOwner(server, (owner) => owner.createSession(request.operationId, {
108
+ ...request.intent,
109
+ expectedDaemonInstanceId: server.generation,
110
+ })));
111
+ }));
112
+ app.post(`${scoped}/sessions/:liveSessionId/open`, route(async (c) => {
113
+ const server = scope(c);
114
+ const result = await options.owners.withOwner(server, (owner) => owner.openSession(workspaceSchema.parse(c.req.param("liveSessionId"))));
115
+ return c.json({ version: 1, server, ...result });
116
+ }));
117
+ app.get(`${scoped}/sessions`, route(async (c) => {
118
+ const server = scope(c);
119
+ return c.json(TmuxServerSessionsResourceSchemaZ.parse({
120
+ version: 1,
121
+ server,
122
+ sessions: await options.owners.withOwner(server, async (owner) => {
123
+ const sessions = await owner.catalog();
124
+ const workspaces = owner.workspaceRegistry.list();
125
+ return sessions.map((session) => {
126
+ const aliases = workspaces.filter((workspace) => workspace.sessionName === session.sessionName);
127
+ return { ...session, workspaceName: aliases.length === 1 ? aliases[0].name : null };
128
+ });
129
+ }),
130
+ }));
131
+ }));
132
+ app.get(`${scoped}/inventory/:workspaceName`, route(async (c) => {
133
+ const server = scope(c);
134
+ const workspaceName = workspaceSchema.parse(c.req.param("workspaceName"));
135
+ const resource = await options.owners.withOwner(server, async (owner) => {
136
+ const sessions = await owner.catalog();
137
+ const workspace = owner.workspaceRegistry.get(workspaceName);
138
+ if (!workspace)
139
+ return null;
140
+ const expected = c.req.query("liveSessionId");
141
+ if (expected &&
142
+ !sessions.some((row) => row.sessionName === workspace.sessionName && row.liveSessionId === expected))
143
+ throw new TmuxServerScopeError("stale-generation");
144
+ const snapshot = await owner.terminalInventoryRuntime.discoverTerminalRuntimeSession(workspace.sessionName, c.req.raw.signal);
145
+ return snapshot ? projectTerminalRuntimeInventory(snapshot, 0) : null;
146
+ });
147
+ return resource
148
+ ? c.json({ version: 1, server, resource })
149
+ : c.json({ error: { code: "workspace-not-found" } }, 404);
150
+ }));
151
+ app.get(`${scoped}/catalog`, route(async (c) => {
152
+ const server = scope(c);
153
+ const resource = await options.owners.withOwner(server, async (owner) => {
154
+ const sessions = await owner.catalog();
155
+ return {
156
+ intents: owner.workspaceRegistry.list().map((entry) => ({
157
+ workspaceName: entry.name,
158
+ sessionName: entry.sessionName,
159
+ source: "workspace",
160
+ availability: sessions.some((row) => row.sessionName === entry.sessionName)
161
+ ? "live"
162
+ : "stopped",
163
+ })),
164
+ liveSessions: sessions.map((row) => ({
165
+ sessionName: row.sessionName,
166
+ fleetSessionId: fleetSessionIdForName(row.sessionName),
167
+ paneCount: row.paneCount,
168
+ })),
169
+ };
170
+ });
171
+ return c.json({ version: 1, server, resource });
172
+ }));
173
+ app.get(`${scoped}/application-shell/:workspaceName`, route(async (c) => {
174
+ const server = scope(c);
175
+ const workspaceName = workspaceSchema.parse(c.req.param("workspaceName"));
176
+ const resource = await options.owners.withOwner(server, async (owner) => {
177
+ const sessions = await owner.catalog();
178
+ const workspace = owner.workspaceRegistry.get(workspaceName);
179
+ if (!workspace)
180
+ return null;
181
+ if (!sessions.some((row) => row.sessionName === workspace.sessionName &&
182
+ row.liveSessionId === c.req.query("liveSessionId")))
183
+ throw new TmuxServerScopeError("stale-generation");
184
+ const snapshot = await owner.terminalInventoryRuntime.discoverTerminalRuntimeSession(workspace.sessionName, c.req.raw.signal);
185
+ return snapshot ? projectApplicationShellResource(snapshot) : null;
186
+ });
187
+ return resource
188
+ ? c.json({ version: 1, server, resource })
189
+ : c.json({ error: { code: "workspace-not-found" } }, 404);
190
+ }));
191
+ // Subscribe before publishing ready: a topology change during the subsequent HTTP read
192
+ // invalidates that read. This shares the owner's retained mirror, never a new tmux client.
193
+ app.get(`${scoped}/session-events/:workspaceName`, route(async (c) => {
194
+ const server = scope(c);
195
+ const workspaceName = workspaceSchema.parse(c.req.param("workspaceName"));
196
+ const owner = await options.owners.withOwner(server, async (candidate) => {
197
+ const sessions = await candidate.catalog();
198
+ const workspace = candidate.workspaceRegistry.get(workspaceName);
199
+ if (!workspace ||
200
+ !sessions.some((row) => row.sessionName === workspace.sessionName &&
201
+ row.liveSessionId === c.req.query("liveSessionId")))
202
+ throw new TmuxServerScopeError("stale-generation");
203
+ return candidate;
204
+ });
205
+ const workspace = owner.workspaceRegistry.get(workspaceName);
206
+ return streamSSE(c, async (stream) => {
207
+ let stopped = false;
208
+ let wake = null;
209
+ let revision = 0;
210
+ const dirty = () => {
211
+ revision++;
212
+ wake?.();
213
+ };
214
+ stream.onAbort(() => {
215
+ stopped = true;
216
+ wake?.();
217
+ });
218
+ const identity = await owner.sessionRuntimeRegistry.describeSessionAuthority(workspace.sessionName);
219
+ let topology = null;
220
+ let sessionReplaced = false;
221
+ const subscription = await owner.sessionRuntimeRegistry.subscribeLayout(workspace.sessionName, () => undefined, {
222
+ expectedRuntimeSessionId: identity.runtimeSessionId,
223
+ expectedSemanticPaneIds: identity.description.panes.map((pane) => pane.semanticPaneId),
224
+ onAuthority(snapshot) {
225
+ if (snapshot.windowLinks.liveSessionId !== c.req.query("liveSessionId")) {
226
+ sessionReplaced = true;
227
+ dirty();
228
+ return;
229
+ }
230
+ // Full replacement observes removed windows and retains no historical keys.
231
+ // Geometry, focus and active-link changes stay entirely on the pane stream.
232
+ const signature = JSON.stringify(snapshot.layouts
233
+ .map((layout) => [
234
+ layout.semanticWindowId,
235
+ layout.windowName,
236
+ layout.panes.map((pane) => pane.semanticPaneId).sort(),
237
+ ])
238
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
239
+ if (topology === signature)
240
+ return;
241
+ topology = signature;
242
+ dirty();
243
+ },
244
+ });
245
+ try {
246
+ options.owners.current(server);
247
+ if (sessionReplaced)
248
+ throw new TmuxServerScopeError("stale-generation");
249
+ await stream.writeSSE({
250
+ data: JSON.stringify({ version: 1, server, type: "ready", revision }),
251
+ });
252
+ let sent = revision;
253
+ let nextIdentityCheck = Date.now() + 5000;
254
+ while (!stopped) {
255
+ await new Promise((resolve) => {
256
+ const timer = setTimeout(resolve, 1000);
257
+ wake = () => {
258
+ clearTimeout(timer);
259
+ resolve();
260
+ };
261
+ if (stopped || revision !== sent)
262
+ wake();
263
+ });
264
+ wake = null;
265
+ if (stopped)
266
+ break;
267
+ if (sessionReplaced)
268
+ throw new TmuxServerScopeError("stale-generation");
269
+ options.owners.current(server);
270
+ if (Date.now() >= nextIdentityCheck) {
271
+ const sessions = await owner.catalog();
272
+ options.owners.current(server);
273
+ if (!sessions.some((row) => row.sessionName === workspace.sessionName &&
274
+ row.liveSessionId === c.req.query("liveSessionId")))
275
+ throw new TmuxServerScopeError("stale-generation");
276
+ nextIdentityCheck = Date.now() + 5000;
277
+ }
278
+ if (revision !== sent) {
279
+ sent = revision;
280
+ await stream.writeSSE({
281
+ data: JSON.stringify({ version: 1, server, type: "invalidate", revision }),
282
+ });
283
+ }
284
+ else
285
+ await stream.writeSSE({
286
+ data: JSON.stringify({ version: 1, server, type: "heartbeat", revision }),
287
+ });
288
+ }
289
+ }
290
+ catch {
291
+ if (!stopped)
292
+ await stream
293
+ .writeSSE({ data: JSON.stringify({ version: 1, server, type: "retired", revision }) })
294
+ .catch(() => undefined);
295
+ }
296
+ finally {
297
+ await subscription.close();
298
+ }
299
+ });
300
+ }));
301
+ app.post(`${scoped}/panes/create`, route(async (c) => {
302
+ const server = scope(c);
303
+ const liveSessionId = workspaceSchema.parse(c.req.query("liveSessionId"));
304
+ const request = WorkspacePaneCreateMutationRequestSchemaZ.parse(await boundedJson(c.req.raw));
305
+ if (request.expectedDaemonInstanceId !== server.generation)
306
+ return c.json({ error: { code: "stale-generation" } }, 409);
307
+ return c.json(await options.owners.withOwner(server, (owner) => owner.createSessionPane(liveSessionId, request)));
308
+ }));
309
+ app.post(`${scoped}/mutations`, route(async (c) => {
310
+ const server = scope(c);
311
+ const request = WorkspaceMultiplexerMutationRequestSchemaZ.parse(await boundedJson(c.req.raw));
312
+ if (request.expectedDaemonInstanceId !== server.generation)
313
+ return c.json({ error: { code: "stale-generation" } }, 409);
314
+ const liveSessionId = c.req.query("liveSessionId");
315
+ const result = await options.owners.withOwner(server, (owner) => liveSessionId
316
+ ? owner.mutateSession(liveSessionId, request, c.req.header("X-Tmux-Ide-Host-Client-Id"))
317
+ : owner.multiplexerBackend.mutate(request, c.req.header("X-Tmux-Ide-Host-Client-Id"), undefined, true));
318
+ return c.json(result);
319
+ }));
320
+ app.post(`${scoped}/pane-streams/issue`, route(async (c) => {
321
+ const server = scope(c);
322
+ return options.owners.withOwner(server, async (owner) => {
323
+ const sessions = await owner.catalog();
324
+ const liveSessionId = c.req.query("liveSessionId");
325
+ if (liveSessionId) {
326
+ const body = (await boundedJson(c.req.raw.clone()));
327
+ const workspace = owner.workspaceRegistry.get(body.stream?.workspaceName ?? "");
328
+ if (!workspace ||
329
+ !sessions.some((row) => row.sessionName === workspace.sessionName && row.liveSessionId === liveSessionId))
330
+ throw new TmuxServerScopeError("stale-generation");
331
+ }
332
+ const issueApp = new Hono();
333
+ mountPaneStreamIssueRoute(issueApp, {
334
+ daemonInstanceId: server.generation,
335
+ ownerToken: options.ownerToken,
336
+ workspaceRegistry: owner.workspaceRegistry,
337
+ backend: owner.paneStreamRuntime.coordinator,
338
+ });
339
+ const url = new URL(c.req.url);
340
+ url.pathname = PANE_STREAM_ISSUE_PATH;
341
+ url.search = "";
342
+ return await issueApp.request(new Request(url, c.req.raw));
343
+ });
344
+ }));
345
+ }
@@ -1,4 +1,6 @@
1
- import { resolveRuntimeNamespace } from "./runtime-namespace.js";
1
+ import { captureTmuxServerProof, captureUnboundTmuxSelectorProof, } from "./tmux-server-proof.js";
2
+ import { createPinnedWorkspaceTmuxRunner, resolveWorkspacePaneTmuxAuthority, } from "./workspace-pane-creation.js";
3
+ import { resolveRuntimeNamespace, resolveTmuxServerIntent } from "./runtime-namespace.js";
2
4
  import { compareProductVersions } from "./semver.js";
3
5
  import { spawn } from "node:child_process";
4
6
  import { resolve } from "node:path";
@@ -64,6 +66,16 @@ function sameCanonicalInstance(left, right) {
64
66
  const defaultDependencies = {
65
67
  // This adapter owns startup, so legacy permission preparation is explicit
66
68
  // here. Injected inspectors remain isolated from real filesystem mutation.
69
+ serverProof: (intent) => {
70
+ try {
71
+ const authority = { ...resolveWorkspacePaneTmuxAuthority(), socketSelector: intent.selector };
72
+ return (captureTmuxServerProof(createPinnedWorkspaceTmuxRunner(authority, { timeoutMs: 1000 })) ??
73
+ captureUnboundTmuxSelectorProof(authority));
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ },
67
79
  inspect: prepareCanonicalDaemonInfoForBootstrap,
68
80
  ownerProvenDead: isCanonicalDaemonRecordOwnerProvenDead,
69
81
  alive: isCanonicalDaemonAlive,
@@ -88,11 +100,14 @@ function needsReplacement(info, expected) {
88
100
  }
89
101
  return info.protocolVersion < DAEMON_WIRE_PROTOCOL_VERSION;
90
102
  }
91
- async function replaceOlderCanonicalDaemon(deps, info, timeoutMs, expectedProductVersion) {
103
+ async function replaceOlderCanonicalDaemon(deps, info, timeoutMs, expectedProductVersion, intent = null) {
92
104
  if (!needsReplacement(info, expectedProductVersion)) {
93
105
  throw new DaemonBootstrapError("incompatible", `Canonical daemon protocol ${info.protocolVersion} cannot be replaced by older protocol ${DAEMON_WIRE_PROTOCOL_VERSION}.`, { reason: "protocol-mismatch" });
94
106
  }
95
- const [identity, health] = await Promise.all([deps.identity(info), deps.health(info)]);
107
+ const [identity, health] = await Promise.all([
108
+ deps.identity(info, undefined, Boolean(intent)),
109
+ deps.health(info),
110
+ ]);
96
111
  if (!info.authToken ||
97
112
  !identity ||
98
113
  !health ||
@@ -105,6 +120,7 @@ async function replaceOlderCanonicalDaemon(deps, info, timeoutMs, expectedProduc
105
120
  health.productVersion !== info.productVersion) {
106
121
  throw new DaemonBootstrapError("incompatible", "The older canonical daemon changed identity before the version upgrade.", { reason: "identity-mismatch" });
107
122
  }
123
+ assertServerIntent(deps, info, identity, intent);
108
124
  const latest = deps.inspect();
109
125
  if (latest.status !== "valid" ||
110
126
  !sameCanonicalInstance(latest.info, info) ||
@@ -131,7 +147,17 @@ async function replaceOlderCanonicalDaemon(deps, info, timeoutMs, expectedProduc
131
147
  }
132
148
  throw new DaemonBootstrapError("control-timeout", "The older canonical daemon did not retire after accepting the version upgrade.", { reason: "protocol-mismatch" });
133
149
  }
134
- async function probeCanonical(deps, expectedProductVersion) {
150
+ function assertServerIntent(deps, info, identity, intent) {
151
+ if (!intent)
152
+ return;
153
+ const proof = deps.serverProof(intent);
154
+ if (info.tmuxServerProofVersion !== 1 || !identity.tmuxServerProof || !proof)
155
+ throw new DaemonBootstrapError("incompatible", "Cannot prove the requested tmux server for this daemon. Its server identity is unavailable or unsupported; the existing daemon was left running.", { reason: "tmux-server-unproven" });
156
+ if (identity.tmuxServerProof.kind !== proof.kind ||
157
+ identity.tmuxServerProof.digest !== proof.digest)
158
+ throw new DaemonBootstrapError("incompatible", "The existing daemon manages a different tmux server. It was left running; select its server or use a separate daemon namespace.", { reason: "tmux-server-mismatch" });
159
+ }
160
+ async function probeCanonical(deps, expectedProductVersion, intent = null) {
135
161
  const state = deps.inspect();
136
162
  if (state.status === "missing")
137
163
  return { status: "absent-or-stale" };
@@ -149,7 +175,7 @@ async function probeCanonical(deps, expectedProductVersion) {
149
175
  if (!(await deps.alive(state.info)))
150
176
  return { status: state.info.supervisionId ? "owner-pending" : "absent-or-stale" };
151
177
  const [identity, health] = await Promise.all([
152
- deps.identity(state.info),
178
+ deps.identity(state.info, undefined, Boolean(intent)),
153
179
  deps.health(state.info),
154
180
  ]);
155
181
  // A living elected generation may publish before its accept loop. Preserve
@@ -162,6 +188,7 @@ async function probeCanonical(deps, expectedProductVersion) {
162
188
  identity.startedAt !== state.info.startedAt) {
163
189
  return { status: "incompatible", reason: "identity-mismatch" };
164
190
  }
191
+ assertServerIntent(deps, state.info, identity, intent);
165
192
  if (state.info.protocolVersion !== DAEMON_WIRE_PROTOCOL_VERSION ||
166
193
  identity.protocolVersion !== state.info.protocolVersion ||
167
194
  health.protocolVersion !== state.info.protocolVersion) {
@@ -211,9 +238,15 @@ function supervisedAdmission(deps, declaredBinding) {
211
238
  };
212
239
  }
213
240
  export function createCanonicalDaemonBootstrapCoordinator(options, dependencies = {}) {
241
+ options = {
242
+ ...options,
243
+ tmuxServerIntent: options.tmuxServerIntent === undefined ? resolveTmuxServerIntent() : options.tmuxServerIntent,
244
+ };
214
245
  const deps = supervisedAdmission({ ...defaultDependencies, ...dependencies }, options.supervisionId);
215
246
  return new DaemonBootstrapCoordinator({
216
- probe: () => probeCanonical(deps, options.expectedProductVersion),
247
+ probe: () => probeCanonical(deps, options.expectedProductVersion, options.tmuxServerIntent === undefined
248
+ ? resolveTmuxServerIntent()
249
+ : options.tmuxServerIntent),
217
250
  spawn: () => deps.spawnOwner(resolve(options.entryPath), resolve(options.cwd ?? process.cwd())),
218
251
  timeoutMs: options.timeoutMs,
219
252
  onPhaseChanged: options.onPhaseChanged,
@@ -222,6 +255,10 @@ export function createCanonicalDaemonBootstrapCoordinator(options, dependencies
222
255
  });
223
256
  }
224
257
  export function ensureCanonicalDaemon(options, dependencies = {}) {
258
+ options = {
259
+ ...options,
260
+ tmuxServerIntent: options.tmuxServerIntent === undefined ? resolveTmuxServerIntent() : options.tmuxServerIntent,
261
+ };
225
262
  const deps = supervisedAdmission({ ...defaultDependencies, ...dependencies }, options.supervisionId);
226
263
  const ensure = () => createCanonicalDaemonBootstrapCoordinator(options, deps).ensure();
227
264
  return ensure().catch(async (error) => {
@@ -246,7 +283,9 @@ export function ensureCanonicalDaemon(options, dependencies = {}) {
246
283
  }
247
284
  const replacing = state.info;
248
285
  try {
249
- await replaceOlderCanonicalDaemon(deps, replacing, options.timeoutMs ?? 15_000, options.expectedProductVersion);
286
+ await replaceOlderCanonicalDaemon(deps, replacing, options.timeoutMs ?? 15_000, options.expectedProductVersion, options.tmuxServerIntent === undefined
287
+ ? resolveTmuxServerIntent()
288
+ : options.tmuxServerIntent);
250
289
  }
251
290
  catch (replacementError) {
252
291
  // A duplicate upgrader can retire the exact owner while this caller is
@@ -265,6 +304,10 @@ export function ensureCanonicalDaemon(options, dependencies = {}) {
265
304
  }
266
305
  /** Retire only a verified older owner; the foreground caller retains ownership of startup. */
267
306
  export async function retireOutdatedCanonicalDaemon(options, dependencies = {}) {
307
+ options = {
308
+ ...options,
309
+ tmuxServerIntent: options.tmuxServerIntent === undefined ? resolveTmuxServerIntent() : options.tmuxServerIntent,
310
+ };
268
311
  const deps = supervisedAdmission({ ...defaultDependencies, ...dependencies }, options.supervisionId);
269
312
  const state = deps.inspect();
270
313
  if (options.supervisionId &&
@@ -283,7 +326,7 @@ export async function retireOutdatedCanonicalDaemon(options, dependencies = {})
283
326
  return false;
284
327
  }
285
328
  try {
286
- await replaceOlderCanonicalDaemon(deps, state.info, options.timeoutMs ?? 15_000, options.expectedProductVersion);
329
+ await replaceOlderCanonicalDaemon(deps, state.info, options.timeoutMs ?? 15_000, options.expectedProductVersion, options.tmuxServerIntent === undefined ? resolveTmuxServerIntent() : options.tmuxServerIntent);
287
330
  return true;
288
331
  }
289
332
  catch (error) {
@@ -294,3 +337,16 @@ export async function retireOutdatedCanonicalDaemon(options, dependencies = {})
294
337
  throw error;
295
338
  }
296
339
  }
340
+ /** Admission for foreground election winners and explicit takeover paths. */
341
+ export async function assertCanonicalDaemonServerIntent(info, intent = resolveTmuxServerIntent(), dependencies = {}) {
342
+ if (!intent)
343
+ return;
344
+ const deps = { ...defaultDependencies, ...dependencies };
345
+ const identity = await deps.identity(info, undefined, true);
346
+ if (!identity ||
347
+ identity.pid !== info.pid ||
348
+ identity.instanceId !== info.instanceId ||
349
+ identity.startedAt !== info.startedAt)
350
+ throw new DaemonBootstrapError("incompatible", "Canonical daemon identity could not be verified for the requested tmux server.", { reason: "identity-mismatch" });
351
+ assertServerIntent(deps, info, identity, intent);
352
+ }
@@ -432,6 +432,9 @@ export function writeCanonicalDaemonInfo(info, claim) {
432
432
  instanceId: info.instanceId,
433
433
  startedAt: info.startedAt,
434
434
  ...(info.environmentId !== undefined ? { environmentId: info.environmentId } : {}),
435
+ ...(info.tmuxServerProofVersion !== undefined
436
+ ? { tmuxServerProofVersion: info.tmuxServerProofVersion }
437
+ : {}),
435
438
  bindHostname: info.bindHostname,
436
439
  authToken: info.authToken,
437
440
  ...(info.provenance !== undefined ? { provenance: info.provenance } : {}),
@@ -795,11 +798,11 @@ export async function probeCanonicalDaemonHealth(info, parentSignal) {
795
798
  }
796
799
  }
797
800
  /** Credential-free endpoint binding probe; callers compare instanceId to daemon.json. */
798
- export async function probeCanonicalDaemonIdentity(info, parentSignal) {
801
+ export async function probeCanonicalDaemonIdentity(info, parentSignal, includeTmuxServerProof = false) {
799
802
  if (!(await isCanonicalDaemonAlive(info)))
800
803
  return null;
801
804
  try {
802
- const res = await fetch(canonicalDaemonUrl("http", info.bindHostname, info.port, "/identity"), {
805
+ const res = await fetch(canonicalDaemonUrl("http", info.bindHostname, info.port, includeTmuxServerProof ? "/identity?tmuxServerProof=1" : "/identity"), {
803
806
  signal: parentSignal ?? timeoutSignal(750),
804
807
  });
805
808
  if (!res.ok)