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.
Files changed (39) hide show
  1. package/README.md +6 -7
  2. package/agent-agy/dist/world-plugin.js +18 -33
  3. package/agent-codex/dist/world-plugin.js +18 -33
  4. package/agent-hermes/dist/world-plugin.js +18 -34
  5. package/agent-omp/dist/world-plugin.js +16 -32
  6. package/agent-pi/dist/world-plugin.js +18 -33
  7. package/client/index.js +2086 -0
  8. package/cordis.patch.yml +21 -7
  9. package/package.json +13 -3
  10. package/superd-hub/dist/agent-roster.d.ts +3 -1
  11. package/superd-hub/dist/agent-roster.js +4 -2
  12. package/superd-hub/dist/carrier.d.ts +41 -28
  13. package/superd-hub/dist/carrier.js +148 -202
  14. package/superd-hub/dist/fusion-transport.d.ts +150 -0
  15. package/superd-hub/dist/fusion-transport.js +369 -0
  16. package/superd-hub/dist/gateway.d.ts +7 -2
  17. package/superd-hub/dist/gateway.js +28 -2
  18. package/superd-hub/dist/hub-route.d.ts +74 -0
  19. package/superd-hub/dist/hub-route.js +188 -0
  20. package/superd-hub/dist/index.d.ts +10 -4
  21. package/superd-hub/dist/index.js +10 -4
  22. package/superd-hub/dist/roster.d.ts +2 -2
  23. package/superd-hub/dist/spawn-world.js +5 -3
  24. package/superd-hub/dist/switch-core.d.ts +630 -0
  25. package/superd-hub/dist/switch-core.js +734 -0
  26. package/superd-hub/dist/web-trust.d.ts +5 -5
  27. package/superd-hub/dist/web-trust.js +5 -5
  28. package/superd-hub/dist/world-entry.d.ts +20 -13
  29. package/superd-hub/dist/world-entry.js +15 -19
  30. package/superd-hub/dist/world-host.d.ts +15 -20
  31. package/superd-hub/dist/world-host.js +25 -4
  32. package/superd-hub/dist/world-join.js +14 -29
  33. package/superd-hub/dist/world-mount.d.ts +48 -26
  34. package/superd-hub/dist/world-mount.js +59 -27
  35. package/superd-hub/dist/world-provision.js +11 -4
  36. package/superd-hub/dist/world-web-server.d.ts +57 -106
  37. package/superd-hub/dist/world-web-server.js +61 -216
  38. package/superd-hub/lib/client/index.js +2086 -0
  39. 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
+ }