super-dsh 0.3.1-d → 0.3.1-f

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/client/index.js CHANGED
@@ -898,6 +898,16 @@ function machineLabelsFrom(list) {
898
898
  * roster-probe failure → revert to native + a visible toast, never a page
899
899
  * lock).
900
900
  *
901
+ * Live-install staleness (2026-10-18): the fusion flag global and the
902
+ * transport script are baked into the SERVED index, so a page that booted
903
+ * before super-dsh was installed runs the freshly hot-loaded client half
904
+ * against a stale page — `fusionPage()` reads false and every switch
905
+ * affordance stays hidden until a manual restart. {@link fusionReloadDecision}
906
+ * is the pure decision behind the client half's heal: one roster answer later,
907
+ * reload the page once. The transport must precede app boot (its upload hook
908
+ * `__DSH_FILE_UPLOAD__` is read at construction), so a client-side flag flip
909
+ * can never arm a stale page honestly — the page must be re-served.
910
+ *
901
911
  * Host-half on purpose: plain TS with every effect injected through
902
912
  * {@link SwitchHost} — no DOM, no client imports — so `test/client-switch.test.mjs`
903
913
  * imports it from `dist/` like every other host module. The client half
@@ -1343,6 +1353,22 @@ function switchAffordance(fusion) {
1343
1353
  hint: FUSION_OFF_HINT
1344
1354
  };
1345
1355
  }
1356
+ /** The sessionStorage marker guarding the stale-page reload: one per tab session. */
1357
+ const FUSION_RELOAD_MARKER = "superd.fusion-armed.reload";
1358
+ /**
1359
+ * The live-install staleness decision (host-half pure so the node tests pin
1360
+ * the table from `dist/`). The host's `hub.fusion` field is the ONLY thing
1361
+ * that distinguishes a page-booted-before-install from a deliberately
1362
+ * flag-off deployment — without it the reload would loop forever on every
1363
+ * `enabled: false` composition (the client half loads there too).
1364
+ */
1365
+ function fusionReloadDecision(input) {
1366
+ if (input.flagPresent) return "armed";
1367
+ if (input.markerPresent) return "already-reloaded";
1368
+ if (input.hubFusion === true) return "reload";
1369
+ if (input.hubFusion === false) return "row-off";
1370
+ return "host-silent";
1371
+ }
1346
1372
  /**
1347
1373
  * The active agent's display name for the Hub row's right-edge selector: the
1348
1374
  * roster label of the atom's `activeAgent` key — the name the selector's
@@ -1621,6 +1647,53 @@ function createReadinessWaiter(faces, options = {}) {
1621
1647
  function fusionPage() {
1622
1648
  return globalThis.__SUPERD_UI_FUSION__ === true;
1623
1649
  }
1650
+ /**
1651
+ * The sessionStorage marker read (undefined-safe: a storage failure is
1652
+ * `false` — the probe then reloads without the loop guard, which only
1653
+ * matters for a host that reports fusion but never bakes the flag).
1654
+ */
1655
+ function staleReloadMarker() {
1656
+ try {
1657
+ return globalThis.sessionStorage?.getItem(FUSION_RELOAD_MARKER) !== null;
1658
+ } catch {
1659
+ return false;
1660
+ }
1661
+ }
1662
+ /**
1663
+ * Live-install staleness probe (2026-10-18): the ONE call the client entry
1664
+ * makes when the page does not carry the fusion flag. A flag-absent page is
1665
+ * either a pre-install page (the client half hot-loaded into HTML served
1666
+ * before the row mounted — the fresh-install case where the Hub row renders
1667
+ * but every switch affordance stays hidden until a manual restart) or a
1668
+ * deliberate flag-off deployment (T-E). The roster's additive `hub.fusion`
1669
+ * answers which; on an armed host the page re-boots ONCE — the marker keeps
1670
+ * a misbehaving host from ever looping the tab. Every failure mode (fetch
1671
+ * error, non-OK, old host without the field) degrades to no action: the
1672
+ * flag-off shapes below are the honest rendering of an unanswered probe.
1673
+ *
1674
+ * Fire-and-forget by contract — never throws, never blocks the apply.
1675
+ */
1676
+ function probeStaleFusionPage() {
1677
+ (async () => {
1678
+ let hubFusion;
1679
+ try {
1680
+ const response = await fetch("/api/agent-runtime", { credentials: "same-origin" });
1681
+ if (!response.ok) return;
1682
+ hubFusion = (await response.json()).hub?.fusion;
1683
+ } catch {
1684
+ return;
1685
+ }
1686
+ if (fusionReloadDecision({
1687
+ flagPresent: fusionPage(),
1688
+ markerPresent: staleReloadMarker(),
1689
+ hubFusion
1690
+ }) !== "reload") return;
1691
+ try {
1692
+ globalThis.sessionStorage?.setItem(FUSION_RELOAD_MARKER, "1");
1693
+ } catch {}
1694
+ globalThis.location?.reload();
1695
+ })();
1696
+ }
1624
1697
  /** The page atom, when the page carries one (it always does on a fusion page). */
1625
1698
  function uiState() {
1626
1699
  const state = globalThis.__SUPERD_UI_STATE__;
@@ -3880,6 +3953,38 @@ function HubPage() {
3880
3953
  });
3881
3954
  }
3882
3955
 
3956
+ //#endregion
3957
+ //#region src/client/pill-geometry.ts
3958
+ /**
3959
+ * Derive the scroll-invariant offsets from one same-frame measurement.
3960
+ * Measuring the DELTAS (not absolute coordinates) is the whole point: they
3961
+ * survive any scroll of any ancestor, so nothing needs to re-run while the
3962
+ * sidebar scrolls.
3963
+ */
3964
+ function measurePillBox(input) {
3965
+ return {
3966
+ dx: input.row.right - input.wrap.left,
3967
+ cy: (input.row.top + input.row.bottom) / 2 - input.wrap.top
3968
+ };
3969
+ }
3970
+ /** The row's own horizontal padding (native `.panelRow`): the selector's inset. */
3971
+ const PILL_ROW_INSET = 8;
3972
+ /**
3973
+ * The pill's placement, wrapper-relative: its right edge sits `inset` px
3974
+ * inside the row's right edge, vertically centered on the row
3975
+ * (`translateY(-50%)`). `selectorWidth` is the render-time pill width (the
3976
+ * mark is present only while an agent is active), so it stays a render
3977
+ * concern; the measured box is not.
3978
+ */
3979
+ function pillStyle(box, selectorWidth, inset = PILL_ROW_INSET) {
3980
+ return {
3981
+ position: "absolute",
3982
+ left: box.dx - inset - selectorWidth,
3983
+ top: box.cy,
3984
+ transform: "translateY(-50%)"
3985
+ };
3986
+ }
3987
+
3883
3988
  //#endregion
3884
3989
  //#region src/client/HubPanelIcon.tsx
3885
3990
  /** Runtime-mark edge inside the selector, and the chevron's own default edge. */
@@ -3893,8 +3998,6 @@ const CHEVRON_SIZE = 14;
3893
3998
  const SELECTOR_HEIGHT = 28;
3894
3999
  const SELECTOR_PAD = 8;
3895
4000
  const SELECTOR_GAP = 4;
3896
- /** The row's own horizontal padding (native `.panelRow`): the selector's inset. */
3897
- const ROW_INSET = 8;
3898
4001
  function HubPanelIcon({ size }) {
3899
4002
  const wide = size <= 16;
3900
4003
  const rootRef = react.useRef(null);
@@ -3917,16 +4020,25 @@ function HubPanelIcon({ size }) {
3917
4020
  }, [fusion]);
