tmux-ide 2.9.0-beta.21 → 2.9.0-beta.26
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.
- package/bin/cli.js +46791 -43589
- package/package.json +2 -2
- package/packages/contracts/src/__tests__/daemon-wire.test.ts +2 -2
- package/packages/contracts/src/__tests__/fixtures/daemon-wire-v3.json +35 -0
- package/packages/contracts/src/__tests__/issue-error.test.ts +1 -1
- package/packages/contracts/src/__tests__/multiplexer-verbs.test.ts +3 -1
- package/packages/contracts/src/__tests__/pane-stream.test.ts +17 -5
- package/packages/contracts/src/__tests__/tmux-server-scope.test.ts +166 -0
- package/packages/contracts/src/__tests__/window-links.test.ts +243 -0
- package/packages/contracts/src/actions-contract.ts +12 -0
- package/packages/contracts/src/daemon-resource-request.test.ts +1 -1
- package/packages/contracts/src/daemon-wire.ts +10 -5
- package/packages/contracts/src/fleet-client-state.ts +3 -0
- package/packages/contracts/src/fleet-lifecycle.ts +5 -0
- package/packages/contracts/src/index.ts +2 -0
- package/packages/contracts/src/interaction-receipts.ts +39 -0
- package/packages/contracts/src/issue-error.ts +1 -1
- package/packages/contracts/src/multiplexer-verbs.ts +25 -1
- package/packages/contracts/src/pane-stream.ts +51 -8
- package/packages/contracts/src/tmux-server-scope.ts +172 -0
- package/packages/contracts/src/window-links.ts +76 -0
- package/packages/contracts/src/workspace-multiplexer.ts +54 -0
- package/packages/core/src/interaction-receipts.ts +4 -0
- package/packages/daemon/dist/command-center/actions/command-definitions.js +6 -0
- package/packages/daemon/dist/command-center/actions/handlers/workspace-multiplexer.js +2 -0
- package/packages/daemon/dist/command-center/actions/registry.js +13 -1
- package/packages/daemon/dist/command-center/discovery.js +2 -9
- package/packages/daemon/dist/command-center/server.js +21 -1
- package/packages/daemon/dist/command-center/tmux-server-native-backing.js +37 -0
- package/packages/daemon/dist/command-center/tmux-servers.js +351 -0
- package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +64 -8
- package/packages/daemon/dist/lib/canonical-daemon.js +5 -2
- package/packages/daemon/dist/lib/daemon-embed.js +198 -30
- package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +147 -0
- package/packages/daemon/dist/lib/fleet-lifecycle-authority.js +31 -11
- package/packages/daemon/dist/lib/headless-daemon.js +10 -1
- package/packages/daemon/dist/lib/runtime-namespace.js +17 -3
- package/packages/daemon/dist/lib/tmux-client-execution.js +59 -0
- package/packages/daemon/dist/lib/tmux-named-socket-fence.js +4 -0
- package/packages/daemon/dist/lib/tmux-server-generation-runner.js +62 -0
- package/packages/daemon/dist/lib/tmux-server-owner.js +285 -0
- package/packages/daemon/dist/lib/tmux-server-owners.js +192 -0
- package/packages/daemon/dist/lib/tmux-server-proof.js +82 -0
- package/packages/daemon/dist/lib/tmux-server-registration.js +85 -0
- package/packages/daemon/dist/lib/tmux-server-session-create.js +43 -0
- package/packages/daemon/dist/lib/tmux-server-session-open.js +101 -0
- package/packages/daemon/dist/lib/tmux-servers-cli.js +134 -0
- package/packages/daemon/dist/lib/tmux-session-mutation-fence.js +55 -0
- package/packages/daemon/dist/lib/tmux-window-link-guard.js +88 -0
- package/packages/daemon/dist/lib/workspace-config-loader.js +3 -0
- package/packages/daemon/dist/lib/workspace-multiplexer-verbs.js +82 -1
- package/packages/daemon/dist/lib/workspace-pane-creation.js +13 -57
- package/packages/daemon/dist/lib/workspace-promotion.js +91 -12
- package/packages/daemon/dist/lib/workspace-registry.js +4 -0
- package/packages/daemon/dist/server/pane-stream-upgrade.js +20 -6
- package/packages/daemon/dist/terminal/attachments/native-runtime.js +61 -17
- package/packages/daemon/dist/terminal/mirror/__tests__/scripted-channel.js +9 -1
- package/packages/daemon/dist/terminal/mirror/__tests__/simulated-channel.js +11 -2
- package/packages/daemon/dist/terminal/mirror/control-channel.js +65 -6
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +44 -0
- package/packages/daemon/dist/terminal/mirror/session-channel.js +210 -50
- package/packages/daemon/dist/terminal/mirror/window-link-authority.js +160 -0
- package/packages/daemon/dist/terminal/pane-stream/lease-manager.js +3 -3
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +40 -6
- package/packages/daemon/dist/terminal/protocol/live-session-identity.js +9 -0
- package/packages/daemon/dist/terminal/protocol/native-backing-client.js +2 -1
- package/packages/daemon/dist/terminal/session-runtime/interaction-receipt-facts.js +9 -0
- package/packages/daemon/dist/terminal/session-runtime/registry.js +6 -0
- package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-executor.js +1 -1
- package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-resource-changes.js +1 -0
- package/packages/daemon/dist/tui/mirror/application-shell-daemon-connection.js +7 -2
- package/packages/daemon/dist/tui/mirror/application-shell-server-connection.js +114 -0
- package/packages/daemon/dist/tui/mirror/menu-model.js +1 -1
- package/packages/daemon/dist/tui/mirror/open-tui-verified-routing.js +34 -4
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +29 -8
- package/packages/daemon/dist/tui/mirror/runtime/application-guided-tour-integration.jsx +119 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-guided-tour-owner.js +132 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-home-agent-roster.jsx +21 -9
- package/packages/daemon/dist/tui/mirror/runtime/application-home-agent-transport.js +63 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +8 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-home-agents.js +3 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-home-catalog-owner.js +11 -5
- package/packages/daemon/dist/tui/mirror/runtime/application-home-catalog.js +140 -75
- package/packages/daemon/dist/tui/mirror/runtime/application-home-experience.js +40 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-home-fleet.js +132 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-agents.js +9 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-catalog.js +12 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +211 -31
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +91 -37
- package/packages/daemon/dist/tui/mirror/runtime/application-performance-log.js +20 -7
- package/packages/daemon/dist/tui/mirror/runtime/application-root-configuration.js +9 -3
- package/packages/daemon/dist/tui/mirror/runtime/application-route-connection.js +95 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-shell-home.jsx +6 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +158 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace-policy.js +43 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +78 -25
- package/packages/daemon/dist/tui/mirror/runtime/application-theme-repaint.js +9 -0
- package/packages/daemon/dist/tui/mirror/runtime/fleet-lifecycle-client.js +44 -1
- package/packages/daemon/dist/tui/mirror/runtime/guided-tour-coach.jsx +54 -0
- package/packages/daemon/dist/tui/mirror/runtime/guided-tour-storage.js +67 -0
- package/packages/daemon/dist/tui/mirror/runtime/guided-tour.js +113 -0
- package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +1 -1
- package/packages/daemon/dist/tui/mirror/runtime/resize-terminal-pane.js +4 -0
- package/packages/daemon/dist/tui/mirror/runtime/select-terminal-pane.js +2 -1
- package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +12 -4
- package/packages/daemon/dist/tui/mirror/runtime/terminal-pane-input-router.js +6 -3
- package/packages/daemon/dist/tui/mirror/workspace/application-command-description.js +11 -2
- package/packages/daemon/dist/tui/mirror/workspace/terminal-window-strip.jsx +2 -1
- package/packages/daemon/src/command-center/actions/command-definitions.ts +6 -0
- package/packages/daemon/src/command-center/actions/errors.ts +4 -0
- package/packages/daemon/src/command-center/actions/handlers/workspace-multiplexer.ts +2 -0
- package/packages/daemon/src/command-center/actions/registry.ts +14 -0
- package/packages/daemon/src/command-center/discovery.ts +2 -10
- package/packages/daemon/src/command-center/server.ts +29 -1
- package/packages/daemon/src/command-center/tmux-server-native-backing.ts +49 -0
- package/packages/daemon/src/command-center/tmux-servers.ts +479 -0
- package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +111 -4
- package/packages/daemon/src/lib/canonical-daemon.ts +15 -3
- package/packages/daemon/src/lib/daemon-embed.ts +241 -28
- package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +190 -0
- package/packages/daemon/src/lib/fleet-lifecycle-authority.ts +33 -9
- package/packages/daemon/src/lib/headless-daemon.ts +18 -1
- package/packages/daemon/src/lib/runtime-namespace.ts +24 -3
- package/packages/daemon/src/lib/tmux-client-execution.ts +56 -0
- package/packages/daemon/src/lib/tmux-named-socket-fence.ts +5 -0
- package/packages/daemon/src/lib/tmux-server-generation-runner.ts +81 -0
- package/packages/daemon/src/lib/tmux-server-owner.ts +336 -0
- package/packages/daemon/src/lib/tmux-server-owners.ts +228 -0
- package/packages/daemon/src/lib/tmux-server-proof.ts +95 -0
- package/packages/daemon/src/lib/tmux-server-registration.ts +105 -0
- package/packages/daemon/src/lib/tmux-server-session-create.ts +52 -0
- package/packages/daemon/src/lib/tmux-server-session-open.ts +106 -0
- package/packages/daemon/src/lib/tmux-servers-cli.ts +186 -0
- package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +62 -0
- package/packages/daemon/src/lib/tmux-window-link-guard.ts +112 -0
- package/packages/daemon/src/lib/workspace-config-loader.ts +3 -0
- package/packages/daemon/src/lib/workspace-multiplexer-verbs.ts +121 -1
- package/packages/daemon/src/lib/workspace-pane-creation.ts +16 -55
- package/packages/daemon/src/lib/workspace-promotion.ts +98 -12
- package/packages/daemon/src/lib/workspace-registry.ts +5 -0
- package/packages/daemon/src/server/pane-stream-upgrade.ts +25 -5
- package/packages/daemon/src/terminal/attachments/native-runtime.ts +70 -16
- package/packages/daemon/src/terminal/mirror/control-channel.ts +64 -7
- package/packages/daemon/src/terminal/mirror/events.ts +2 -0
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +56 -0
- package/packages/daemon/src/terminal/mirror/session-channel.ts +245 -50
- package/packages/daemon/src/terminal/mirror/window-link-authority.ts +203 -0
- package/packages/daemon/src/terminal/pane-stream/lease-manager.ts +3 -3
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +50 -6
- package/packages/daemon/src/terminal/protocol/live-session-identity.ts +14 -0
- package/packages/daemon/src/terminal/protocol/native-backing-client.ts +4 -1
- package/packages/daemon/src/terminal/session-runtime/interaction-receipt-facts.ts +9 -0
- package/packages/daemon/src/terminal/session-runtime/registry.ts +9 -0
- package/packages/daemon/src/terminal/session-runtime/semantic-mutation-executor.ts +2 -2
- package/packages/daemon/src/terminal/session-runtime/semantic-mutation-resource-changes.ts +1 -0
- package/packages/daemon/src/tui/mirror/application-shell-daemon-connection.ts +28 -2
- package/packages/daemon/src/tui/mirror/application-shell-server-connection.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +1 -1
- package/packages/daemon/src/tui/mirror/open-tui-verified-routing.ts +41 -3
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +47 -6
- package/packages/daemon/src/tui/mirror/runtime/application-entry.ts +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-session-actions.tsx +10 -4
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-tabs.ts +2 -0
- package/packages/daemon/src/tui/mirror/runtime/application-guided-tour-integration.tsx +199 -0
- package/packages/daemon/src/tui/mirror/runtime/application-guided-tour-owner.ts +162 -0
- package/packages/daemon/src/tui/mirror/runtime/application-home-agent-roster.tsx +36 -10
- package/packages/daemon/src/tui/mirror/runtime/application-home-agent-transport.ts +69 -0
- package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +12 -2
- package/packages/daemon/src/tui/mirror/runtime/application-home-agents.ts +13 -2
- package/packages/daemon/src/tui/mirror/runtime/application-home-catalog-owner.ts +13 -3
- package/packages/daemon/src/tui/mirror/runtime/application-home-catalog.ts +172 -100
- package/packages/daemon/src/tui/mirror/runtime/application-home-experience.ts +59 -0
- package/packages/daemon/src/tui/mirror/runtime/application-home-fleet.ts +175 -0
- package/packages/daemon/src/tui/mirror/runtime/application-machine-agents.ts +9 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-catalog.ts +19 -7
- package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +285 -36
- package/packages/daemon/src/tui/mirror/runtime/application-machine-overlays.tsx +1 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +124 -40
- package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +3 -0
- package/packages/daemon/src/tui/mirror/runtime/application-performance-log.ts +18 -6
- package/packages/daemon/src/tui/mirror/runtime/application-root-configuration.ts +12 -3
- package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +40 -40
- package/packages/daemon/src/tui/mirror/runtime/application-route-connection.ts +93 -1
- package/packages/daemon/src/tui/mirror/runtime/application-shell-catalog.tsx +4 -0
- package/packages/daemon/src/tui/mirror/runtime/application-shell-home.tsx +20 -1
- package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +10 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +177 -2
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace-policy.ts +62 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +87 -30
- package/packages/daemon/src/tui/mirror/runtime/application-theme-repaint.ts +19 -0
- package/packages/daemon/src/tui/mirror/runtime/fleet-lifecycle-client.ts +55 -0
- package/packages/daemon/src/tui/mirror/runtime/guided-tour-coach.tsx +118 -0
- package/packages/daemon/src/tui/mirror/runtime/guided-tour-storage.ts +72 -0
- package/packages/daemon/src/tui/mirror/runtime/guided-tour.ts +147 -0
- package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +1 -1
- package/packages/daemon/src/tui/mirror/runtime/resize-terminal-pane.ts +3 -0
- package/packages/daemon/src/tui/mirror/runtime/select-terminal-pane.ts +3 -0
- package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +11 -4
- package/packages/daemon/src/tui/mirror/runtime/terminal-pane-input-router.ts +15 -4
- package/packages/daemon/src/tui/mirror/workspace/application-command-description.ts +13 -2
- package/packages/daemon/src/tui/mirror/workspace/terminal-window-strip.tsx +5 -1
- package/packages/daemon-client/package.json +3 -1
- package/packages/daemon-client/src/index.ts +2 -0
- package/packages/daemon-client/src/pane-stream-client.test.ts +108 -38
- package/packages/daemon-client/src/pane-stream-client.ts +26 -0
- package/packages/daemon-client/src/scoped-tmux-server-transport.test.ts +114 -0
- package/packages/daemon-client/src/scoped-tmux-server-transport.ts +385 -0
- package/packages/daemon-client/src/tmux-server-client.test.ts +279 -0
- package/packages/daemon-client/src/tmux-server-client.ts +287 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { tmuxClientEnvironment } from "./tmux-client-execution.ts";
|
|
15
|
+
import {
|
|
16
|
+
captureUnixSocketIdentity,
|
|
17
|
+
revalidateUnixSocketIdentity,
|
|
18
|
+
} from "./unix-socket-authority.ts";
|
|
19
|
+
import {
|
|
20
|
+
MAX_TMUX_SERVER_OWNERS,
|
|
21
|
+
TmuxServerRegistrationSchemaZ,
|
|
22
|
+
type TmuxServerObservation,
|
|
23
|
+
type TmuxServerRegistration,
|
|
24
|
+
} from "./tmux-server-owners.ts";
|
|
25
|
+
|
|
26
|
+
const exec = promisify(execFile);
|
|
27
|
+
const FileSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
version: z.literal(1),
|
|
30
|
+
servers: z.array(TmuxServerRegistrationSchemaZ).max(MAX_TMUX_SERVER_OWNERS),
|
|
31
|
+
})
|
|
32
|
+
.strict()
|
|
33
|
+
.superRefine((file, ctx) => {
|
|
34
|
+
if (new Set(file.servers.map((server) => server.serverId)).size !== file.servers.length)
|
|
35
|
+
ctx.addIssue({ code: "custom", message: "Duplicate server registration" });
|
|
36
|
+
});
|
|
37
|
+
export function readTmuxServerRegistrations(path: string): readonly TmuxServerRegistration[] {
|
|
38
|
+
try {
|
|
39
|
+
return FileSchema.parse(JSON.parse(readFileSync(path, "utf8"))).servers;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function writeTmuxServerRegistrations(
|
|
46
|
+
path: string,
|
|
47
|
+
registrations: readonly TmuxServerRegistration[],
|
|
48
|
+
): void {
|
|
49
|
+
const file = FileSchema.parse({ version: 1, servers: registrations });
|
|
50
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
51
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
52
|
+
try {
|
|
53
|
+
writeFileSync(temporary, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
54
|
+
renameSync(temporary, path);
|
|
55
|
+
} finally {
|
|
56
|
+
try {
|
|
57
|
+
unlinkSync(temporary);
|
|
58
|
+
} catch {
|
|
59
|
+
/* rename already removed it */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Discovery never creates a server, loads configuration, or attaches a client. */
|
|
64
|
+
export function createTmuxServerProbe(
|
|
65
|
+
executable: string,
|
|
66
|
+
): (selector: TmuxServerRegistration["selector"]) => Promise<TmuxServerObservation | null> {
|
|
67
|
+
const executablePath = realpathSync(executable);
|
|
68
|
+
const env = tmuxClientEnvironment(process.env);
|
|
69
|
+
return async (selector) => {
|
|
70
|
+
const args = selector.kind === "path" ? ["-S", selector.path] : ["-L", selector.name];
|
|
71
|
+
const read = async (selection: readonly string[]) =>
|
|
72
|
+
(
|
|
73
|
+
await exec(
|
|
74
|
+
executablePath,
|
|
75
|
+
[...selection, "-N", "display-message", "-p", "#{socket_path}|#{pid}|#{start_time}"],
|
|
76
|
+
{ env, encoding: "utf8", timeout: 500, maxBuffer: 8192 },
|
|
77
|
+
)
|
|
78
|
+
).stdout.trimEnd();
|
|
79
|
+
try {
|
|
80
|
+
const first = await read(args);
|
|
81
|
+
const match = /^(.*)\|([1-9][0-9]*)\|([1-9][0-9]*)$/u.exec(first);
|
|
82
|
+
if (!match) return null;
|
|
83
|
+
const socket = captureUnixSocketIdentity(match[1]!);
|
|
84
|
+
if ((await read(["-S", socket.path])) !== first) return null;
|
|
85
|
+
revalidateUnixSocketIdentity(socket);
|
|
86
|
+
return {
|
|
87
|
+
nativeServerIdentity: { pid: match[2]!, startTime: match[3]! },
|
|
88
|
+
fingerprint: createHash("sha256")
|
|
89
|
+
.update(JSON.stringify([socket.path, match[2], match[3]]))
|
|
90
|
+
.digest("hex"),
|
|
91
|
+
authority: { executablePath, socketSelector: { kind: "path", path: socket.path } },
|
|
92
|
+
valid: () => {
|
|
93
|
+
try {
|
|
94
|
+
revalidateUnixSocketIdentity(socket);
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WorkspaceSessionCreateArgumentsSchemaZ,
|
|
3
|
+
type WorkspaceSessionCreateArguments,
|
|
4
|
+
type WorkspaceSessionCreateResult,
|
|
5
|
+
} from "@tmux-ide/contracts";
|
|
6
|
+
import { FleetLifecycleAuthority } from "./fleet-lifecycle-authority.ts";
|
|
7
|
+
import type { WorkspaceRegistry } from "./workspace-registry.ts";
|
|
8
|
+
|
|
9
|
+
/** Bounded online-server creation. The existing lifecycle owns idempotency and workspace naming. */
|
|
10
|
+
export function createNativeTmuxSessionCreator(options: {
|
|
11
|
+
generation: string;
|
|
12
|
+
registry: WorkspaceRegistry;
|
|
13
|
+
run(args: readonly string[]): string;
|
|
14
|
+
assertOpen(): void;
|
|
15
|
+
}) {
|
|
16
|
+
let disposed = false;
|
|
17
|
+
const pending = new Set<Promise<WorkspaceSessionCreateResult>>();
|
|
18
|
+
const assertOpen = () => {
|
|
19
|
+
options.assertOpen();
|
|
20
|
+
if (disposed) throw new Error("Session creation is retired");
|
|
21
|
+
};
|
|
22
|
+
const authority = new FleetLifecycleAuthority({
|
|
23
|
+
daemonInstanceId: options.generation,
|
|
24
|
+
productVersion: "scoped-owner",
|
|
25
|
+
startedAt: new Date().toISOString(),
|
|
26
|
+
registry: options.registry,
|
|
27
|
+
runTmux(args) {
|
|
28
|
+
assertOpen();
|
|
29
|
+
return options.run(args);
|
|
30
|
+
},
|
|
31
|
+
readFleet: () => [],
|
|
32
|
+
ensureChromeUpdater: false,
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
createSession(operationId: string, input: WorkspaceSessionCreateArguments) {
|
|
36
|
+
assertOpen();
|
|
37
|
+
const parsed = WorkspaceSessionCreateArgumentsSchemaZ.parse(input);
|
|
38
|
+
if (parsed.expectedDaemonInstanceId && parsed.expectedDaemonInstanceId !== options.generation)
|
|
39
|
+
throw new Error("Selected tmux server generation changed");
|
|
40
|
+
if (pending.size >= 16) throw new Error("Session creation capacity reached");
|
|
41
|
+
const operation = authority
|
|
42
|
+
.createSession(operationId, options.generation, parsed)
|
|
43
|
+
.finally(() => pending.delete(operation));
|
|
44
|
+
pending.add(operation);
|
|
45
|
+
return operation;
|
|
46
|
+
},
|
|
47
|
+
async dispose() {
|
|
48
|
+
disposed = true;
|
|
49
|
+
await Promise.allSettled([...pending]);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { WorkspaceCatalogLiveSessionIdSchemaZ } from "@tmux-ide/contracts";
|
|
3
|
+
import { liveSessionIdForNativeIdentity } from "../terminal/protocol/live-session-identity.ts";
|
|
4
|
+
import { fleetSessionIdForName } from "../command-center/resources/fleet-catalog.ts";
|
|
5
|
+
import { isVisibleFleetSession } from "../command-center/discovery.ts";
|
|
6
|
+
import { WorkspacePromotionAuthority } from "./workspace-promotion.ts";
|
|
7
|
+
import type { WorkspaceRegistry } from "./workspace-registry.ts";
|
|
8
|
+
import { shellEscape } from "./shell.ts";
|
|
9
|
+
|
|
10
|
+
/** Owner-local bounded admission; all native IO must already fence the server generation. */
|
|
11
|
+
export function createNativeTmuxSessionOpener(options: {
|
|
12
|
+
generation: string;
|
|
13
|
+
registry: WorkspaceRegistry;
|
|
14
|
+
run(args: readonly string[]): Promise<string>;
|
|
15
|
+
assertOpen(): void;
|
|
16
|
+
}) {
|
|
17
|
+
let disposed = false;
|
|
18
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
19
|
+
const pending = new Map<string, Promise<{ workspaceName: string; liveSessionId: string }>>();
|
|
20
|
+
const assertOpen = () => {
|
|
21
|
+
options.assertOpen();
|
|
22
|
+
if (disposed) throw new Error("Session admission is retired");
|
|
23
|
+
};
|
|
24
|
+
const open = async (liveSessionId: string) => {
|
|
25
|
+
assertOpen();
|
|
26
|
+
const records = await options.run([
|
|
27
|
+
"list-sessions",
|
|
28
|
+
"-F",
|
|
29
|
+
"#{pid}\t#{session_id}\t#{session_created}\t#{session_name}",
|
|
30
|
+
]);
|
|
31
|
+
const matches = records
|
|
32
|
+
.trim()
|
|
33
|
+
.split("\n")
|
|
34
|
+
.map((line) => line.split("\t"))
|
|
35
|
+
.filter(
|
|
36
|
+
([pid, id, created, name]) =>
|
|
37
|
+
pid &&
|
|
38
|
+
/^\d+$/.test(pid) &&
|
|
39
|
+
id &&
|
|
40
|
+
/^\$\d+$/.test(id) &&
|
|
41
|
+
created &&
|
|
42
|
+
/^\d+$/.test(created) &&
|
|
43
|
+
name &&
|
|
44
|
+
isVisibleFleetSession(name) &&
|
|
45
|
+
liveSessionIdForNativeIdentity(pid, id, created) === liveSessionId,
|
|
46
|
+
);
|
|
47
|
+
if (matches.length !== 1) throw new Error("Selected live tmux session is no longer available");
|
|
48
|
+
const [, nativeSessionId, created, name] = matches[0]!;
|
|
49
|
+
// Each command checks the exact native session on the server accepting it.
|
|
50
|
+
// A name-only validation followed by stamping would cross a session replacement.
|
|
51
|
+
const run = async (args: readonly string[]) => {
|
|
52
|
+
assertOpen();
|
|
53
|
+
const sentinel = `tmux-session-stale.${randomUUID()}`;
|
|
54
|
+
const condition = `#{&&:#{==:#{session_id},${nativeSessionId}},#{==:#{session_created},${created}}}`;
|
|
55
|
+
const command = args.map((arg) => (arg === ";" ? ";" : shellEscape(arg))).join(" ");
|
|
56
|
+
const output = await options.run([
|
|
57
|
+
"if-shell",
|
|
58
|
+
"-t",
|
|
59
|
+
nativeSessionId!,
|
|
60
|
+
"-F",
|
|
61
|
+
condition,
|
|
62
|
+
command,
|
|
63
|
+
`display-message -p '${sentinel}'`,
|
|
64
|
+
]);
|
|
65
|
+
assertOpen();
|
|
66
|
+
if (output.trim() === sentinel)
|
|
67
|
+
throw new Error("Selected live tmux session changed during admission");
|
|
68
|
+
return output;
|
|
69
|
+
};
|
|
70
|
+
const promotion = new WorkspacePromotionAuthority({
|
|
71
|
+
daemonInstanceId: options.generation,
|
|
72
|
+
registry: options.registry,
|
|
73
|
+
io: { runTmux: run },
|
|
74
|
+
});
|
|
75
|
+
try {
|
|
76
|
+
const result = await promotion.promote({
|
|
77
|
+
operationId: randomUUID(),
|
|
78
|
+
expectedDaemonInstanceId: options.generation,
|
|
79
|
+
intent: { sessionId: fleetSessionIdForName(name!) },
|
|
80
|
+
});
|
|
81
|
+
await run(["display-message", "-p", "-t", nativeSessionId!, "#{session_id}"]);
|
|
82
|
+
return { workspaceName: result.resource.workspaceName, liveSessionId };
|
|
83
|
+
} finally {
|
|
84
|
+
await promotion.dispose();
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
openSession(liveSessionId: string) {
|
|
89
|
+
WorkspaceCatalogLiveSessionIdSchemaZ.parse(liveSessionId);
|
|
90
|
+
assertOpen();
|
|
91
|
+
const previous = pending.get(liveSessionId);
|
|
92
|
+
if (previous) return previous;
|
|
93
|
+
if (pending.size >= 16) throw new Error("Session admission capacity reached");
|
|
94
|
+
const operation = tail
|
|
95
|
+
.then(() => open(liveSessionId))
|
|
96
|
+
.finally(() => pending.delete(liveSessionId));
|
|
97
|
+
pending.set(liveSessionId, operation);
|
|
98
|
+
tail = operation.catch(() => {});
|
|
99
|
+
return operation;
|
|
100
|
+
},
|
|
101
|
+
async dispose() {
|
|
102
|
+
disposed = true;
|
|
103
|
+
await tail;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
SavedMachineSchema,
|
|
4
|
+
TmuxServerIdSchemaZ,
|
|
5
|
+
TmuxServerRegistrationRequestSchemaZ,
|
|
6
|
+
WorkspaceSessionCreateArgumentsSchemaZ,
|
|
7
|
+
type TmuxServerRegistrationRequest,
|
|
8
|
+
} from "@tmux-ide/contracts";
|
|
9
|
+
import {
|
|
10
|
+
createTmuxServerClient,
|
|
11
|
+
listTmuxServers,
|
|
12
|
+
registerTmuxServer,
|
|
13
|
+
removeTmuxServer,
|
|
14
|
+
TmuxServerClientError,
|
|
15
|
+
type TmuxServerClientOptions,
|
|
16
|
+
} from "@tmux-ide/daemon-client/tmux-server-client";
|
|
17
|
+
import { canonicalDaemonUrl } from "./canonical-daemon.ts";
|
|
18
|
+
import { ensureCanonicalDaemon } from "./canonical-daemon-bootstrap.ts";
|
|
19
|
+
import { openSshDaemonTransport } from "./ssh-daemon-transport.ts";
|
|
20
|
+
import { loadSavedMachines } from "./saved-machines.ts";
|
|
21
|
+
|
|
22
|
+
export interface TmuxServersCliOptions {
|
|
23
|
+
readonly command?: string;
|
|
24
|
+
readonly serverId?: string;
|
|
25
|
+
readonly socketName?: string;
|
|
26
|
+
readonly socketPath?: string;
|
|
27
|
+
readonly label?: string;
|
|
28
|
+
readonly sessionName?: string;
|
|
29
|
+
readonly cwd?: string;
|
|
30
|
+
readonly ssh?: string;
|
|
31
|
+
}
|
|
32
|
+
type Operation =
|
|
33
|
+
| { command: "list" }
|
|
34
|
+
| { command: "add"; registration: TmuxServerRegistrationRequest }
|
|
35
|
+
| { command: "remove" | "sessions"; serverId: string }
|
|
36
|
+
| { command: "create"; serverId: string; input: { displayName: string; cwd?: string } };
|
|
37
|
+
|
|
38
|
+
/** Parse before acquiring a transport; invalid input cannot start a daemon. */
|
|
39
|
+
export function parseTmuxServersOperation(options: TmuxServersCliOptions): Operation {
|
|
40
|
+
const command = options.command ?? "list";
|
|
41
|
+
if (options.ssh !== undefined) SavedMachineSchema.shape.sshTarget.parse(options.ssh);
|
|
42
|
+
if (command === "create") {
|
|
43
|
+
if (
|
|
44
|
+
options.socketName !== undefined ||
|
|
45
|
+
options.socketPath !== undefined ||
|
|
46
|
+
options.label !== undefined
|
|
47
|
+
)
|
|
48
|
+
throw new Error("servers create requires a registered server ID and --session-name NAME");
|
|
49
|
+
return {
|
|
50
|
+
command,
|
|
51
|
+
serverId: TmuxServerIdSchemaZ.parse(options.serverId),
|
|
52
|
+
input: WorkspaceSessionCreateArgumentsSchemaZ.parse({
|
|
53
|
+
displayName: options.sessionName,
|
|
54
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (options.sessionName !== undefined || options.cwd !== undefined)
|
|
59
|
+
throw new Error("--session-name and --dir are only supported by servers create");
|
|
60
|
+
if (command === "add") {
|
|
61
|
+
if (
|
|
62
|
+
options.serverId ||
|
|
63
|
+
(options.socketName === undefined) === (options.socketPath === undefined)
|
|
64
|
+
)
|
|
65
|
+
throw new Error(
|
|
66
|
+
"servers add requires exactly one of --socket-name NAME or --socket-path /PATH",
|
|
67
|
+
);
|
|
68
|
+
return {
|
|
69
|
+
command,
|
|
70
|
+
registration: TmuxServerRegistrationRequestSchemaZ.parse({
|
|
71
|
+
label: options.label ?? options.socketName ?? "Custom server",
|
|
72
|
+
selector:
|
|
73
|
+
options.socketName !== undefined
|
|
74
|
+
? { kind: "name", name: options.socketName }
|
|
75
|
+
: { kind: "path", path: options.socketPath },
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (
|
|
80
|
+
options.socketName !== undefined ||
|
|
81
|
+
options.socketPath !== undefined ||
|
|
82
|
+
options.label !== undefined
|
|
83
|
+
)
|
|
84
|
+
throw new Error("Socket selectors and --name are only supported by servers add");
|
|
85
|
+
if (command === "list" && options.serverId === undefined) return { command };
|
|
86
|
+
if (command === "remove" || command === "sessions")
|
|
87
|
+
return { command, serverId: TmuxServerIdSchemaZ.parse(options.serverId) };
|
|
88
|
+
throw new Error(
|
|
89
|
+
"Usage: tmux-ide servers list|add|remove <server-id>|sessions <server-id>|create <server-id> --session-name NAME [--ssh HOST] [--json]",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface TmuxServersCliConnection {
|
|
94
|
+
readonly client: TmuxServerClientOptions;
|
|
95
|
+
dispose(): void;
|
|
96
|
+
}
|
|
97
|
+
export async function connectTmuxServersCli(options: {
|
|
98
|
+
readonly ssh?: string;
|
|
99
|
+
readonly entryPath: string;
|
|
100
|
+
readonly expectedProductVersion?: string;
|
|
101
|
+
}): Promise<TmuxServersCliConnection> {
|
|
102
|
+
const hostClientId = randomUUID();
|
|
103
|
+
if (options.ssh !== undefined) {
|
|
104
|
+
const transport = await openSshDaemonTransport({ alias: options.ssh });
|
|
105
|
+
try {
|
|
106
|
+
const expected = loadSavedMachines().machines.find(
|
|
107
|
+
(machine) => machine.sshTarget === options.ssh,
|
|
108
|
+
)?.expectedEnvironmentId;
|
|
109
|
+
if (expected && transport.daemon.environmentId !== expected)
|
|
110
|
+
throw new Error("SSH route reached a different environment than the saved profile");
|
|
111
|
+
return {
|
|
112
|
+
client: {
|
|
113
|
+
baseUrl: transport.baseUrl,
|
|
114
|
+
ownerToken: transport.daemon.authToken,
|
|
115
|
+
hostClientId,
|
|
116
|
+
origin: transport.baseUrl,
|
|
117
|
+
},
|
|
118
|
+
dispose: () => transport.dispose(),
|
|
119
|
+
};
|
|
120
|
+
} catch (error) {
|
|
121
|
+
transport.dispose();
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Server registration addresses the daemon, not the invoking shell's tmux server.
|
|
126
|
+
const { candidate } = await ensureCanonicalDaemon({
|
|
127
|
+
entryPath: options.entryPath,
|
|
128
|
+
expectedProductVersion: options.expectedProductVersion,
|
|
129
|
+
tmuxServerIntent: null,
|
|
130
|
+
});
|
|
131
|
+
if (!candidate.authToken)
|
|
132
|
+
throw new Error("Daemon owner authority is unavailable; update the local daemon");
|
|
133
|
+
const baseUrl = canonicalDaemonUrl("http", candidate.bindHostname, candidate.port);
|
|
134
|
+
return {
|
|
135
|
+
client: { baseUrl, ownerToken: candidate.authToken, hostClientId, origin: baseUrl },
|
|
136
|
+
dispose() {},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function runTmuxServersCli(
|
|
141
|
+
options: TmuxServersCliOptions,
|
|
142
|
+
connect: () => Promise<TmuxServersCliConnection>,
|
|
143
|
+
): Promise<unknown> {
|
|
144
|
+
const operation = parseTmuxServersOperation(options);
|
|
145
|
+
const connection = await connect();
|
|
146
|
+
try {
|
|
147
|
+
// Probe the versioned resource before writes. An older daemon is unsupported,
|
|
148
|
+
// never a reason to execute a raw command against a default server.
|
|
149
|
+
let catalog;
|
|
150
|
+
try {
|
|
151
|
+
catalog = await listTmuxServers(connection.client);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof TmuxServerClientError && [404, 405, 426].includes(error.status ?? 0))
|
|
154
|
+
throw new Error(
|
|
155
|
+
"This daemon does not support tmux server selection; update the selected machine's daemon",
|
|
156
|
+
{ cause: error },
|
|
157
|
+
);
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
if (operation.command === "list") return catalog;
|
|
161
|
+
if (operation.command === "add")
|
|
162
|
+
return await registerTmuxServer(connection.client, operation.registration);
|
|
163
|
+
const server = catalog.servers.find((candidate) => candidate.serverId === operation.serverId);
|
|
164
|
+
if (!server)
|
|
165
|
+
throw new Error("Selected tmux server is not registered; run tmux-ide servers list");
|
|
166
|
+
if (operation.command === "remove") {
|
|
167
|
+
await removeTmuxServer(connection.client, server.serverId);
|
|
168
|
+
return { removed: true, serverId: server.serverId };
|
|
169
|
+
}
|
|
170
|
+
if (server.state !== "online")
|
|
171
|
+
throw new Error("Selected tmux server is offline; no session operation was attempted");
|
|
172
|
+
const client = createTmuxServerClient(connection.client, {
|
|
173
|
+
serverId: server.serverId,
|
|
174
|
+
generation: server.generation,
|
|
175
|
+
});
|
|
176
|
+
try {
|
|
177
|
+
return operation.command === "create"
|
|
178
|
+
? await client.createSession(randomUUID(), operation.input)
|
|
179
|
+
: await client.sessions();
|
|
180
|
+
} finally {
|
|
181
|
+
client.dispose();
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
connection.dispose();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { WorkspaceCatalogLiveSessionIdSchemaZ } from "@tmux-ide/contracts";
|
|
4
|
+
import { liveSessionIdForNativeIdentity } from "../terminal/protocol/live-session-identity.ts";
|
|
5
|
+
import { shellEscape } from "./shell.ts";
|
|
6
|
+
|
|
7
|
+
/** Session identity travels with the mutation lane, never in global mutable selection. */
|
|
8
|
+
export function createTmuxSessionMutationFence() {
|
|
9
|
+
const context = new AsyncLocalStorage<{ id: string; created: string; name: string }>();
|
|
10
|
+
return {
|
|
11
|
+
wrap(run: (args: readonly string[]) => string) {
|
|
12
|
+
return (args: readonly string[]) => {
|
|
13
|
+
const selected = context.getStore();
|
|
14
|
+
if (!selected) return run(args);
|
|
15
|
+
const sentinel = `tmux-session-stale.${randomUUID()}`;
|
|
16
|
+
const output = run([
|
|
17
|
+
"if-shell",
|
|
18
|
+
"-t",
|
|
19
|
+
`=${selected.name}`,
|
|
20
|
+
"-F",
|
|
21
|
+
`#{&&:#{==:#{session_id},${selected.id}},#{==:#{session_created},${selected.created}}}`,
|
|
22
|
+
args.map((arg) => (arg === ";" ? ";" : shellEscape(arg))).join(" "),
|
|
23
|
+
`display-message -p '${sentinel}'`,
|
|
24
|
+
]);
|
|
25
|
+
if (output.trim() === sentinel)
|
|
26
|
+
throw new Error("Selected live tmux session changed before mutation");
|
|
27
|
+
return output;
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
async execute<T>(options: {
|
|
31
|
+
liveSessionId: string;
|
|
32
|
+
sessionName: string;
|
|
33
|
+
run(args: readonly string[]): Promise<string>;
|
|
34
|
+
mutate(): Promise<T>;
|
|
35
|
+
}): Promise<T> {
|
|
36
|
+
WorkspaceCatalogLiveSessionIdSchemaZ.parse(options.liveSessionId);
|
|
37
|
+
const raw = await options.run([
|
|
38
|
+
"list-sessions",
|
|
39
|
+
"-F",
|
|
40
|
+
"#{pid}\t#{session_id}\t#{session_created}\t#{session_name}",
|
|
41
|
+
]);
|
|
42
|
+
const rows = raw
|
|
43
|
+
.trim()
|
|
44
|
+
.split("\n")
|
|
45
|
+
.map((line) => line.split("\t"))
|
|
46
|
+
.filter(
|
|
47
|
+
([pid, id, created, name]) =>
|
|
48
|
+
pid &&
|
|
49
|
+
/^\d+$/.test(pid) &&
|
|
50
|
+
id &&
|
|
51
|
+
/^\$\d+$/.test(id) &&
|
|
52
|
+
created &&
|
|
53
|
+
/^\d+$/.test(created) &&
|
|
54
|
+
name === options.sessionName &&
|
|
55
|
+
liveSessionIdForNativeIdentity(pid, id, created) === options.liveSessionId,
|
|
56
|
+
);
|
|
57
|
+
if (rows.length !== 1) throw new Error("Selected live tmux session is no longer available");
|
|
58
|
+
const [, id, created] = rows[0]!;
|
|
59
|
+
return context.run({ id: id!, created: created!, name: options.sessionName }, options.mutate);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Private native boundary: callers must resolve opaque link handles and fence
|
|
2
|
+
* daemon/server/session generation before using this command. Never accept native
|
|
3
|
+
* addresses from clients. Execute argv through the pinned runner without a shell.
|
|
4
|
+
*/
|
|
5
|
+
export interface NativeWindowLinkAddress {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
windowIndex: number;
|
|
8
|
+
expectedWindowId: string;
|
|
9
|
+
expectedServerPid?: string;
|
|
10
|
+
expectedSessionCreated?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const WINDOW_LINK_GUARD_APPLIED = "link-guard.ok";
|
|
14
|
+
export const WINDOW_LINK_GUARD_STALE = "link-guard.stale";
|
|
15
|
+
|
|
16
|
+
/** if-shell -F and its first branch command do not yield in tmux's command queue.
|
|
17
|
+
* Keep the mutation first and synchronous. Do not insert run-shell/wait-for/UI.
|
|
18
|
+
* This checks a current tuple, not an immutable historical winlink (tmux has none).
|
|
19
|
+
* Use buildNativeWindowLinkPaneSelectGuard for guarded compound pane selection.
|
|
20
|
+
*/
|
|
21
|
+
export function buildNativeWindowLinkGuard(
|
|
22
|
+
address: NativeWindowLinkAddress,
|
|
23
|
+
action: "select" | "unlink",
|
|
24
|
+
): string[] {
|
|
25
|
+
const { sessionId, windowIndex, expectedWindowId } = address;
|
|
26
|
+
if (
|
|
27
|
+
!/^\$(?:0|[1-9]\d{0,15})$/u.test(sessionId) ||
|
|
28
|
+
!/^@(?:0|[1-9]\d{0,15})$/u.test(expectedWindowId) ||
|
|
29
|
+
!Number.isSafeInteger(Number(sessionId.slice(1))) ||
|
|
30
|
+
!Number.isSafeInteger(Number(expectedWindowId.slice(1))) ||
|
|
31
|
+
!Number.isSafeInteger(windowIndex) ||
|
|
32
|
+
windowIndex < 0 ||
|
|
33
|
+
(address.expectedServerPid !== undefined &&
|
|
34
|
+
!/^[1-9][0-9]{0,15}$/u.test(address.expectedServerPid)) ||
|
|
35
|
+
(address.expectedSessionCreated !== undefined &&
|
|
36
|
+
!/^(?:0|[1-9][0-9]{0,15})$/u.test(address.expectedSessionCreated)) ||
|
|
37
|
+
(action !== "select" && action !== "unlink")
|
|
38
|
+
) {
|
|
39
|
+
throw new Error("Invalid native window link guard address or action");
|
|
40
|
+
}
|
|
41
|
+
const target = `${sessionId}:${windowIndex}`;
|
|
42
|
+
// CANFAIL target lookup makes all three comparisons necessary for missing targets.
|
|
43
|
+
let condition = `#{&&:#{==:#{session_id},${sessionId}},#{&&:#{==:#{window_index},${windowIndex}},#{==:#{window_id},${expectedWindowId}}}}`;
|
|
44
|
+
if (address.expectedServerPid !== undefined)
|
|
45
|
+
condition = `#{&&:#{==:#{pid},${address.expectedServerPid}},${condition}}`;
|
|
46
|
+
if (address.expectedSessionCreated !== undefined)
|
|
47
|
+
condition = `#{&&:#{==:#{session_created},${address.expectedSessionCreated}},${condition}}`;
|
|
48
|
+
const verb = action === "select" ? "select-window" : "unlink-window";
|
|
49
|
+
// Validated native IDs/index contain no quote, separator, or format syntax.
|
|
50
|
+
return [
|
|
51
|
+
"if-shell",
|
|
52
|
+
"-F",
|
|
53
|
+
"-t",
|
|
54
|
+
target,
|
|
55
|
+
condition,
|
|
56
|
+
`${verb} -t '${target}' ; display-message -p '${WINDOW_LINK_GUARD_APPLIED}'`,
|
|
57
|
+
`display-message -p '${WINDOW_LINK_GUARD_STALE}'`,
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Select an exact link and pane while preserving native user hooks. The window
|
|
62
|
+
* selection hook may yield or move topology, so revalidate after it. A failed
|
|
63
|
+
* second check is indeterminate: the window selection may already have happened.
|
|
64
|
+
*/
|
|
65
|
+
export function buildNativeWindowLinkPaneSelectGuard(
|
|
66
|
+
address: NativeWindowLinkAddress,
|
|
67
|
+
paneId: string,
|
|
68
|
+
): string[] {
|
|
69
|
+
const windowGuard = buildNativeWindowLinkGuard(address, "select");
|
|
70
|
+
if (!/^%(?:0|[1-9]\d{0,15})$/u.test(paneId) || !Number.isSafeInteger(Number(paneId.slice(1)))) {
|
|
71
|
+
throw new Error("Invalid native pane guard address");
|
|
72
|
+
}
|
|
73
|
+
const target = `${address.sessionId}:${address.windowIndex}`;
|
|
74
|
+
const paneCondition = `#{&&:#{==:#{pane_id},${paneId}},#{==:#{window_id},${address.expectedWindowId}}}`;
|
|
75
|
+
const stale = `display-message -p '${WINDOW_LINK_GUARD_STALE}'`;
|
|
76
|
+
const interrupted = "display-message -p 'link-guard.interrupted'";
|
|
77
|
+
// All interpolated IDs are canonical numeric native identities. Braces are
|
|
78
|
+
// tmux command groups, not a host shell or a caller-provided command language.
|
|
79
|
+
const linkCheck = (yes: string, no: string) =>
|
|
80
|
+
`if-shell -F -t '${target}' '${windowGuard[4]!}' { ${yes} } { ${no} }`;
|
|
81
|
+
const paneCheck = (yes: string, no: string) =>
|
|
82
|
+
`if-shell -F -t '${paneId}' '${paneCondition}' { ${yes} } { ${no} }`;
|
|
83
|
+
const selectPane = `select-pane -t '${paneId}' ; display-message -p '${WINDOW_LINK_GUARD_APPLIED}'`;
|
|
84
|
+
const afterHook = paneCheck(linkCheck(selectPane, interrupted), interrupted);
|
|
85
|
+
return [
|
|
86
|
+
"if-shell",
|
|
87
|
+
"-F",
|
|
88
|
+
"-t",
|
|
89
|
+
paneId,
|
|
90
|
+
paneCondition,
|
|
91
|
+
linkCheck(`select-window -t '${target}' ; ${afterHook}`, stale),
|
|
92
|
+
stale,
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Consume only output associated with this request's own native command boundary.
|
|
97
|
+
* Nonzero completion reports native failure, including last-link unlink refusal.
|
|
98
|
+
* For compound selection this can follow partial mutation; it is not rollback proof.
|
|
99
|
+
* Missing/extra output or interrupted completion is indeterminate, never success;
|
|
100
|
+
* it must not trigger an automatic retry of a potentially completed mutation.
|
|
101
|
+
*/
|
|
102
|
+
export function classifyNativeWindowLinkGuardResult(
|
|
103
|
+
status: number | null,
|
|
104
|
+
stdout: string,
|
|
105
|
+
): "applied" | "stale" | "native-refused" | "indeterminate" {
|
|
106
|
+
if (status === null) return "indeterminate";
|
|
107
|
+
if (status !== 0) return "native-refused";
|
|
108
|
+
const output = stdout.trim();
|
|
109
|
+
if (output === WINDOW_LINK_GUARD_APPLIED) return "applied";
|
|
110
|
+
if (output === WINDOW_LINK_GUARD_STALE) return "stale";
|
|
111
|
+
return "indeterminate";
|
|
112
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Consumers outside the daemon tsconfig need the bundled declaration as well.
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
|
3
|
+
/// <reference path="../js-yaml.d.ts" />
|
|
1
4
|
/**
|
|
2
5
|
* Side-effect-free loader for repository-scoped WorkspaceConfigV1 files.
|
|
3
6
|
*
|