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