3918
4021
  const measure = react.useCallback(() => {
3919
4022
  const row = rootRef.current?.closest("button") ?? null;
3920
- if (row === null) {
4023
+ const wrap = selectorRef.current?.parentElement ?? null;
4024
+ if (row === null || wrap === null) {
3921
4025
  setBox(null);
3922
4026
  return;
3923
4027
  }
3924
- const rect = row.getBoundingClientRect();
3925
- setBox((prev) => prev !== null && prev.top === rect.top && prev.bottom === rect.bottom && prev.right === rect.right ? prev : {
3926
- top: rect.top,
3927
- bottom: rect.bottom,
3928
- right: rect.right
4028
+ const rowRect = row.getBoundingClientRect();
4029
+ const wrapRect = wrap.getBoundingClientRect();
4030
+ const next = measurePillBox({
4031
+ row: {
4032
+ top: rowRect.top,
4033
+ bottom: rowRect.bottom,
4034
+ right: rowRect.right
4035
+ },
4036
+ wrap: {
4037
+ left: wrapRect.left,
4038
+ top: wrapRect.top
4039
+ }
3929
4040
  });
4041
+ setBox((prev) => prev !== null && prev.dx === next.dx && prev.cy === next.cy ? prev : next);
3930
4042
  }, []);
3931
4043
  react.useLayoutEffect(() => {
3932
4044
  if (!wide || !switchUi.available) return;
@@ -4036,7 +4148,7 @@ function HubPanelIcon({ size }) {
4036
4148
  children: [
4037
4149
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HubGlyph, { size }),
4038
4150
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SuperdToastHost, {}),
4039
- wide && box !== null && switchUi.available && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Menu, {
4151
+ wide && switchUi.available && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Menu, {
4040
4152
  open,
4041
4153
  onClose: () => {
4042
4154
  setOpen(false);
@@ -4057,10 +4169,11 @@ function HubPanelIcon({ size }) {
4057
4169
  "aria-expanded": open,
4058
4170
  title: activeLabel === void 0 ? "Agent selector" : `Agent selector: ${activeLabel}`,
4059
4171
  style: {
4060
- position: "fixed",
4061
- left: box.right - ROW_INSET - selectorWidth,
4062
- top: box.top + (box.bottom - box.top) / 2,
4063
- transform: "translateY(-50%)",
4172
+ ...pillStyle(box ?? {
4173
+ dx: 0,
4174
+ cy: 0
4175
+ }, selectorWidth),
4176
+ visibility: box === null ? "hidden" : "visible",
4064
4177
  display: "inline-flex",
4065
4178
  alignItems: "center",
4066
4179
  justifyContent: "center",
@@ -4105,9 +4218,17 @@ const inject = ["slots"];
4105
4218
  * absent (a composition without the sidebar renders nothing — every seat
4106
4219
  * here is an optional occupant by contract).
4107
4220
  *
4108
- * @param ctx - client root context.
4221
+ * Live-install staleness: the fusion flag is baked into the SERVED index, so
4222
+ * a page that booted before super-dsh was installed carries no flag even
4223
+ * though this very apply is the hot-loaded client half — the Hub row would
4224
+ * render with every switch affordance hidden until a manual restart. When
4225
+ * the flag is absent the entry probes the roster's `hub.fusion` once and
4226
+ * re-boots the page on an armed host (switch.ts `probeStaleFusionPage`; the
4227
+ * sessionStorage marker bounds it to one reload per tab session). A flag-off
4228
+ * deployment answers `false` and keeps the T-E inert shape with no reload.
4109
4229
  */
4110
4230
  function apply(ctx) {
4231
+ if (!fusionPage()) probeStaleFusionPage();
4111
4232
  ctx.inject(["slots"], (raw) => {
4112
4233
  const scope = raw;
4113
4234
  scope.effect(() => scope.slots.inject("main", () => scope.slots.register({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-dsh",
3
- "version": "0.3.1-d",
3
+ "version": "0.3.1-f",
4
4
  "description": "Super D for the DeepSeek Harness (DSH) — one dsh plugin that mounts the superd hub (runtime selector, roster, per-runtime agents) and joins local foreign-agent runtimes (OMP, Codex, Claude, Pi, Hermes, Antigravity) into the DSH Web UI. Each runtime keeps its native home and session store; the hub only bridges.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,6 +10,14 @@ export interface A2aHostOptions {
10
10
  * ({@link bridgeTypertGateway}).
11
11
  */
12
12
  nativeGateway?: RawTypertGatewayFace;
13
+ /**
14
+ * LAZY provider of ctx0's owner browser Peer (connection.browserPeer) —
15
+ * 0.2.1 gateways read peer.signal/principal on every invocation. A
16
+ * provider, not a value: typertGateway mounts before connection, so a
17
+ * wiring-time read would capture undefined forever; resolution happens
18
+ * per call, when connection is long live.
19
+ */
20
+ nativePeer?: () => unknown;
13
21
  }
14
22
  /**
15
23
  * The RAW typertGateway service face — upstream canonical stream order
@@ -20,8 +28,8 @@ export interface A2aHostOptions {
20
28
  * mesh-gateway wire adapters (dual-face, 3rd-argument dispatch).
21
29
  */
22
30
  export interface RawTypertGatewayFace {
23
- dispatchRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<unknown>;
24
- openWireStream(endpoint: string, payload: unknown, uplink: undefined, peer: undefined, signal: AbortSignal, control: AbortController): Promise<AsyncIterable<unknown>> | AsyncIterable<unknown>;
31
+ dispatchRpc(endpoint: string, payload: unknown, signal: AbortSignal, peer: unknown): Promise<unknown>;
32
+ openWireStream(endpoint: string, payload: unknown, uplink: undefined, peer: unknown, signal: AbortSignal, control: AbortController): Promise<AsyncIterable<unknown>> | AsyncIterable<unknown>;
25
33
  }
26
34
  /**
27
35
  * Bridge one RAW typertGateway instance to Task 2's 3-arg `A2aGateway` face
@@ -38,7 +46,7 @@ export interface RawTypertGatewayFace {
38
46
  * @param real - a raw in-process typertGateway instance (ctx0's own or a local world's).
39
47
  * @returns the gateway Task 2's service consumes.
40
48
  */
41
- export declare function bridgeTypertGateway(real: RawTypertGatewayFace): A2aGateway;
49
+ export declare function bridgeTypertGateway(real: RawTypertGatewayFace, peer?: unknown): A2aGateway;
42
50
  /**
43
51
  * Build the gatewayFor resolver Task 2's service consumes: federated table
44
52
  * first, shared target registry second, local id-map inversion third,
@@ -59,10 +59,10 @@ function localKeyOfA6(home, a6) {
59
59
  * @param real - a raw in-process typertGateway instance (ctx0's own or a local world's).
60
60
  * @returns the gateway Task 2's service consumes.
61
61
  */
62
- export function bridgeTypertGateway(real) {
62
+ export function bridgeTypertGateway(real, peer) {
63
63
  return {
64
- dispatchRpc: (endpoint, payload, signal) => real.dispatchRpc(endpoint, payload, signal),
65
- openWireStream: (endpoint, payload, signal) => real.openWireStream(endpoint, payload, undefined, undefined, signal, new AbortController()),
64
+ dispatchRpc: (endpoint, payload, signal) => real.dispatchRpc(endpoint, payload, signal, peer),
65
+ openWireStream: (endpoint, payload, signal) => real.openWireStream(endpoint, payload, undefined, peer, signal, new AbortController()),
66
66
  };
67
67
  }
68
68
  /**
@@ -95,17 +95,17 @@ export function resolveA2aGatewayFor(options) {
95
95
  if (key === 'native') {
96
96
  if (options.nativeGateway === undefined)
97
97
  throw new A2aAddressError('unreachable', addr);
98
- return bridgeTypertGateway(options.nativeGateway);
98
+ return bridgeTypertGateway(options.nativeGateway, options.nativePeer?.());
99
99
  }
100
100
  // The registry's declared GatewayFace (targets.ts) types the stream
101
101
  // face as the 3-arg subset, but a LOCAL row's runtime object is the RAW
102
102
  // typertGateway instance (world-join.ts registers it verbatim) — re-cast
103
103
  // to RawTypertGatewayFace so the bridge above binds the canonical 6-arg
104
104
  // positional order instead of the declared subset.
105
- const local = getTarget(key)?.gateway;
106
- if (local === undefined)
105
+ const target = getTarget(key);
106
+ if (target?.gateway === undefined)
107
107
  throw new A2aAddressError('unreachable', addr);
108
- return bridgeTypertGateway(local);
108
+ return bridgeTypertGateway(target.gateway, target.peer);
109
109
  }
110
110
  // The a6 resolves nowhere — classify the miss (D7): a roster row exists →
111
111
  // its plane is down; else the machine decides agent-vs-machine blame.
@@ -56,12 +56,18 @@ export function wireHubToolRow(ctx, opts = {}) {
56
56
  // RAW instance in, bridged at the resolver (fix round 1: one shared
57
57
  // bridge in a2a-host.ts covers the native seam AND all local world rows).
58
58
  const raw = t.get('typertGateway');
59
+ // 0.2.1: every invocation reads the caller Peer — ctx0's owner browser
60
+ // Peer rides the native seam as a LAZY provider: typertGateway mounts
61
+ // BEFORE connection (the service registers in its constructor), so a
62
+ // wiring-time value would capture undefined forever; resolution happens
63
+ // per call, when connection is long live.
64
+ const nativePeer = () => ctx.get('connection')?.browserPeer;
59
65
  // postoffice R4: ONE resolver, ONE ingest, TWO consumers — the initiator
60
66
  // service's self-routing leg calls the very same ingest handler the HTTP
61
67
  // endpoint calls (protocol-identical by construction). The service takes
62
68
  // a LAZY provider: the ingest's delivery face buffers through the
63
69
  // service's own buffer leg, so it can only be built after the service.
64
- const gatewayFor = resolveA2aGatewayFor({ home, nativeGateway: raw });
70
+ const gatewayFor = resolveA2aGatewayFor({ home, nativeGateway: raw, nativePeer });
65
71
  // Operational journal: the pure modules only format lines; this is
66
72
  // where they meet the process console and land in the unit log (4999 rig
67
73
  // / dev3 dsh.service journal). NOT ctx.logger: the pack composes no
@@ -1,22 +1,24 @@
1
1
  import { type WorldRequest, type WorldResponse } from './world-web-server.js';
2
2
  import { WorldMuxServer, type MuxFailure } from './world-mux.js';
3
- /** The three-method face of a world's typertGateway service instance. */
3
+ /** The face of a world's typertGateway service instance the hub forwards through. */
4
4
  export interface CarrierGatewayFace {
5
- dispatchRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<unknown>;
5
+ dispatchRpc(endpoint: string, payload: unknown, signal: AbortSignal, peer: unknown): Promise<unknown>;
6
6
  /**
7
7
  * Streams return an async iterable (upstream face is synchronous-returning).
8
8
  * Canonical order: (endpoint, payload, uplink, peer, signal, control) — the
9
- * carrier passes no peer (ctx0's fence already authenticated the client),
10
- * the mux controller's signal, and the controller itself, exactly as the
11
- * native gateway's mux wires its own streams.
9
+ * carrier passes the world's owner browser Peer (0.2.1 gateways read it on
10
+ * every invocation), the mux controller's signal, and the controller
11
+ * itself, exactly as the native gateway's mux wires its own streams.
12
12
  */
13
- openWireStream(endpoint: string, payload: unknown, uplink: AsyncIterable<unknown>, peer: undefined, signal: AbortSignal, control: AbortController): Promise<AsyncIterable<unknown>> | AsyncIterable<unknown>;
13
+ openWireStream(endpoint: string, payload: unknown, uplink: AsyncIterable<unknown>, peer: unknown, signal: AbortSignal, control: AbortController): Promise<AsyncIterable<unknown>> | AsyncIterable<unknown>;
14
14
  }
15
15
  export declare function writeJson(res: WorldResponse, status: number, body: string): void;
16
16
  /** Deps the forwarding kernel needs to answer for one world's traffic. */
17
17
  export interface ForwardOptions {
18
18
  /** The addressed world's gateway instance. */
19
19
  gateway: CarrierGatewayFace;
20
+ /** The addressed world's owner browser Peer (0.2.1 invocation identity). */
21
+ peer?: unknown;
20
22
  /** World key addressing the world's published `/api` fetch handler. */
21
23
  label: string;
22
24
  /** URL prefix this carrier strips before the world sees the request (`/omp`, `/hub`). */
@@ -47,7 +49,7 @@ export declare function forwardWorldFetch(options: {
47
49
  */
48
50
  export declare function forwardApiCall(options: ForwardOptions, req: WorldRequest, res: WorldResponse): Promise<void>;
49
51
  /** One WorldMuxServer bound to `gateway` — the canonical openWireStream wiring in one place. */
50
- export declare function worldMuxOf(gateway: CarrierGatewayFace, options?: {
52
+ export declare function worldMuxOf(gateway: CarrierGatewayFace, peer?: unknown, options?: {
51
53
  heartbeatIntervalMs?: number;
52
54
  failure?: (error: unknown) => MuxFailure;
53
55
  }): WorldMuxServer;
@@ -177,7 +177,7 @@ export async function forwardApiCall(options, req, res) {
177
177
  }
178
178
  rpcId = envelope.rpcId;
179
179
  const controller = new AbortController();
180
- const result = await options.gateway.dispatchRpc(envelope.method, envelope.payload, controller.signal);
180
+ const result = await options.gateway.dispatchRpc(envelope.method, envelope.payload, controller.signal, options.peer);
181
181
  // The gateway already returns a Result (`{ok:true,value}` | `{ok:false,error}`),
182
182
  // which is exactly the server-response's `result` field.
183
183
  writeJson(res, 200, serverResult(rpcId, result));
@@ -194,8 +194,8 @@ export async function forwardApiCall(options, req, res) {
194
194
  }
195
195
  }
196
196
  /** One WorldMuxServer bound to `gateway` — the canonical openWireStream wiring in one place. */
197
- export function worldMuxOf(gateway, options) {
198
- return new WorldMuxServer((endpoint, payload, uplink, control) => Promise.resolve(gateway.openWireStream(endpoint, payload, uplink, undefined, control.signal, control)), {
197
+ export function worldMuxOf(gateway, peer, options) {
198
+ return new WorldMuxServer((endpoint, payload, uplink, control) => Promise.resolve(gateway.openWireStream(endpoint, payload, uplink, peer, control.signal, control)), {
199
199
  ...(options?.heartbeatIntervalMs === undefined ? {} : { heartbeatIntervalMs: options.heartbeatIntervalMs }),
200
200
  ...(options?.failure === undefined ? {} : { failure: options.failure }),
201
201
  });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pill geometry for the Hub sidebar row's agent selector (2026-10-18): the
3
+ * pure math behind anchoring the selector to its row. Split out of
4
+ * HubPanelIcon so node tests can pin it from `dist/client/` — the
5
+ * mesh-groups precedent (pure logic beside the components, no DOM, no
6
+ * React, no @deepseek-ai imports).
7
+ *
8
+ * Why absolute-in-flow and not `position: fixed` + re-measure: the native
9
+ * sidebar scrolls (many workspaces; iOS Safari drags the whole page), and a
10
+ * fixed pill tracks the VIEWPORT, not the content — without a scroll
11
+ * listener it stayed at its mount-time viewport spot while the row moved
12
+ * (the reported mis-alignment). Anchoring the pill to its positioned
13
+ * ancestor (the `Menu` wrapper span — upstream `.root` is
14
+ * `position: relative`, in-flow inside the row) makes the composite ride ANY
15
+ * scroll natively: zero JS per scrolled frame, exact by construction, and
16
+ * the open menu keeps following through its own capture-phase scroll
17
+ * placement (`getAnchorRect` reads the pill live).
18
+ *
19
+ * @module superd-hub/client/pill-geometry
20
+ */
21
+ /**
22
+ * The measured boxes one anchoring reads. All viewport px, straight from
23
+ * `getBoundingClientRect()` — same-frame reads, so any scroll moves both
24
+ * boxes equally and the derived offsets are scroll-invariant by
25
+ * construction.
26
+ */
27
+ export interface PillMeasureInput {
28
+ /** The row button's box (the `.panelRow` the glyph sits in). */
29
+ row: {
30
+ readonly top: number;
31
+ readonly bottom: number;
32
+ readonly right: number;
33
+ };
34
+ /** The pill's positioned ancestor box (the `Menu` wrapper span). */
35
+ wrap: {
36
+ readonly left: number;
37
+ readonly top: number;
38
+ };
39
+ }
40
+ /** Wrapper-relative offsets the pill is placed from. */
41
+ export interface PillBox {
42
+ /** Wrapper left edge → row right edge (px; both ride the same flow). */
43
+ readonly dx: number;
44
+ /** Wrapper top edge → row vertical center (px). */
45
+ readonly cy: number;
46
+ }
47
+ /**
48
+ * Derive the scroll-invariant offsets from one same-frame measurement.
49
+ * Measuring the DELTAS (not absolute coordinates) is the whole point: they
50
+ * survive any scroll of any ancestor, so nothing needs to re-run while the
51
+ * sidebar scrolls.
52
+ */
53
+ export declare function measurePillBox(input: PillMeasureInput): PillBox;
54
+ /** The row's own horizontal padding (native `.panelRow`): the selector's inset. */
55
+ export declare const PILL_ROW_INSET = 8;
56
+ /** Structural CSS style the render side spreads into the pill span. */
57
+ export interface PillStyle {
58
+ readonly position: 'absolute';
59
+ readonly left: number;
60
+ readonly top: number;
61
+ readonly transform: string;
62
+ }
63
+ /**
64
+ * The pill's placement, wrapper-relative: its right edge sits `inset` px
65
+ * inside the row's right edge, vertically centered on the row
66
+ * (`translateY(-50%)`). `selectorWidth` is the render-time pill width (the
67
+ * mark is present only while an agent is active), so it stays a render
68
+ * concern; the measured box is not.
69
+ */
70
+ export declare function pillStyle(box: PillBox, selectorWidth: number, inset?: number): PillStyle;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Pill geometry for the Hub sidebar row's agent selector (2026-10-18): the
3
+ * pure math behind anchoring the selector to its row. Split out of
4
+ * HubPanelIcon so node tests can pin it from `dist/client/` — the
5
+ * mesh-groups precedent (pure logic beside the components, no DOM, no
6
+ * React, no @deepseek-ai imports).
7
+ *
8
+ * Why absolute-in-flow and not `position: fixed` + re-measure: the native
9
+ * sidebar scrolls (many workspaces; iOS Safari drags the whole page), and a
10
+ * fixed pill tracks the VIEWPORT, not the content — without a scroll
11
+ * listener it stayed at its mount-time viewport spot while the row moved
12
+ * (the reported mis-alignment). Anchoring the pill to its positioned
13
+ * ancestor (the `Menu` wrapper span — upstream `.root` is
14
+ * `position: relative`, in-flow inside the row) makes the composite ride ANY
15
+ * scroll natively: zero JS per scrolled frame, exact by construction, and
16
+ * the open menu keeps following through its own capture-phase scroll
17
+ * placement (`getAnchorRect` reads the pill live).
18
+ *
19
+ * @module superd-hub/client/pill-geometry
20
+ */
21
+ /**
22
+ * Derive the scroll-invariant offsets from one same-frame measurement.
23
+ * Measuring the DELTAS (not absolute coordinates) is the whole point: they
24
+ * survive any scroll of any ancestor, so nothing needs to re-run while the
25
+ * sidebar scrolls.
26
+ */
27
+ export function measurePillBox(input) {
28
+ return {
29
+ dx: input.row.right - input.wrap.left,
30
+ cy: (input.row.top + input.row.bottom) / 2 - input.wrap.top,
31
+ };
32
+ }
33
+ /** The row's own horizontal padding (native `.panelRow`): the selector's inset. */
34
+ export const PILL_ROW_INSET = 8;
35
+ /**
36
+ * The pill's placement, wrapper-relative: its right edge sits `inset` px
37
+ * inside the row's right edge, vertically centered on the row
38
+ * (`translateY(-50%)`). `selectorWidth` is the render-time pill width (the
39
+ * mark is present only while an agent is active), so it stays a render
40
+ * concern; the measured box is not.
41
+ */
42
+ export function pillStyle(box, selectorWidth, inset = PILL_ROW_INSET) {
43
+ return {
44
+ position: 'absolute',
45
+ left: box.dx - inset - selectorWidth,
46
+ top: box.cy,
47
+ transform: 'translateY(-50%)',
48
+ };
49
+ }
@@ -107,6 +107,21 @@ export interface SwitchSessions {
107
107
  }
108
108
  /** Whether this page is a fusion page: the baked flag global is present. */
109
109
  export declare function fusionPage(): boolean;
110
+ /**
111
+ * Live-install staleness probe (2026-10-18): the ONE call the client entry
112
+ * makes when the page does not carry the fusion flag. A flag-absent page is
113
+ * either a pre-install page (the client half hot-loaded into HTML served
114
+ * before the row mounted — the fresh-install case where the Hub row renders
115
+ * but every switch affordance stays hidden until a manual restart) or a
116
+ * deliberate flag-off deployment (T-E). The roster's additive `hub.fusion`
117
+ * answers which; on an armed host the page re-boots ONCE — the marker keeps
118
+ * a misbehaving host from ever looping the tab. Every failure mode (fetch
119
+ * error, non-OK, old host without the field) degrades to no action: the
120
+ * flag-off shapes below are the honest rendering of an unanswered probe.
121
+ *
122
+ * Fire-and-forget by contract — never throws, never blocks the apply.
123
+ */
124
+ export declare function probeStaleFusionPage(): void;
110
125
  /** The page atom, when the page carries one (it always does on a fusion page). */
111
126
  export declare function uiState(): SuperdUiState | undefined;
112
127
  /**
@@ -37,7 +37,7 @@
37
37
  *
38
38
  * @module superd-hub/client/switch
39
39
  */
40
- import { createReadinessWaiter, createSwitchController, decidePreFlight, observeCachesRefreshed, runMachineDownFeedback, SESSIONS_CURRENT_KEY, } from '../switch-core.js';
40
+ import { createReadinessWaiter, createSwitchController, decidePreFlight, observeCachesRefreshed, runMachineDownFeedback, SESSIONS_CURRENT_KEY, FUSION_RELOAD_MARKER, fusionReloadDecision, } from '../switch-core.js';
41
41
  import { showSuperdToast } from '../toast-core.js';
42
42
  import { fetchMachines, reprobeMachine } from './mesh-api.js';
43
43
  import { fetchRoster } from './roster.js';
@@ -45,6 +45,59 @@ import { fetchRoster } from './roster.js';
45
45
  export function fusionPage() {
46
46
  return globalThis.__SUPERD_UI_FUSION__ === true;
47
47
  }
48
+ /**
49
+ * The sessionStorage marker read (undefined-safe: a storage failure is
50
+ * `false` — the probe then reloads without the loop guard, which only
51
+ * matters for a host that reports fusion but never bakes the flag).
52
+ */
53
+ function staleReloadMarker() {
54
+ try {
55
+ const storage = globalThis.sessionStorage;
56
+ return storage?.getItem(FUSION_RELOAD_MARKER) !== null;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ /**
63
+ * Live-install staleness probe (2026-10-18): the ONE call the client entry
64
+ * makes when the page does not carry the fusion flag. A flag-absent page is
65
+ * either a pre-install page (the client half hot-loaded into HTML served
66
+ * before the row mounted — the fresh-install case where the Hub row renders
67
+ * but every switch affordance stays hidden until a manual restart) or a
68
+ * deliberate flag-off deployment (T-E). The roster's additive `hub.fusion`
69
+ * answers which; on an armed host the page re-boots ONCE — the marker keeps
70
+ * a misbehaving host from ever looping the tab. Every failure mode (fetch
71
+ * error, non-OK, old host without the field) degrades to no action: the
72
+ * flag-off shapes below are the honest rendering of an unanswered probe.
73
+ *
74
+ * Fire-and-forget by contract — never throws, never blocks the apply.
75
+ */
76
+ export function probeStaleFusionPage() {
77
+ void (async () => {
78
+ let hubFusion;
79
+ try {
80
+ const response = await fetch('/api/agent-runtime', { credentials: 'same-origin' });
81
+ if (!response.ok)
82
+ return;
83
+ hubFusion = (await response.json()).hub?.fusion;
84
+ }
85
+ catch {
86
+ return;
87
+ }
88
+ if (fusionReloadDecision({ flagPresent: fusionPage(), markerPresent: staleReloadMarker(), hubFusion }) !== 'reload')
89
+ return;
90
+ try {
91
+ ;
92
+ globalThis.sessionStorage?.setItem(FUSION_RELOAD_MARKER, '1');
93
+ }
94
+ catch {
95
+ /* private-mode storage: reload without the marker (a truthful host re-arms the page, ending the flow) */
96
+ }
97
+ ;
98
+ globalThis.location?.reload();
99
+ })();
100
+ }
48
101
  /** The page atom, when the page carries one (it always does on a fusion page). */
49
102
  export function uiState() {
50
103
  const state = globalThis.__SUPERD_UI_STATE__;
@@ -3,8 +3,9 @@ import { createHash } from 'node:crypto';
3
3
  import { readFile, mkdir, unlink, rmdir } from 'node:fs/promises';
4
4
  import { join, dirname } from 'node:path';
5
5
  import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
6
- // done marks a CONFIRMED first use (Better observed installed). A receipt with attempted
7
- // but neither done nor error is an interrupted first use — e.g. killed by the restart
6
+ // done marks a CONFIRMED first use (Better observed installed); removed marks a
7
+ // migrated legacy removal record. A receipt with attempted but neither done,
8
+ // error, nor removed is an interrupted first use — e.g. killed by the restart
8
9
  // that installing Super itself triggers — and must re-arm, not suppress, the retry.
9
10
  const message = (error) => error instanceof Error ? error.message : String(error);
10
11
  async function optionalJson(path) {
@@ -48,9 +49,10 @@ export class BetterDashCompanion {
48
49
  return this.initialized ??= (async () => {
49
50
  const saved = await optionalJson(this.receipt);
50
51
  if (saved !== undefined) {
51
- // Never-resurrect applies only to a CONFIRMED first use or a recorded
52
- // failure; an interrupted first use (no done, no error) re-arms.
53
- this.intent = saved.attempted && saved.done !== true && saved.error === undefined
52
+ // Never-resurrect applies to a CONFIRMED first use, a recorded failure,
53
+ // or a migrated removal record; an interrupted first use (no done, no
54
+ // error, no removal marker) re-arms.
55
+ this.intent = saved.attempted && saved.done !== true && saved.error === undefined && saved.removed !== true
54
56
  ? { attempted: false } : saved;
55
57
  await this.cleanLegacy();
56
58
  return;
@@ -59,7 +61,7 @@ export class BetterDashCompanion {
59
61
  // Any old registration was first use. An absent Better now means removal,
60
62
  // including when Super has since changed version; never resurrect it.
61
63
  if (old?.['super-dsh:better-dsh'] !== undefined) {
62
- this.intent = { attempted: true };
64
+ this.intent = { attempted: true, removed: true };
63
65
  await this.save();
64
66
  await this.cleanLegacy();
65
67
  }
@@ -117,6 +117,13 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
117
117
  }>>, Schemastery.ObjectT<NoInfer<{
118
118
  enabled: z<boolean, boolean, "defined">;
119
119
  }>>, "plain">;
120
+ /**
121
+ * Whether the fusion row currently injects the transport script + flag
122
+ * global into served indexes. `false` before the first mount and after
123
+ * disposal — the roster GET answers with exactly this, and a stale page's
124
+ * client half reloads on `true` only (switch-core's `fusionReloadDecision`).
125
+ */
126
+ export declare function fusionTransportArmed(): boolean;
120
127
  /** Cordis plugin name. */
121
128
  export declare const name = "superd-fusion-transport";
122
129
  /**
@@ -132,8 +139,18 @@ export declare const inject: string[];
132
139
  * the {@link FUSION_FLAG_GLOBAL} baked global row (Task 4): one gate, both
133
140
  * halves.
134
141
  *
142
+ * Armed state: while the listener is registered the row publishes
143
+ * {@link fusionTransportArmed} = true (counter-owned, reset on effect
144
+ * disposal) — the one fact an already-open page needs to know that its
145
+ * stale, pre-install HTML cannot tell it. Consumed additively as
146
+ * `hub.fusion` on the roster GET (rpc.ts).
135
147
  * Off → the component registers nothing: no listener, no row, no script,
136
- * no flag global.
148
+ * no flag global. When ON, the row also publishes its armed state through
149
+ * {@link fusionTransportArmed}, which the roster RPC's additive `hub.fusion`
150
+ * field carries to already-open pages: their client half probes it and
151
+ * re-boots the page once (see switch-core's live-install staleness block —
152
+ * the served index predates the install, so only a re-serve can bake the
153
+ * flag + transport script).
137
154
  * @param ctx - host plugin context.
138
155
  * @param config - the row config (`enabled`, default on).
139
156
  */
@@ -356,6 +356,25 @@ export const FUSION_FLAG_GLOBAL = '__SUPERD_UI_FUSION__';
356
356
  export const Config = z.object({
357
357
  enabled: z.boolean().default(true),
358
358
  });
359
+ /**
360
+ * How many live index-inject listeners this row has registered (one per
361
+ * mounted component fiber). Module-level because the consumer (the roster
362
+ * RPC) is a DIFFERENT row in the same bundle: a cross-row import of live
363
+ * composition state, the same single-instance guarantee that lets the
364
+ * client half share the shell's module table. Reset by the listener
365
+ * effect's disposal, so a runtime row disable stops the advertisement
366
+ * immediately.
367
+ */
368
+ let armedRows = 0;
369
+ /**
370
+ * Whether the fusion row currently injects the transport script + flag
371
+ * global into served indexes. `false` before the first mount and after
372
+ * disposal — the roster GET answers with exactly this, and a stale page's
373
+ * client half reloads on `true` only (switch-core's `fusionReloadDecision`).
374
+ */
375
+ export function fusionTransportArmed() {
376
+ return armedRows > 0;
377
+ }
359
378
  /** Cordis plugin name. */
360
379
  export const name = 'superd-fusion-transport';
361
380
  /**
@@ -371,8 +390,18 @@ export const inject = [];
371
390
  * the {@link FUSION_FLAG_GLOBAL} baked global row (Task 4): one gate, both
372
391
  * halves.
373
392
  *
393
+ * Armed state: while the listener is registered the row publishes
394
+ * {@link fusionTransportArmed} = true (counter-owned, reset on effect
395
+ * disposal) — the one fact an already-open page needs to know that its
396
+ * stale, pre-install HTML cannot tell it. Consumed additively as
397
+ * `hub.fusion` on the roster GET (rpc.ts).
374
398
  * Off → the component registers nothing: no listener, no row, no script,
375
- * no flag global.
399
+ * no flag global. When ON, the row also publishes its armed state through
400
+ * {@link fusionTransportArmed}, which the roster RPC's additive `hub.fusion`
401
+ * field carries to already-open pages: their client half probes it and
402
+ * re-boots the page once (see switch-core's live-install staleness block —
403
+ * the served index predates the install, so only a re-serve can bake the
404
+ * flag + transport script).
376
405
  * @param ctx - host plugin context.
377
406
  * @param config - the row config (`enabled`, default on).
378
407
  */
@@ -382,10 +411,20 @@ export function apply(ctx, config) {
382
411
  if (config?.enabled === false)
383
412
  return;
384
413
  ctx.inject(['webServer'], () => {
385
- ctx.on('webserver/index-inject', (table) => {
386
- table.push({ kind: 'script', placement: 'head', text: renderFusionTransport() });
387
- table.push({ kind: 'global', name: FUSION_FLAG_GLOBAL, value: true });
388
- });
414
+ // The armed state rides the LISTENER's lifetime (an effect, not a bare
415
+ // mount): a live row disable/unload resets it, so the roster answer can
416
+ // never advertise fusion for a row that no longer injects anything.
417
+ ctx.effect(() => {
418
+ armedRows += 1;
419
+ const offInject = ctx.on('webserver/index-inject', (table) => {
420
+ table.push({ kind: 'script', placement: 'head', text: renderFusionTransport() });
421
+ table.push({ kind: 'global', name: FUSION_FLAG_GLOBAL, value: true });
422
+ });
423
+ return () => {
424
+ armedRows -= 1;
425
+ offInject();
426
+ };
427
+ }, 'superd-fusion-transport: index injection (armed state)');
389
428
  });
390
429
  }
391
430
  export default { name, inject, Config, apply };
@@ -90,7 +90,14 @@ export function apply(ctx, config) {
90
90
  real: real,
91
91
  authorize: (req, res) => connection.authorizeIndex(req, res),
92
92
  requestRejection: (req) => connection.requestRejection(req),
93
- gatewayOf: (label) => (federatedGatewayOf(label) ?? getTarget(label)?.gateway),
93
+ gatewayOf: (label) => {
94
+ const federated = federatedGatewayOf(label);
95
+ if (federated !== undefined)
96
+ return { key: label, gateway: federated };
97
+ // The stored face IS the service instance (canonical methods); targets.ts
98
+ // keeps a looser structural view for the mesh wire adapters.
99
+ return getTarget(label);
100
+ },
94
101
  }), 'superd-hub: /hub route family');
95
102
  // P4 §8.2: the PTY executor's upgrade route — same gate as the /hub family
96
103
  // (authorizeIndex cookie auth first, then the browser-trust fence). The
@@ -63,8 +63,11 @@ export interface HubRouteOptions {
63
63
  * answer, so even the demux 404s never speak to untrusted traffic.
64
64
  */
65
65
  requestRejection?(req: unknown): number | undefined;
66
- /** The live gateway of a registered label, or undefined (unknown / not yet spawned). */
67
- gatewayOf(label: string): CarrierGatewayFace | undefined;
66
+ /** The live target (gateway + owner peer) of a registered label, or undefined (unknown / not yet spawned). */
67
+ gatewayOf(label: string): {
68
+ gateway: CarrierGatewayFace;
69
+ peer?: unknown;
70
+ } | undefined;
68
71
  }
69
72
  /**
70
73
  * Register the family on ctx0's webServer.
@@ -130,15 +130,15 @@ export function registerHubRoute(options) {
130
130
  });
131
131
  return;
132
132
  }
133
- const gateway = options.gatewayOf(agent);
134
- if (gateway === undefined) {
133
+ const target = options.gatewayOf(agent);
134
+ if (target === undefined) {
135
135
  answer404(res, { error: 'unknown-agent', label: agent });
136
136
  return;
137
137
  }
138
138
  // Consume the family plumbing: the kernel forwards a native-shaped URL
139
139
  // (the kernel carries the same fence face as the carrier's own kernel).
140
140
  req.url = worldUrlOf(req.url);
141
- await forwardApiCall({ gateway, label: agent, mountPath: HUB_MOUNT_PATH, requestRejection: options.requestRejection }, req, res);
141
+ await forwardApiCall({ gateway: target.gateway, peer: target.peer, label: agent, mountPath: HUB_MOUNT_PATH, requestRejection: options.requestRejection }, req, res);
142
142
  };
143
143
  const handleUpgrade = (req, socket, head) => {
144
144
  if (!gateUpgrade(options, req, socket))
@@ -155,8 +155,8 @@ export function registerHubRoute(options) {
155
155
  rejectUpgrade(socket, 404, JSON.stringify({ error: 'foreign-only' }), 'application/json');
156
156
  return;
157
157
  }
158
- const gateway = options.gatewayOf(agent);
159
- if (gateway === undefined) {
158
+ const target = options.gatewayOf(agent);
159
+ if (target === undefined) {
160
160
  rejectUpgrade(socket, 404, JSON.stringify({ error: 'unknown-agent', label: agent }), 'application/json');
161
161
  return;
162
162
  }
@@ -165,7 +165,7 @@ export function registerHubRoute(options) {
165
165
  // a heartbeat interval and a per-connection WebSocketServer; on socket
166
166
  // close it must be CLOSED (world-mux clears the heartbeat only in close),
167
167
  // or every reconnect leaks the timer plus the mux graph forever.
168
- const mux = worldMuxOf(gateway);
168
+ const mux = worldMuxOf(target.gateway, target.peer);
169
169
  socket.once?.('close', () => {
170
170
  void mux.close().catch(() => undefined);
171
171
  });
@@ -37,6 +37,11 @@ export interface AgentRuntimeDeps {
37
37
  * ADDITIVE contract (mesh P1): the GET body gained `hub: {machineId, label,
38
38
  * version}` and every `agents` row gained `id` (a6); `available` and all
39
39
  * pre-existing agents fields are unchanged — old clients ignore the new
40
+ * fields, new clients read them. Live-install staleness (2026-10-18): the
41
+ * hub block also carries `fusion` — the fusion row's armed state — so a
42
+ * page that booted before super-dsh was installed can tell its own
43
+ * staleness apart from a flag-off deployment and reload once (switch-core
44
+ * `fusionReloadDecision`).
40
45
  * fields, new clients read them.
41
46
  * @param request - the incoming `/api/agent-runtime` request.
42
47
  * @param source - live runtime registry view.
@@ -3,6 +3,7 @@ import { PACK_VERSION } from './pack-version.js';
3
3
  import { listRuntimeKeys } from './targets.js';
4
4
  import { agentLinks } from './agent-roster.js';
5
5
  import { ensureSelf } from './machines.js';
6
+ import { fusionTransportArmed } from './fusion-transport.js';
6
7
  /** The shared-channel Fetch route path this RPC face owns. */
7
8
  export const AGENT_RUNTIME_RPC_PATH = '/api/agent-runtime';
8
9
  /** The live registry view: native plus every registered foreign context. */
@@ -27,6 +28,11 @@ function json(status, body) {
27
28
  * ADDITIVE contract (mesh P1): the GET body gained `hub: {machineId, label,
28
29
  * version}` and every `agents` row gained `id` (a6); `available` and all
29
30
  * pre-existing agents fields are unchanged — old clients ignore the new
31
+ * fields, new clients read them. Live-install staleness (2026-10-18): the
32
+ * hub block also carries `fusion` — the fusion row's armed state — so a
33
+ * page that booted before super-dsh was installed can tell its own
34
+ * staleness apart from a flag-off deployment and reload once (switch-core
35
+ * `fusionReloadDecision`).
30
36
  * fields, new clients read them.
31
37
  * @param request - the incoming `/api/agent-runtime` request.
32
38
  * @param source - live runtime registry view.
@@ -51,7 +57,20 @@ export async function handleAgentRuntime(request, source, deps) {
51
57
  const links = new URL(request.url).searchParams.get('mesh') === '1'
52
58
  ? agentLinks(home).filter((l) => l.machine === 'local')
53
59
  : agentLinks(home);
54
- return json(200, { available, agents: links, hub: { machineId: self.id, label: self.label, version: HUB_VERSION } });
60
+ return json(200, {
61
+ available,
62
+ agents: links,
63
+ hub: {
64
+ machineId: self.id,
65
+ label: self.label,
66
+ version: HUB_VERSION,
67
+ // Additive (live-install staleness): `true` exactly while the fusion
68
+ // row is armed — the fact a pre-install page cannot read off its own
69
+ // HTML. Peers' `?mesh=1` probes ignore it (same additivity rule as
70
+ // every hub-block field before it).
71
+ fusion: fusionTransportArmed(),
72
+ },
73
+ });
55
74
  }
56
75
  // A POST that used to switch a global selector has no meaning any more:
57
76
  // the addressed world is the mount path of the request, not a shared value.
@@ -9,6 +9,16 @@
9
9
  * roster-probe failure → revert to native + a visible toast, never a page
10
10
  * lock).
11
11
  *
12
+ * Live-install staleness (2026-10-18): the fusion flag global and the
13
+ * transport script are baked into the SERVED index, so a page that booted
14
+ * before super-dsh was installed runs the freshly hot-loaded client half
15
+ * against a stale page — `fusionPage()` reads false and every switch
16
+ * affordance stays hidden until a manual restart. {@link fusionReloadDecision}
17
+ * is the pure decision behind the client half's heal: one roster answer later,
18
+ * reload the page once. The transport must precede app boot (its upload hook
19
+ * `__DSH_FILE_UPLOAD__` is read at construction), so a client-side flag flip
20
+ * can never arm a stale page honestly — the page must be re-served.
21
+ *
12
22
  * Host-half on purpose: plain TS with every effect injected through
13
23
  * {@link SwitchHost} — no DOM, no client imports — so `test/client-switch.test.mjs`
14
24
  * imports it from `dist/` like every other host module. The client half
@@ -445,6 +455,37 @@ export interface SwitchAffordance {
445
455
  * Host-half pure logic so `test/client-switch.test.mjs` pins it from `dist/`.
446
456
  */
447
457
  export declare function switchAffordance(fusion: boolean): SwitchAffordance;
458
+ /** The sessionStorage marker guarding the stale-page reload: one per tab session. */
459
+ export declare const FUSION_RELOAD_MARKER = "superd.fusion-armed.reload";
460
+ /** What the stale-page probe decides (the client binding executes it). */
461
+ export type FusionReloadDecision =
462
+ /** Flag present — the page is armed; nothing to heal. */
463
+ 'armed'
464
+ /** The marker is set — this tab already reloaded once; never loop. */
465
+ | 'already-reloaded'
466
+ /** The host answered `hub.fusion: false` — the T-E flag-off shape; stay degraded. */
467
+ | 'row-off'
468
+ /** No usable answer (old host without the additive field, or the fetch failed) — stay degraded. */
469
+ | 'host-silent'
470
+ /** Stale page + an armed host — set the marker and reload once. */
471
+ | 'reload';
472
+ /** The inputs of one stale-page probe decision. */
473
+ export interface FusionReloadInput {
474
+ /** Whether the page carries the baked `__SUPERD_UI_FUSION__` flag global. */
475
+ flagPresent: boolean;
476
+ /** Whether {@link FUSION_RELOAD_MARKER} is already in sessionStorage. */
477
+ markerPresent: boolean;
478
+ /** The roster answer's additive `hub.fusion` field (undefined: absent). */
479
+ hubFusion?: unknown;
480
+ }
481
+ /**
482
+ * The live-install staleness decision (host-half pure so the node tests pin
483
+ * the table from `dist/`). The host's `hub.fusion` field is the ONLY thing
484
+ * that distinguishes a page-booted-before-install from a deliberately
485
+ * flag-off deployment — without it the reload would loop forever on every
486
+ * `enabled: false` composition (the client half loads there too).
487
+ */
488
+ export declare function fusionReloadDecision(input: FusionReloadInput): FusionReloadDecision;
448
489
  /**
449
490
  * Structural slice of one roster row the Hub UI helpers read (the client
450
491
  * roster's `AgentLink` shape mirrored structurally — this module never imports
@@ -9,6 +9,16 @@
9
9
  * roster-probe failure → revert to native + a visible toast, never a page
10
10
  * lock).
11
11
  *
12
+ * Live-install staleness (2026-10-18): the fusion flag global and the
13
+ * transport script are baked into the SERVED index, so a page that booted
14
+ * before super-dsh was installed runs the freshly hot-loaded client half
15
+ * against a stale page — `fusionPage()` reads false and every switch
16
+ * affordance stays hidden until a manual restart. {@link fusionReloadDecision}
17
+ * is the pure decision behind the client half's heal: one roster answer later,
18
+ * reload the page once. The transport must precede app boot (its upload hook
19
+ * `__DSH_FILE_UPLOAD__` is read at construction), so a client-side flag flip
20
+ * can never arm a stale page honestly — the page must be re-served.
21
+ *
12
22
  * Host-half on purpose: plain TS with every effect injected through
13
23
  * {@link SwitchHost} — no DOM, no client imports — so `test/client-switch.test.mjs`
14
24
  * imports it from `dist/` like every other host module. The client half
@@ -488,6 +498,36 @@ export const FUSION_OFF_HINT = 'fusion off — no foreign entry';
488
498
  export function switchAffordance(fusion) {
489
499
  return fusion ? { available: true, hint: '' } : { available: false, hint: FUSION_OFF_HINT };
490
500
  }
501
+ // ---- Live-install staleness: the once-per-session reload heal -------------
502
+ //
503
+ // The flag global is `webserver/index-inject` state: a page served BEFORE
504
+ // the fusion row mounted never carries it, while the client graph hot-update
505
+ // (dsh-client-hmr) still applies the client half to that page — the Hub row
506
+ // appears, the selector does not, and only a manual restart healed it. The
507
+ // heal re-boots the page ONCE (a sessionStorage marker keeps a host that
508
+ // reports fusion but never bakes the flag from looping the tab), because the
509
+ // transport script is boot-time by design: a stale page cannot be armed in
510
+ // place, only re-served.
511
+ /** The sessionStorage marker guarding the stale-page reload: one per tab session. */
512
+ export const FUSION_RELOAD_MARKER = 'superd.fusion-armed.reload';
513
+ /**
514
+ * The live-install staleness decision (host-half pure so the node tests pin
515
+ * the table from `dist/`). The host's `hub.fusion` field is the ONLY thing
516
+ * that distinguishes a page-booted-before-install from a deliberately
517
+ * flag-off deployment — without it the reload would loop forever on every
518
+ * `enabled: false` composition (the client half loads there too).
519
+ */
520
+ export function fusionReloadDecision(input) {
521
+ if (input.flagPresent)
522
+ return 'armed';
523
+ if (input.markerPresent)
524
+ return 'already-reloaded';
525
+ if (input.hubFusion === true)
526
+ return 'reload';
527
+ if (input.hubFusion === false)
528
+ return 'row-off';
529
+ return 'host-silent';
530
+ }
491
531
  /**
492
532
  * The active agent's display name for the Hub row's right-edge selector: the
493
533
  * roster label of the atom's `activeAgent` key — the name the selector's
@@ -22,6 +22,13 @@ export interface ForeignTarget {
22
22
  key: string;
23
23
  /** The context's typertGateway service instance (or a wire adapter with the same face). */
24
24
  gateway: GatewayFace;
25
+ /**
26
+ * The world's owner browser Peer (connection.browserPeer) for in-process
27
+ * faces — DSH 0.2.1 gateways read peer.signal/principal on every forwarded
28
+ * invocation, so the hub must present the world's shared owner identity.
29
+ * Wire adapters (mesh federation) omit it: the far end supplies its own.
30
+ */
31
+ peer?: unknown;
25
32
  }
26
33
  /** Register (or replace) the target for a runtime key. */
27
34
  export declare function registerForeignTarget(target: ForeignTarget): void;
@@ -48,7 +48,7 @@ export function ownWorld(ctx, entry, spawn, onFailure) {
48
48
  unregisterForeignTarget(key);
49
49
  setReady(key, false);
50
50
  }, `superd.world.${key}: availability`);
51
- target = { key, gateway: world.get('typertGateway') };
51
+ target = { key, gateway: world.get('typertGateway'), peer: world.get('connection')?.browserPeer };
52
52
  registerForeignTarget(target);
53
53
  setReady(key, true);
54
54
  return world;
@@ -898,6 +898,16 @@ function machineLabelsFrom(list) {
898
898
  * roster-probe failure → revert to native + a visible toast, never a page
899
899
  * lock).
900
900
  *
901
+ * Live-install staleness (2026-10-18): the fusion flag global and the
902
+ * transport script are baked into the SERVED index, so a page that booted
903
+ * before super-dsh was installed runs the freshly hot-loaded client half
904
+ * against a stale page — `fusionPage()` reads false and every switch
905
+ * affordance stays hidden until a manual restart. {@link fusionReloadDecision}
906
+ * is the pure decision behind the client half's heal: one roster answer later,
907
+ * reload the page once. The transport must precede app boot (its upload hook
908
+ * `__DSH_FILE_UPLOAD__` is read at construction), so a client-side flag flip
909
+ * can never arm a stale page honestly — the page must be re-served.
910
+ *
901
911
  * Host-half on purpose: plain TS with every effect injected through
902
912
  * {@link SwitchHost} — no DOM, no client imports — so `test/client-switch.test.mjs`
903
913
  * imports it from `dist/` like every other host module. The client half
@@ -1343,6 +1353,22 @@ function switchAffordance(fusion) {
1343
1353
  hint: FUSION_OFF_HINT
1344
1354
  };
1345
1355
  }
1356
+ /** The sessionStorage marker guarding the stale-page reload: one per tab session. */
1357
+ const FUSION_RELOAD_MARKER = "superd.fusion-armed.reload";
1358
+ /**
1359
+ * The live-install staleness decision (host-half pure so the node tests pin
1360
+ * the table from `dist/`). The host's `hub.fusion` field is the ONLY thing
1361
+ * that distinguishes a page-booted-before-install from a deliberately
1362
+ * flag-off deployment — without it the reload would loop forever on every
1363
+ * `enabled: false` composition (the client half loads there too).
1364
+ */
1365
+ function fusionReloadDecision(input) {
1366
+ if (input.flagPresent) return "armed";
1367
+ if (input.markerPresent) return "already-reloaded";
1368
+ if (input.hubFusion === true) return "reload";
1369
+ if (input.hubFusion === false) return "row-off";
1370
+ return "host-silent";
1371
+ }
1346
1372
  /**
1347
1373
  * The active agent's display name for the Hub row's right-edge selector: the
1348
1374
  * roster label of the atom's `activeAgent` key — the name the selector's
@@ -1621,6 +1647,53 @@ function createReadinessWaiter(faces, options = {}) {
1621
1647
  function fusionPage() {
1622
1648
  return globalThis.__SUPERD_UI_FUSION__ === true;
1623
1649
  }
1650
+ /**
1651
+ * The sessionStorage marker read (undefined-safe: a storage failure is
1652
+ * `false` — the probe then reloads without the loop guard, which only
1653
+ * matters for a host that reports fusion but never bakes the flag).
1654
+ */
1655
+ function staleReloadMarker() {
1656
+ try {
1657
+ return globalThis.sessionStorage?.getItem(FUSION_RELOAD_MARKER) !== null;
1658
+ } catch {
1659
+ return false;
1660
+ }
1661
+ }
1662
+ /**
1663
+ * Live-install staleness probe (2026-10-18): the ONE call the client entry
1664
+ * makes when the page does not carry the fusion flag. A flag-absent page is
1665
+ * either a pre-install page (the client half hot-loaded into HTML served
1666
+ * before the row mounted — the fresh-install case where the Hub row renders
1667
+ * but every switch affordance stays hidden until a manual restart) or a
1668
+ * deliberate flag-off deployment (T-E). The roster's additive `hub.fusion`
1669
+ * answers which; on an armed host the page re-boots ONCE — the marker keeps
1670
+ * a misbehaving host from ever looping the tab. Every failure mode (fetch
1671
+ * error, non-OK, old host without the field) degrades to no action: the
1672
+ * flag-off shapes below are the honest rendering of an unanswered probe.
1673
+ *
1674
+ * Fire-and-forget by contract — never throws, never blocks the apply.
1675
+ */
1676
+ function probeStaleFusionPage() {
1677
+ (async () => {
1678
+ let hubFusion;
1679
+ try {
1680
+ const response = await fetch("/api/agent-runtime", { credentials: "same-origin" });
1681
+ if (!response.ok) return;
1682
+ hubFusion = (await response.json()).hub?.fusion;
1683
+ } catch {
1684
+ return;
1685
+ }
1686
+ if (fusionReloadDecision({
1687
+ flagPresent: fusionPage(),
1688
+ markerPresent: staleReloadMarker(),
1689
+ hubFusion
1690
+ }) !== "reload") return;
1691
+ try {
1692
+ globalThis.sessionStorage?.setItem(FUSION_RELOAD_MARKER, "1");
1693
+ } catch {}
1694
+ globalThis.location?.reload();
1695
+ })();
1696
+ }
1624
1697
  /** The page atom, when the page carries one (it always does on a fusion page). */
1625
1698
  function uiState() {
1626
1699
  const state = globalThis.__SUPERD_UI_STATE__;
@@ -3880,6 +3953,38 @@ function HubPage() {
3880
3953
  });
3881
3954
  }
3882
3955
 
3956
+ //#endregion
3957
+ //#region src/client/pill-geometry.ts
3958
+ /**
3959
+ * Derive the scroll-invariant offsets from one same-frame measurement.
3960
+ * Measuring the DELTAS (not absolute coordinates) is the whole point: they
3961
+ * survive any scroll of any ancestor, so nothing needs to re-run while the
3962
+ * sidebar scrolls.
3963
+ */
3964
+ function measurePillBox(input) {
3965
+ return {
3966
+ dx: input.row.right - input.wrap.left,
3967
+ cy: (input.row.top + input.row.bottom) / 2 - input.wrap.top
3968
+ };
3969
+ }
3970
+ /** The row's own horizontal padding (native `.panelRow`): the selector's inset. */
3971
+ const PILL_ROW_INSET = 8;
3972
+ /**
3973
+ * The pill's placement, wrapper-relative: its right edge sits `inset` px
3974
+ * inside the row's right edge, vertically centered on the row
3975
+ * (`translateY(-50%)`). `selectorWidth` is the render-time pill width (the
3976
+ * mark is present only while an agent is active), so it stays a render
3977
+ * concern; the measured box is not.
3978
+ */
3979
+ function pillStyle(box, selectorWidth, inset = PILL_ROW_INSET) {
3980
+ return {
3981
+ position: "absolute",
3982
+ left: box.dx - inset - selectorWidth,
3983
+ top: box.cy,
3984
+ transform: "translateY(-50%)"
3985
+ };
3986
+ }
3987
+
3883
3988
  //#endregion
3884
3989
  //#region src/client/HubPanelIcon.tsx
3885
3990
  /** Runtime-mark edge inside the selector, and the chevron's own default edge. */
@@ -3893,8 +3998,6 @@ const CHEVRON_SIZE = 14;
3893
3998
  const SELECTOR_HEIGHT = 28;
3894
3999
  const SELECTOR_PAD = 8;
3895
4000
  const SELECTOR_GAP = 4;
3896
- /** The row's own horizontal padding (native `.panelRow`): the selector's inset. */
3897
- const ROW_INSET = 8;
3898
4001
  function HubPanelIcon({ size }) {
3899
4002
  const wide = size <= 16;
3900
4003
  const rootRef = react.useRef(null);
@@ -3917,16 +4020,25 @@ function HubPanelIcon({ size }) {
3917
4020
  }, [fusion]);
3918
4021
  const measure = react.useCallback(() => {
3919
4022
  const row = rootRef.current?.closest("button") ?? null;
3920
- if (row === null) {
4023
+ const wrap = selectorRef.current?.parentElement ?? null;
4024
+ if (row === null || wrap === null) {
3921
4025
  setBox(null);
3922
4026
  return;
3923
4027
  }
3924
- const rect = row.getBoundingClientRect();
3925
- setBox((prev) => prev !== null && prev.top === rect.top && prev.bottom === rect.bottom && prev.right === rect.right ? prev : {
3926
- top: rect.top,
3927
- bottom: rect.bottom,
3928
- right: rect.right
4028
+ const rowRect = row.getBoundingClientRect();
4029
+ const wrapRect = wrap.getBoundingClientRect();
4030
+ const next = measurePillBox({
4031
+ row: {
4032
+ top: rowRect.top,
4033
+ bottom: rowRect.bottom,
4034
+ right: rowRect.right
4035
+ },
4036
+ wrap: {
4037
+ left: wrapRect.left,
4038
+ top: wrapRect.top
4039
+ }
3929
4040
  });
4041
+ setBox((prev) => prev !== null && prev.dx === next.dx && prev.cy === next.cy ? prev : next);
3930
4042
  }, []);
3931
4043
  react.useLayoutEffect(() => {
3932
4044
  if (!wide || !switchUi.available) return;
@@ -4036,7 +4148,7 @@ function HubPanelIcon({ size }) {
4036
4148
  children: [
4037
4149
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HubGlyph, { size }),
4038
4150
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SuperdToastHost, {}),
4039
- wide && box !== null && switchUi.available && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Menu, {
4151
+ wide && switchUi.available && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Menu, {
4040
4152
  open,
4041
4153
  onClose: () => {
4042
4154
  setOpen(false);
@@ -4057,10 +4169,11 @@ function HubPanelIcon({ size }) {
4057
4169
  "aria-expanded": open,
4058
4170
  title: activeLabel === void 0 ? "Agent selector" : `Agent selector: ${activeLabel}`,
4059
4171
  style: {
4060
- position: "fixed",
4061
- left: box.right - ROW_INSET - selectorWidth,
4062
- top: box.top + (box.bottom - box.top) / 2,
4063
- transform: "translateY(-50%)",
4172
+ ...pillStyle(box ?? {
4173
+ dx: 0,
4174
+ cy: 0
4175
+ }, selectorWidth),
4176
+ visibility: box === null ? "hidden" : "visible",
4064
4177
  display: "inline-flex",
4065
4178
  alignItems: "center",
4066
4179
  justifyContent: "center",
@@ -4105,9 +4218,17 @@ const inject = ["slots"];
4105
4218
  * absent (a composition without the sidebar renders nothing — every seat
4106
4219
  * here is an optional occupant by contract).
4107
4220
  *
4108
- * @param ctx - client root context.
4221
+ * Live-install staleness: the fusion flag is baked into the SERVED index, so
4222
+ * a page that booted before super-dsh was installed carries no flag even
4223
+ * though this very apply is the hot-loaded client half — the Hub row would
4224
+ * render with every switch affordance hidden until a manual restart. When
4225
+ * the flag is absent the entry probes the roster's `hub.fusion` once and
4226
+ * re-boots the page on an armed host (switch.ts `probeStaleFusionPage`; the
4227
+ * sessionStorage marker bounds it to one reload per tab session). A flag-off
4228
+ * deployment answers `false` and keeps the T-E inert shape with no reload.
4109
4229
  */
4110
4230
  function apply(ctx) {
4231
+ if (!fusionPage()) probeStaleFusionPage();
4111
4232
  ctx.inject(["slots"], (raw) => {
4112
4233
  const scope = raw;
4113
4234
  scope.effect(() => scope.slots.inject("main", () => scope.slots.register({