@unoverse-platform/studio 0.1.97 → 0.1.99

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 (120) hide show
  1. package/bin/studio.mjs +2 -2
  2. package/local/catalog.ts +5 -1
  3. package/local/nodes.ts +28 -41
  4. package/local/plugin.ts +71 -13
  5. package/local/sample-identity/brand.yaml +24 -0
  6. package/local/sample-identity/organisation.yaml +14 -0
  7. package/local/sample-identity/purpose.yaml +14 -0
  8. package/local/sample-identity/story.yaml +18 -0
  9. package/local/scaffold.mjs +43 -23
  10. package/local/source.ts +3 -8
  11. package/package.json +1 -1
  12. package/screens/AgentsView.tsx +173 -0
  13. package/screens/ComponentsView.tsx +2 -2
  14. package/screens/EmptyLibrary.tsx +1 -1
  15. package/screens/IdentityView.tsx +3 -3
  16. package/screens/PromptBlocksView.tsx +1 -1
  17. package/screens/TemplatesLibraryView.tsx +91 -6
  18. package/screens/TemplatesView.tsx +24 -9
  19. package/screens/index.ts +2 -0
  20. package/src/App.tsx +8 -3
  21. package/src/host.ts +1 -14
  22. package/vendor/base/src/agent-mcp/discovery.ts +76 -18
  23. package/vendor/base/src/agent-mcp/handoff.ts +11 -31
  24. package/vendor/base/src/agent-mcp/index.ts +5 -5
  25. package/vendor/base/src/agent-mcp/invoke.ts +37 -19
  26. package/vendor/base/src/agent-mcp/lean.ts +19 -0
  27. package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
  28. package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
  29. package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
  30. package/vendor/base/src/agent-mcp/tools.ts +136 -7
  31. package/vendor/base/src/agent-mcp/types.ts +47 -10
  32. package/vendor/base/src/auth/auth.ts +35 -5
  33. package/vendor/base/src/definitions/isolated.ts +37 -0
  34. package/vendor/base/src/definitions/load/compile/component.ts +59 -4
  35. package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
  36. package/vendor/base/src/definitions/load/compile/template.ts +19 -15
  37. package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
  38. package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
  39. package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
  40. package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
  41. package/vendor/base/src/definitions/load/load.ts +37 -28
  42. package/vendor/base/src/definitions/model.ts +8 -12
  43. package/vendor/base/src/definitions/registry/agents.ts +117 -0
  44. package/vendor/base/src/definitions/registry/catalog.ts +40 -24
  45. package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
  46. package/vendor/base/src/definitions/registry/homes.ts +69 -5
  47. package/vendor/base/src/definitions/registry/objects.ts +413 -0
  48. package/vendor/base/src/definitions/registry/orgDocuments.ts +69 -29
  49. package/vendor/base/src/definitions/registry/orgPack.ts +11 -11
  50. package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
  51. package/vendor/base/src/definitions/theme.ts +1 -2
  52. package/vendor/base/src/items/collect.ts +29 -25
  53. package/vendor/base/src/items/publish.ts +30 -6
  54. package/vendor/base/src/lint/context.mjs +17 -0
  55. package/vendor/base/src/lint/design/file.mjs +199 -2
  56. package/vendor/base/src/lint/design/index.mjs +66 -73
  57. package/vendor/base/src/lint/design/vocabulary.mjs +5 -2
  58. package/vendor/base/src/lint/design/walk.mjs +14 -4
  59. package/vendor/base/src/lint/index.mjs +15 -9
  60. package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
  61. package/vendor/base/src/lint/nodes/index.mjs +5 -0
  62. package/vendor/base/src/lint/nodes/node.mjs +157 -7
  63. package/vendor/base/src/lint/objects/index.d.mts +32 -0
  64. package/vendor/base/src/lint/objects/index.mjs +324 -0
  65. package/vendor/base/src/lint/objects/rules.d.mts +44 -0
  66. package/vendor/base/src/lint/objects/rules.mjs +446 -0
  67. package/vendor/base/src/lint/prompts/index.d.mts +34 -0
  68. package/vendor/base/src/lint/prompts/index.mjs +198 -0
  69. package/vendor/base/src/manifests/compose.ts +80 -9
  70. package/vendor/base/src/manifests/executor/callback.ts +15 -4
  71. package/vendor/base/src/manifests/executor/context.ts +9 -1
  72. package/vendor/base/src/manifests/executor/memory.ts +83 -3
  73. package/vendor/base/src/manifests/executor/session.ts +50 -0
  74. package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
  75. package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
  76. package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
  77. package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
  78. package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
  79. package/vendor/base/src/manifests/runtime/context.ts +18 -6
  80. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
  81. package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
  82. package/vendor/base/src/manifests/runtime/events.ts +39 -3
  83. package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
  84. package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
  85. package/vendor/base/src/manifests/runtime/index.ts +3 -2
  86. package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
  87. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
  88. package/vendor/base/src/manifests/runtime/usage.ts +10 -0
  89. package/vendor/base/src/paths.ts +16 -8
  90. package/vendor/base/src/platform/audioManager.ts +32 -0
  91. package/vendor/base/src/platform/executionContext.ts +14 -0
  92. package/vendor/base/src/platform/liveInput.ts +52 -0
  93. package/vendor/base/src/plugins/discovery.ts +1 -1
  94. package/vendor/base/src/template/SafeExpression.ts +6 -0
  95. package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
  96. package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
  97. package/vendor/sdk/appEngine.tsx +30 -11
  98. package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
  99. package/vendor/sdk/lib/app.tsx +70 -95
  100. package/vendor/sdk/lib/connection.tsx +47 -57
  101. package/vendor/sdk/lib/core/actions.ts +6 -0
  102. package/vendor/sdk/lib/core/apps.ts +66 -81
  103. package/vendor/sdk/lib/core/client.ts +5 -14
  104. package/vendor/sdk/lib/core/connection.ts +30 -0
  105. package/vendor/sdk/lib/core/index.ts +2 -2
  106. package/vendor/sdk/lib/core/store.ts +90 -15
  107. package/vendor/sdk/lib/core/types.ts +9 -8
  108. package/vendor/sdk/lib/primitives.tsx +113 -4
  109. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
  110. package/vendor/sdk/lib/render.tsx +3 -3
  111. package/vendor/sdk/lib/voice.tsx +27 -13
  112. package/local/sample-identity/brand/brand.yaml +0 -88
  113. package/local/sample-identity/brand/manifest.yaml +0 -13
  114. package/local/sample-identity/organisation/manifest.yaml +0 -15
  115. package/local/sample-identity/organisation/organisation.yaml +0 -53
  116. package/local/sample-identity/purpose/manifest.yaml +0 -12
  117. package/local/sample-identity/purpose/purpose.yaml +0 -46
  118. package/local/sample-identity/story/manifest.yaml +0 -12
  119. package/local/sample-identity/story/story.yaml +0 -63
  120. package/vendor/base/src/definitions/registry/identitySeed.ts +0 -17
