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,203 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
WINDOW_LINK_MAX_LINKS,
|
|
4
|
+
WindowLinkTargetSchemaZ,
|
|
5
|
+
WindowLinkTopologySchemaZ,
|
|
6
|
+
WorkspaceCatalogLiveSessionIdSchemaZ,
|
|
7
|
+
TerminalAttachmentSemanticWindowIdSchemaZ,
|
|
8
|
+
type WindowLinkTarget,
|
|
9
|
+
type WindowLinkTopology,
|
|
10
|
+
} from "@tmux-ide/contracts";
|
|
11
|
+
|
|
12
|
+
/** Trusted native observation. Native addresses never become public link handles. */
|
|
13
|
+
export interface NativeWindowLinkObservation {
|
|
14
|
+
readonly index: number;
|
|
15
|
+
readonly runtimeWindowId: string;
|
|
16
|
+
readonly semanticWindowId: string;
|
|
17
|
+
readonly active: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ResolvedWindowLink {
|
|
21
|
+
readonly runtimeSessionId: string;
|
|
22
|
+
readonly runtimeWindowId: string;
|
|
23
|
+
readonly index: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type WindowLinkRefusal =
|
|
27
|
+
| "window_link_stale"
|
|
28
|
+
| "window_link_session_mismatch"
|
|
29
|
+
| "window_link_backing_mismatch"
|
|
30
|
+
| "window_link_ambiguous";
|
|
31
|
+
|
|
32
|
+
export class WindowLinkResolutionError extends Error {
|
|
33
|
+
constructor(readonly reason: WindowLinkRefusal) {
|
|
34
|
+
super(reason);
|
|
35
|
+
this.name = "WindowLinkResolutionError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface LinkRecord extends NativeWindowLinkObservation {
|
|
40
|
+
readonly linkId: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One authority per daemon/server/session incarnation. This is an observation
|
|
45
|
+
* model, not an immutable native winlink identity: unobserved unlink/relink ABA
|
|
46
|
+
* is unknowable. Resolved targets still require an execution-time native guard.
|
|
47
|
+
* Not installed into the v1 stream; C2 activates it with the complete link-aware
|
|
48
|
+
* producer/consumer boundary.
|
|
49
|
+
*/
|
|
50
|
+
export class WindowLinkAuthority {
|
|
51
|
+
private records = new Map<number, LinkRecord>();
|
|
52
|
+
private revision = 0;
|
|
53
|
+
private topology: WindowLinkTopology | null = null;
|
|
54
|
+
private disposed = false;
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
readonly liveSessionId: string,
|
|
58
|
+
private readonly runtimeSessionId: string,
|
|
59
|
+
) {
|
|
60
|
+
WorkspaceCatalogLiveSessionIdSchemaZ.parse(liveSessionId);
|
|
61
|
+
if (
|
|
62
|
+
!/^\$(?:0|[1-9]\d{0,15})$/u.test(runtimeSessionId) ||
|
|
63
|
+
!Number.isSafeInteger(Number(runtimeSessionId.slice(1)))
|
|
64
|
+
)
|
|
65
|
+
throw new TypeError("Invalid native session id");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A malformed fresh observation revokes old actions; no partial topology is published. */
|
|
69
|
+
reconcile(observations: readonly NativeWindowLinkObservation[]): WindowLinkTopology {
|
|
70
|
+
try {
|
|
71
|
+
return this.stageAndPublish(observations);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
this.invalidate();
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private stageAndPublish(
|
|
79
|
+
observations: readonly NativeWindowLinkObservation[],
|
|
80
|
+
): WindowLinkTopology {
|
|
81
|
+
if (this.disposed) throw new WindowLinkResolutionError("window_link_stale");
|
|
82
|
+
if (observations.length === 0 || observations.length > WINDOW_LINK_MAX_LINKS)
|
|
83
|
+
throw new TypeError("Invalid window link count");
|
|
84
|
+
const indexes = new Set<number>();
|
|
85
|
+
const semanticByRuntime = new Map<string, string>();
|
|
86
|
+
const runtimeBySemantic = new Map<string, string>();
|
|
87
|
+
let activeCount = 0;
|
|
88
|
+
for (const row of observations) {
|
|
89
|
+
if (
|
|
90
|
+
!Number.isSafeInteger(row.index) ||
|
|
91
|
+
row.index < 0 ||
|
|
92
|
+
indexes.has(row.index) ||
|
|
93
|
+
!/^@(?:0|[1-9]\d{0,15})$/u.test(row.runtimeWindowId) ||
|
|
94
|
+
!Number.isSafeInteger(Number(row.runtimeWindowId.slice(1))) ||
|
|
95
|
+
typeof row.active !== "boolean"
|
|
96
|
+
)
|
|
97
|
+
throw new TypeError("Malformed native window link observation");
|
|
98
|
+
TerminalAttachmentSemanticWindowIdSchemaZ.parse(row.semanticWindowId);
|
|
99
|
+
indexes.add(row.index);
|
|
100
|
+
if (row.active) activeCount++;
|
|
101
|
+
const semantic = semanticByRuntime.get(row.runtimeWindowId);
|
|
102
|
+
const runtime = runtimeBySemantic.get(row.semanticWindowId);
|
|
103
|
+
if (
|
|
104
|
+
(semantic !== undefined && semantic !== row.semanticWindowId) ||
|
|
105
|
+
(runtime !== undefined && runtime !== row.runtimeWindowId)
|
|
106
|
+
)
|
|
107
|
+
throw new TypeError("Window backing identity is inconsistent");
|
|
108
|
+
semanticByRuntime.set(row.runtimeWindowId, row.semanticWindowId);
|
|
109
|
+
runtimeBySemantic.set(row.semanticWindowId, row.runtimeWindowId);
|
|
110
|
+
}
|
|
111
|
+
if (activeCount !== 1) throw new TypeError("Expected one active window link");
|
|
112
|
+
const rows = [...observations].sort((left, right) => left.index - right.index);
|
|
113
|
+
const changed =
|
|
114
|
+
rows.length !== this.records.size ||
|
|
115
|
+
rows.some((row) => {
|
|
116
|
+
const prior = this.records.get(row.index);
|
|
117
|
+
return (
|
|
118
|
+
prior?.runtimeWindowId !== row.runtimeWindowId ||
|
|
119
|
+
prior?.semanticWindowId !== row.semanticWindowId
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
const revision = this.revision + (changed ? 1 : 0);
|
|
123
|
+
if (!Number.isSafeInteger(revision)) throw new RangeError("Window link revision exhausted");
|
|
124
|
+
const next = new Map<number, LinkRecord>();
|
|
125
|
+
for (const row of rows) {
|
|
126
|
+
const prior = this.records.get(row.index);
|
|
127
|
+
const retained =
|
|
128
|
+
prior?.runtimeWindowId === row.runtimeWindowId &&
|
|
129
|
+
prior.semanticWindowId === row.semanticWindowId;
|
|
130
|
+
next.set(row.index, {
|
|
131
|
+
...row,
|
|
132
|
+
linkId: retained ? prior.linkId : `window-link.${randomUUID().replaceAll("-", "")}`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const records = [...next.values()];
|
|
136
|
+
const topology = WindowLinkTopologySchemaZ.parse({
|
|
137
|
+
liveSessionId: this.liveSessionId,
|
|
138
|
+
linkRevision: revision,
|
|
139
|
+
activeLinkId: records.find((row) => row.active)!.linkId,
|
|
140
|
+
links: records.map((row) => ({
|
|
141
|
+
linkId: row.linkId,
|
|
142
|
+
semanticWindowId: row.semanticWindowId,
|
|
143
|
+
displayIndex: row.index,
|
|
144
|
+
})),
|
|
145
|
+
});
|
|
146
|
+
this.records = next;
|
|
147
|
+
this.revision = revision;
|
|
148
|
+
this.topology = topology;
|
|
149
|
+
return this.snapshot()!;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
snapshot(): WindowLinkTopology | null {
|
|
153
|
+
return this.topology
|
|
154
|
+
? { ...this.topology, links: this.topology.links.map((link) => ({ ...link })) }
|
|
155
|
+
: null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Reconnect/gap/discontinuous observation must retire all previously issued handles. */
|
|
159
|
+
invalidate(): void {
|
|
160
|
+
this.records.clear();
|
|
161
|
+
this.topology = null;
|
|
162
|
+
this.revision++;
|
|
163
|
+
if (!Number.isSafeInteger(this.revision)) this.disposed = true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
dispose(): void {
|
|
167
|
+
this.invalidate();
|
|
168
|
+
this.disposed = true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
resolve(target: WindowLinkTarget): ResolvedWindowLink {
|
|
172
|
+
WindowLinkTargetSchemaZ.parse(target);
|
|
173
|
+
if (target.liveSessionId !== this.liveSessionId)
|
|
174
|
+
throw new WindowLinkResolutionError("window_link_session_mismatch");
|
|
175
|
+
if (!this.topology || this.disposed || target.linkRevision !== this.revision)
|
|
176
|
+
throw new WindowLinkResolutionError("window_link_stale");
|
|
177
|
+
const record = [...this.records.values()].find((row) => row.linkId === target.linkId);
|
|
178
|
+
if (!record) throw new WindowLinkResolutionError("window_link_stale");
|
|
179
|
+
if (record.semanticWindowId !== target.expectedSemanticWindowId)
|
|
180
|
+
throw new WindowLinkResolutionError("window_link_backing_mismatch");
|
|
181
|
+
return {
|
|
182
|
+
runtimeSessionId: this.runtimeSessionId,
|
|
183
|
+
runtimeWindowId: record.runtimeWindowId,
|
|
184
|
+
index: record.index,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Legacy backing-only selection is safe only when exactly one link applies. */
|
|
189
|
+
uniqueTargetForBacking(semanticWindowId: string): WindowLinkTarget {
|
|
190
|
+
if (!this.topology || this.disposed) throw new WindowLinkResolutionError("window_link_stale");
|
|
191
|
+
const links = [...this.records.values()].filter(
|
|
192
|
+
(row) => row.semanticWindowId === semanticWindowId,
|
|
193
|
+
);
|
|
194
|
+
if (links.length === 0) throw new WindowLinkResolutionError("window_link_stale");
|
|
195
|
+
if (links.length !== 1) throw new WindowLinkResolutionError("window_link_ambiguous");
|
|
196
|
+
return {
|
|
197
|
+
liveSessionId: this.liveSessionId,
|
|
198
|
+
linkId: links[0]!.linkId,
|
|
199
|
+
expectedSemanticWindowId: semanticWindowId,
|
|
200
|
+
linkRevision: this.revision,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
* PaneStreamLeaseManager — in-memory authority for pane-stream leases (m43
|
|
12
12
|
* card 2), following the terminal-attachment lease discipline:
|
|
13
13
|
*
|
|
14
|
-
* - a one-time `
|
|
14
|
+
* - a one-time `ps2_` bearer ticket whose SHA-256 digest is all the manager
|
|
15
15
|
* retains, burned before activation so no concurrent caller can replay it;
|
|
16
16
|
* - the ticket TTL bounds credential DELIVERY (the authenticated redemption
|
|
17
17
|
* frame must arrive in time); execution after delivery gets its own bounded
|
|
@@ -36,7 +36,7 @@ const SessionNameSchemaZ = z
|
|
|
36
36
|
.min(1)
|
|
37
37
|
.max(256)
|
|
38
38
|
.refine((value) => !/[\0\r\n]/u.test(value));
|
|
39
|
-
const TicketPattern = /^
|
|
39
|
+
const TicketPattern = /^ps2_[A-Za-z0-9_-]{43}$/u;
|
|
40
40
|
|
|
41
41
|
export interface PaneStreamLeaseBinding {
|
|
42
42
|
readonly daemonInstanceId: string;
|
|
@@ -197,7 +197,7 @@ export class PaneStreamLeaseManager {
|
|
|
197
197
|
"The secure random source returned an invalid ticket.",
|
|
198
198
|
);
|
|
199
199
|
}
|
|
200
|
-
const redemptionTicket = `
|
|
200
|
+
const redemptionTicket = `ps2_${Buffer.from(ticketBytes).toString("base64url")}`;
|
|
201
201
|
const state: LeaseState = {
|
|
202
202
|
leaseId,
|
|
203
203
|
requestId,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PaneStreamLayoutSnapshotFrameSchemaZ, type WindowLinkTopology } from "@tmux-ide/contracts";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import {
|
|
3
4
|
PANE_STREAM_MAX_HELD_DELTAS,
|
|
@@ -72,7 +73,7 @@ import {
|
|
|
72
73
|
} from "./wire-ledger.ts";
|
|
73
74
|
|
|
74
75
|
/**
|
|
75
|
-
* The pane-stream WebSocket endpoint (m43 card 2): redeems a one-time `
|
|
76
|
+
* The pane-stream WebSocket endpoint (m43 card 2): redeems a one-time `ps2_`
|
|
76
77
|
* ticket and bridges MirrorService subscriptions to wire frames. Admission is
|
|
77
78
|
* the direct-websocket discipline verbatim — Origin-gated upgrade reservation,
|
|
78
79
|
* digest-only pending tickets, one text redemption frame, delivery-bound TTL —
|
|
@@ -126,7 +127,7 @@ function hasCanonicalInputFramePrefix(raw: Buffer): boolean {
|
|
|
126
127
|
startsWithBuffer(raw, TYPE_FIRST_INPUT_FRAME_PREFIX)
|
|
127
128
|
);
|
|
128
129
|
}
|
|
129
|
-
const TicketPattern = /^
|
|
130
|
+
const TicketPattern = /^ps2_[A-Za-z0-9_-]{43}$/u;
|
|
130
131
|
const BindingIdSchemaZ = z
|
|
131
132
|
.string()
|
|
132
133
|
.min(1)
|
|
@@ -593,7 +594,7 @@ export class PaneStreamAdmissionCoordinator {
|
|
|
593
594
|
if (this.#shuttingDown) {
|
|
594
595
|
return { accepted: false, code: "daemon-shutting-down", httpStatus: 503 };
|
|
595
596
|
}
|
|
596
|
-
if (input.path !==
|
|
597
|
+
if (input.path !== new URL(this.#webSocketUrl).pathname) {
|
|
597
598
|
return { accepted: false, code: "invalid-path", httpStatus: 404 };
|
|
598
599
|
}
|
|
599
600
|
if (input.protocols.length !== 1 || input.protocols[0] !== PANE_STREAM_WEBSOCKET_SUBPROTOCOL) {
|
|
@@ -1069,6 +1070,7 @@ export class PaneStreamLiveConnection {
|
|
|
1069
1070
|
readonly #semanticLayouts = new Map<string, MirrorLayoutEvent>();
|
|
1070
1071
|
#semanticExpectedPaneIds: readonly string[] | null = null;
|
|
1071
1072
|
#semanticRuntimeSessionId: string | null = null;
|
|
1073
|
+
#semanticWindowLinks: WindowLinkTopology | null = null;
|
|
1072
1074
|
#semanticTopologyEpoch = -1;
|
|
1073
1075
|
readonly #sendQueue: QueuedSend[] = [];
|
|
1074
1076
|
readonly #semanticDrainWaiters = new Map<string, Array<() => void>>();
|
|
@@ -1411,7 +1413,15 @@ export class PaneStreamLiveConnection {
|
|
|
1411
1413
|
authority.topologyEpoch < 0
|
|
1412
1414
|
? null
|
|
1413
1415
|
: this.#validateInitialLayout(authority.layouts, expectedPaneIds);
|
|
1414
|
-
if (
|
|
1416
|
+
if (
|
|
1417
|
+
!stagedInitialFrames ||
|
|
1418
|
+
!PaneStreamLayoutSnapshotFrameSchemaZ.safeParse({
|
|
1419
|
+
type: "layout-snapshot",
|
|
1420
|
+
topologyEpoch: authority?.topologyEpoch,
|
|
1421
|
+
layouts: stagedInitialFrames,
|
|
1422
|
+
windowLinks: authority?.windowLinks,
|
|
1423
|
+
}).success
|
|
1424
|
+
) {
|
|
1415
1425
|
await subscription.close().catch(() => undefined);
|
|
1416
1426
|
this.#failTopologyChanged();
|
|
1417
1427
|
return;
|
|
@@ -1513,7 +1523,15 @@ export class PaneStreamLiveConnection {
|
|
|
1513
1523
|
authority.topologyEpoch < 0
|
|
1514
1524
|
? null
|
|
1515
1525
|
: this.#validateInitialLayout(authority.layouts, expectedPaneIds);
|
|
1516
|
-
if (
|
|
1526
|
+
if (
|
|
1527
|
+
!initialFrames ||
|
|
1528
|
+
!PaneStreamLayoutSnapshotFrameSchemaZ.safeParse({
|
|
1529
|
+
type: "layout-snapshot",
|
|
1530
|
+
topologyEpoch: authority?.topologyEpoch,
|
|
1531
|
+
layouts: initialFrames,
|
|
1532
|
+
windowLinks: authority?.windowLinks,
|
|
1533
|
+
}).success
|
|
1534
|
+
) {
|
|
1517
1535
|
await Promise.all(opened.map(({ delivery }) => delivery.close().catch(() => undefined)));
|
|
1518
1536
|
this.#failTopologyChanged();
|
|
1519
1537
|
return;
|
|
@@ -1521,6 +1539,7 @@ export class PaneStreamLiveConnection {
|
|
|
1521
1539
|
this.#recordDiagnosticLifecycle("pane-stream-layout-validated");
|
|
1522
1540
|
this.#semanticExpectedPaneIds = expectedPaneIds;
|
|
1523
1541
|
this.#semanticRuntimeSessionId = authority!.runtimeSessionId;
|
|
1542
|
+
this.#semanticWindowLinks = authority!.windowLinks;
|
|
1524
1543
|
this.#semanticTopologyEpoch = authority!.topologyEpoch;
|
|
1525
1544
|
this.#semanticLayouts.clear();
|
|
1526
1545
|
for (const event of authority!.layouts)
|
|
@@ -1529,6 +1548,7 @@ export class PaneStreamLiveConnection {
|
|
|
1529
1548
|
type: "layout-snapshot",
|
|
1530
1549
|
topologyEpoch: authority!.topologyEpoch,
|
|
1531
1550
|
layouts: initialFrames,
|
|
1551
|
+
windowLinks: authority!.windowLinks,
|
|
1532
1552
|
});
|
|
1533
1553
|
layoutActivated = true;
|
|
1534
1554
|
this.#recordDiagnosticLifecycle("pane-stream-delivery-open");
|
|
@@ -1854,10 +1874,33 @@ export class PaneStreamLiveConnection {
|
|
|
1854
1874
|
return;
|
|
1855
1875
|
}
|
|
1856
1876
|
const frames = this.#validateInitialLayout(snapshot.layouts, this.#semanticExpectedPaneIds);
|
|
1857
|
-
if (
|
|
1877
|
+
if (
|
|
1878
|
+
!frames ||
|
|
1879
|
+
!PaneStreamLayoutSnapshotFrameSchemaZ.safeParse({
|
|
1880
|
+
type: "layout-snapshot",
|
|
1881
|
+
topologyEpoch: snapshot.topologyEpoch,
|
|
1882
|
+
layouts: frames,
|
|
1883
|
+
windowLinks: snapshot.windowLinks,
|
|
1884
|
+
}).success
|
|
1885
|
+
) {
|
|
1886
|
+
this.#failTopologyChanged();
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
const previousLinks = this.#semanticWindowLinks;
|
|
1890
|
+
if (
|
|
1891
|
+
!previousLinks ||
|
|
1892
|
+
snapshot.windowLinks.liveSessionId !== previousLinks.liveSessionId ||
|
|
1893
|
+
snapshot.windowLinks.linkRevision < previousLinks.linkRevision ||
|
|
1894
|
+
(snapshot.windowLinks.linkRevision === previousLinks.linkRevision &&
|
|
1895
|
+
JSON.stringify(
|
|
1896
|
+
[...snapshot.windowLinks.links].sort((a, b) => a.linkId.localeCompare(b.linkId)),
|
|
1897
|
+
) !==
|
|
1898
|
+
JSON.stringify([...previousLinks.links].sort((a, b) => a.linkId.localeCompare(b.linkId))))
|
|
1899
|
+
) {
|
|
1858
1900
|
this.#failTopologyChanged();
|
|
1859
1901
|
return;
|
|
1860
1902
|
}
|
|
1903
|
+
this.#semanticWindowLinks = snapshot.windowLinks;
|
|
1861
1904
|
this.#semanticTopologyEpoch = snapshot.topologyEpoch;
|
|
1862
1905
|
this.#semanticLayouts.clear();
|
|
1863
1906
|
for (const event of snapshot.layouts)
|
|
@@ -1866,6 +1909,7 @@ export class PaneStreamLiveConnection {
|
|
|
1866
1909
|
type: "layout-snapshot",
|
|
1867
1910
|
topologyEpoch: snapshot.topologyEpoch,
|
|
1868
1911
|
layouts: frames,
|
|
1912
|
+
windowLinks: snapshot.windowLinks,
|
|
1869
1913
|
});
|
|
1870
1914
|
}
|
|
1871
1915
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Shared opaque incarnation identity for catalog and terminal authority. */
|
|
4
|
+
export function liveSessionIdForNativeIdentity(
|
|
5
|
+
serverPid: string,
|
|
6
|
+
sessionId: string,
|
|
7
|
+
sessionCreated: string,
|
|
8
|
+
) {
|
|
9
|
+
const digest = createHash("sha256")
|
|
10
|
+
.update(`${serverPid}\0${sessionId}\0${sessionCreated}`)
|
|
11
|
+
.digest("hex")
|
|
12
|
+
.slice(0, 20);
|
|
13
|
+
return `live-session.${digest}` as const;
|
|
14
|
+
}
|
|
@@ -15,6 +15,8 @@ export type ReadNativeBacking = (
|
|
|
15
15
|
/** One bounded read on entering scrollback. No captures are issued by wheel movement. */
|
|
16
16
|
export async function readNativeBacking(options: {
|
|
17
17
|
baseUrl: string;
|
|
18
|
+
/** Trusted host-selected owner route; credentials and proof remain unchanged. */
|
|
19
|
+
resourcePath?: string;
|
|
18
20
|
ownerToken: string;
|
|
19
21
|
workspaceName: string;
|
|
20
22
|
paneId: string;
|
|
@@ -22,7 +24,8 @@ export async function readNativeBacking(options: {
|
|
|
22
24
|
signal: AbortSignal;
|
|
23
25
|
}): Promise<NativeGridCapture | null> {
|
|
24
26
|
const url = new URL(
|
|
25
|
-
|
|
27
|
+
options.resourcePath ??
|
|
28
|
+
`/api/project/${encodeURIComponent(options.workspaceName)}/terminal-native-backing/${encodeURIComponent(options.paneId)}`,
|
|
26
29
|
options.baseUrl,
|
|
27
30
|
);
|
|
28
31
|
for (const [key, value] of Object.entries(options.expected))
|
|
@@ -22,6 +22,12 @@ export function sessionRuntimeInteractionFacts(
|
|
|
22
22
|
target: { kind: "pane", semanticPaneId: intent.semanticPaneId },
|
|
23
23
|
summary: { operationKind: intent.verb, direction: intent.direction },
|
|
24
24
|
};
|
|
25
|
+
case "workspace.window.link.select":
|
|
26
|
+
case "workspace.window.link.unlink":
|
|
27
|
+
return {
|
|
28
|
+
target: { kind: "window-link", target: intent.target },
|
|
29
|
+
summary: { operationKind: intent.verb },
|
|
30
|
+
};
|
|
25
31
|
case "workspace.window.kill":
|
|
26
32
|
return {
|
|
27
33
|
target: { kind: "window", target: intent.target },
|
|
@@ -103,6 +109,9 @@ export function sessionRuntimeObservedProof(
|
|
|
103
109
|
if (result.verb !== intent.verb)
|
|
104
110
|
throw new TypeError("Mutation result verb does not match intent");
|
|
105
111
|
switch (result.verb) {
|
|
112
|
+
case "workspace.window.link.select":
|
|
113
|
+
case "workspace.window.link.unlink":
|
|
114
|
+
return { operationKind: result.verb, outcome: result.outcome, target: result.target };
|
|
106
115
|
case "workspace.window.split":
|
|
107
116
|
return {
|
|
108
117
|
operationKind: result.verb,
|
|
@@ -550,6 +550,15 @@ export class SessionRuntimeRegistry implements PaneStreamMirror {
|
|
|
550
550
|
);
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
+
/** Called by the serialized semantic mutation executor, never by a transport directly. */
|
|
554
|
+
executeWindowLinkAction(
|
|
555
|
+
session: string,
|
|
556
|
+
request: Parameters<MirrorService["executeWindowLinkAction"]>[1],
|
|
557
|
+
): ReturnType<MirrorService["executeWindowLinkAction"]> {
|
|
558
|
+
if (this.#disposed) return Promise.reject(new Error("Session runtime disposed"));
|
|
559
|
+
return this.#mirror.executeWindowLinkAction(session, request);
|
|
560
|
+
}
|
|
561
|
+
|
|
553
562
|
/** Retire one no-longer-registered session without disturbing siblings. */
|
|
554
563
|
async retireSession(session: string): Promise<void> {
|
|
555
564
|
const runtime = this.#sessions.get(session);
|
|
@@ -56,7 +56,7 @@ export interface SessionSemanticMutationExecutorOptions {
|
|
|
56
56
|
nowMicros(): number;
|
|
57
57
|
record(operation: string, startedAtMicros: number, endedAtMicros: number): void;
|
|
58
58
|
}>,
|
|
59
|
-
) => SessionRuntimeIntentResult
|
|
59
|
+
) => SessionRuntimeIntentResult | Promise<SessionRuntimeIntentResult>;
|
|
60
60
|
/** Publishes through the daemon's existing replayable interaction journal. */
|
|
61
61
|
readonly publishReceipt: (receipt: SessionRuntimeReceiptInput) => InteractionReceipt;
|
|
62
62
|
/** Publishes the same replayable invalidation contract as HTTP semantic actions. */
|
|
@@ -416,7 +416,7 @@ export class SessionSemanticMutationExecutor {
|
|
|
416
416
|
// Admission can wait behind prior work. Revalidate the opaque principal
|
|
417
417
|
// at the last synchronous boundary before tmux receives any effect.
|
|
418
418
|
authorizeBeforeEffect?.();
|
|
419
|
-
result = this.#options.execute(operationId, intent, timing);
|
|
419
|
+
result = await this.#options.execute(operationId, intent, timing);
|
|
420
420
|
} catch (cause) {
|
|
421
421
|
if (needsTmuxObservation) this.#deletePending(session, operationId);
|
|
422
422
|
const error = new SessionRuntimeIntentError(
|
|
@@ -32,6 +32,7 @@ export function semanticMutationResourceChanges(
|
|
|
32
32
|
if (
|
|
33
33
|
result.verb === "workspace.window.split" ||
|
|
34
34
|
result.verb === "workspace.window.kill" ||
|
|
35
|
+
result.verb === "workspace.window.link.unlink" ||
|
|
35
36
|
result.verb === "workspace.pane.kill" ||
|
|
36
37
|
result.verb === "workspace.session.kill"
|
|
37
38
|
) {
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import { prepareScopedOpenTuiConnection } from "./application-shell-server-connection.ts";
|
|
1
2
|
import { OpenTuiStartupError, startupFailureFromError } from "./startup-failure.ts";
|
|
2
3
|
import type { OpenTuiSessionWorkspaceEnsureResult } from "./configless-session-bootstrap.ts";
|
|
3
4
|
import {
|
|
5
|
+
applicationDaemonEndpoint,
|
|
6
|
+
type ApplicationDaemonEndpoint,
|
|
4
7
|
readApplicationDaemonInfo as readCanonicalDaemonInfo,
|
|
5
8
|
isApplicationDaemonAlive as isCanonicalDaemonAlive,
|
|
6
9
|
} from "./runtime/application-daemon-authority.ts";
|
|
7
10
|
import {
|
|
8
11
|
APPLICATION_SHELL_RESOURCE_V2_VERSION,
|
|
9
12
|
type CanonicalDaemonInfo,
|
|
13
|
+
type TmuxServerScope,
|
|
10
14
|
type DesktopApplicationShellTarget,
|
|
11
15
|
type DesktopDaemonHostDescriptor,
|
|
12
16
|
type WorkspaceCatalogResourceV2,
|
|
@@ -18,7 +22,10 @@ import {
|
|
|
18
22
|
type TerminalFirstDaemonTransport,
|
|
19
23
|
} from "@tmux-ide/daemon-client/direct-application-shell-transport";
|
|
20
24
|
import type { PreparedTerminalRuntimeInventory } from "@tmux-ide/daemon-client/workspace-event-supervisor";
|
|
21
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
WorkspaceClientCatalogPort,
|
|
27
|
+
WorkspaceClientOwnerActionPort,
|
|
28
|
+
} from "@tmux-ide/daemon-client/workspace-client-types";
|
|
22
29
|
import { WebSocket } from "ws";
|
|
23
30
|
|
|
24
31
|
import { canonicalDaemonUrl } from "../../lib/canonical-daemon.ts";
|
|
@@ -41,6 +48,8 @@ import {
|
|
|
41
48
|
*/
|
|
42
49
|
export interface OpenTuiApplicationShellConnection {
|
|
43
50
|
readonly workspaceName: string;
|
|
51
|
+
readonly server?: TmuxServerScope;
|
|
52
|
+
readonly ownerActions?: WorkspaceClientOwnerActionPort;
|
|
44
53
|
/** Incarnation captured by the connection's routing read, never copied from a later catalog. */
|
|
45
54
|
readonly liveSessionId?: string | null;
|
|
46
55
|
readonly target: DesktopApplicationShellTarget;
|
|
@@ -53,6 +62,9 @@ export interface OpenTuiApplicationShellConnection {
|
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
export interface OpenTuiApplicationShellConnectionDependencies {
|
|
65
|
+
readonly server?: TmuxServerScope;
|
|
66
|
+
readonly readDaemonEndpoint?: () => ApplicationDaemonEndpoint;
|
|
67
|
+
readonly expectedLiveSessionId?: string;
|
|
56
68
|
readonly readCanonicalDaemonInfo: () => CanonicalDaemonInfo | null;
|
|
57
69
|
readonly isCanonicalDaemonAlive: (daemon: CanonicalDaemonInfo) => Promise<boolean>;
|
|
58
70
|
readonly fetchCanonicalWorkspaceRouting: (
|
|
@@ -171,7 +183,15 @@ export async function resolveOpenTuiApplicationShellConnection(
|
|
|
171
183
|
applicationShellResourceVersion: APPLICATION_SHELL_RESOURCE_V2_VERSION,
|
|
172
184
|
...(diagnose ? { terminalRuntimeDiagnostic: diagnose } : {}),
|
|
173
185
|
});
|
|
174
|
-
const routing = createOpenTuiVerifiedRoutingContext(
|
|
186
|
+
const routing = createOpenTuiVerifiedRoutingContext(
|
|
187
|
+
daemon,
|
|
188
|
+
workspaceName,
|
|
189
|
+
sessionName,
|
|
190
|
+
undefined,
|
|
191
|
+
dependencies.readDaemonEndpoint ?? applicationDaemonEndpoint,
|
|
192
|
+
undefined,
|
|
193
|
+
liveSessionId ?? undefined,
|
|
194
|
+
);
|
|
175
195
|
let disposed = false;
|
|
176
196
|
let diagnosticClientFetchOrdinal = 0;
|
|
177
197
|
let diagnosticPrewarmOrdinal = 0;
|
|
@@ -368,6 +388,12 @@ export async function prepareOpenTuiApplicationShellConnection(
|
|
|
368
388
|
): Promise<OpenTuiApplicationShellConnection | null> {
|
|
369
389
|
const dependencies = { ...DEFAULT_DEPENDENCIES, ...overrides };
|
|
370
390
|
try {
|
|
391
|
+
if (dependencies.server) {
|
|
392
|
+
const unscoped = { ...dependencies, server: undefined };
|
|
393
|
+
return await prepareScopedOpenTuiConnection(sessionName, dependencies, () =>
|
|
394
|
+
prepareOpenTuiApplicationShellConnection(sessionName, unscoped),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
371
397
|
const result = await dependencies.ensureSessionWorkspace(sessionName);
|
|
372
398
|
if (result === false) return null;
|
|
373
399
|
if (typeof result !== "boolean" && result.status === "unavailable") {
|