super-dsh 0.1.4 → 0.1.5-a
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/README.md +6 -7
- package/agent-agy/dist/world-plugin.js +18 -33
- package/agent-codex/dist/world-plugin.js +18 -33
- package/agent-hermes/dist/world-plugin.js +18 -34
- package/agent-omp/dist/world-plugin.js +16 -32
- package/agent-pi/dist/world-plugin.js +18 -33
- package/client/index.js +2086 -0
- package/cordis.patch.yml +21 -7
- package/package.json +13 -3
- package/superd-hub/dist/agent-roster.d.ts +3 -1
- package/superd-hub/dist/agent-roster.js +4 -2
- package/superd-hub/dist/carrier.d.ts +41 -28
- package/superd-hub/dist/carrier.js +148 -202
- package/superd-hub/dist/fusion-transport.d.ts +150 -0
- package/superd-hub/dist/fusion-transport.js +369 -0
- package/superd-hub/dist/gateway.d.ts +7 -2
- package/superd-hub/dist/gateway.js +28 -2
- package/superd-hub/dist/hub-route.d.ts +74 -0
- package/superd-hub/dist/hub-route.js +188 -0
- package/superd-hub/dist/index.d.ts +10 -4
- package/superd-hub/dist/index.js +10 -4
- package/superd-hub/dist/roster.d.ts +2 -2
- package/superd-hub/dist/spawn-world.js +5 -3
- package/superd-hub/dist/switch-core.d.ts +630 -0
- package/superd-hub/dist/switch-core.js +734 -0
- package/superd-hub/dist/web-trust.d.ts +5 -5
- package/superd-hub/dist/web-trust.js +5 -5
- package/superd-hub/dist/world-entry.d.ts +20 -13
- package/superd-hub/dist/world-entry.js +15 -19
- package/superd-hub/dist/world-host.d.ts +15 -20
- package/superd-hub/dist/world-host.js +25 -4
- package/superd-hub/dist/world-join.js +14 -29
- package/superd-hub/dist/world-mount.d.ts +48 -26
- package/superd-hub/dist/world-mount.js +59 -27
- package/superd-hub/dist/world-provision.js +11 -4
- package/superd-hub/dist/world-web-server.d.ts +57 -106
- package/superd-hub/dist/world-web-server.js +61 -216
- package/superd-hub/lib/client/index.js +2086 -0
- package/superd-hub/package.json +12 -0
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Switch orchestration core (native UI fusion, plan Task 4) — the pure logic
|
|
3
|
+
* behind the in-page agent switch: storage-namespace key computation, the
|
|
4
|
+
* session landing decision over the persisted `dsh.sessions.current`
|
|
5
|
+
* selection, the agent-selector click-interception decision (a fusion page
|
|
6
|
+
* never navigates on a left click — spec: 融合页 = state 切换,拦截 click,无导航;
|
|
7
|
+
* T-B: consumed by the Hub UI surfaces, the sole switch surface since the
|
|
8
|
+
* RuntimeSeat retirement), and the fallback state machine (timeout or
|
|
9
|
+
* roster-probe failure → revert to native + a visible toast, never a page
|
|
10
|
+
* lock).
|
|
11
|
+
*
|
|
12
|
+
* Host-half on purpose: plain TS with every effect injected through
|
|
13
|
+
* {@link SwitchHost} — no DOM, no client imports — so `test/client-switch.test.mjs`
|
|
14
|
+
* imports it from `dist/` like every other host module. The client half
|
|
15
|
+
* (`src/client/switch.ts`) builds the real host face from the client context:
|
|
16
|
+
* the `__SUPERD_UI_STATE__` atom (Task 3), `ctx.connection`,
|
|
17
|
+
* `ctx.uiWorkspace`, the Task-3 namespaced `Storage`, the control-plane
|
|
18
|
+
* roster fetch, and a minimal self-owned toast overlay.
|
|
19
|
+
*
|
|
20
|
+
* Orchestration sequence (spec §3.5 as amended 2026-09-26, T-A + T-F
|
|
21
|
+
* errata; user rulings 7/8 and the T-F ruling "按原生的语义去理解…把这个
|
|
22
|
+
* last visit session 也做一下"):
|
|
23
|
+
* 1. `setAgent(label)` — state + NS swap immediately; subsequent requests
|
|
24
|
+
* demux to the target world (Task 3's call-time patches read the atom).
|
|
25
|
+
* 2. `reconnect()` — the generation re-opens against the retargeted data
|
|
26
|
+
* plane; the gateway emits `connection/reset` on establishment, the
|
|
27
|
+
* designed "wire-derived caches must repull" signal, so sessions /
|
|
28
|
+
* config / theme re-derive on their own. Nothing navigates before the
|
|
29
|
+
* repull: the chat area is never cleared and no loading skeleton is
|
|
30
|
+
* inserted — old content stays in place awaiting the landing.
|
|
31
|
+
* 3. Session landing (T-F: NATIVE restore semantics) — upstream's
|
|
32
|
+
* fresh-load restore is `watchNavigation`/`restoreSelection`
|
|
33
|
+
* (navigation.ts:284-347): it fires only when BOTH `workspaces.list`
|
|
34
|
+
* and `sessions.list` reached phase `'ready'`, and then walks
|
|
35
|
+
* `saved.subagentAddress` → `saved.sessionId` looked up in the FRESH
|
|
36
|
+
* `sessions.byId` (stale-proof) → recent-workspace → the fresh-boot
|
|
37
|
+
* default workspace. The landing replicates exactly that: it fires once
|
|
38
|
+
* when BOTH wire-derived caches have installed fresh post-establishment
|
|
39
|
+
* baselines ({@link observeCachesRefreshed} — the T-A gate watched
|
|
40
|
+
* `workspaces.list` alone, so a plane whose sessions list landed later
|
|
41
|
+
* served stale rows to the decision and broke native's own
|
|
42
|
+
* last-visited restore; T-G additionally holds the gate while a
|
|
43
|
+
* persisted pointer has not yet joined the freshly minted INCREMENTAL
|
|
44
|
+
* sessions list — membership via `byId` — firing on membership or on
|
|
45
|
+
* the quiet-window ladder, {@link LANDING_QUIET_MS}, so partial-list
|
|
46
|
+
* first edges stop laddering every switch into a brand-new session),
|
|
47
|
+
* then reads the target NS's persisted
|
|
48
|
+
* `dsh.sessions.current` (the NS swap has long since happened) and
|
|
49
|
+
* executes {@link planRestoreLanding}'s precedence through the public
|
|
50
|
+
* faces. The landing is BOUNDED: if the baselines never install within
|
|
51
|
+
* one further fallback window after the settle, a staleness watchdog
|
|
52
|
+
* disarms it — an armed landing never outlives its switch by more than
|
|
53
|
+
* that window (the safe direction: no landing at all beats a late
|
|
54
|
+
* surprise navigation).
|
|
55
|
+
* 4. Fallback: no generation within {@link SWITCH_FALLBACK_MS}, or a failed
|
|
56
|
+
* roster probe → `setAgent('native')` + `reconnect()` back to the native
|
|
57
|
+
* plane + toast. A revert also cancels a pending landing — a
|
|
58
|
+
* reverted switch never lands.
|
|
59
|
+
*
|
|
60
|
+
* Upstream anchors (dsh-v0.1.7-rc.1):
|
|
61
|
+
* - `ctx.connection.reconnect()` — `packages/client/connection/src/client/index.ts`
|
|
62
|
+
* `ConnectionHandle.reconnect` (declared :148, wired :264-266); the
|
|
63
|
+
* controller resets retry progression and replaces the current attempt
|
|
64
|
+
* immediately (`connection.ts:113-121`).
|
|
65
|
+
* - `connection/reset` — emitted by the gateway client on every generation
|
|
66
|
+
* establishment (`packages/api/gateway/src/client/index.ts:169`),
|
|
67
|
+
* declared as the repull signal
|
|
68
|
+
* (`packages/client/connection/src/client/index.ts:9-17`).
|
|
69
|
+
* - `uiWorkspace.openSession(target)` / `startSession(workspaceId?)` /
|
|
70
|
+
* `openWorkspace(workspaceId)` — the navigation face
|
|
71
|
+
* (`packages/client/ui-workspace/src/client/navigation.ts:34-92`);
|
|
72
|
+
* `SessionTarget = SessionId | SubagentAddress`
|
|
73
|
+
* (`packages/api/session-controller/src/client/contract/sessions.ts:22`).
|
|
74
|
+
* - `watchNavigation` / `restoreSelection` — the fresh-load restore this
|
|
75
|
+
* landing replicates (`navigation.ts:284-347`): both-lists ready gate
|
|
76
|
+
* (:292), `subagentAddress` first (:320-323), the fresh-`byId` session
|
|
77
|
+
* lookup with the blank-reuse nuance (:324-335), `recentWorkspace`
|
|
78
|
+
* fallback (:337, the tie-break at :434-453), and
|
|
79
|
+
* `initializeDefaultWorkspace` on a fresh boot (:338-342, :349-361).
|
|
80
|
+
* - `startSession()`'s workspace resolution — `workspaceId ??
|
|
81
|
+
* currentWorkspaceId ?? recent` over the wire-derived `workspaces.list`
|
|
82
|
+
* / `sessions.list` snapshots (`navigation.ts:223-238`); `connectWorkspace`
|
|
83
|
+
* → `reuseOrCreateBlank` reuses the pointer's own blank whenever the
|
|
84
|
+
* private restore path would (:179-198).
|
|
85
|
+
* - The wire-derived stores: `workspaces.list` installs fresh data only
|
|
86
|
+
* through its follow-stream baseline (`packages/api/workspace-controller/src/client/model.ts`
|
|
87
|
+
* `replaceBaseline` → `installViews`, a new items array every install);
|
|
88
|
+
* `sessions.list` is projected with a fresh `ids` array on every manager
|
|
89
|
+
* change (`packages/api/session-controller/src/client/sessions/service.ts:607-677`)
|
|
90
|
+
* — array identity is the freshness token for both.
|
|
91
|
+
* - `dsh.sessions.current` — the per-tab last-selection store
|
|
92
|
+
* (`navigation.ts:130-131`); its restore reads `subagentAddress` first,
|
|
93
|
+
* then `sessionId` (`navigation.ts:319-330`) — {@link parseLandingTarget}
|
|
94
|
+
* keeps that precedence. The store hydrates once at page boot and
|
|
95
|
+
* persists on write (`packages/client/store/src/index.ts:155-174`), so
|
|
96
|
+
* the landing read must go through the namespaced `Storage` (Task 3
|
|
97
|
+
* patch 5), which prefixes `'<label>:'` at call time.
|
|
98
|
+
*
|
|
99
|
+
* @module @pgmi-builds/superd-hub/switch-core
|
|
100
|
+
*/
|
|
101
|
+
/** The active-agent label meaning the native (ctx0) data plane. */
|
|
102
|
+
export const NATIVE_AGENT = 'native';
|
|
103
|
+
/** The persisted last-selection key ui-workspace owns (survey §2.6). */
|
|
104
|
+
export const SESSIONS_CURRENT_KEY = 'dsh.sessions.current';
|
|
105
|
+
/**
|
|
106
|
+
* The fallback window: how long a switch may wait for the target world's
|
|
107
|
+
* generation (mux re-open + repull) before reverting to native. A
|
|
108
|
+
* human-scale constant; Task 5's config face may expose it later.
|
|
109
|
+
*/
|
|
110
|
+
export const SWITCH_FALLBACK_MS = 10_000;
|
|
111
|
+
/**
|
|
112
|
+
* The membership-wait quiet window (T-G): after both navigation caches are
|
|
113
|
+
* fresh, a persisted pointer the sessions list has not shown yet holds the
|
|
114
|
+
* landing gate — the list is minted INCREMENTALLY post-reconnect (upstream
|
|
115
|
+
* `handleSessionAdded` applies one session at a time, each application
|
|
116
|
+
* re-projecting the store with a fresh `ids` array, service.ts:677), so the
|
|
117
|
+
* first fresh edge sees a partial `byId` that legitimately lacks the
|
|
118
|
+
* pointer's session. The gate fires on membership; while the pointer is
|
|
119
|
+
* still absent, this window of list SILENCE (no `ids` identity change) is
|
|
120
|
+
* the concession point: the gate fires anyway and the plan takes the ladder
|
|
121
|
+
* — an honest miss, because a stored blank pointer legitimately never
|
|
122
|
+
* appears (matching upstream's own boot restore of a stale/absent id). An
|
|
123
|
+
* identity change during the window restarts it (the list is still growing).
|
|
124
|
+
* The machine's post-settle staleness watchdog remains the hard bound on
|
|
125
|
+
* the whole wait.
|
|
126
|
+
*/
|
|
127
|
+
export const LANDING_QUIET_MS = 600;
|
|
128
|
+
/**
|
|
129
|
+
* The storage namespace for an active agent: native prefixes nothing
|
|
130
|
+
* (byte-identical to stock behavior), a foreign agent owns `'<label>:'`.
|
|
131
|
+
* Mirrors Task 3's storage patch (`ns()` in fusion-transport).
|
|
132
|
+
*/
|
|
133
|
+
export function storageNamespace(label) {
|
|
134
|
+
return typeof label === 'string' && label !== '' && label !== NATIVE_AGENT ? `${label}:` : '';
|
|
135
|
+
}
|
|
136
|
+
/** The full storage key for `key` while `label` is the active agent. */
|
|
137
|
+
export function namespacedKey(label, key) {
|
|
138
|
+
return `${storageNamespace(label)}${key}`;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The persisted-selection parse feeding {@link planRestoreLanding}.
|
|
142
|
+
*
|
|
143
|
+
* A value is navigable when it parses to a ui-workspace `MainSelection`
|
|
144
|
+
* carrying a usable `SessionTarget`: a non-null `subagentAddress` object
|
|
145
|
+
* wins (upstream restore order), else a non-empty string `sessionId`.
|
|
146
|
+
* Everything else — no value, a read failure (surfaced here as `null`), a
|
|
147
|
+
* parse failure, an unusable shape — parses to `null` and the plan falls to
|
|
148
|
+
* the recent-workspace / default-workspace ladder.
|
|
149
|
+
*
|
|
150
|
+
* @returns the subagent address object, the session id string, or `null`.
|
|
151
|
+
*/
|
|
152
|
+
export function parseLandingTarget(raw) {
|
|
153
|
+
if (typeof raw !== 'string' || raw === '')
|
|
154
|
+
return null;
|
|
155
|
+
let parsed;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(raw);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
163
|
+
return null;
|
|
164
|
+
const selection = parsed;
|
|
165
|
+
if (selection.subagentAddress !== undefined && selection.subagentAddress !== null
|
|
166
|
+
&& typeof selection.subagentAddress === 'object') {
|
|
167
|
+
return selection.subagentAddress;
|
|
168
|
+
}
|
|
169
|
+
if (typeof selection.sessionId === 'string' && selection.sessionId !== '')
|
|
170
|
+
return selection.sessionId;
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The most-recent workspace per upstream `recentWorkspace`
|
|
175
|
+
* (navigation.ts:434-453): the latest member-session `updatedAt` wins, a
|
|
176
|
+
* workspace with no listed sessions falls back to its `createdAt`.
|
|
177
|
+
*/
|
|
178
|
+
function recentWorkspaceId(items, byId) {
|
|
179
|
+
let selected;
|
|
180
|
+
let selectedTime = Number.NEGATIVE_INFINITY;
|
|
181
|
+
for (const workspace of items) {
|
|
182
|
+
let latest = Number.NEGATIVE_INFINITY;
|
|
183
|
+
for (const sessionId of workspace.sessionIds) {
|
|
184
|
+
const session = byId[sessionId];
|
|
185
|
+
if (session !== undefined && typeof session.updatedAt === 'number') {
|
|
186
|
+
latest = Math.max(latest, session.updatedAt);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (latest === Number.NEGATIVE_INFINITY && workspace.createdAt !== undefined) {
|
|
190
|
+
latest = Date.parse(workspace.createdAt);
|
|
191
|
+
}
|
|
192
|
+
if (selected === undefined || latest > selectedTime) {
|
|
193
|
+
selected = workspace.workspaceId;
|
|
194
|
+
selectedTime = latest;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return selected;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The landing decision with NATIVE restore semantics (T-F): replicate
|
|
201
|
+
* upstream `restoreSelection` (navigation.ts:318-347) against the fresh
|
|
202
|
+
* caches and the parsed persisted selection ({@link parseLandingTarget}'s
|
|
203
|
+
* result, which already applies the `subagentAddress`-first precedence).
|
|
204
|
+
*
|
|
205
|
+
* - `subagentAddress` → open it directly, no cache consultation
|
|
206
|
+
* (navigation.ts:320-323).
|
|
207
|
+
* - `sessionId` found in the FRESH `byId`: non-blank — or blank but
|
|
208
|
+
* workspace-less — opens the id (navigation.ts:324-329); blank with a
|
|
209
|
+
* workspace resolves to that workspace (navigation.ts:333-335's private
|
|
210
|
+
* `reuseBlank` + :337's `workspace.workspaceId` target — the public
|
|
211
|
+
* `startSession(workspaceId)` face covers both: its `connectWorkspace`
|
|
212
|
+
* → `reuseOrCreateBlank` reuses the pointer's own blank whenever the
|
|
213
|
+
* private pre-check would pass, because the eligibility scan is the
|
|
214
|
+
* same).
|
|
215
|
+
* - A stale id (absent from the fresh `byId`) falls through to the
|
|
216
|
+
* no-pointer ladder exactly as upstream's `summary === undefined` does.
|
|
217
|
+
* - No pointer / stale pointer: the recent workspace gets the new-session
|
|
218
|
+
* flow (navigation.ts:337); both lists empty is the fresh boot and
|
|
219
|
+
* mirrors `initializeDefaultWorkspace` (navigation.ts:338-342).
|
|
220
|
+
* - `caches` unavailable (the host's read failed) → `undefined`: land
|
|
221
|
+
* nothing — no landing beats a wrong one. Same for the degenerate
|
|
222
|
+
* zero-workspaces-with-sessions shape, where upstream simply leaves the
|
|
223
|
+
* view as it is (no target, both-empty guard false, :337-342).
|
|
224
|
+
*
|
|
225
|
+
* @param parsed - the persisted-selection parse ({@link parseLandingTarget}).
|
|
226
|
+
* @param caches - the fresh both-lists snapshots, or `undefined` unreadable.
|
|
227
|
+
* @returns the action to execute, or `undefined` to land nothing.
|
|
228
|
+
*/
|
|
229
|
+
export function planRestoreLanding(parsed, caches) {
|
|
230
|
+
if (caches === undefined)
|
|
231
|
+
return undefined;
|
|
232
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
233
|
+
return { kind: 'open-session', target: parsed };
|
|
234
|
+
}
|
|
235
|
+
if (typeof parsed === 'string' && parsed !== '') {
|
|
236
|
+
const summary = caches.sessions.byId[parsed];
|
|
237
|
+
if (summary !== undefined) {
|
|
238
|
+
const workspace = caches.workspaces.items.find((item) => item.sessionIds.includes(parsed));
|
|
239
|
+
if (summary.blank !== true || workspace === undefined) {
|
|
240
|
+
return { kind: 'open-session', target: parsed };
|
|
241
|
+
}
|
|
242
|
+
return { kind: 'start-session', workspaceId: workspace.workspaceId };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const target = recentWorkspaceId(caches.workspaces.items, caches.sessions.byId);
|
|
246
|
+
if (target !== undefined)
|
|
247
|
+
return { kind: 'start-session', workspaceId: target };
|
|
248
|
+
if (caches.workspaces.items.length === 0 && caches.sessions.ids.length === 0) {
|
|
249
|
+
return { kind: 'default-workspace' };
|
|
250
|
+
}
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The caches-fresh gate behind the deferred landing (T-F; T-G made it
|
|
255
|
+
* pointer-membership-aware): fires `listener` AT MOST once, when BOTH
|
|
256
|
+
* navigation caches demonstrably hold the TARGET plane's data — the
|
|
257
|
+
* client-face equivalent of upstream's both-lists ready gate
|
|
258
|
+
* (`watchNavigation`, navigation.ts:292: `workspace.phase !== 'ready'
|
|
259
|
+
* || sessions.phase !== 'ready'` → return).
|
|
260
|
+
*
|
|
261
|
+
* The freshness token is each snapshot's `items` ARRAY IDENTITY: upstream's
|
|
262
|
+
* workspace model mints a new array on every baseline install
|
|
263
|
+
* (`installViews` → `[...installed.values()]`, model.ts:398) and the session
|
|
264
|
+
* list projection mints a fresh `ids` array on every manager change
|
|
265
|
+
* (service.ts:607-677), while carrier churn keeps references. The gate arms
|
|
266
|
+
* at the first generation establishment OBSERVED BY ITS OWN subscription —
|
|
267
|
+
* the anchors are retaken there, and nothing before that point can pass, so
|
|
268
|
+
* the dying old plane's last increments racing the reconnect are absorbed;
|
|
269
|
+
* the first replacements after establishment are the target plane's
|
|
270
|
+
* baselines. The `phase === 'ready'` checks ride along as a guard against a
|
|
271
|
+
* page whose first baseline never arrived. Both edges are tracked
|
|
272
|
+
* independently, in either arrival order (the T-A gap: watching workspaces
|
|
273
|
+
* alone let a late sessions baseline leave the pointer lookup reading stale
|
|
274
|
+
* `byId` rows).
|
|
275
|
+
*
|
|
276
|
+
* T-G — the pointer holds the gate. The sessions list is minted
|
|
277
|
+
* INCREMENTALLY after the reconnect (one `handleSessionAdded` per session,
|
|
278
|
+
* each re-projecting the store with a fresh `ids` array), so the first fresh
|
|
279
|
+
* edge sees a partial `byId` that legitimately lacks the pointer's session;
|
|
280
|
+
* consuming the landing there laddered every switch into a brand-new
|
|
281
|
+
* session (the browser-proven defect). When a pointer exists (the injected
|
|
282
|
+
* {@link LandingGateOptions.pointerSessionId} face, read at decision time),
|
|
283
|
+
* the gate therefore does NOT fire on the first fresh edge. It fires when:
|
|
284
|
+
* - the sessions list CONTAINS the pointer's session — the membership
|
|
285
|
+
* face (`byId`, consulted on EVERY sessions notification: `byId` can
|
|
286
|
+
* gain a row without a new `ids` identity, service.ts:565), or
|
|
287
|
+
* - the sessions list has gone QUIET — no `ids` identity change for the
|
|
288
|
+
* configured quiet window ({@link LANDING_QUIET_MS}; an identity change
|
|
289
|
+
* during the window restarts it) while the pointer is still absent:
|
|
290
|
+
* the honest miss, firing with the ladder exactly like upstream's boot
|
|
291
|
+
* restore of a stored id its fresh list does not know.
|
|
292
|
+
* No pointer → the first fresh edge fires, byte-identical to T-F (the
|
|
293
|
+
* fresh-boot path). The machine's post-settle staleness watchdog bounds the
|
|
294
|
+
* whole membership wait: it disposes this gate, which also cancels a
|
|
295
|
+
* running quiet window.
|
|
296
|
+
*
|
|
297
|
+
* Establishment strictly precedes any target-plane baseline (the follow
|
|
298
|
+
* streams re-open on top of the established generation), so a fire implies
|
|
299
|
+
* both "generation established" and "repull landed" — the two preconditions
|
|
300
|
+
* of a correct landing resolution.
|
|
301
|
+
*
|
|
302
|
+
* Host-half pure logic: the client binding composes it with the real
|
|
303
|
+
* `ctx.workspaces.list` + `ctx.sessions.list` stores and the connection
|
|
304
|
+
* generation observable; the machine consumes it through
|
|
305
|
+
* {@link SwitchHost.onCachesRefreshed}.
|
|
306
|
+
*
|
|
307
|
+
* @returns the disposer stopping all three subscriptions (and the quiet window).
|
|
308
|
+
*/
|
|
309
|
+
export function observeCachesRefreshed(workspaces, sessions, generation, listener, gate = {}) {
|
|
310
|
+
// Establishment is an event, not a level: a live OLD generation at arming
|
|
311
|
+
// time must not open the gate to that plane's churn. Monotonic — a later
|
|
312
|
+
// generation loss mints no arrays, a re-establishment retakes the anchors.
|
|
313
|
+
let established = false;
|
|
314
|
+
// At most once per subscription — the SwitchHost contract. (The machine
|
|
315
|
+
// also disposes its gate at fire time; this flag makes the factory honest
|
|
316
|
+
// on its own.)
|
|
317
|
+
let fired = false;
|
|
318
|
+
let workspacesAnchor = workspaces.getSnapshot().items;
|
|
319
|
+
let sessionsAnchor = sessions.getSnapshot().items;
|
|
320
|
+
let workspacesFresh = false;
|
|
321
|
+
let sessionsFresh = false;
|
|
322
|
+
// The quiet window's live handle, `undefined` while not holding.
|
|
323
|
+
let quietTimer;
|
|
324
|
+
const quietMs = gate.quietMs ?? LANDING_QUIET_MS;
|
|
325
|
+
const clearQuiet = () => {
|
|
326
|
+
if (quietTimer === undefined)
|
|
327
|
+
return;
|
|
328
|
+
if (gate.clearTimeout !== undefined)
|
|
329
|
+
gate.clearTimeout(quietTimer);
|
|
330
|
+
quietTimer = undefined;
|
|
331
|
+
};
|
|
332
|
+
const fire = () => {
|
|
333
|
+
if (fired)
|
|
334
|
+
return;
|
|
335
|
+
fired = true;
|
|
336
|
+
clearQuiet();
|
|
337
|
+
listener();
|
|
338
|
+
};
|
|
339
|
+
const pointerSessionId = () => {
|
|
340
|
+
try {
|
|
341
|
+
return gate.pointerSessionId?.();
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return undefined; // a pointer read failure is the no-pointer path (same ladder)
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
/**
|
|
348
|
+
* The decision point, run on every workspaces/sessions notification once
|
|
349
|
+
* establishment happened. `sessionsIdentityChanged` distinguishes a real
|
|
350
|
+
* list growth (restarts the quiet window) from a same-identity
|
|
351
|
+
* notification like publishRetention's byId-only set (consults
|
|
352
|
+
* membership, must NOT restart the window).
|
|
353
|
+
*/
|
|
354
|
+
const check = (sessionsIdentityChanged) => {
|
|
355
|
+
if (fired || !established || !workspacesFresh || !sessionsFresh)
|
|
356
|
+
return;
|
|
357
|
+
const pointer = pointerSessionId();
|
|
358
|
+
if (pointer === undefined) {
|
|
359
|
+
fire(); // no pointer: the T-F first-fresh-edge behavior (fresh-boot path)
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (sessions.contains?.(pointer)) {
|
|
363
|
+
fire(); // membership: the pointer's session has arrived in the fresh list
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// Holding: the pointer has not shown up in the (possibly still growing)
|
|
367
|
+
// list. A real identity change restarts the window; so does arriving
|
|
368
|
+
// here with no window running (the first hold).
|
|
369
|
+
if (sessionsIdentityChanged || quietTimer === undefined) {
|
|
370
|
+
clearQuiet();
|
|
371
|
+
if (gate.setTimeout !== undefined) {
|
|
372
|
+
quietTimer = gate.setTimeout(() => {
|
|
373
|
+
quietTimer = undefined;
|
|
374
|
+
fire(); // quiet + still no membership → the honest miss (the ladder)
|
|
375
|
+
}, quietMs);
|
|
376
|
+
}
|
|
377
|
+
// Without a timer face the hold is membership-only; the machine's
|
|
378
|
+
// watchdog remains the bound (it disposes this gate).
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
const disposeGeneration = generation.subscribe(() => {
|
|
382
|
+
if (generation.getSnapshot() === undefined)
|
|
383
|
+
return;
|
|
384
|
+
established = true;
|
|
385
|
+
workspacesFresh = false;
|
|
386
|
+
sessionsFresh = false;
|
|
387
|
+
clearQuiet(); // a re-establishment re-anchors: the old window's silence says nothing now
|
|
388
|
+
workspacesAnchor = workspaces.getSnapshot().items;
|
|
389
|
+
sessionsAnchor = sessions.getSnapshot().items;
|
|
390
|
+
});
|
|
391
|
+
const disposeWorkspaces = workspaces.subscribe(() => {
|
|
392
|
+
if (!established)
|
|
393
|
+
return;
|
|
394
|
+
const snapshot = workspaces.getSnapshot();
|
|
395
|
+
if (snapshot.phase === 'ready' && snapshot.items !== workspacesAnchor)
|
|
396
|
+
workspacesFresh = true;
|
|
397
|
+
check(false);
|
|
398
|
+
});
|
|
399
|
+
const disposeSessions = sessions.subscribe(() => {
|
|
400
|
+
if (!established)
|
|
401
|
+
return;
|
|
402
|
+
const snapshot = sessions.getSnapshot();
|
|
403
|
+
let identityChanged = false;
|
|
404
|
+
if (snapshot.phase === 'ready' && snapshot.items !== sessionsAnchor) {
|
|
405
|
+
sessionsFresh = true;
|
|
406
|
+
identityChanged = true;
|
|
407
|
+
// Retake the anchor so the NEXT same-reference notification is not
|
|
408
|
+
// mistaken for growth (quiet-window restart discipline).
|
|
409
|
+
sessionsAnchor = snapshot.items;
|
|
410
|
+
}
|
|
411
|
+
check(identityChanged);
|
|
412
|
+
});
|
|
413
|
+
return () => {
|
|
414
|
+
clearQuiet();
|
|
415
|
+
disposeWorkspaces();
|
|
416
|
+
disposeSessions();
|
|
417
|
+
disposeGeneration();
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* The agent-selector click-interception contract as a pure table (native UI
|
|
422
|
+
* fusion; originally landed for the sidebar-bottom RuntimeSeat, which T-B
|
|
423
|
+
* retired — the Hub UI surfaces (HubPage rows + the HubPanelIcon chevron
|
|
424
|
+
* menu) are now the sole switch surface and consume this SAME table, so the
|
|
425
|
+
* contract below is inherited verbatim).
|
|
426
|
+
*
|
|
427
|
+
* - World page (`fusion: false`): no interception, no orchestration — the
|
|
428
|
+
* real hyperlink navigates (spec §5 双半边).
|
|
429
|
+
* - Fusion page, active entry: intercepted, no attempt — switching to the
|
|
430
|
+
* active agent is a no-op, and the click must never fall through to the
|
|
431
|
+
* anchor (an `href="/"` reload would discard the in-page switch state; a
|
|
432
|
+
* foreign `href` would abandon the fused page entirely).
|
|
433
|
+
* - Fusion page, inactive entry: intercepted and the attempt runs. The
|
|
434
|
+
* attempt's `false` (orchestration unbound) still keeps the page: a
|
|
435
|
+
* swallowed click beats silent navigation away from the fusion surface.
|
|
436
|
+
*
|
|
437
|
+
* Host-half pure logic so `test/client-switch.test.mjs` pins the table from
|
|
438
|
+
* `dist/`; the Hub surfaces map `preventDefault` onto the DOM event.
|
|
439
|
+
*/
|
|
440
|
+
export function seatClickDecision(input) {
|
|
441
|
+
if (!input.fusion)
|
|
442
|
+
return { preventDefault: false, attempted: false };
|
|
443
|
+
if (input.active)
|
|
444
|
+
return { preventDefault: true, attempted: false };
|
|
445
|
+
const attempted = input.attempt() === true;
|
|
446
|
+
return { preventDefault: true, attempted };
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Which Hub UI roster row shows as the active agent (T-B: the Hub surfaces —
|
|
450
|
+
* HubPage rows and the HubPanelIcon chevron menu — are the sole switch
|
|
451
|
+
* surface after the RuntimeSeat retirement).
|
|
452
|
+
*
|
|
453
|
+
* - Fusion page: the page atom's `activeAgent` — the switch state — decides
|
|
454
|
+
* by roster key; the URL is irrelevant on a fused page. The `native`
|
|
455
|
+
* entry participates like any other key.
|
|
456
|
+
* - Non-fusion page (flag off; the UI's navigation arm is retired since
|
|
457
|
+
* T-C): the mount path is the selection — the entry whose path equals
|
|
458
|
+
* the page's normalized mount. The pure arm survives as contract; no
|
|
459
|
+
* served page can hit the foreign case anymore.
|
|
460
|
+
*
|
|
461
|
+
* Host-half pure logic so `test/client-switch.test.mjs` pins it from `dist/`.
|
|
462
|
+
*/
|
|
463
|
+
export function hubRowActive(input) {
|
|
464
|
+
if (input.fusion)
|
|
465
|
+
return input.agentKey === input.activeAgent;
|
|
466
|
+
return input.agentPath === input.mountPath;
|
|
467
|
+
}
|
|
468
|
+
/** The degradation hint a Hub UI surface shows while the flag is off (T-E). */
|
|
469
|
+
export const FUSION_OFF_HINT = 'fusion off — no foreign entry';
|
|
470
|
+
/**
|
|
471
|
+
* The flag-off shape of the Hub UI switch surfaces (path-separation Part 2
|
|
472
|
+
* T-E closeout). With the per-label URL family retired (T-C) and the world
|
|
473
|
+
* web UIs disabled (T-D), a flag-OFF ctx0 page has NO foreign entry at all —
|
|
474
|
+
* and since T-C the Hub UI ships no hyperlinks either, so the smallest honest
|
|
475
|
+
* rendering is an inert roster with a visible degradation hint instead of a
|
|
476
|
+
* silently dead control:
|
|
477
|
+
*
|
|
478
|
+
* - `available: true` (fusion page): the affordance renders — the HubPage
|
|
479
|
+
* `switch` action and the HubPanelIcon chevron menu.
|
|
480
|
+
* - `available: false` (flag off): the affordance is NOT rendered; the
|
|
481
|
+
* roster stays informational and the surface shows the degradation hint
|
|
482
|
+
* ({@link FUSION_OFF_HINT}) once (HubPage: a single chip under the
|
|
483
|
+
* section head, not one per row). Nothing navigates — there is
|
|
484
|
+
* nothing to navigate to.
|
|
485
|
+
*
|
|
486
|
+
* Host-half pure logic so `test/client-switch.test.mjs` pins it from `dist/`.
|
|
487
|
+
*/
|
|
488
|
+
export function switchAffordance(fusion) {
|
|
489
|
+
return fusion ? { available: true, hint: '' } : { available: false, hint: FUSION_OFF_HINT };
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* The active agent's display name for the Hub row's right-edge selector: the
|
|
493
|
+
* roster label of the atom's `activeAgent` key — the name the selector's
|
|
494
|
+
* `title` carries, since the row label itself is the constant `Hub`. A key the
|
|
495
|
+
* (seeded) roster does not know degrades to the raw key. `undefined`/empty
|
|
496
|
+
* (flag off, or no atom read) means "no active agent".
|
|
497
|
+
*
|
|
498
|
+
* Host-half pure logic so `test/client-switch.test.mjs` pins it from `dist/`.
|
|
499
|
+
*/
|
|
500
|
+
export function activeAgentName(activeAgent, roster) {
|
|
501
|
+
if (activeAgent === undefined || activeAgent === '')
|
|
502
|
+
return undefined;
|
|
503
|
+
return roster.find((row) => row.key === activeAgent)?.label ?? activeAgent;
|
|
504
|
+
}
|
|
505
|
+
/** Which machine one roster row groups under: the row's own machine (parser-guaranteed). */
|
|
506
|
+
export function machineGroup(row) {
|
|
507
|
+
return row.machine;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Group a roster by machine for the HubPanelIcon chevron menu's native
|
|
511
|
+
* workspace-list look: one group per distinct `machineGroup` key, groups in
|
|
512
|
+
* first-appearance order, members keeping the roster's order inside their
|
|
513
|
+
* group. A single-group roster (today's local-only shape) renders exactly
|
|
514
|
+
* like the old flat list minus the card border.
|
|
515
|
+
*
|
|
516
|
+
* Host-half pure logic so `test/client-switch.test.mjs` pins it from `dist/`.
|
|
517
|
+
*/
|
|
518
|
+
export function machineGroups(rows) {
|
|
519
|
+
const groups = [];
|
|
520
|
+
const byMachine = new Map();
|
|
521
|
+
for (const row of rows) {
|
|
522
|
+
const machine = machineGroup(row);
|
|
523
|
+
let group = byMachine.get(machine);
|
|
524
|
+
if (group === undefined) {
|
|
525
|
+
group = { machine, agents: [] };
|
|
526
|
+
byMachine.set(machine, group);
|
|
527
|
+
groups.push(group);
|
|
528
|
+
}
|
|
529
|
+
group.agents.push(row);
|
|
530
|
+
}
|
|
531
|
+
return groups;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Build the switch machine over an injected {@link SwitchHost}.
|
|
535
|
+
*
|
|
536
|
+
* Only one attempt is ever armed at a time: starting a new switch finishes
|
|
537
|
+
* the previous one silently (timer cleared, subscription released, promise
|
|
538
|
+
* resolved) and cancels its pending landing, so a stale attempt can never
|
|
539
|
+
* revert or land over a newer switch. The landing cancel is held at
|
|
540
|
+
* CONTROLLER scope, not reached through the current-attempt pointer: the
|
|
541
|
+
* deferred landing deliberately survives the settle — which removes the
|
|
542
|
+
* attempt from `current` — so a supersede must still kill the armed landing
|
|
543
|
+
* of a PRIOR, ALREADY-SETTLED attempt (final-review round 2: without the
|
|
544
|
+
* controller-scope handle, a settled switch's landing fired over the newer
|
|
545
|
+
* switch's pointer-restored view).
|
|
546
|
+
*/
|
|
547
|
+
export function createSwitchController(host, options = {}) {
|
|
548
|
+
const fallbackMs = options.fallbackMs ?? SWITCH_FALLBACK_MS;
|
|
549
|
+
const quietWindowMs = options.quietWindowMs ?? LANDING_QUIET_MS;
|
|
550
|
+
let current;
|
|
551
|
+
// The cancel closure of the latest attempt's still-armed deferred landing,
|
|
552
|
+
// surviving that attempt's settle. Every subsequent run() disarms it first
|
|
553
|
+
// (and clears it the moment the landing fires), whatever state the arming
|
|
554
|
+
// attempt is in.
|
|
555
|
+
let armedLanding;
|
|
556
|
+
return {
|
|
557
|
+
run(label) {
|
|
558
|
+
const prior = current;
|
|
559
|
+
return new Promise((resolve) => {
|
|
560
|
+
let timer;
|
|
561
|
+
// The landing staleness watchdog: armed at settle while the landing
|
|
562
|
+
// is still pending; one more fallback window later the landing is
|
|
563
|
+
// disarmed. A baseline that never installs must not leave a landing
|
|
564
|
+
// armed forever — a much-later unrelated cache replacement would
|
|
565
|
+
// otherwise fire a surprise blank chat long after the switch.
|
|
566
|
+
let watchdog;
|
|
567
|
+
let disposer = () => { };
|
|
568
|
+
let disposeLanding = () => { };
|
|
569
|
+
let landingArmed = false;
|
|
570
|
+
const cancelWatchdog = () => {
|
|
571
|
+
if (watchdog === undefined)
|
|
572
|
+
return;
|
|
573
|
+
host.clearTimeout(watchdog);
|
|
574
|
+
watchdog = undefined;
|
|
575
|
+
};
|
|
576
|
+
const attempt = {
|
|
577
|
+
finished: false,
|
|
578
|
+
dead: false,
|
|
579
|
+
cancelLanding() {
|
|
580
|
+
attempt.dead = true;
|
|
581
|
+
landingArmed = false;
|
|
582
|
+
disposeLanding();
|
|
583
|
+
disposeLanding = () => { };
|
|
584
|
+
cancelWatchdog();
|
|
585
|
+
},
|
|
586
|
+
/** The one exit: disarm, release, resolve — a revert only adds effects first. */
|
|
587
|
+
finish() {
|
|
588
|
+
if (attempt.finished)
|
|
589
|
+
return;
|
|
590
|
+
attempt.finished = true;
|
|
591
|
+
host.clearTimeout(timer);
|
|
592
|
+
disposer();
|
|
593
|
+
if (current === attempt)
|
|
594
|
+
current = undefined;
|
|
595
|
+
// The landing survives the settle — but not indefinitely: while
|
|
596
|
+
// still pending, bound it to one more fallback window.
|
|
597
|
+
if (landingArmed)
|
|
598
|
+
watchdog = host.setTimeout(() => { attempt.cancelLanding(); }, fallbackMs);
|
|
599
|
+
resolve();
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
// Supersede, landing first: the previous attempt's still-armed
|
|
603
|
+
// deferred landing dies through the controller-scope handle —
|
|
604
|
+
// INCLUDING a settled attempt, which is already out of `current`
|
|
605
|
+
// (the landing outlives the settle by design). The newer switch owns
|
|
606
|
+
// the page from here; a stale landing must never fire over it.
|
|
607
|
+
if (armedLanding !== undefined) {
|
|
608
|
+
const cancelPriorLanding = armedLanding;
|
|
609
|
+
armedLanding = undefined;
|
|
610
|
+
cancelPriorLanding();
|
|
611
|
+
}
|
|
612
|
+
if (prior !== undefined && !prior.finished)
|
|
613
|
+
prior.finish();
|
|
614
|
+
current = attempt;
|
|
615
|
+
const revert = (reason) => {
|
|
616
|
+
if (attempt.finished || current !== attempt)
|
|
617
|
+
return;
|
|
618
|
+
attempt.cancelLanding();
|
|
619
|
+
try {
|
|
620
|
+
host.setAgent(NATIVE_AGENT);
|
|
621
|
+
}
|
|
622
|
+
catch { /* keep going: the fallback must land the page somewhere */ }
|
|
623
|
+
try {
|
|
624
|
+
host.reconnect();
|
|
625
|
+
}
|
|
626
|
+
catch { /* the native plane's own recovery loop stays in charge */ }
|
|
627
|
+
try {
|
|
628
|
+
host.toast(`Super D: switch to ${label} failed (${reason === 'timeout' ? 'no response' : 'target not reachable'}) — back to native.`);
|
|
629
|
+
}
|
|
630
|
+
catch { /* feedback is best-effort */ }
|
|
631
|
+
attempt.finish();
|
|
632
|
+
};
|
|
633
|
+
// Step 1: state + NS swap — subsequent requests demux to `label`.
|
|
634
|
+
try {
|
|
635
|
+
host.setAgent(label);
|
|
636
|
+
}
|
|
637
|
+
catch { /* the atom face is guarded; landing still reads the old NS */ }
|
|
638
|
+
// Step 2: replace the generation attempt against the retargeted plane.
|
|
639
|
+
// Nothing navigates here (T-F): every landing — the pointer included —
|
|
640
|
+
// defers to the both-lists gate, because native restore semantics
|
|
641
|
+
// resolve ONLY against fresh, plane-consistent data (the pre-reconnect
|
|
642
|
+
// pointer landing read stale sessions and broke native's own
|
|
643
|
+
// last-visited restore).
|
|
644
|
+
try {
|
|
645
|
+
host.reconnect();
|
|
646
|
+
}
|
|
647
|
+
catch { /* fallback below still applies */ }
|
|
648
|
+
// Step 3: the fallback race — generation establishment settles, the
|
|
649
|
+
// timer and the roster probe revert.
|
|
650
|
+
timer = host.setTimeout(() => { revert('timeout'); }, fallbackMs);
|
|
651
|
+
disposer = host.onGenerationEstablished(() => {
|
|
652
|
+
if (attempt.finished || current !== attempt)
|
|
653
|
+
return;
|
|
654
|
+
attempt.finish();
|
|
655
|
+
});
|
|
656
|
+
// The landing (T-F): NATIVE restore semantics. Fires once BOTH
|
|
657
|
+
// wire-derived caches are fresh, then reads the target NS's persisted
|
|
658
|
+
// selection (the NS swap happened in step 1; the active agent only
|
|
659
|
+
// changes again on a revert, which kills this landing first) and
|
|
660
|
+
// executes the restoreSelection precedence through the public faces.
|
|
661
|
+
// Deliberately NOT released by `finish()`: the baselines land after
|
|
662
|
+
// the generation, so the landing must survive the settle; a revert
|
|
663
|
+
// kills it directly and a supersede kills it through the
|
|
664
|
+
// controller-scope handle (a settled attempt included — see the
|
|
665
|
+
// run() preamble). Fires at most once; the settle arms the staleness
|
|
666
|
+
// watchdog that disarms it if the baselines never install.
|
|
667
|
+
landingArmed = true;
|
|
668
|
+
const cancelThisLanding = () => { attempt.cancelLanding(); };
|
|
669
|
+
armedLanding = cancelThisLanding;
|
|
670
|
+
// T-G: the machine's side of the membership-aware gate. The pointer
|
|
671
|
+
// face reads the SAME persisted-selection source the landing callback
|
|
672
|
+
// below reads at fire time — at gate DECISION time the NS swap has
|
|
673
|
+
// long since happened (step 1 precedes the reconnect), and the active
|
|
674
|
+
// agent only changes again on a revert/supersede, both of which kill
|
|
675
|
+
// this landing before the read could go stale. A throwing read is
|
|
676
|
+
// `undefined`: the no-pointer path, the same ladder a failed read
|
|
677
|
+
// reaches through the callback's own guard.
|
|
678
|
+
const gateRequest = {
|
|
679
|
+
pointerSessionId: () => {
|
|
680
|
+
let parsed;
|
|
681
|
+
try {
|
|
682
|
+
parsed = parseLandingTarget(host.readPersistedSelection());
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
return undefined;
|
|
686
|
+
}
|
|
687
|
+
return typeof parsed === 'string' && parsed !== '' ? parsed : undefined;
|
|
688
|
+
},
|
|
689
|
+
quietMs: quietWindowMs,
|
|
690
|
+
setTimeout: (fn, ms) => host.setTimeout(fn, ms),
|
|
691
|
+
clearTimeout: (handle) => host.clearTimeout(handle),
|
|
692
|
+
};
|
|
693
|
+
disposeLanding = host.onCachesRefreshed(() => {
|
|
694
|
+
if (attempt.dead)
|
|
695
|
+
return;
|
|
696
|
+
if (armedLanding === cancelThisLanding)
|
|
697
|
+
armedLanding = undefined;
|
|
698
|
+
landingArmed = false;
|
|
699
|
+
cancelWatchdog();
|
|
700
|
+
disposeLanding();
|
|
701
|
+
disposeLanding = () => { };
|
|
702
|
+
let caches;
|
|
703
|
+
try {
|
|
704
|
+
caches = host.readRestoreCaches();
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
caches = undefined; // unreadable caches land nothing (safe direction)
|
|
708
|
+
}
|
|
709
|
+
let raw = null;
|
|
710
|
+
try {
|
|
711
|
+
raw = host.readPersistedSelection();
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
raw = null;
|
|
715
|
+
}
|
|
716
|
+
const plan = planRestoreLanding(parseLandingTarget(raw), caches);
|
|
717
|
+
if (plan === undefined)
|
|
718
|
+
return;
|
|
719
|
+
try {
|
|
720
|
+
if (plan.kind === 'open-session')
|
|
721
|
+
host.openSession(plan.target);
|
|
722
|
+
else if (plan.kind === 'start-session')
|
|
723
|
+
host.startSession(plan.workspaceId);
|
|
724
|
+
else
|
|
725
|
+
host.openDefaultWorkspace();
|
|
726
|
+
}
|
|
727
|
+
catch { /* landing never throws */ }
|
|
728
|
+
}, gateRequest);
|
|
729
|
+
void host.probeTarget(label).then((alive) => { if (alive !== true)
|
|
730
|
+
revert('probe'); }, () => { revert('probe'); });
|
|
731
|
+
});
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
}
|