@@ -40,6 +40,9 @@ export interface UnoverseSessionParams {
40
40
  conversationId: string;
41
41
  /** Groups one request→response turn. Defaults to conversationId if omitted. */
42
42
  chatId?: string;
43
+ /** The app this session shows (`<org>/<name>`). The server draws an app that declares places
44
+ * from its screen document (docs/unoverse/design/screen/UNOVERSE_SCREEN.md). */
45
+ appId?: string;
43
46
  }
44
47
 
45
48
  export interface UnoverseConnection {
@@ -63,7 +66,7 @@ export interface UnoverseConnection {
63
66
  /** Answer a pending ELICITATION with the submitted component state (the native
64
67
  * collection path — §3.3). Returns false when no elicitation is pending (the
65
68
  * channel then falls back to `sendAction`). */
66
- resolveElicitation: (state: Record<string, unknown>) => boolean;
69
+ resolveElicitation: (state: Record<string, unknown>, action?: "accept" | "cancel") => boolean;
67
70
  /** The workflow-selected template (MCP app) name, or null. The channel renders
68
71
  * `unoverse://apps/{activeApp}/definition` when set — the official resources/read load. */
69
72
  activeApp: string | null;
@@ -83,7 +86,7 @@ const UNOVERSE_EVENT = "notifications/unoverse/event";
83
86
  * one visitor); a host with a token is untouched.
84
87
  */
85
88
  const GUEST_KEY = "unoverse:guestId";
86
- function resolveGuestId(): string {
89
+ export function resolveGuestId(): string {
87
90
  let id: string | null = null;
88
91
  try {
89
92
  id = localStorage.getItem(GUEST_KEY);
@@ -101,19 +104,38 @@ function resolveGuestId(): string {
101
104
  return id;
102
105
  }
103
106
 
107
+ /**
108
+ * THE ONE IDENTITY RULE for every lane the SDK opens: with a token the host's userId passes
109
+ * through; without one the platform sees a guest and requires a guest-prefixed id, so a
110
+ * host-supplied guest id wins and anything else is replaced by the stored guest id. The data
111
+ * plane and the audio lane both call this, so a page never speaks with two names.
112
+ */
113
+ export function sessionUserId(userId: string | undefined, getAccessToken: unknown): string {
114
+ return getAccessToken || /^guest[-:]/.test(userId ?? "") ? (userId as string) : resolveGuestId();
115
+ }
116
+
104
117
  export function useUnoverseConnection(
105
118
  config: UnoverseConnectionConfig,
106
119
  session: UnoverseSessionParams,
107
120
  store: ComponentStore,
108
- options: { enabled?: boolean } = {},
121
+ options: { enabled?: boolean; onAppSwitch?: (app: string) => void } = {},
109
122
  ): UnoverseConnection {
110
123
  const enabled = options.enabled ?? true;
124
+ // Read through a ref so a host's inline arrow does not tear down the stream on
125
+ // every render — the connection effect must not depend on the callback's identity.
126
+ const onAppSwitchRef = useRef(options.onAppSwitch);
127
+ onAppSwitchRef.current = options.onAppSwitch;
128
+ const onAppSwitch = (app: string) => onAppSwitchRef.current?.(app);
129
+ // Read through a ref too: the server follows app switches itself, so a new app must not
130
+ // tear the stream down; the next registration simply names it.
131
+ const appIdRef = useRef(session.appId);
132
+ appIdRef.current = session.appId;
111
133
  const { streamUrl, getAccessToken } = config;
112
134
  const { workflowId, targetTriggerNode, conversationId } = session;
113
135
  // No token ⇒ the platform sees a GUEST session and requires a guest-prefixed id —
114
136
  // supply one automatically (host-supplied guest ids win; with a token the host's
115
137
  // real userId passes through untouched). This is the whole guest story for a host.
116
- const userId = getAccessToken || /^guest[-:]/.test(session.userId ?? "") ? session.userId : resolveGuestId();
138
+ const userId = sessionUserId(session.userId, getAccessToken);
117
139
  const chatId = session.chatId ?? conversationId;
118
140
 
119
141
  const [isConnected, setIsConnected] = useState(false);
@@ -125,7 +147,7 @@ export function useUnoverseConnection(
125
147
  // The pending elicitation's resolver (one at a time per session) — set while the
126
148
  // server is asking this session for structured input; the channel's submit branch
127
149
  // answers it via `resolveElicitation`.
128
- const pendingElicitRef = useRef<((state: Record<string, unknown>) => void) | null>(null);
150
+ const pendingElicitRef = useRef<((state: Record<string, unknown>, action: "accept" | "cancel") => void) | null>(null);
129
151
 
130
152
  // ---- connection lifecycle (MCP stream) ----
131
153
  useEffect(() => {
@@ -187,8 +209,9 @@ export function useUnoverseConnection(
187
209
  const requested =
188
210
  (req.params as unknown as { requestedSchema?: { properties?: Record<string, unknown> } }).requestedSchema?.properties ?? {};
189
211
  return await new Promise((resolve) => {
190
- pendingElicitRef.current = (state: Record<string, unknown>) => {
212
+ pendingElicitRef.current = (state: Record<string, unknown>, action: "accept" | "cancel") => {
191
213
  pendingElicitRef.current = null;
214
+ if (action === "cancel") return resolve({ action: "cancel" } as never);
192
215
  const content: Record<string, unknown> = {};
193
216
  for (const k of Object.keys(requested)) if (state[k] !== undefined) content[k] = state[k];
194
217
  resolve({ action: "accept", content } as never);
@@ -234,6 +257,16 @@ export function useUnoverseConnection(
234
257
  if (typeof evt.event === "string" && evt.event) emitAnalytics(evt.event, evt.params ?? {});
235
258
  return;
236
259
  }
260
+ // AN APP SWAP IS THE HOST'S CALL, not the renderer's. Handed straight out, like
261
+ // ANALYTICS_EVENT above and for the same reason: it does not touch the store —
262
+ // the conversation and everything already on screen survive a swap, which is the
263
+ // whole point of swapping rather than starting again. The host re-points its one
264
+ // engine (appEngine.tsx); a host that ignores this simply stays where it is.
265
+ if (evt.type === "APP_SWITCH") {
266
+ const next = (msg as unknown as { app?: unknown }).app;
267
+ if (typeof next === "string" && next) onAppSwitch?.(next);
268
+ return;
269
+ }
237
270
  applyServerMessage(store, msg, seenRef.current);
238
271
  };
239
272
 
@@ -264,7 +297,7 @@ export function useUnoverseConnection(
264
297
  // stream and an immediate SESSION_READY.
265
298
  const bind = await client.callTool({
266
299
  name: "register_session",
267
- arguments: { userId, conversationId, workflowId, targetTriggerNode, chatId },
300
+ arguments: { userId, conversationId, workflowId, targetTriggerNode, chatId, ...(appIdRef.current ? { appId: appIdRef.current } : {}) },
268
301
  });
269
302
  // A refusal (public-entry gate, requires.role, …) is an isError tool RESULT,
270
303
  // not a thrown error — surface it and STOP, never retry: the server has
@@ -344,60 +377,17 @@ export function useUnoverseConnection(
344
377
  (text: string) => {
345
378
  const message = text.trim();
346
379
  if (!message) return;
347
- /**
348
- * THE LATCH RIDES THE ORDINARY SEND (docs/MCP_COMPLETE_GUIDE.md §The Component Latch).
349
- *
350
- * Handled HERE rather than in a host, because both hosts call this one function and
351
- * two hosts each deciding "what happens on send" is how the composer regression
352
- * happened.
353
- *
354
- * READ, NOT RE-DERIVED. `app.tsx` runs `resolveLatch` every render and publishes
355
- * `latched`/`latchKey` into app state for the composer's pill; this reads that same
356
- * published fact. Deriving it again here is what left the send inert for two weeks:
357
- * `resolveLatch` needs the app DEFINITION and this file holds a raw MCP `Client` with
358
- * no definition cache, so the second derivation could never see `def.latches` and
359
- * silently answered "not latched" on every send.
360
- */
361
- const app = store.getAppState() as Record<string, unknown>;
362
- const held =
363
- app.latched === true && typeof app.latchKey === "string" && app.latchKey ? store.split(app.latchKey) : null;
364
- /**
365
- * THE RUN REUSES THE LATCHED TURN; the guest's message does not.
366
- *
367
- * The user turn is minted normally either way — the guest said something and it
368
- * belongs in the timeline where they said it. What the latch changes is where the
369
- * ANSWER opens: `startResponse` and the trigger both take the held component's own
370
- * chatId, so every render in that run keys `chatId:nodeId` to what is already on
371
- * screen, and a repeated COMPONENT_INIT on a seen key is already a props merge
372
- * (applyServerMessage). Hydrate in place, by construction, with no new return path.
373
- */
374
380
  const turn = mintTurn();
375
381
  store.addUserMessage(turn, message); // optimistic user turn
376
- const run = held ? held.chatId : turn;
382
+ // The answer opens under the message that asked for it. What is on screen is the
383
+ // SERVER's fact (the standing set, the open pages); the send carries nothing about it.
384
+ const run = turn;
377
385
  // Optimistically open the assistant turn too, so the avatar + thinking dots
378
386
  // appear IMMEDIATELY on send instead of waiting for the backend's
379
387
  // WORKFLOW_STARTED. startResponse is idempotent — the later WORKFLOW_STARTED
380
- // (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId), and
381
- // while latched that turn is the page's own, which is exactly the point.
388
+ // (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId).
382
389
  store.startResponse(run);
383
- trigger(
384
- held
385
- ? {
386
- message,
387
- // `state` is the component's live slice, so the workflow is told what the page
388
- // currently holds without a round trip, and it stays right after local
389
- // `setValue` writes the server never saw. Sent as one `latch` object so an
390
- // unlatched send is byte-for-byte what it was before.
391
- latch: {
392
- chatId: held.chatId,
393
- nodeId: held.nodeId,
394
- view: viewOf(store.get(held.chatId, held.nodeId) as Record<string, unknown>) ?? "",
395
- state: store.get(held.chatId, held.nodeId) ?? {},
396
- },
397
- }
398
- : { message },
399
- run,
400
- );
390
+ trigger({ message }, run);
401
391
  },
402
392
  [mintTurn, store, trigger],
403
393
  );
@@ -421,10 +411,10 @@ export function useUnoverseConnection(
421
411
  );
422
412
 
423
413
  // Answer a pending elicitation with the submitted component state (native path).
424
- const resolveElicitation = useCallback((state: Record<string, unknown>): boolean => {
414
+ const resolveElicitation = useCallback((state: Record<string, unknown>, action: "accept" | "cancel" = "accept"): boolean => {
425
415
  const pending = pendingElicitRef.current;
426
416
  if (!pending) return false;
427
- pending(state);
417
+ pending(state, action);
428
418
  return true;
429
419
  }, []);
430
420
 
@@ -72,6 +72,12 @@ export function dispatchAction(
72
72
  if (ctx.nodeId && ("state" in patch || "view" in patch || "defaultState" in patch)) {
73
73
  const entered = patch.state ?? patch.view ?? patch.defaultState;
74
74
  ctx.sendToServer?.("viewEntered", { layout: entered, view: entered });
75
+ } else if (ctx.nodeId && "step" in patch) {
76
+ // A CONFIRMED STEP IS NEWS TOO (SIGNAL.md § 6b): a form's Continue writes the
77
+ // compiler's hidden step key (`STEP` in packages/base compile/component.ts; the
78
+ // author wrote `state`), so the face never changes and no view is entered. Reported
79
+ // as its own message: the server saves the slice and runs no face lifecycle.
80
+ ctx.sendToServer?.("stepEntered", { step: patch.step });
75
81
  }
76
82
  } else if (spec.type === "setAppValue") {
77
83
  // Write the dev's chosen keys into TEMPLATE state — the template-state twin of setValue.
@@ -48,6 +48,24 @@ export function orderedClaims(def: UnoverseDefinition | null | undefined): strin
48
48
  * it can never emit invalid CSS (or an invalid native constraint). */
49
49
  export const cssWidth = (w: unknown): string | undefined => (typeof w === "string" && /\d/.test(w) ? w : undefined);
50
50
 
51
+ /**
52
+ * THE ONE WIDTH LAW FOR A PLACE (docs/design/apps, Width). A panel, a slot frame or a
53
+ * mounted template declares `appWidth` once. A NAMED size resolves via theme.appSize and
54
+ * raw CSS passes through: the place is that width, always (`flex: 0 0 auto`). `flex` is
55
+ * the SDK's own word, never a token: "fill the space remaining beside whatever core is
56
+ * open" (`flex: 1 1 0%`). Anything else declares nothing and the place's own style decides.
57
+ *
58
+ * ONE function for every caller, because the slot frame once had its own copy without the
59
+ * `flex` word: beside a full-width app it rendered the task surface in a zero-width frame
60
+ * the moment the org's `flex` token went (SAB's comparison, blank, 2026-09-07).
61
+ */
62
+ export function placeWidthStyle(declared: unknown, appSize?: Record<string, string>): Record<string, unknown> | null {
63
+ const width = cssWidth(typeof declared === "string" ? (appSize?.[declared] ?? declared) : undefined);
64
+ if (width) return { width, flex: "0 0 auto" };
65
+ if (declared === "flex") return { flex: "1 1 0%", minWidth: 0 };
66
+ return null;
67
+ }
68
+
51
69
  /** The author's declared prop defaults (def.props[k].default) → a flat data object. Content, not policy. */
52
70
  export function propDefaults(props?: Record<string, unknown>): Record<string, unknown> {
53
71
  const out: Record<string, unknown> = {};
@@ -142,6 +160,16 @@ export function computeAppWidth(root: unknown, store: ComponentStore, appSize?:
142
160
  if (piece) walk(piece);
143
161
  return;
144
162
  }
163
+ // A PLACE HOLDING SOMETHING sizes the app like an open panel (UNOVERSE_SCREEN.md §2.2).
164
+ if (n.type === "Place") {
165
+ const name = (n as { name?: unknown }).name;
166
+ if (typeof name === "string" && store.placeItems(name).length) {
167
+ const w = rw(n.appWidth);
168
+ if (w) widths.push(w);
169
+ else if (n.appWidth === "flex") flexOpen = true;
170
+ }
171
+ return;
172
+ }
145
173
  if (n !== r) {
146
174
  if (n.type === "ComponentSlot") {
147
175
  const claim = n.select?.where;
@@ -195,6 +223,22 @@ export function resolveActiveLayout(def: UnoverseDefinition | null | undefined,
195
223
  return layouts[view] ?? layouts[def.defaultLayout ?? "main"] ?? def.root;
196
224
  }
197
225
 
226
+ /**
227
+ * MOUNT CLAIMS AS ALIASES: a state that mounts a template claims the template's own states
228
+ * (loader-stamped `claims`, UNOVERSE_TEMPLATE_MODEL §12). A face in a claimed state IS a
229
+ * face in the mount state — `grid` under `page` is `page` — for the ladder walk and for
230
+ * rule 4's cancellation alike (the two read faces the same way, or a card wearing `grid`
231
+ * survives a higher state that should have cleared it; observed live 2026-09-06).
232
+ */
233
+ export function mountAliases(def: UnoverseDefinition | null | undefined, valid: Set<string>): Map<string, string> {
234
+ const aliasOf = new Map<string, string>();
235
+ for (const [sname, entry] of Object.entries((def?.templates ?? {}) as Record<string, { template?: string; claims?: string[] }>)) {
236
+ if (!entry?.template || !valid.has(sname)) continue;
237
+ for (const c of entry.claims ?? []) aliasOf.set(c, sname);
238
+ }
239
+ return aliasOf;
240
+ }
241
+
198
242
  /**
199
243
  * THE TEMPLATE'S ACTIVE STATE — the ONE derivation every consumer shares.
200
244
  *
@@ -222,11 +266,7 @@ export function resolveActiveView(
222
266
  // `claims`, UNOVERSE_TEMPLATE_MODEL §12): an instance arriving in any of them is a
223
267
  // match FOR THE MOUNT STATE — "the grid holds cards" is what enters `courses`, and
224
268
  // the empty grid is what releases it. Same walk, one more spelling of a match.
225
- const aliasOf = new Map<string, string>();
226
- for (const [sname, entry] of Object.entries((def?.templates ?? {}) as Record<string, { template?: string; claims?: string[] }>)) {
227
- if (!entry?.template || !valid.has(sname)) continue;
228
- for (const c of entry.claims ?? []) aliasOf.set(c, sname);
229
- }
269
+ const aliasOf = mountAliases(def, valid);
230
270
  // A mount-claim match counts only within THE CURRENT DELIVERY (hold: latest):
231
271
  // released instances must release the state too, or the app would sit in an
232
272
  // arrangement whose template just emptied.
@@ -266,81 +306,6 @@ export function resolveActiveView(
266
306
  return { view, byGuest };
267
307
  }
268
308
 
269
- /**
270
- * THE LATCH (docs/MCP_COMPLETE_GUIDE.md §The Component Latch) — which component the
271
- * conversation is currently ADDRESSED AT, or none.
272
- *
273
- * DERIVED, never stored, for the same reason `lifecycle` is (STATE_MODEL §3): nothing
274
- * registers a latch and nothing unregisters one. THE STATE IS WHAT IS LATCHABLE, not the
275
- * component (moved off the manifest 2026-08-31): an instance holds the latch while it is
276
- * wearing a state the app declared a `latch` on, and stops the moment it wears one that
277
- * says nothing — retracted inline, superseded, evicted, all the same answer with no code.
278
- * There is no set, no stack, and no "latch this one too" — this function returns ONE
279
- * holder or null, so two latches are not a bug to prevent but a state that cannot be
280
- * represented.
281
- *
282
- * THE HOLDER MUST BE THE STATE ON SCREEN, which is why `active` is a required argument
283
- * rather than something derived again here. Being ON the ladder is not enough: an app whose
284
- * ladder is [page, perfect-day] shows `page` the moment a card arrives wearing that face,
285
- * and the composed page — still live, still ranked, just not showing — went on holding the
286
- * latch. The guest then reads a pill naming a page that is nowhere on screen and types into
287
- * it (observed live 2026-08-31: three search cards took the surface and "Your Perfect Day"
288
- * stayed up over them). A pill is a promise about what the words will reach.
289
- *
290
- * SAME WALK AS `resolveActiveView` otherwise: the highest-ranked wins, ties going to the
291
- * most recent write. So a page retracted to `inline` (superseded by an arrival, released by
292
- * `cancelBelow`, closed by the guest) silently stops holding it too.
293
- *
294
- * Returns the FULL key. `chatId` alone reaches the turn, not the component inside it: a
295
- * turn carrying two components would hydrate the wrong one.
296
- */
297
- export function resolveLatch(
298
- def: UnoverseDefinition | null | undefined,
299
- store: ComponentStore,
300
- /** The state the app is ACTUALLY showing (`resolveActiveView().view`). Empty = nothing is
301
- * surfaced, so nothing can be addressed. */
302
- active: string,
303
- dismissed?: string,
304
- ): { key: string; chatId: string; nodeId: string; latch: { title: string; icon?: string }; view: string } | null {
305
- const order = def?.stateOrder?.length ? def.stateOrder : undefined;
306
- if (!order) return null; // no ladder, no ranking — abstain rather than guess a holder
307
- const latches = def?.latches;
308
- if (!latches || !Object.keys(latches).length) return null; // no state is addressable here
309
- // WHAT IS ON SCREEN, first: an addressable state that is not the active one is not being
310
- // shown, and a pill over someone else's surface points at nothing the guest can see.
311
- if (!active || !latches[active]) return null;
312
- // `key` is the component key this candidate came from, carried so the return can name the
313
- // FULL key (line 269) rather than re-deriving it from chatId, which reaches the turn and
314
- // not the component inside it.
315
- let best:
316
- | { key: string; chatId: string; nodeId: string; latch: { title: string; icon?: string }; view: string; rank: number; at: number }
317
- | null = null;
318
- for (const turn of store.getResponses())
319
- for (const p of turn.components) {
320
- // THE ✕ IS THE ONLY EXIT, and this is it: the guest dismissed THIS holder by key, so
321
- // it is passed over and the walk continues to the next candidate. Keyed rather than a
322
- // bare flag so the dismissal cannot outlive the thing dismissed — a different
323
- // component taking the latch is a different key and is not affected.
324
- if (dismissed && dismissed === p) continue;
325
- const { chatId, nodeId } = store.split(p);
326
- const slice = store.get(chatId, nodeId) as Record<string, unknown>;
327
- const view = viewOf(slice);
328
- if (!view) continue;
329
- const rank = order.indexOf(view);
330
- if (rank < 0) continue; // not on the surface (inline/retracted): holds nothing
331
- // THE STATE DECIDES, and it must be the state SHOWING. Same lookup for every instance,
332
- // so nothing has to be told it is addressable and nothing has to be untold — the face
333
- // it currently wears is the whole question, and a face that is not the active one is
334
- // not on screen to be addressed.
335
- if (view !== active) continue;
336
- const latch = latches[view];
337
- if (!latch) continue;
338
- const at = store.touchOf(p);
339
- if (!best || rank < best.rank || (rank === best.rank && at > best.at))
340
- best = { key: p, chatId, nodeId, latch, view, rank, at };
341
- }
342
- return best ? { key: best.key, chatId: best.chatId, nodeId: best.nodeId, latch: best.latch, view: best.view } : null;
343
- }
344
309
 
345
310
  /** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
346
311
  export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?: Set<string>): string[] {
@@ -390,8 +355,28 @@ export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?
390
355
  if (Array.isArray(within)) return within.includes(v);
391
356
  return ne !== undefined ? v !== ne : v === eq;
392
357
  });
393
- pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
394
358
  }
359
+ // THE MOST RECENT WINS (the `limit: 1` law) for EVERY capped select, not only a state
360
+ // select. A slot pinned to a component by `type` with `limit: 1` kept the OLDEST card:
361
+ // the sort lived inside the `where` branch, so a name-pinned cap sliced arrival order
362
+ // (SAB voice, two Account Agent cards, 2026-09-10). A cap means "the newest N"; an
363
+ // uncapped flow keeps arrival order.
364
+ if (sel.where || sel.limit != null) pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
395
365
  if (sel.limit != null) pointers = pointers.slice(0, sel.limit);
396
366
  return pointers;
397
367
  }
368
+
369
+ /**
370
+ * THE HASH NAMES THE STATE (owner ruling 2026-09-09: "make this the standard way to change
371
+ * states in an app, component or layout"). `unoverse://components/acme/deal#page` is the
372
+ * deal, opened on its page, the way a URL's hash means "this, opened here". Every reader
373
+ * splits the address here: the part before the hash is what the server resolves, the part
374
+ * after is the public `state` the thing opens in. No hash, and the definition's declared
375
+ * initial state stands.
376
+ */
377
+ export function stateOfUri(address: string): { uri: string; state?: string } {
378
+ const i = address.indexOf("#");
379
+ if (i < 0) return { uri: address };
380
+ const state = address.slice(i + 1);
381
+ return { uri: address.slice(0, i), ...(state ? { state } : {}) };
382
+ }
@@ -11,6 +11,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
11
11
  // ChatGPT's widget CSP. See connection.tsx for the full rationale.
12
12
  import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
13
13
  import type { UnoverseDefinition, ResolvedTheme } from "./types";
14
+ import { stateOfUri } from "./apps";
14
15
 
15
16
  export interface UnoverseClientOptions {
16
17
  /**
@@ -128,21 +129,11 @@ export class UnoverseClient {
128
129
  return this.withSession(() => this.client.callTool({ name, arguments: args }));
129
130
  }
130
131
 
131
- /**
132
- * The cached definition for a URI, or null — SYNCHRONOUS, and it never fetches.
133
- *
134
- * For derivations that must answer INSIDE an event, where awaiting is not an option: the
135
- * latch resolves on send (`sendMessage`), and a send cannot wait on a resources/read.
136
- * Safe because the only caller asks about the template it is already rendering, which
137
- * means `readDefinition` resolved before the first frame. Null is a legitimate answer
138
- * (nothing rendered yet) and every caller must treat it as "no opinion", never as "no".
139
- */
140
- peekDefinition(uri: string): UnoverseDefinition | null {
141
- return this.cache.get(uri) ?? null;
142
- }
143
132
 
144
- /** resources/read a definition by URI (e.g. unoverse://components/card). Cached. */
145
- async readDefinition(uri: string): Promise<UnoverseDefinition> {
133
+ /** resources/read a definition by URI (e.g. unoverse://components/card). Cached. A hash on
134
+ * the address names the state it opens in (`stateOfUri`) and is never sent to the server. */
135
+ async readDefinition(address: string): Promise<UnoverseDefinition> {
136
+ const { uri } = stateOfUri(address);
146
137
  const cached = this.cache.get(uri);
147
138
  if (cached) return cached;
148
139
  const pending = this.inflight.get(uri);
@@ -137,6 +137,19 @@ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, se
137
137
  else if (m.state === "ERROR" || m.state === "WORKFLOW_ERROR") store.setLifecycle("error");
138
138
  return;
139
139
  }
140
+ case "COMPONENT_CLOSE": {
141
+ // A CLOSE, not a placement (RENDER_PIPELINE §3.3): the LLM chose content over an
142
+ // open task (screen_show), and the task closes exactly as the guest's own ✕ closes
143
+ // it — a guest-hand write to `inline`. A plain face write would be refused by the
144
+ // guest-hold shield when the guest had opened the task themselves (observed live
145
+ // 2026-09-07: closed on the server, still on screen).
146
+ // The state to wear rides the message when the sender names one (a step-aside on an app with
147
+ // places, docs/unoverse/design/screen/UNOVERSE_SCREEN.md §2.6); else the universal `inline`.
148
+ const m = msg as { chatId?: string; nodeId?: string; state?: unknown };
149
+ if (typeof m.chatId === "string" && typeof m.nodeId === "string")
150
+ store.mergeComponentState(m.chatId, m.nodeId, { state: typeof m.state === "string" && m.state ? m.state : "inline" });
151
+ return;
152
+ }
140
153
  case "COMPONENTS_CLEAR": {
141
154
  // Rule 5's empty delivery (STATE_MODEL §5, amended 2026-08-26): a turn that ran
142
155
  // with cards enabled and surfaced none owns the screen — earlier-turn components
@@ -162,6 +175,23 @@ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, se
162
175
  }
163
176
  // No AUDIO_STATE case: voice is a NATIVE SERVICE on the WS audio lane (useVoiceService),
164
177
  // NOT the structured MCP stream. Audio-state events never arrive here (UNOVERSE_SPEC §2e-1).
178
+ case "SCREEN_PLACE": {
179
+ // THE SCREEN DOCUMENT (docs/unoverse/design/screen/UNOVERSE_SCREEN.md): one place's full contents,
180
+ // in order. The server decided where everything is; this only records it.
181
+ const m = msg as { place?: unknown; items?: unknown };
182
+ if (typeof m.place !== "string" || !Array.isArray(m.items)) return;
183
+ const pointers: string[] = [];
184
+ const states: Record<string, string> = {};
185
+ for (const x of m.items) {
186
+ const i = x as { chatId?: unknown; nodeId?: unknown; state?: unknown };
187
+ if (typeof i?.chatId !== "string" || typeof i?.nodeId !== "string") continue;
188
+ const p = `${i.chatId}:${i.nodeId}`;
189
+ pointers.push(p);
190
+ if (typeof i.state === "string" && i.state) states[p] = i.state;
191
+ }
192
+ store.setPlace(m.place, pointers, states);
193
+ return;
194
+ }
165
195
  case "APP_DATA": {
166
196
  // Generic write into TEMPLATE STATE (the template-state twin of COMPONENT_DATA). The
167
197
  // PRODUCER names the keys (the Suggestions node sends `{ faqs }`); the template picks
@@ -35,14 +35,14 @@ export { dispatchAction, resolveValue, type ActionContext } from "./actions";
35
35
  export {
36
36
  collectSurfacedViews,
37
37
  computeAppWidth,
38
- cssWidth,
38
+ cssWidth, placeWidthStyle,
39
39
  formatRelative,
40
40
  isViewField,
41
+ mountAliases,
41
42
  orderedClaims,
42
43
  propDefaults,
43
44
  resolveActiveLayout,
44
45
  resolveActiveView,
45
- resolveLatch,
46
46
  selectPointers,
47
47
  viewOf,
48
48
  } from "./apps";