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
@@ -214,9 +214,10 @@ function agentStatus(status) {
214
214
  */
215
215
  export function TerminalWindowStrip(props) {
216
216
  const tabs = () => (typeof props.tabs === "function" ? props.tabs() : props.tabs);
217
- const tabId = (tab) => tab.semanticWindowId ?? `window:${tab.index}`;
217
+ const tabId = (tab) => tab.linkId ?? tab.semanticWindowId ?? `window:${tab.index}`;
218
218
  const items = createMemo(() => tabs().map((tab) => ({
219
219
  id: tabId(tab),
220
+ disabled: tab.disabled,
220
221
  windowIndex: tab.index,
221
222
  title: tab.name,
222
223
  agentStatus: agentStatus(tab.status),
@@ -48,6 +48,12 @@ const ACTION_COMMAND_METADATA: Record<ActionName, ActionCommandMetadata> = {
48
48
  "workspace.open.cancel": { label: "Cancel workspace handoff", category: "workspace" },
49
49
  "workspace.promote": { label: "Promote live session to workspace", category: "workspace" },
50
50
  "workspace.app-window.mutate": { label: "Mutate application window", category: "workspace" },
51
+ "workspace.window.link.select": { label: "Focus window link", category: "workspace" },
52
+ "workspace.window.link.unlink": {
53
+ label: "Unlink window",
54
+ category: "workspace",
55
+ dangerous: true,
56
+ },
51
57
  "workspace.window.split": { label: "Split pane", category: "workspace" },
52
58
  "workspace.window.kill": { label: "Close window", category: "workspace", dangerous: true },
53
59
  "workspace.pane.kill": { label: "Close pane", category: "workspace", dangerous: true },
@@ -72,6 +72,10 @@ export type ActionErrorCode =
72
72
  | "pane_not_found"
73
73
  | "window_not_found"
74
74
  | "ambiguous_target"
75
+ | "window_link_stale"
76
+ | "window_link_ambiguous"
77
+ | "window_link_session_mismatch"
78
+ | "window_link_backing_mismatch"
75
79
  | "last_window_refused"
76
80
  | "last_pane_refused"
77
81
  | "mutation_failed"
@@ -84,6 +84,8 @@ function verbHandler<V extends WorkspaceMultiplexerVerb>(verb: V) {
84
84
  ): Promise<ActionResult<V>> => (await runVerb(verb, input, context, deps)) as ActionResult<V>;
85
85
  }
86
86
 
87
+ export const workspaceWindowLinkSelectHandler = verbHandler("workspace.window.link.select");
88
+ export const workspaceWindowLinkUnlinkHandler = verbHandler("workspace.window.link.unlink");
87
89
  export const workspaceWindowSplitHandler = verbHandler("workspace.window.split");
88
90
  export const workspaceWindowKillHandler = verbHandler("workspace.window.kill");
89
91
  export const workspacePaneKillHandler = verbHandler("workspace.pane.kill");
@@ -70,6 +70,8 @@ import {
70
70
  workspaceRenameHandler,
71
71
  workspaceSessionKillHandler,
72
72
  workspaceWindowKillHandler,
73
+ workspaceWindowLinkSelectHandler,
74
+ workspaceWindowLinkUnlinkHandler,
73
75
  workspaceWindowSplitHandler,
74
76
  } from "./handlers/workspace-multiplexer.ts";
75
77
 
@@ -301,6 +303,18 @@ export const actionRegistry: RegistryShape = {
301
303
  handler: (input) => workspacePaneZoomToggleHandler(input),
302
304
  handlerWithContext: workspacePaneZoomToggleHandler,
303
305
  },
306
+ "workspace.window.link.select": {
307
+ inputSchema: ActionContractsZ["workspace.window.link.select"].input,
308
+ resultSchema: ActionContractsZ["workspace.window.link.select"].result,
309
+ handler: (input) => workspaceWindowLinkSelectHandler(input),
310
+ handlerWithContext: workspaceWindowLinkSelectHandler,
311
+ },
312
+ "workspace.window.link.unlink": {
313
+ inputSchema: ActionContractsZ["workspace.window.link.unlink"].input,
314
+ resultSchema: ActionContractsZ["workspace.window.link.unlink"].result,
315
+ handler: (input) => workspaceWindowLinkUnlinkHandler(input),
316
+ handlerWithContext: workspaceWindowLinkUnlinkHandler,
317
+ },
304
318
  "workspace.pane.select": {
305
319
  inputSchema: ActionContractsZ["workspace.pane.select"].input,
306
320
  resultSchema: ActionContractsZ["workspace.pane.select"].result,
@@ -1,6 +1,6 @@
1
+ import { liveSessionIdForNativeIdentity } from "../terminal/protocol/live-session-identity.ts";
1
2
  import { runtimeTmuxArgs } from "../lib/runtime-namespace.ts";
2
3
  import { execFileSync } from "node:child_process";
3
- import { createHash } from "node:crypto";
4
4
  import { listSessionPanes } from "../widgets/lib/pane-comms.ts";
5
5
  import type { PaneInfo } from "@tmux-ide/contracts";
6
6
  import { getDefaultWorkspaceRegistry } from "../lib/workspace-registry.ts";
@@ -80,14 +80,6 @@ export interface LiveSessionSummary {
80
80
  readonly paneCount: number;
81
81
  }
82
82
 
83
- function liveSessionId(serverPid: string, sessionId: string, sessionCreated: string) {
84
- const digest = createHash("sha256")
85
- .update(`${serverPid}\0${sessionId}\0${sessionCreated}`)
86
- .digest("hex")
87
- .slice(0, 20);
88
- return `live-session.${digest}` as const;
89
- }
90
-
91
83
  /**
92
84
  * Enumerate observed tmux truth without consulting the workspace registry.
93
85
  *
@@ -120,7 +112,7 @@ export function discoverLiveSessionSummaries(
120
112
  if (!/^\d+$/u.test(serverPid) || !/^\$\d+$/u.test(sessionId) || !/^\d+$/u.test(sessionCreated))
121
113
  continue;
122
114
  if (!sessionName || !isVisibleFleetSession(sessionName)) continue;
123
- const identity = liveSessionId(serverPid, sessionId, sessionCreated);
115
+ const identity = liveSessionIdForNativeIdentity(serverPid, sessionId, sessionCreated);
124
116
  const previous = sessions.get(identity);
125
117
  sessions.set(identity, {
126
118
  liveSessionId: identity,
@@ -1,3 +1,4 @@
1
+ import { mountTmuxServerRoutes } from "./tmux-servers.ts";
1
2
  import { streamBoundedLogs } from "./log-stream.ts";
2
3
  import { mountWorkspaceAdmissionRoute } from "./resources/workspace-admission-route.ts";
3
4
  import { mountFleetPreviewRoute } from "./resources/fleet-preview-route.ts";
@@ -162,6 +163,11 @@ import {
162
163
  } from "./resources/startup-readiness-route.ts";
163
164
  import { readWidgetAsset } from "../lib/widget-asset-store.ts";
164
165
  export interface CreateAppOptions {
166
+ tmuxServerOwners?: import("../lib/tmux-server-owners.ts").TmuxServerOwners<
167
+ import("../lib/tmux-server-owner.ts").NativeTmuxServerOwner
168
+ >;
169
+ /** Fresh, credential-free proof of this owner's pinned tmux server. */
170
+ tmuxServerProof?: () => Promise<import("../lib/tmux-server-proof.ts").TmuxServerProof | null>;
165
171
  authService?: AuthService;
166
172
  authConfig?: AuthConfig;
167
173
  remoteAccess?: {
@@ -533,6 +539,12 @@ export function createApp(options: CreateAppOptions = {}): Hono {
533
539
  // Allow cross-origin (Next.js dashboard, Tailscale, etc.)
534
540
  app.use("/*", cors());
535
541
 
542
+ if (options.tmuxServerOwners)
543
+ mountTmuxServerRoutes(app, {
544
+ ownerToken: options.remoteAccess?.ownerToken ?? null,
545
+ owners: options.tmuxServerOwners,
546
+ });
547
+
536
548
  // Owner-only reads use the same early routing boundary as issuance below:
537
549
  // remote/project credentials neither authorize nor block the owner bearer.
538
550
  mountDiagnosticsRoute(app, {
@@ -743,10 +755,26 @@ export function createApp(options: CreateAppOptions = {}): Hono {
743
755
  // Credential-free endpoint binding. A desktop host reads the nonce from the
744
756
  // owner-only canonical record, probes this endpoint, and compares before it
745
757
  // sends any remote-access or local-bypass credential.
746
- app.get("/identity", (c) => {
758
+ // Coalesce concurrent probes, but never reuse a completed server proof.
759
+ let pendingTmuxProof: Promise<
760
+ import("../lib/tmux-server-proof.ts").TmuxServerProof | null
761
+ > | null = null;
762
+ app.get("/identity", async (c) => {
763
+ const wantsTmuxProof = c.req.query("tmuxServerProof") === "1";
764
+ let tmuxServerProof = null;
765
+ if (wantsTmuxProof && options.tmuxServerProof) {
766
+ pendingTmuxProof ??= Promise.resolve()
767
+ .then(options.tmuxServerProof)
768
+ .catch(() => null)
769
+ .finally(() => {
770
+ pendingTmuxProof = null;
771
+ });
772
+ tmuxServerProof = await pendingTmuxProof;
773
+ }
747
774
  return c.json({
748
775
  ok: true,
749
776
  pid: process.pid,
777
+ ...(wantsTmuxProof && options.tmuxServerProof ? { tmuxServerProof } : {}),
750
778
  protocolVersion: DAEMON_WIRE_PROTOCOL_VERSION,
751
779
  productVersion: daemonIdentity.productVersion,
752
780
  instanceId: daemonIdentity.instanceId,
@@ -0,0 +1,49 @@
1
+ import { Hono } from "hono";
2
+ import { TmuxServerScopeSchemaZ } from "@tmux-ide/contracts";
3
+ import { TmuxServerScopeError, type TmuxServerOwners } from "../lib/tmux-server-owners.ts";
4
+ import type { NativeTmuxServerOwner } from "../lib/tmux-server-owner.ts";
5
+ import { mountTerminalNativeBackingRoute } from "./resources/terminal-native-backing-route.ts";
6
+
7
+ /** Retain admission/coalescing limits for the lifetime of each independent owner. */
8
+ export function mountTmuxServerNativeBackingRoute(
9
+ app: Hono,
10
+ options: {
11
+ scopedPath: string;
12
+ ownerToken: string | null;
13
+ owners: TmuxServerOwners<NativeTmuxServerOwner>;
14
+ },
15
+ ): void {
16
+ const routers = new WeakMap<NativeTmuxServerOwner, Hono>();
17
+ app.get(`${options.scopedPath}/native-backing/:workspaceName/:pane`, async (c) => {
18
+ const scope = TmuxServerScopeSchemaZ.safeParse({
19
+ serverId: c.req.param("serverId"),
20
+ generation: c.req.param("generation"),
21
+ });
22
+ if (!scope.success) return c.json({ status: "invalid" }, 400);
23
+ try {
24
+ return await options.owners.withOwner(scope.data, async (owner) => {
25
+ let router = routers.get(owner);
26
+ if (!router) {
27
+ router = new Hono();
28
+ mountTerminalNativeBackingRoute(router, {
29
+ ownerToken: options.ownerToken,
30
+ generation: owner.generation,
31
+ resolveSession: (workspace) =>
32
+ owner.workspaceRegistry.get(workspace)?.sessionName ?? null,
33
+ capture: (session, pane, expected) =>
34
+ owner.sessionRuntimeRegistry.captureNativeBacking(session, pane, expected),
35
+ });
36
+ routers.set(owner, router);
37
+ }
38
+ const url = new URL(c.req.url);
39
+ url.pathname = `/api/project/${encodeURIComponent(c.req.param("workspaceName"))}/terminal-native-backing/${encodeURIComponent(c.req.param("pane"))}`;
40
+ return await router.request(new Request(url, c.req.raw));
41
+ });
42
+ } catch (error) {
43
+ return c.json(
44
+ { status: error instanceof TmuxServerScopeError ? "retired" : "unavailable" },
45
+ error instanceof TmuxServerScopeError ? 409 : 503,
46
+ );
47
+ }
48
+ });
49
+ }
@@ -0,0 +1,468 @@
1
+ import { mountTmuxServerNativeBackingRoute } from "./tmux-server-native-backing.ts";
2
+ import { Hono, type Context } from "hono";
3
+ import { streamSSE } from "hono/streaming";
4
+ import { projectApplicationShellResource } from "./resources/application-shell.ts";
5
+ import { fleetSessionIdForName } from "./resources/fleet-catalog.ts";
6
+ import { z } from "zod";
7
+ import {
8
+ TMUX_SERVERS_API_PATH,
9
+ PANE_STREAM_ISSUE_PATH,
10
+ TmuxServerIdSchemaZ,
11
+ TmuxServerScopeSchemaZ,
12
+ TmuxServersResourceSchemaZ,
13
+ TmuxServerSessionsResourceSchemaZ,
14
+ WorkspaceMultiplexerMutationRequestSchemaZ,
15
+ WorkspacePaneCreateMutationRequestSchemaZ,
16
+ WorkspaceSessionCreateArgumentsSchemaZ,
17
+ } from "@tmux-ide/contracts";
18
+ import {
19
+ TmuxServerOwners,
20
+ TmuxServerRegistrationSchemaZ,
21
+ TmuxServerScopeError,
22
+ } from "../lib/tmux-server-owners.ts";
23
+ import type { NativeTmuxServerOwner } from "../lib/tmux-server-owner.ts";
24
+ import { WorkspaceMultiplexerError } from "../lib/workspace-multiplexer-verbs.ts";
25
+ import { ownerBearerMatches } from "./owner-authority.ts";
26
+ import { mountPaneStreamIssueRoute } from "./pane-stream-issue.ts";
27
+ import { projectTerminalRuntimeInventory } from "./resources/terminal-runtime-inventory.ts";
28
+
29
+ export interface TmuxServerRoutesOptions {
30
+ readonly ownerToken: string | null;
31
+ readonly owners: TmuxServerOwners<NativeTmuxServerOwner>;
32
+ }
33
+ const registrationSchema = TmuxServerRegistrationSchemaZ.omit({ serverId: true }).strict();
34
+ const workspaceSchema = z
35
+ .string()
36
+ .min(1)
37
+ .max(160)
38
+ .refine((value) => !/[\0\r\n]/u.test(value));
39
+ async function boundedJson(request: Request): Promise<unknown> {
40
+ if (!request.body) throw new TypeError("Missing body");
41
+ const reader = request.body.getReader();
42
+ const chunks: Uint8Array[] = [];
43
+ let size = 0;
44
+ try {
45
+ for (;;) {
46
+ const next = await reader.read();
47
+ if (next.done) break;
48
+ size += next.value.byteLength;
49
+ if (size > 64 * 1024) {
50
+ void reader.cancel().catch(() => undefined);
51
+ throw new TypeError("Body too large");
52
+ }
53
+ chunks.push(next.value);
54
+ }
55
+ } finally {
56
+ reader.releaseLock();
57
+ }
58
+ const bytes = new Uint8Array(size);
59
+ let offset = 0;
60
+ for (const chunk of chunks) {
61
+ bytes.set(chunk, offset);
62
+ offset += chunk.byteLength;
63
+ }
64
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
65
+ }
66
+ function failure(c: Context, error: unknown): Response {
67
+ if (error instanceof TmuxServerScopeError)
68
+ return c.json(
69
+ { error: { code: error.code } },
70
+ error.code === "not-found" ? 404 : error.code === "capacity" ? 429 : 409,
71
+ );
72
+ if (error instanceof WorkspaceMultiplexerError)
73
+ return c.json({ error: { code: error.code } }, 409);
74
+ if (error instanceof z.ZodError || error instanceof SyntaxError || error instanceof TypeError)
75
+ return c.json({ error: { code: "invalid-request" } }, 400);
76
+ return c.json({ error: { code: "server-unavailable" } }, 503);
77
+ }
78
+ /** Owner capability is mandatory even when the ordinary local HTTP surface is open. */
79
+ export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOptions): void {
80
+ const base = TMUX_SERVERS_API_PATH;
81
+ const authorize = async (c: Context, next: () => Promise<void>) => {
82
+ c.header("Cache-Control", "no-store");
83
+ if (!ownerBearerMatches(c.req.header("Authorization"), options.ownerToken))
84
+ return c.json({ error: { code: "owner-required" } }, 401);
85
+ await next();
86
+ };
87
+ app.use(base, authorize);
88
+ app.use(`${base}/*`, authorize);
89
+ const route = (work: (c: Context) => Promise<Response>) => async (c: Context) => {
90
+ try {
91
+ return await work(c);
92
+ } catch (error) {
93
+ return failure(c, error);
94
+ }
95
+ };
96
+ const scope = (c: Context) =>
97
+ TmuxServerScopeSchemaZ.parse({
98
+ serverId: c.req.param("serverId"),
99
+ generation: c.req.param("generation"),
100
+ });
101
+ app.get(
102
+ base,
103
+ route(async (c) =>
104
+ c.json(
105
+ TmuxServersResourceSchemaZ.parse({ version: 1, servers: await options.owners.refresh() }),
106
+ ),
107
+ ),
108
+ );
109
+ app.post(
110
+ base,
111
+ route(async (c) =>
112
+ c.json(
113
+ await options.owners.register(registrationSchema.parse(await boundedJson(c.req.raw))),
114
+ 201,
115
+ ),
116
+ ),
117
+ );
118
+ app.delete(
119
+ `${base}/:serverId`,
120
+ route(async (c) => {
121
+ await options.owners.remove(TmuxServerIdSchemaZ.parse(c.req.param("serverId")));
122
+ return c.json({ removed: true });
123
+ }),
124
+ );
125
+ const scoped = `${base}/:serverId/:generation`;
126
+ mountTmuxServerNativeBackingRoute(app, {
127
+ scopedPath: scoped,
128
+ ownerToken: options.ownerToken,
129
+ owners: options.owners,
130
+ });
131
+ app.post(
132
+ `${scoped}/sessions/create`,
133
+ route(async (c) => {
134
+ const server = scope(c);
135
+ const request = z
136
+ .object({
137
+ operationId: z.uuid(),
138
+ expectedDaemonInstanceId: z.uuid(),
139
+ intent: WorkspaceSessionCreateArgumentsSchemaZ,
140
+ })
141
+ .strict()
142
+ .parse(await boundedJson(c.req.raw));
143
+ if (
144
+ request.expectedDaemonInstanceId !== server.generation ||
145
+ (request.intent.expectedDaemonInstanceId &&
146
+ request.intent.expectedDaemonInstanceId !== server.generation)
147
+ )
148
+ return c.json({ error: { code: "stale-generation" } }, 409);
149
+ return c.json(
150
+ await options.owners.withOwner(server, (owner) =>
151
+ owner.createSession(request.operationId, {
152
+ ...request.intent,
153
+ expectedDaemonInstanceId: server.generation,
154
+ }),
155
+ ),
156
+ );
157
+ }),
158
+ );
159
+ app.post(
160
+ `${scoped}/sessions/:liveSessionId/open`,
161
+ route(async (c) => {
162
+ const server = scope(c);
163
+ const result = await options.owners.withOwner(server, (owner) =>
164
+ owner.openSession(workspaceSchema.parse(c.req.param("liveSessionId"))),
165
+ );
166
+ return c.json({ version: 1, server, ...result });
167
+ }),
168
+ );
169
+ app.get(
170
+ `${scoped}/sessions`,
171
+ route(async (c) => {
172
+ const server = scope(c);
173
+ return c.json(
174
+ TmuxServerSessionsResourceSchemaZ.parse({
175
+ version: 1,
176
+ server,
177
+ sessions: await options.owners.withOwner(server, async (owner) => {
178
+ const sessions = await owner.catalog();
179
+ const workspaces = owner.workspaceRegistry.list();
180
+ return sessions.map((session) => {
181
+ const aliases = workspaces.filter(
182
+ (workspace) => workspace.sessionName === session.sessionName,
183
+ );
184
+ return { ...session, workspaceName: aliases.length === 1 ? aliases[0]!.name : null };
185
+ });
186
+ }),
187
+ }),
188
+ );
189
+ }),
190
+ );
191
+ app.get(
192
+ `${scoped}/inventory/:workspaceName`,
193
+ route(async (c) => {
194
+ const server = scope(c);
195
+ const workspaceName = workspaceSchema.parse(c.req.param("workspaceName"));
196
+ const resource = await options.owners.withOwner(server, async (owner) => {
197
+ const sessions = await owner.catalog();
198
+ const workspace = owner.workspaceRegistry.get(workspaceName);
199
+ if (!workspace) return null;
200
+ const expected = c.req.query("liveSessionId");
201
+ if (
202
+ expected &&
203
+ !sessions.some(
204
+ (row) => row.sessionName === workspace.sessionName && row.liveSessionId === expected,
205
+ )
206
+ )
207
+ throw new TmuxServerScopeError("stale-generation");
208
+ const snapshot = await owner.terminalInventoryRuntime.discoverTerminalRuntimeSession(
209
+ workspace.sessionName,
210
+ c.req.raw.signal,
211
+ );
212
+ return snapshot ? projectTerminalRuntimeInventory(snapshot, 0) : null;
213
+ });
214
+ return resource
215
+ ? c.json({ version: 1, server, resource })
216
+ : c.json({ error: { code: "workspace-not-found" } }, 404);
217
+ }),
218
+ );
219
+ app.get(
220
+ `${scoped}/catalog`,
221
+ route(async (c) => {
222
+ const server = scope(c);
223
+ const resource = await options.owners.withOwner(server, async (owner) => {
224
+ const sessions = await owner.catalog();
225
+ return {
226
+ intents: owner.workspaceRegistry.list().map((entry) => ({
227
+ workspaceName: entry.name,
228
+ sessionName: entry.sessionName,
229
+ source: "workspace",
230
+ availability: sessions.some((row) => row.sessionName === entry.sessionName)
231
+ ? "live"
232
+ : "stopped",
233
+ })),
234
+ liveSessions: sessions.map((row) => ({
235
+ sessionName: row.sessionName,
236
+ fleetSessionId: fleetSessionIdForName(row.sessionName),
237
+ paneCount: row.paneCount,
238
+ })),
239
+ };
240
+ });
241
+ return c.json({ version: 1, server, resource });
242
+ }),
243
+ );
244
+ app.get(
245
+ `${scoped}/application-shell/:workspaceName`,
246
+ route(async (c) => {
247
+ const server = scope(c);
248
+ const workspaceName = workspaceSchema.parse(c.req.param("workspaceName"));
249
+ const resource = await options.owners.withOwner(server, async (owner) => {
250
+ const sessions = await owner.catalog();
251
+ const workspace = owner.workspaceRegistry.get(workspaceName);
252
+ if (!workspace) return null;
253
+ if (
254
+ !sessions.some(
255
+ (row) =>
256
+ row.sessionName === workspace.sessionName &&
257
+ row.liveSessionId === c.req.query("liveSessionId"),
258
+ )
259
+ )
260
+ throw new TmuxServerScopeError("stale-generation");
261
+ const snapshot = await owner.terminalInventoryRuntime.discoverTerminalRuntimeSession(
262
+ workspace.sessionName,
263
+ c.req.raw.signal,
264
+ );
265
+ return snapshot ? projectApplicationShellResource(snapshot) : null;
266
+ });
267
+ return resource
268
+ ? c.json({ version: 1, server, resource })
269
+ : c.json({ error: { code: "workspace-not-found" } }, 404);
270
+ }),
271
+ );
272
+ // Subscribe before publishing ready: a topology change during the subsequent HTTP read
273
+ // invalidates that read. This shares the owner's retained mirror, never a new tmux client.
274
+ app.get(
275
+ `${scoped}/session-events/:workspaceName`,
276
+ route(async (c) => {
277
+ const server = scope(c);
278
+ const workspaceName = workspaceSchema.parse(c.req.param("workspaceName"));
279
+ const owner = await options.owners.withOwner(server, async (candidate) => {
280
+ const sessions = await candidate.catalog();
281
+ const workspace = candidate.workspaceRegistry.get(workspaceName);
282
+ if (
283
+ !workspace ||
284
+ !sessions.some(
285
+ (row) =>
286
+ row.sessionName === workspace.sessionName &&
287
+ row.liveSessionId === c.req.query("liveSessionId"),
288
+ )
289
+ )
290
+ throw new TmuxServerScopeError("stale-generation");
291
+ return candidate;
292
+ });
293
+ const workspace = owner.workspaceRegistry.get(workspaceName)!;
294
+ return streamSSE(c, async (stream) => {
295
+ let stopped = false;
296
+ let wake: (() => void) | null = null;
297
+ let revision = 0;
298
+ const dirty = () => {
299
+ revision++;
300
+ wake?.();
301
+ };
302
+ stream.onAbort(() => {
303
+ stopped = true;
304
+ wake?.();
305
+ });
306
+ const identity = await owner.sessionRuntimeRegistry.describeSessionAuthority(
307
+ workspace.sessionName,
308
+ );
309
+ let topology: string | null = null;
310
+ let sessionReplaced = false;
311
+ const subscription = await owner.sessionRuntimeRegistry.subscribeLayout(
312
+ workspace.sessionName,
313
+ () => undefined,
314
+ {
315
+ expectedRuntimeSessionId: identity.runtimeSessionId,
316
+ expectedSemanticPaneIds: identity.description.panes.map((pane) => pane.semanticPaneId),
317
+ onAuthority(snapshot) {
318
+ if (snapshot.windowLinks.liveSessionId !== c.req.query("liveSessionId")) {
319
+ sessionReplaced = true;
320
+ dirty();
321
+ return;
322
+ }
323
+ // Full replacement observes removed windows and retains no historical keys.
324
+ // Geometry, focus and active-link changes stay entirely on the pane stream.
325
+ const signature = JSON.stringify(
326
+ snapshot.layouts
327
+ .map((layout) => [
328
+ layout.semanticWindowId,
329
+ layout.windowName,
330
+ layout.panes.map((pane) => pane.semanticPaneId).sort(),
331
+ ])
332
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
333
+ );
334
+ if (topology === signature) return;
335
+ topology = signature;
336
+ dirty();
337
+ },
338
+ },
339
+ );
340
+ try {
341
+ options.owners.current(server);
342
+ if (sessionReplaced) throw new TmuxServerScopeError("stale-generation");
343
+ await stream.writeSSE({
344
+ data: JSON.stringify({ version: 1, server, type: "ready", revision }),
345
+ });
346
+ let sent = revision;
347
+ let nextIdentityCheck = Date.now() + 5000;
348
+ while (!stopped) {
349
+ await new Promise<void>((resolve) => {
350
+ const timer = setTimeout(resolve, 1000);
351
+ wake = () => {
352
+ clearTimeout(timer);
353
+ resolve();
354
+ };
355
+ if (stopped || revision !== sent) wake();
356
+ });
357
+ wake = null;
358
+ if (stopped) break;
359
+ if (sessionReplaced) throw new TmuxServerScopeError("stale-generation");
360
+ options.owners.current(server);
361
+ if (Date.now() >= nextIdentityCheck) {
362
+ const sessions = await owner.catalog();
363
+ options.owners.current(server);
364
+ if (
365
+ !sessions.some(
366
+ (row) =>
367
+ row.sessionName === workspace.sessionName &&
368
+ row.liveSessionId === c.req.query("liveSessionId"),
369
+ )
370
+ )
371
+ throw new TmuxServerScopeError("stale-generation");
372
+ nextIdentityCheck = Date.now() + 5000;
373
+ }
374
+ if (revision !== sent) {
375
+ sent = revision;
376
+ await stream.writeSSE({
377
+ data: JSON.stringify({ version: 1, server, type: "invalidate", revision }),
378
+ });
379
+ } else
380
+ await stream.writeSSE({
381
+ data: JSON.stringify({ version: 1, server, type: "heartbeat", revision }),
382
+ });
383
+ }
384
+ } catch {
385
+ if (!stopped)
386
+ await stream
387
+ .writeSSE({ data: JSON.stringify({ version: 1, server, type: "retired", revision }) })
388
+ .catch(() => undefined);
389
+ } finally {
390
+ await subscription.close();
391
+ }
392
+ });
393
+ }),
394
+ );
395
+ app.post(
396
+ `${scoped}/panes/create`,
397
+ route(async (c) => {
398
+ const server = scope(c);
399
+ const liveSessionId = workspaceSchema.parse(c.req.query("liveSessionId"));
400
+ const request = WorkspacePaneCreateMutationRequestSchemaZ.parse(await boundedJson(c.req.raw));
401
+ if (request.expectedDaemonInstanceId !== server.generation)
402
+ return c.json({ error: { code: "stale-generation" } }, 409);
403
+ return c.json(
404
+ await options.owners.withOwner(server, (owner) =>
405
+ owner.createSessionPane(liveSessionId, request),
406
+ ),
407
+ );
408
+ }),
409
+ );
410
+ app.post(
411
+ `${scoped}/mutations`,
412
+ route(async (c) => {
413
+ const server = scope(c);
414
+ const request = WorkspaceMultiplexerMutationRequestSchemaZ.parse(
415
+ await boundedJson(c.req.raw),
416
+ );
417
+ if (request.expectedDaemonInstanceId !== server.generation)
418
+ return c.json({ error: { code: "stale-generation" } }, 409);
419
+ const liveSessionId = c.req.query("liveSessionId");
420
+ const result = await options.owners.withOwner(server, (owner) =>
421
+ liveSessionId
422
+ ? owner.mutateSession(liveSessionId, request, c.req.header("X-Tmux-Ide-Host-Client-Id"))
423
+ : owner.multiplexerBackend.mutate(
424
+ request,
425
+ c.req.header("X-Tmux-Ide-Host-Client-Id"),
426
+ undefined,
427
+ true,
428
+ ),
429
+ );
430
+ return c.json(result);
431
+ }),
432
+ );
433
+ app.post(
434
+ `${scoped}/pane-streams/issue`,
435
+ route(async (c) => {
436
+ const server = scope(c);
437
+ return options.owners.withOwner(server, async (owner) => {
438
+ const sessions = await owner.catalog();
439
+ const liveSessionId = c.req.query("liveSessionId");
440
+ if (liveSessionId) {
441
+ const body = (await boundedJson(c.req.raw.clone())) as {
442
+ stream?: { workspaceName?: string };
443
+ };
444
+ const workspace = owner.workspaceRegistry.get(body.stream?.workspaceName ?? "");
445
+ if (
446
+ !workspace ||
447
+ !sessions.some(
448
+ (row) =>
449
+ row.sessionName === workspace.sessionName && row.liveSessionId === liveSessionId,
450
+ )
451
+ )
452
+ throw new TmuxServerScopeError("stale-generation");
453
+ }
454
+ const issueApp = new Hono();
455
+ mountPaneStreamIssueRoute(issueApp, {
456
+ daemonInstanceId: server.generation,
457
+ ownerToken: options.ownerToken,
458
+ workspaceRegistry: owner.workspaceRegistry,
459
+ backend: owner.paneStreamRuntime.coordinator,
460
+ });
461
+ const url = new URL(c.req.url);
462
+ url.pathname = PANE_STREAM_ISSUE_PATH;
463
+ url.search = "";
464
+ return await issueApp.request(new Request(url, c.req.raw));
465
+ });
466
+ }),
467
+ );
468
+ }