@unoverse-platform/studio 0.1.36 → 0.1.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -315,7 +315,9 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
315
315
  if (!current) return;
316
316
  // Width from the def; height stays AUTO — the canvas node hugs the rendered
317
317
  // component's height (components flex width, own height).
318
- const size = (current as any).nodeSize ?? { width: 400 };
318
+ // 768 = tablet, this view's own default stage width: a component copied to the
319
+ // canvas lands at the width it was just designed at, not narrower.
320
+ const size = (current as any).nodeSize ?? { width: 768 };
319
321
  const node = {
320
322
  id: `${current.name.toLowerCase()}1`,
321
323
  type: "Component",
@@ -323,7 +325,11 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
323
325
  data: {
324
326
  label: current.name,
325
327
  status: "ready",
326
- config: { component: current.name, ...defaultsOf(current) },
328
+ // IDENTITY ONLY — config is what the AUTHOR wires, never a copy of the sample
329
+ // data. UIComponentNode layers props [schema defaults] < [executed] < [config], so
330
+ // anything written here outranks the live definition permanently: spreading the
331
+ // previews in froze them into the workflow at copy time.
332
+ config: { component: current.name },
327
333
  },
328
334
  style: { width: size.width },
329
335
  draggable: true,
@@ -30,8 +30,10 @@ export function NativeAppHost({
30
30
  userId: string;
31
31
  conversationId: string;
32
32
  /** The iframe app owns the ONLY live connection — it posts `unoverse:status`
33
- * (appEngine) and this relays it, so the host's badge can tell the truth. */
34
- onStatus?: (s: { ready: boolean; connected: boolean }) => void;
33
+ * (appEngine) and this relays it, so the host's badge can tell the truth.
34
+ * `refused` carries the server's terminal refusal text (register_session refused
35
+ * the session — public-entry gate, requires.role, stale token); undefined otherwise. */
36
+ onStatus?: (s: { ready: boolean; connected: boolean; refused?: string }) => void;
35
37
  }) {
36
38
  const [html, setHtml] = useState<string | null>(null);
37
39
  const [error, setError] = useState<string | null>(null);
@@ -44,7 +46,8 @@ export function NativeAppHost({
44
46
  const onSize = (e: MessageEvent) => {
45
47
  if (e.source !== frame.current?.contentWindow) return;
46
48
  if (e.data?.type === "unoverse:size" && e.data.width) setAppWidth(String(e.data.width));
47
- if (e.data?.type === "unoverse:status") onStatus?.({ ready: !!e.data.ready, connected: !!e.data.connected });
49
+ if (e.data?.type === "unoverse:status")
50
+ onStatus?.({ ready: !!e.data.ready, connected: !!e.data.connected, refused: typeof e.data.refused === "string" ? e.data.refused : undefined });
48
51
  };
49
52
  window.addEventListener("message", onSize);
50
53
  return () => window.removeEventListener("message", onSize);
@@ -208,7 +208,7 @@ export function TemplatesView({
208
208
  // connection and posts `unoverse:status` up; the in-page `conn` below is never
209
209
  // opened in native mode, so a badge reading it said "Offline" while the app
210
210
  // streamed (observed live). Native status wins whenever the iframe has reported.
211
- const [nativeStatus, setNativeStatus] = useState<{ ready: boolean; connected: boolean } | null>(null);
211
+ const [nativeStatus, setNativeStatus] = useState<{ ready: boolean; connected: boolean; refused?: string } | null>(null);
212
212
  useEffect(() => setNativeStatus(null), [selected, live]);
213
213
  // LIVE output inspector — the last submitted app output, projected through the
214
214
  // component's declared `outputs` (the same object the held tool call returns to
@@ -774,11 +774,13 @@ export function TemplatesView({
774
774
  style={{ borderColor: theme.color["border.subtle"], color: theme.color["text.secondary"] }}
775
775
  title="Live connection settings"
776
776
  >
777
+ {/* Refused wins over connected: the transport is up, but the server has
778
+ terminally refused the session — "Connecting…" would be a lie. */}
777
779
  <span
778
780
  className="inline-block h-2 w-2 shrink-0 rounded-full"
779
- style={{ background: (nativeStatus?.ready ?? conn.isReady) ? "#22c55e" : (nativeStatus?.connected ?? conn.isConnected) ? "#eab308" : "#9ca3af" }}
781
+ style={{ background: nativeStatus?.refused ? "#ef4444" : (nativeStatus?.ready ?? conn.isReady) ? "#22c55e" : (nativeStatus?.connected ?? conn.isConnected) ? "#eab308" : "#9ca3af" }}
780
782
  />
781
- <span className="font-medium">{(nativeStatus?.ready ?? conn.isReady) ? "Connected" : (nativeStatus?.connected ?? conn.isConnected) ? "Connecting…" : "Offline"}</span>
783
+ <span className="font-medium">{nativeStatus?.refused ? "Refused" : (nativeStatus?.ready ?? conn.isReady) ? "Connected" : (nativeStatus?.connected ?? conn.isConnected) ? "Connecting…" : "Offline"}</span>
782
784
  {binding?.workflow && <span className="font-mono text-[10px] opacity-60">· {binding.workflow}</span>}
783
785
  <span className="opacity-60">▾</span>
784
786
  </button>
@@ -791,10 +793,17 @@ export function TemplatesView({
791
793
  >
792
794
  <div className="mb-2 flex items-center justify-between">
793
795
  <span className="text-[10px] font-semibold uppercase tracking-wider opacity-60">Live connection</span>
794
- <span className="text-[10px]" style={{ color: (nativeStatus?.ready ?? conn.isReady) ? "#22c55e" : (nativeStatus?.connected ?? conn.isConnected) ? "#eab308" : theme.color["text.tertiary"] }}>
795
- {(nativeStatus?.ready ?? conn.isReady) ? "ready" : (nativeStatus?.connected ?? conn.isConnected) ? "connecting…" : "offline"}
796
+ <span className="text-[10px]" style={{ color: nativeStatus?.refused ? "#ef4444" : (nativeStatus?.ready ?? conn.isReady) ? "#22c55e" : (nativeStatus?.connected ?? conn.isConnected) ? "#eab308" : theme.color["text.tertiary"] }}>
797
+ {nativeStatus?.refused ? "refused" : (nativeStatus?.ready ?? conn.isReady) ? "ready" : (nativeStatus?.connected ?? conn.isConnected) ? "connecting…" : "offline"}
796
798
  </span>
797
799
  </div>
800
+ {nativeStatus?.refused && (
801
+ /* The server's own refusal text, verbatim — it already names the fix
802
+ (sign in again, toggle the trigger public, …). */
803
+ <div className="mb-2 rounded border px-2 py-1.5 text-[11px] leading-snug" style={{ borderColor: "#ef4444", color: "#ef4444" }}>
804
+ {nativeStatus.refused}
805
+ </div>
806
+ )}
798
807
  {/* Read-only: the app decides. Server is environment; Workflow + Trigger
799
808
  come from the selected app's manifest binding (no manual entry). */}
800
809
  {(
@@ -55,6 +55,19 @@ export function AppEngine(cfg: AppConfig) {
55
55
  [serverUrl],
56
56
  );
57
57
  const [binding, setBinding] = useState<{ workflow?: string; trigger?: string; service?: string }>({});
58
+ // RENDER-TIME reset (React's derived-state-from-props pattern), not an effect: on an
59
+ // app switch the connection effect and a reset effect share one commit, and the
60
+ // connection runs first — so an effect-based reset still let one session open with the
61
+ // OLD app's trigger against the NEW app's conversation before correcting (observed
62
+ // live 2026-08-16, twice: first with no reset, then with the reset as an effect).
63
+ // Setting state during render makes React re-render BEFORE the commit, so the
64
+ // connection effect only ever sees either the old app intact or an empty binding —
65
+ // never the cross-app hybrid.
66
+ const [boundFor, setBoundFor] = useState(templateId);
67
+ if (boundFor !== templateId) {
68
+ setBoundFor(templateId);
69
+ setBinding({});
70
+ }
58
71
  // STATE-OWNED WIDTH (rx data): the layout root's `appWidth` = the core surface's
59
72
  // constant width; an occupied panel slot's `appWidth` adds to it (computeAppWidth).
60
73
  // No declared width → the host keeps its own default. There is no other path.
@@ -68,11 +81,22 @@ export function AppEngine(cfg: AppConfig) {
68
81
  const [def, setDef] = useState<UnoverseDefinition | null>(null);
69
82
  const lastWidthRef = useRef<string | undefined>(undefined);
70
83
  // Named app sizes resolve via the served theme (theme.appSize), like every token.
71
- const theme = useUnoverseTheme(client, def ? (def.org ? `${def.org}/light` : "light") : null);
84
+ // The theme's name is the template's org. When templateId already carries it
85
+ // ("org/name" — every production boot), start the theme read IMMEDIATELY, in
86
+ // parallel with the template read; waiting for the definition just to learn an org
87
+ // we were handed serialized two round trips for nothing. Bare ids (dev) still wait.
88
+ const orgFromId = templateId.includes("/") ? templateId.split("/")[0] : null;
89
+ const theme = useUnoverseTheme(
90
+ client,
91
+ orgFromId ? `${orgFromId}/light` : def ? (def.org ? `${def.org}/light` : "light") : null,
92
+ );
72
93
 
73
94
  // The app owns which workflow it runs — read it from its manifest (§4b), not from config.
74
95
  useEffect(() => {
75
96
  let alive = true;
97
+ // The stale-binding reset lives at RENDER time (boundFor, above) — resetting here,
98
+ // in an effect, ran too late: the connection effect had already opened a session on
99
+ // the previous app's trigger within the same commit.
76
100
  client
77
101
  .connect()
78
102
  .then(() => (client as any).client.readResource({ uri: `unoverse://apps/${templateId.toLowerCase()}` }))
@@ -119,6 +143,17 @@ export function AppEngine(cfg: AppConfig) {
119
143
  }, [surface, conversationId, userId, analyticsDebug]);
120
144
 
121
145
  useEffect(() => {
146
+ // A NEW APP RE-ANNOUNCES ITS WIDTH, ALWAYS. The native host keeps ONE iframe for every
147
+ // app (its srcdoc is keyed on the server, not the app — it just re-posts
148
+ // `unoverse:config` with a new templateId) and this engine is re-rendered in place, so
149
+ // the change-guard below outlives the switch — while the host RESETS its frame to the
150
+ // default on it (NativeAppHost/TemplatesView). Two apps of the same declared width is
151
+ // the ordinary case (one org, one `chat` size), and the incoming app then reported
152
+ // nothing: its frame sat at the host default (100%) while its core panel held its
153
+ // declared width, leaving a dead band beside it (observed live 2026-08-13,
154
+ // bpp-voice-layout). Clearing the guard here makes the first report after an app
155
+ // switch unconditional; the guard still suppresses repeats within one app.
156
+ lastWidthRef.current = undefined;
122
157
  const report = () => {
123
158
  // NAME-SYNC: width is computed off the ACTIVE layout (multi-layout templates
124
159
  // swap whole arrangements); single-layout defs resolve to their root.
@@ -154,8 +189,10 @@ export function AppEngine(cfg: AppConfig) {
154
189
  // reads "offline" forever while this session streams (observed live). Report the
155
190
  // truth the same way size is reported; hosts that don't listen lose nothing.
156
191
  useEffect(() => {
157
- postToEmbedHost({ type: "unoverse:status", ready: !!conn.isReady, connected: !!conn.isConnected });
158
- }, [conn.isReady, conn.isConnected]);
192
+ // `refused` carries the server's terminal refusal text (register_session isError) —
193
+ // the one state a "Connecting…" badge must not sit on forever.
194
+ postToEmbedHost({ type: "unoverse:status", ready: !!conn.isReady, connected: !!conn.isConnected, refused: conn.refusal ?? undefined });
195
+ }, [conn.isReady, conn.isConnected, conn.refusal]);
159
196
 
160
197
  // VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
161
198
  // in the shared renderer (StreamedUnoverseTemplate), identical to Studio. The host only
@@ -45,6 +45,12 @@ export interface UnoverseSessionParams {
45
45
  export interface UnoverseConnection {
46
46
  isConnected: boolean;
47
47
  isReady: boolean;
48
+ /** The server's refusal text when register_session answered isError (public-entry
49
+ * gate, requires.role, stale token) — null while unrefused. A refusal is TERMINAL
50
+ * for the attempt (the server has answered; retrying the same identity loops), so
51
+ * hosts must surface this instead of showing "connecting" forever. Cleared when a
52
+ * new connect sequence starts. */
53
+ refusal: string | null;
48
54
  /** Composer target: optimistic user turn + REST workflow trigger. */
49
55
  sendMessage: (text: string) => void;
50
56
  /** Fire the bound workflow WITHOUT a user turn — the "load → run the workflow" ping
@@ -112,6 +118,7 @@ export function useUnoverseConnection(
112
118
 
113
119
  const [isConnected, setIsConnected] = useState(false);
114
120
  const [isReady, setIsReady] = useState(false);
121
+ const [refusal, setRefusal] = useState<string | null>(null);
115
122
  const clientRef = useRef<Client | null>(null);
116
123
  // First-seen component keys for this connection (repeat INIT → props merge).
117
124
  const seenRef = useRef<Set<string>>(new Set());
@@ -128,62 +135,108 @@ export function useUnoverseConnection(
128
135
  return;
129
136
  }
130
137
  let cancelled = false;
138
+ let attempt = 0;
139
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
140
+ // Kept ACROSS reconnects on purpose: after a re-bind the server may re-push
141
+ // components this session already placed, and a repeat INIT on a seen key merges
142
+ // props instead of re-placing (applyServerMessage) — the screen never jumps.
131
143
  seenRef.current = new Set();
132
144
 
133
- // Declare the ELICITATION capability (form mode) — the native collection path
134
- // (UNOVERSE_MCP_TEMPLATE_PROTOCOL §3.3): the server pauses inside an app tool
135
- // call and asks THIS session for the user's structured input. The interactive
136
- // component already on screen IS the form; its `submit` answers the elicitation.
137
- const client = new Client(
138
- { name: "unoverse-channel", version: "0.0.1" },
139
- { capabilities: { elicitation: {} }, jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
140
- );
141
- client.setRequestHandler(ElicitRequestSchema, async (req) => {
142
- // Pend until the user submits the component (the channel calls resolveElicitation
143
- // from its submit branch). The branded UI renders the request — never a generic form.
144
- // The accept content MUST match the requestedSchema (spec: flat primitives only) —
145
- // project the submitted component state to the REQUESTED keys, or the SDK rejects
146
- // the response (view-state like a `courses` array fails the primitive union).
147
- const requested =
148
- (req.params as unknown as { requestedSchema?: { properties?: Record<string, unknown> } }).requestedSchema?.properties ?? {};
149
- return await new Promise((resolve) => {
150
- pendingElicitRef.current = (state: Record<string, unknown>) => {
151
- pendingElicitRef.current = null;
152
- const content: Record<string, unknown> = {};
153
- for (const k of Object.keys(requested)) if (state[k] !== undefined) content[k] = state[k];
154
- resolve({ action: "accept", content } as never);
155
- };
145
+ /**
146
+ * SESSION-DEATH RECOVERY (the MCP spec's own rule): a session id the server no
147
+ * longer knows answers 404, and the client MUST start a NEW session rather than
148
+ * mourn the old one. Sessions are process memory server-side, so every restart —
149
+ * a dev edit, a prod deploy — orphans every open channel; before this, the error
150
+ * was logged once ("stream connect failed" / "client error") and the surface sat
151
+ * disconnected forever. Now ANY client error recycles the whole client and
152
+ * re-runs the connect sequence with backoff: register_session is idempotent, a
153
+ * re-bind emits SESSION_READY, and mount-reads re-hydrate state, so downstream
154
+ * already copes. Blunt on purpose — a transport error we fail to recognise as
155
+ * session death costs one spurious reconnect, while one we miss is a dead screen.
156
+ */
157
+ const recycle = (dead: Client, why: unknown) => {
158
+ // Only the CURRENT client may trigger recovery: a dying client's teardown fires
159
+ // stray errors (observed: four AbortErrors in one restart) and each would
160
+ // otherwise schedule its own storm of reconnects.
161
+ if (cancelled || clientRef.current !== dead) return;
162
+ clientRef.current = null;
163
+ void dead.close().catch(() => {});
164
+ setIsConnected(false);
165
+ setIsReady(false);
166
+ const delay = Math.min(15_000, 500 * 2 ** Math.min(attempt++, 5));
167
+ console.warn(`[unoverse] session lost — reconnecting in ${delay}ms (attempt ${attempt})`, why);
168
+ retryTimer = setTimeout(() => void connect(), delay);
169
+ };
170
+
171
+ const connect = async () => {
172
+ if (cancelled) return;
173
+ // Declare the ELICITATION capability (form mode) — the native collection path
174
+ // (UNOVERSE_MCP_TEMPLATE_PROTOCOL §3.3): the server pauses inside an app tool
175
+ // call and asks THIS session for the user's structured input. The interactive
176
+ // component already on screen IS the form; its `submit` answers the elicitation.
177
+ const client = new Client(
178
+ { name: "unoverse-channel", version: "0.0.1" },
179
+ { capabilities: { elicitation: {} }, jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
180
+ );
181
+ client.setRequestHandler(ElicitRequestSchema, async (req) => {
182
+ // Pend until the user submits the component (the channel calls resolveElicitation
183
+ // from its submit branch). The branded UI renders the request — never a generic form.
184
+ // The accept content MUST match the requestedSchema (spec: flat primitives only) —
185
+ // project the submitted component state to the REQUESTED keys, or the SDK rejects
186
+ // the response (view-state like a `courses` array fails the primitive union).
187
+ const requested =
188
+ (req.params as unknown as { requestedSchema?: { properties?: Record<string, unknown> } }).requestedSchema?.properties ?? {};
189
+ return await new Promise((resolve) => {
190
+ pendingElicitRef.current = (state: Record<string, unknown>) => {
191
+ pendingElicitRef.current = null;
192
+ const content: Record<string, unknown> = {};
193
+ for (const k of Object.keys(requested)) if (state[k] !== undefined) content[k] = state[k];
194
+ resolve({ action: "accept", content } as never);
195
+ };
196
+ });
156
197
  });
157
- });
158
- // The SDK fire-and-forgets the standalone GET SSE open on `initialized`; a failure
159
- // lands ONLY here. Unset, a dead inbound stream is invisible — no events, no error.
160
- client.onerror = (e) => console.error("[unoverse] client error:", e);
161
- clientRef.current = client;
198
+ // The SDK fire-and-forgets the standalone GET SSE open on `initialized`; a failure
199
+ // lands ONLY here. This is also where a dead session surfaces mid-life (the SDK's
200
+ // own GET reconnect meets the restarted server's 404) — so it feeds recovery.
201
+ client.onerror = (e) => {
202
+ // TEARDOWN NOISE, not an error: closing a client aborts its SSE streams and the
203
+ // SDK reports every abort here. When the close was OURS (effect cleanup on an
204
+ // app switch, or a recycle that already replaced this client), a red error log
205
+ // reads as breakage — it made a clean app switch look like a failure (observed
206
+ // live 2026-08-16). Quiet log, no recovery; a live client's errors still recycle.
207
+ if (cancelled || clientRef.current !== client) {
208
+ // eslint-disable-next-line no-console
209
+ console.debug("[unoverse] client closed:", (e as Error)?.message ?? e);
210
+ return;
211
+ }
212
+ console.error("[unoverse] client error:", e);
213
+ recycle(client, e);
214
+ };
215
+ clientRef.current = client;
162
216
 
163
- // The server pushes the live stream as `notifications/unoverse/event` over the
164
- // GET SSE channel. Custom notification methods land on the fallback handler —
165
- // route their `params` (one ServerMessage) into the store.
166
- client.fallbackNotificationHandler = async (notification) => {
167
- if (notification.method !== UNOVERSE_EVENT) return;
168
- const msg = (notification.params ?? {}) as unknown as ServerMessage;
169
- if ((msg as { type?: string }).type === "SESSION_READY") {
170
- setIsReady(true);
171
- return;
172
- }
173
- // A manifest-declared analytics moment, resolved SERVER-side beside the lifecycle
174
- // hook that shares it (componentLifecycle.ts). The SDK adds nothing and decides
175
- // nothing here: it hands the event to the same sink every node-attached emit uses,
176
- // and the host realm delivers or drops it. News, never instruction — it does not
177
- // touch the store.
178
- const evt = msg as unknown as { type?: string; event?: string; params?: Record<string, unknown> };
179
- if (evt.type === "ANALYTICS_EVENT") {
180
- if (typeof evt.event === "string" && evt.event) emitAnalytics(evt.event, evt.params ?? {});
181
- return;
182
- }
183
- applyServerMessage(store, msg, seenRef.current);
184
- };
217
+ // The server pushes the live stream as `notifications/unoverse/event` over the
218
+ // GET SSE channel. Custom notification methods land on the fallback handler —
219
+ // route their `params` (one ServerMessage) into the store.
220
+ client.fallbackNotificationHandler = async (notification) => {
221
+ if (notification.method !== UNOVERSE_EVENT) return;
222
+ const msg = (notification.params ?? {}) as unknown as ServerMessage;
223
+ if ((msg as { type?: string }).type === "SESSION_READY") {
224
+ setIsReady(true);
225
+ return;
226
+ }
227
+ // A manifest-declared analytics moment, resolved SERVER-side beside the lifecycle
228
+ // hook that shares it (componentLifecycle.ts). The SDK adds nothing and decides
229
+ // nothing here: it hands the event to the same sink every node-attached emit uses,
230
+ // and the host realm delivers or drops it. News, never instruction — it does not
231
+ // touch the store.
232
+ const evt = msg as unknown as { type?: string; event?: string; params?: Record<string, unknown> };
233
+ if (evt.type === "ANALYTICS_EVENT") {
234
+ if (typeof evt.event === "string" && evt.event) emitAnalytics(evt.event, evt.params ?? {});
235
+ return;
236
+ }
237
+ applyServerMessage(store, msg, seenRef.current);
238
+ };
185
239
 
186
- (async () => {
187
240
  // Inject the bearer on every underlying request (fresh per call → refresh-safe).
188
241
  const authFetch = getAccessToken
189
242
  ? async (url: string | URL, init?: RequestInit): Promise<Response> => {
@@ -199,11 +252,12 @@ export function useUnoverseConnection(
199
252
  authFetch ? { fetch: authFetch } : undefined,
200
253
  );
201
254
  await client.connect(transport);
202
- if (cancelled) {
255
+ if (cancelled || clientRef.current !== client) {
203
256
  await client.close().catch(() => {});
204
257
  return;
205
258
  }
206
259
  setIsConnected(true);
260
+ setRefusal(null); // a fresh attempt owns a fresh verdict
207
261
  // Bind this stream to the session — the server then pushes the live component
208
262
  // stream and an immediate SESSION_READY.
209
263
  const bind = await client.callTool({
@@ -211,21 +265,35 @@ export function useUnoverseConnection(
211
265
  arguments: { userId, conversationId, workflowId, targetTriggerNode, chatId },
212
266
  });
213
267
  // A refusal (public-entry gate, requires.role, …) is an isError tool RESULT,
214
- // not a thrown error — surface it, or the session is an eternal silent
215
- // "Connecting…" (the server logs the same refusal as REFUSED:).
268
+ // not a thrown error — surface it and STOP, never retry: the server has
269
+ // answered, and re-asking with the same identity is a polite way to loop
270
+ // (the server logs the same refusal as REFUSED:). The reason is kept as state,
271
+ // not just logged: a console.error alone left the host chip on "Connecting…"
272
+ // forever with the cause visible to nobody (observed live 2026-08-16).
216
273
  if ((bind as { isError?: boolean }).isError) {
217
274
  const c = (bind as { content?: Array<{ type?: string; text?: string }> }).content;
218
- console.error("[unoverse] session refused:", c?.[0]?.text ?? "unauthorized");
275
+ const reason = c?.[0]?.text ?? "unauthorized";
276
+ console.error("[unoverse] session refused:", reason);
277
+ setRefusal(reason);
278
+ return;
219
279
  }
280
+ // A completed re-bind is health — the next death starts its backoff fresh.
281
+ attempt = 0;
220
282
  } catch (e) {
221
- if (!cancelled) console.error("[unoverse] stream connect failed", e);
283
+ if (cancelled) return;
284
+ console.error("[unoverse] stream connect failed", e);
285
+ recycle(client, e);
222
286
  }
223
- })();
287
+ };
288
+
289
+ void connect();
224
290
 
225
291
  return () => {
226
292
  cancelled = true;
293
+ if (retryTimer !== null) clearTimeout(retryTimer);
227
294
  setIsConnected(false);
228
295
  setIsReady(false);
296
+ setRefusal(null);
229
297
  void clientRef.current?.close().catch(() => {});
230
298
  clientRef.current = null;
231
299
  };
@@ -357,5 +425,5 @@ export function useUnoverseConnection(
357
425
  return true;
358
426
  }, []);
359
427
 
360
- return { isConnected, isReady, sendMessage, trigger, sendAction, resolveElicitation, activeTemplate };
428
+ return { isConnected, isReady, refusal, sendMessage, trigger, sendAction, resolveElicitation, activeTemplate };
361
429
  }