@unoverse-platform/studio 0.1.116 → 0.1.117

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 (52) hide show
  1. package/local/scaffold.d.mts +1 -0
  2. package/local/scaffold.mjs +12 -0
  3. package/package.json +1 -1
  4. package/screens/AgentChain.tsx +41 -25
  5. package/screens/AgentsView.tsx +41 -1
  6. package/screens/TemplatesLibraryView.tsx +32 -3
  7. package/screens/TemplatesView.tsx +2 -2
  8. package/screens/index.ts +1 -1
  9. package/src/host.ts +5 -0
  10. package/src/index.css +11 -0
  11. package/vendor/base/src/agent-mcp/discovery.ts +1 -1
  12. package/vendor/base/src/agent-mcp/index.ts +3 -1
  13. package/vendor/base/src/agent-mcp/invoke.ts +62 -18
  14. package/vendor/base/src/agent-mcp/progressive.ts +124 -0
  15. package/vendor/base/src/agent-mcp/tools.ts +1 -1
  16. package/vendor/base/src/definitions/load/expand/inherit.ts +1 -1
  17. package/vendor/base/src/definitions/load/expand/refs.ts +1 -1
  18. package/vendor/base/src/definitions/load/expand/walk.ts +1 -1
  19. package/vendor/base/src/definitions/registry/agentFolders.ts +2 -2
  20. package/vendor/base/src/definitions/registry/catalog.ts +1 -1
  21. package/vendor/base/src/definitions/registry/objects.ts +94 -6
  22. package/vendor/base/src/definitions/registry/pipelines.ts +13 -2
  23. package/vendor/base/src/http/uwsFetchAdapter.ts +39 -3
  24. package/vendor/base/src/items/collect.ts +10 -0
  25. package/vendor/base/src/items/publish.ts +10 -0
  26. package/vendor/base/src/lint/design/index.mjs +4 -1
  27. package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
  28. package/vendor/base/src/lint/objects/index.mjs +1 -1
  29. package/vendor/base/src/lint/objects/rules.d.mts +3 -0
  30. package/vendor/base/src/lint/objects/rules.mjs +93 -5
  31. package/vendor/base/src/manifests/compose.ts +3 -0
  32. package/vendor/base/src/manifests/executor/callback.ts +3 -2
  33. package/vendor/base/src/manifests/executor/memory.ts +27 -0
  34. package/vendor/base/src/manifests/executor/toolBridge.ts +70 -11
  35. package/vendor/base/src/manifests/runtime/auth/oauth.ts +43 -10
  36. package/vendor/base/src/manifests/runtime/events.ts +1 -1
  37. package/vendor/base/src/manifests/runtime/http/request.ts +43 -3
  38. package/vendor/base/src/manifests/runtime/loops/poll.ts +27 -5
  39. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +1 -1
  40. package/vendor/base/src/platform/inbound.ts +67 -0
  41. package/vendor/base/src/platform/tokenCost.ts +27 -8
  42. package/vendor/sdk/lib/core/actions.ts +10 -0
  43. package/vendor/sdk/lib/core/apps.ts +2 -2
  44. package/vendor/sdk/lib/core/client.ts +11 -0
  45. package/vendor/sdk/lib/core/store.ts +1 -1
  46. package/vendor/sdk/lib/core/types.ts +2 -0
  47. package/vendor/sdk/lib/dom/app.ts +3 -2
  48. package/vendor/sdk/lib/dom/component.ts +38 -1
  49. package/vendor/sdk/lib/dom/element.ts +23 -0
  50. package/vendor/sdk/lib/dom/primitives.ts +97 -6
  51. package/vendor/sdk/lib/voiceService.ts +3 -1
  52. package/vendor/sdk/main.ts +1 -1
@@ -140,3 +140,30 @@ export function sessionTurnRecorder(
140
140
  flush: save,
141
141
  };
142
142
  }
143
+
144
+ /**
145
+ * THE TASK THE APP'S ADDRESS NAMES, in the call before it starts (docs/mcp/MCP_TASKS.md § 5c).
146
+ * A task found in Spatial lands in the conversation as the search's result, so the model knows
147
+ * it; a task opened by an address has no search, so what it holds is replayed instead, read by
148
+ * its id with the official client (`tasks/get`), the same way the memory block above is. Only an
149
+ * id: a URI (`<org>/<name>`, which a task's own run also carries) is not an opened task.
150
+ */
151
+ export async function hydrateOpenedTask(config: any, executionContext: any): Promise<any> {
152
+ const id = executionContext?.publishingContext?.task;
153
+ if (typeof id !== "string" || !id || id.includes("/")) return config;
154
+ try {
155
+ const harness: any = await import("../../agent-mcp/index.js");
156
+ const task = await harness.readTask(id, { accessToken: executionContext?.auth?.accessToken });
157
+ const block =
158
+ `This conversation was opened on a task (${task.status}).` +
159
+ (task.statusMessage ? ` ${task.statusMessage}` : "") +
160
+ (task.asks ? `\nIt asks: ${JSON.stringify(task.asks)}` : "") +
161
+ (task.result ? `\nWhat it holds:\n${task.result}` : "");
162
+ const history = Array.isArray(config?.conversationHistory) ? config.conversationHistory : [];
163
+ console.log(`[manifests] opened task ${id} replayed into the call (${task.status})`);
164
+ return { ...config, conversationHistory: [...history, { role: "user", content: block }] };
165
+ } catch (e: any) {
166
+ console.log(`[manifests] opened task ${id} could not be read (non-blocking): ${e?.message ?? e}`);
167
+ return config;
168
+ }
169
+ }
@@ -12,6 +12,7 @@
12
12
  import type { ToolBridge } from "../runtime/index.js";
13
13
  import { engineUrl } from "./context.js";
14
14
  import { saveAvailableTools } from "../../platform/serviceCalls.js";
15
+ import { recordCall } from "../runtime/tools/toolloop.js";
15
16
 
16
17
  /** The screen's verbs on an app with places (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.3). */
17
18
  const SCREEN_VERBS = {
@@ -182,22 +183,79 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
182
183
  }
183
184
  }
184
185
 
185
- return {
186
+ /**
187
+ * THE TASK THE APP'S ADDRESS NAMES, by its id (docs/mcp/MCP_TASKS.md § 5c). An edge or Spatial
188
+ * hands the model a task to start; an id names one that already exists, connected through the
189
+ * official client's `session.task(id)`. The model can read it, and answer it while it waits:
190
+ * the two things MCP offers on an existing task. A URI (`<org>/<name>`, which a task's own run
191
+ * also carries) is not this. Fail-soft: a task that cannot be read adds nothing.
192
+ */
193
+ const named = pub?.task;
194
+ if (typeof named === "string" && named && !named.includes("/")) {
195
+ try {
196
+ const now = await harness.readTask(named, identity);
197
+ appInvokers.set("read_task", () => harness.readTask(named, identity));
198
+ preMinted.push({
199
+ name: "read_task",
200
+ description: `The task this conversation was opened on (${now.status}). Read it for where it is, what it asks and what it produced.`,
201
+ parameters: { type: "object", properties: {} },
202
+ });
203
+ if (now.status === "input_required") {
204
+ appInvokers.set("answer_task", async (input: any) => {
205
+ await harness.answerTask(named, (input?.answers ?? {}) as Record<string, unknown>, identity);
206
+ return harness.readTask(named, identity);
207
+ });
208
+ preMinted.push({
209
+ name: "answer_task",
210
+ description: "Answer what the task this conversation was opened on asks, each answer under the key it asks with.",
211
+ parameters: { type: "object", properties: { answers: { type: "object", description: "Each answer under the key the task asks with." } }, required: ["answers"] },
212
+ });
213
+ }
214
+ console.log(`[manifests] task ${named} connected (${now.status})`);
215
+ } catch (e: any) {
216
+ console.log(`[manifests] task ${named} could not be read: ${e?.message ?? e}`);
217
+ }
218
+ }
219
+
220
+ /** The tools held behind search_tools on this run, or null when every tool is offered. */
221
+ let held: Array<{ name: string; description: string; parameters: unknown; server?: string }> | null = null;
222
+
223
+ const bridge: ToolBridge = {
186
224
  async discover() {
187
- const tools = [
188
- ...Object.entries<any>(schema.methods).map(([name, m]) => ({
189
- name,
190
- description: m.description || `Execute ${name}`,
191
- parameters: m.input || { type: "object", properties: {} },
192
- })),
193
- ...preMinted,
194
- ];
225
+ const wired = Object.entries<any>(schema.methods).map(([name, m]) => ({
226
+ name,
227
+ description: m.description || `Execute ${name}`,
228
+ parameters: m.input || { type: "object", properties: {} },
229
+ ...(m.server ? { server: String(m.server) } : {}),
230
+ }));
231
+ // MANY TOOLS ARE HELD BEHIND SEARCH (harness.shouldDefer, MCP Client Best Practices). Spatial
232
+ // search stays offered: it is how an Agent finds what it does not hold, and what it finds is
233
+ // minted as a tool. The screen and open pages (preMinted) are never held.
234
+ const deferrable = wired.filter((t) => !harness.DISCOVERY_TOOL_NAMES.includes(t.name));
235
+ held = harness.shouldDefer(deferrable) ? deferrable : null;
236
+ if (held) console.log(`[manifests] ${held.length} tools held behind ${harness.SEARCH_TOOLS} (about ${harness.estimateTokens(held)} tokens of definitions)`);
237
+ const strip = ({ server: _server, ...t }: any) => t;
238
+ const tools = [...(held ? [...wired.filter((t) => !held!.includes(t)), ...harness.metaTools(held)] : wired).map(strip), ...preMinted];
195
239
  // Everything the model starts with, the screen and open pages included.
196
240
  if (caller.executionId && caller.nodeId) saveAvailableTools(caller.executionId, caller.nodeId, tools.map((t) => t.name));
197
241
  return tools;
198
242
  },
199
243
 
200
- async call(name, args, hooks) {
244
+ async call(name, args, hooks): Promise<string> {
245
+ // A SEARCH IS A CALL LIKE ANY OTHER, recorded on the run with what it asked and what it found:
246
+ // without it nobody could see which tools an Agent looked for, or why it never used one.
247
+ if (held && name === harness.SEARCH_TOOLS) {
248
+ const startTime = Date.now();
249
+ const answer = harness.searchAnswer(held, args?.query);
250
+ recordCall({ executionId: caller.executionId, parentNodeId: caller.nodeId, toolName: harness.SEARCH_TOOLS, args, result: answer, startTime, success: true, who: "harness" });
251
+ return answer;
252
+ }
253
+ // A held tool, called by name. Only a tool this Agent was given: the list is its limit.
254
+ if (held && name === harness.CALL_TOOL) {
255
+ const target = String(args?.name ?? "");
256
+ if (!held.some((t) => t.name === target)) return JSON.stringify({ error: `No tool named "${target}". Find it with ${harness.SEARCH_TOOLS} first.` });
257
+ return bridge.call(target, args?.arguments ?? {}, hooks);
258
+ }
201
259
  const app = appInvokers.get(name);
202
260
  // A followed task's status, as it changes, goes to whoever asked (the tool loop narrates it).
203
261
  if (app) return stringify(await app(args, hooks));
@@ -276,7 +334,7 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
276
334
  // A WORKFLOW-BOUND APP (path A) OPENS, then ends the turn. It used to only end
277
335
  // the turn: nothing registered an invoker, so the model's call fell through to
278
336
  // `api.callService` and died as an unknown SERVICE method while the run reported
279
- // success and the screen stayed blank (SAB, 2026-09-07). Both, in this order —
337
+ // success and the screen stayed blank (a bank, 2026-09-07). Both, in this order —
280
338
  // the app tool renders the surface and fires no workflow itself (server
281
339
  // mcp/server/tools.ts), so the handoff that follows is not a second run.
282
340
  appInvokers.set(def.name, harness.workflowAppInvoker(mcp, called));
@@ -315,4 +373,5 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
315
373
  return harness.isTurnEndingHandoff(calls, handoffTools);
316
374
  },
317
375
  };
376
+ return bridge;
318
377
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * OAUTH2 CLIENT CREDENTIALS. An auth scheme, so it is written once here rather than in
3
- * every node that needs it (DECLARATIVE_NODES.md §2).
2
+ * OAUTH2 CLIENT CREDENTIALS AND REFRESH TOKENS. Auth schemes, so they are written once here
3
+ * rather than in every node that needs them (DECLARATIVE_NODES.md §2).
4
4
  *
5
5
  * Unlike a bearer key, this is not a value the manifest can template: the credential is a
6
6
  * client id and secret that must be EXCHANGED for a short-lived token, over a form-encoded
@@ -10,7 +10,15 @@
10
10
  * The token response is put back into scope as `token`, because some vendors return where
11
11
  * to talk as well as how. Salesforce returns `instance_url` and the org's API lives there,
12
12
  * not at the login host you authenticated against.
13
+ *
14
+ * A REFRESH TOKEN is the same exchange with one more secret. Some vendors (LinkedIn) never
15
+ * issue a token to an app alone: a person approves the app once, and what that leaves behind
16
+ * is a long-lived refresh token the app trades for short-lived access tokens with nobody
17
+ * signed in. The person makes it once in the vendor's developer portal and it is pasted into
18
+ * the credential, as the client secret is.
13
19
  */
20
+ import { createHash } from "node:crypto";
21
+
14
22
 
15
23
  interface Minted {
16
24
  accessToken: string;
@@ -22,16 +30,23 @@ interface Minted {
22
30
  * Cached per connected-app identity, not per node or per run.
23
31
  *
24
32
  * A workflow that makes six calls must not mint six tokens, and a vendor will rate-limit or
25
- * refuse if it does. Keyed by client id AND token url so two orgs never share an entry.
33
+ * refuse if it does. Keyed by client id AND token url so two orgs never share an entry, and
34
+ * for a refresh token by that token too: two orgs may connect through the same vendor app.
26
35
  */
27
36
  const CACHE = new Map<string, Minted>();
28
37
 
29
38
  /** Default life. Salesforce client-credentials sessions are ~2h; refresh well before. */
30
39
  const DEFAULT_TTL_MS = 110 * 60 * 1000;
31
40
 
41
+ function cacheKey(tokenUrl: string, clientId: string, refreshToken?: string): string {
42
+ // A digest, never the refresh token itself, so the key holds no secret.
43
+ const who = refreshToken ? `#${createHash("sha256").update(refreshToken).digest("hex").slice(0, 16)}` : "";
44
+ return `${clientId}@${tokenUrl}${who}`;
45
+ }
46
+
32
47
  /** Drop a cached token, so the next call mints a fresh one. */
33
- export function invalidateToken(tokenUrl: string, clientId: string): void {
34
- CACHE.delete(`${clientId}@${tokenUrl}`);
48
+ export function invalidateToken(tokenUrl: string, clientId: string, refreshToken?: string): void {
49
+ CACHE.delete(cacheKey(tokenUrl, clientId, refreshToken));
35
50
  }
36
51
 
37
52
  export async function mintClientCredentials(
@@ -41,15 +56,33 @@ export async function mintClientCredentials(
41
56
  scope: string | undefined,
42
57
  force = false,
43
58
  ): Promise<Minted> {
44
- const key = `${clientId}@${tokenUrl}`;
59
+ const form = new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret });
60
+ if (scope) form.set("scope", scope);
61
+ return mint("oauth2ClientCredentials", tokenUrl, form, cacheKey(tokenUrl, clientId), force);
62
+ }
63
+
64
+ export async function mintFromRefreshToken(
65
+ tokenUrl: string,
66
+ clientId: string,
67
+ clientSecret: string,
68
+ refreshToken: string,
69
+ force = false,
70
+ ): Promise<Minted> {
71
+ const form = new URLSearchParams({
72
+ grant_type: "refresh_token",
73
+ refresh_token: refreshToken,
74
+ client_id: clientId,
75
+ client_secret: clientSecret,
76
+ });
77
+ return mint("oauth2RefreshToken", tokenUrl, form, cacheKey(tokenUrl, clientId, refreshToken), force);
78
+ }
79
+
80
+ async function mint(scheme: string, tokenUrl: string, form: URLSearchParams, key: string, force: boolean): Promise<Minted> {
45
81
  const hit = CACHE.get(key);
46
82
  if (!force && hit && hit.expiresAt > Date.now()) return hit;
47
83
 
48
84
  // FORM ENCODED, not JSON. The OAuth2 spec says so and vendors enforce it, which is why
49
85
  // this cannot ride the ordinary body path: that one always sends JSON.
50
- const form = new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret });
51
- if (scope) form.set("scope", scope);
52
-
53
86
  const res = await fetch(tokenUrl, {
54
87
  method: "POST",
55
88
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
@@ -61,7 +94,7 @@ export async function mintClientCredentials(
61
94
  // The description, not just the status: "invalid_client" tells you which of the two
62
95
  // secrets is wrong, and a bare 400 does not.
63
96
  const detail = data?.error_description ?? data?.error ?? res.statusText;
64
- throw new Error(`oauth2ClientCredentials: token request to ${tokenUrl} failed (${res.status}): ${detail}`);
97
+ throw new Error(`${scheme}: token request to ${tokenUrl} failed (${res.status}): ${detail}`);
65
98
  }
66
99
 
67
100
  const minted: Minted = {
@@ -211,7 +211,7 @@ export function makeEmitter(node: ComposedNode, onEmit: (e: Emission) => void, b
211
211
  * word after the first sitting in `pending`, and the next thing to move it is whatever
212
212
  * arrives next, however much later.
213
213
  *
214
- * Seen live (SAB Account Agent, 2026-09-08): the model said "I'm checking your account
214
+ * Seen live (a bank's account agent, 2026-09-08): the model said "I'm checking your account
215
215
  * activity for food spending last month" in a few milliseconds, the reader saw "I", and
216
216
  * the rest appeared eleven seconds afterwards — when a delta from the turn AFTER the
217
217
  * tool call finally pushed it out. It read as the stream stopping for the tool. It was
@@ -10,7 +10,7 @@ import { render, evaluate, primeTemplating } from "../templating.js";
10
10
  import { assertAllowedHost } from "./allowedHosts.js";
11
11
  import { readSettled, assertOk } from "./response.js";
12
12
  import { performState, type StateStore } from "../state.js";
13
- import { mintClientCredentials, invalidateToken } from "../auth/oauth.js";
13
+ import { mintClientCredentials, mintFromRefreshToken, invalidateToken } from "../auth/oauth.js";
14
14
  import { signAwsRequest, presignAwsUrl, encodeDynamoJson, decodeDynamoJson, type AwsSigning } from "../auth/aws.js";
15
15
  import { fetchPaginated } from "../loops/paginate.js";
16
16
  import { fetchPolled } from "../loops/poll.js";
@@ -116,6 +116,17 @@ export async function buildRequest(
116
116
  scoped = { ...ctx, token: { instanceUrl: minted.instanceUrl } } as RunContext;
117
117
  break;
118
118
  }
119
+ case "oauth2RefreshToken": {
120
+ const minted = await mintFromRefreshToken(
121
+ await required(auth.tokenUrl, "a token url"),
122
+ await required(auth.clientId, "a client id"),
123
+ await required(auth.clientSecret, "a client secret"),
124
+ await required(auth.refreshToken, "a refresh token"),
125
+ forceReauth,
126
+ );
127
+ authHeaders.Authorization = `Bearer ${minted.accessToken}`;
128
+ break;
129
+ }
119
130
  case "awsSigV4": {
120
131
  // The manifest names the SERVICE and the REGION, because those decide what the
121
132
  // signature is scoped to and they are properties of the endpoint being described.
@@ -237,6 +248,15 @@ async function resolveValue(value: any, ctx: RunContext): Promise<unknown> {
237
248
  * A timeout is an ERROR, not an empty result: it fails with a message naming the limit and the call,
238
249
  * so the manifest's own number appears in the log that reports it.
239
250
  */
251
+ /** How many times a call whose connection dropped is made again: 2s, 4s, 8s, 16s, 32s, 60s, 60s, 60s, 60s, about five minutes. */
252
+ export const DROPPED_RETRIES = 9;
253
+ /** A failure with no reply at all: the connection dropped, was refused, reset, or never resolved. Never a status. */
254
+ export function dropped(error: any): boolean {
255
+ const code = String(error?.cause?.code ?? error?.code ?? "");
256
+ const text = String(error?.message ?? error);
257
+ return /^(ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|EPIPE|ETIMEDOUT|UND_ERR_SOCKET|UND_ERR_CONNECT_TIMEOUT)$/.test(code) || /^fetch failed$/i.test(text) || /socket hang up|other side closed/i.test(text);
258
+ }
259
+
240
260
  export async function fetchWithTimeout(url: string, init: any, timeoutMs: unknown, label: string): Promise<Response> {
241
261
  const ms = typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : 0;
242
262
  if (!ms) return fetch(url, init);
@@ -408,6 +428,21 @@ export async function sendRequest(node: ComposedNode, requestSpec: any, ctx: Run
408
428
  waiter?.unref?.();
409
429
  try {
410
430
  res = await fetchWithTimeout(url, init, requestSpec?.timeoutMs, label);
431
+ } catch (error: any) {
432
+ /**
433
+ * A DROPPED CONNECTION IS ASKED AGAIN (2026-10-22). A host that scales to zero answers its first
434
+ * call by booting; the gateway in front of it drops the connection while a model loads (NuExtract
435
+ * on Beam: four and a half minutes), and the engine saw "fetch failed" with no status at all. That
436
+ * is not the vendor's answer, it is no answer, so the call is made again with a growing wait, up
437
+ * to about five minutes in all. A reply with a status, good or bad, is never retried here.
438
+ */
439
+ if (dropped(error) && attempt <= DROPPED_RETRIES) {
440
+ const wait = Math.min(60_000, 2_000 * 2 ** (attempt - 1));
441
+ console.warn(`[manifests] ${label}: the connection dropped (${String(error?.cause?.code ?? error?.message ?? error).slice(0, 80)}); asking again in ${Math.round(wait / 1000)}s (${attempt}/${DROPPED_RETRIES})`);
442
+ await new Promise((r) => setTimeout(r, wait));
443
+ continue;
444
+ }
445
+ throw error;
411
446
  } finally {
412
447
  if (waiter) clearInterval(waiter);
413
448
  }
@@ -428,8 +463,13 @@ export async function sendRequest(node: ComposedNode, requestSpec: any, ctx: Run
428
463
  // A minted token can be revoked or expire early, and the vendor says so with a 401.
429
464
  // Refresh ONCE and retry, exactly as the retired client did. Guarded by !forceReauth so
430
465
  // a genuinely rejected credential fails rather than looping.
431
- if (res.status === 401 && requestSpec?.credential?.scheme === "oauth2ClientCredentials" && !forceReauth) {
432
- invalidateToken(String(render(requestSpec.credential.tokenUrl, ctx)), String(render(requestSpec.credential.clientId, ctx)));
466
+ const scheme = requestSpec?.credential?.scheme;
467
+ if (res.status === 401 && (scheme === "oauth2ClientCredentials" || scheme === "oauth2RefreshToken") && !forceReauth) {
468
+ invalidateToken(
469
+ String(render(requestSpec.credential.tokenUrl, ctx)),
470
+ String(render(requestSpec.credential.clientId, ctx)),
471
+ scheme === "oauth2RefreshToken" ? String(render(requestSpec.credential.refreshToken, ctx)) : undefined,
472
+ );
433
473
  console.warn(`[manifests] ${label}: 401, refreshing the access token and retrying once`);
434
474
  return sendRequest(node, requestSpec, ctx, label, true);
435
475
  }
@@ -141,12 +141,34 @@ export async function fetchPolled(
141
141
  `[manifests] ${label}: job started — polling ${url.split("?")[0]} every ${Math.round(interval / 1000)}s (up to ${attempts} attempts)`,
142
142
  );
143
143
 
144
+ // THE FIRST CHECKS COME QUICKLY (2026-10-25): one second, then two, four, doubling up to the manifest's interval. A job
145
+ // answered from the vendor's cache is ready at once, and a fixed ten-second wait before the first check made every
146
+ // stored parse take ten seconds; a long job still settles into the full interval, so its patience is barely shorter.
147
+ let wait = Math.min(1000, interval);
144
148
  for (let attempt = 1; attempt <= attempts; attempt++) {
145
- await new Promise((r) => setTimeout(r, interval));
146
-
147
- const res = await sendRequest(node, statusCall, ctx, `${label} (poll ${attempt}/${attempts})`);
148
- const payload = await readSettled(res, call.transport, call.encoding);
149
- await assertOk(node, call, payload, `${label} (poll ${attempt}/${attempts})`);
149
+ await new Promise((r) => setTimeout(r, wait));
150
+ wait = Math.min(wait * 2, interval);
151
+
152
+ /**
153
+ * A MISSED POLL IS NOT A LOST JOB (2026-10-22). The job runs on the vendor's side; a status ask
154
+ * that cannot get through (the connection dropped, a gateway answered 502/503/504 while a
155
+ * container turned over) says nothing about the job. One such miss ended a parse that the
156
+ * vendor's own log shows finished sixteen seconds later. A miss is counted as an attempt and
157
+ * the wait goes on; only the attempts bound, or an answer that is an error, ends it.
158
+ */
159
+ let payload: any;
160
+ try {
161
+ const res = await sendRequest(node, statusCall, ctx, `${label} (poll ${attempt}/${attempts})`);
162
+ payload = await readSettled(res, call.transport, call.encoding);
163
+ await assertOk(node, call, payload, `${label} (poll ${attempt}/${attempts})`);
164
+ } catch (error: any) {
165
+ const text = String(error?.message ?? error);
166
+ if (attempt < attempts && /\b50[234]\b|fetch failed|ECONN|EAI_AGAIN|socket|timed out|aborted/i.test(text)) {
167
+ console.warn(`[manifests] ${label}: poll ${attempt}/${attempts} could not reach the job (${text.slice(0, 160)}); asking again`);
168
+ continue;
169
+ }
170
+ throw error;
171
+ }
150
172
 
151
173
  const status = typeof payload?.status === "string" ? payload.status : undefined;
152
174
  if (status !== lastStatus || attempt % heartbeat === 0)
@@ -331,7 +331,7 @@ export async function runToolLoop(
331
331
  // window — and if the model answered inside a single window, that is the whole answer
332
332
  // but its first token. It then surfaces on the next turn's first delta, which is after
333
333
  // any tool this turn called: the reader saw "I", then the rest eleven seconds later,
334
- // and it read as the stream stopping for the tool (SAB, 2026-09-08).
334
+ // and it read as the stream stopping for the tool (a bank, 2026-09-08).
335
335
  //
336
336
  // Here rather than in the manifest, and by the LOOP rather than a timer: only the loop
337
337
  // knows where a turn ends, which is the same reason it owns counting them.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * IS THIS MESSAGE GENUINE? (docs/auth-security/INBOUND.md § 2 step 3; UNO-239)
3
+ *
4
+ * The verifiers, written here and nowhere else. A node only NAMES its scheme and the field of its
5
+ * credential holding the secret (`inbound:` in node.yaml); it can never describe its own check, so
6
+ * no node can switch checking off. Each one fails closed: no secret, no timestamp, a wrong or old
7
+ * signature, all refused. Compared in constant time over the raw bytes, before anything is parsed.
8
+ *
9
+ * A service's one-time address check (Slack's `url_verification`) is answered here too, but only
10
+ * once the request has passed the same check: an unsigned challenge is refused like anything else.
11
+ */
12
+ import { createHmac, timingSafeEqual } from "node:crypto";
13
+
14
+ /** What a verifier decides. `challenge`: the service is checking the address; echo this, do nothing else. */
15
+ export type Verdict = { ok: false; reason: string } | { ok: true; challenge?: string };
16
+
17
+ /** The schemes a node may name. */
18
+ export const SCHEMES = ["slack"] as const;
19
+ export type Scheme = (typeof SCHEMES)[number];
20
+
21
+ /** Older than this, or this far in the future, and a message is a replay (Slack's own window). */
22
+ export const MAX_SKEW_SECONDS = 300;
23
+
24
+ function same(a: string, b: string): boolean {
25
+ const x = Buffer.from(a);
26
+ const y = Buffer.from(b);
27
+ return x.length === y.length && timingSafeEqual(x, y);
28
+ }
29
+
30
+ /**
31
+ * SLACK (docs.slack.dev, "Verifying requests from Slack"): `X-Slack-Signature` is `v0=` and the hex
32
+ * HMAC-SHA256, keyed by the app's signing secret, of `v0:<X-Slack-Request-Timestamp>:<raw body>`.
33
+ */
34
+ function slack(headers: Headers, raw: string, secret: string, nowSeconds: number): Verdict {
35
+ const ts = headers.get("x-slack-request-timestamp") ?? "";
36
+ const signature = headers.get("x-slack-signature") ?? "";
37
+ if (!/^\d+$/.test(ts) || !signature) return { ok: false, reason: "unsigned" };
38
+ if (Math.abs(nowSeconds - Number(ts)) > MAX_SKEW_SECONDS) return { ok: false, reason: "too old" };
39
+ const expected = `v0=${createHmac("sha256", secret).update(`v0:${ts}:${raw}`).digest("hex")}`;
40
+ if (!same(signature, expected)) return { ok: false, reason: "wrong signature" };
41
+ try {
42
+ const body = JSON.parse(raw);
43
+ if (body?.type === "url_verification" && typeof body.challenge === "string") return { ok: true, challenge: body.challenge };
44
+ } catch {
45
+ /* a signed body that is not JSON is still genuine; what it says is the next step's to read */
46
+ }
47
+ return { ok: true };
48
+ }
49
+
50
+ const VERIFIERS: Record<Scheme, (headers: Headers, raw: string, secret: string, nowSeconds: number) => Verdict> = { slack };
51
+
52
+ /**
53
+ * Check one message with the scheme its node names. `secrets` holds the current secret and, while
54
+ * one is being replaced, the next: either passing is genuine. No secret at all is a refusal.
55
+ */
56
+ export function verifyInbound(scheme: string, headers: Headers, raw: string, secrets: Array<string | undefined | null>, nowSeconds = Math.floor(Date.now() / 1000)): Verdict {
57
+ const verify = VERIFIERS[scheme as Scheme];
58
+ if (!verify) return { ok: false, reason: `no verifier for "${scheme}"` };
59
+ const usable = secrets.filter((s): s is string => typeof s === "string" && s.length > 0);
60
+ if (!usable.length) return { ok: false, reason: "no secret" };
61
+ let last: Verdict = { ok: false, reason: "no secret" };
62
+ for (const secret of usable) {
63
+ last = verify(headers, raw, secret, nowSeconds);
64
+ if (last.ok) return last;
65
+ }
66
+ return last;
67
+ }
@@ -9,7 +9,7 @@
9
9
  type RawUsage = any;
10
10
  /** What a model costs, per 1M tokens (or per minute). */
11
11
  export interface ModelPricing {
12
- input?: number; cachedInput?: number; output?: number; perMinute?: number;
12
+ input?: number; cachedInput?: number; cacheWrite?: number; output?: number; perMinute?: number;
13
13
  audioInput?: number; cachedAudioInput?: number; audioOutput?: number; imageInput?: number; cachedImageInput?: number;
14
14
  }
15
15
 
@@ -69,6 +69,9 @@ const parseOpenAI = (usage: RawUsage) => {
69
69
  const inDetails = usage.input_tokens_details || usage.input_token_details || {};
70
70
  const outDetails = usage.output_tokens_details || usage.output_token_details || {};
71
71
  const cachedTokens = inDetails.cached_tokens || 0;
72
+ // WRITTEN TO THE CACHE, the other part of input with its own price (GPT-5.6 bills a cache write
73
+ // above a fresh token). Like cached, it is counted inside `input_tokens`.
74
+ const cacheWriteTokens = inDetails.cache_write_tokens || 0;
72
75
  const reasoningTokens = outDetails.reasoning_tokens || 0;
73
76
  // AUDIO IS PRICED DIFFERENTLY from text, so voice cost cannot be read off a total alone.
74
77
  const audioIn = inDetails.audio_tokens || 0;
@@ -101,9 +104,11 @@ const parseOpenAI = (usage: RawUsage) => {
101
104
  output: usage.output_tokens || 0,
102
105
  reasoning: reasoningTokens,
103
106
  cached: cachedTokens,
107
+ cacheWrite: cacheWriteTokens,
104
108
  breakdown: {
105
109
  input: usage.input_tokens || 0,
106
110
  cached: cachedTokens,
111
+ ...(cacheWriteTokens ? { cacheWrite: cacheWriteTokens } : {}),
107
112
  reasoning: reasoningTokens,
108
113
  completion: (usage.output_tokens || 0) - reasoningTokens,
109
114
  // Present only on a voice call; omitted rather than shown as 0 for text models, so a
@@ -208,13 +213,18 @@ const parseBedrockClaude = (usage: RawUsage) => {
208
213
  const parseGeneric = (usage: RawUsage) => {
209
214
  const input = usage.input_tokens || usage.inputTokens || usage.prompt_tokens || 0;
210
215
  const output = usage.output_tokens || usage.outputTokens || usage.completion_tokens || 0;
216
+ // CACHED IS A SUBSET OF INPUT, as OpenAI reports it, and every OpenAI-compatible vendor (Z.AI
217
+ // GLM) copies that shape. Dropping it priced every cached token at the fresh rate: a GLM Agent's
218
+ // turn cost about three times what Z.AI bills.
219
+ const cached = usage.prompt_tokens_details?.cached_tokens || usage.input_tokens_details?.cached_tokens || 0;
211
220
 
212
221
  return {
213
222
  total: usage.total_tokens || input + output,
214
223
  input,
215
224
  output,
216
225
  reasoning: 0,
217
- breakdown: { input, output },
226
+ cached,
227
+ breakdown: { input, cached, output },
218
228
  };
219
229
  };
220
230
 
@@ -259,7 +269,14 @@ export const calculateTokenCost = (model: string, parsedUsage: any): string => {
259
269
  ).toFixed(4);
260
270
  }
261
271
 
262
- const inputCost = per(parsedUsage.input, pricing.input);
272
+ // Cached input and cache writes are parts of input, each billed at its own rate where the vendor
273
+ // has one; the rest is fresh.
274
+ const cached = Math.min(parsedUsage.cached || 0, parsedUsage.input || 0);
275
+ const written = Math.min(parsedUsage.cacheWrite || 0, (parsedUsage.input || 0) - cached);
276
+ const inputCost =
277
+ per((parsedUsage.input || 0) - cached - written, pricing.input) +
278
+ per(cached, pricing.cachedInput ?? pricing.input) +
279
+ per(written, pricing.cacheWrite ?? pricing.input);
263
280
  const outputCost = per(parsedUsage.output, pricing.output);
264
281
 
265
282
  return (inputCost + outputCost).toFixed(4);
@@ -270,11 +287,13 @@ export const calculateTokenCost = (model: string, parsedUsage: any): string => {
270
287
  */
271
288
  export const getModelPricing = (model: string): ModelPricing => {
272
289
  const pricing: Record<string, ModelPricing> = {
273
- // GPT-5.6 family (Sol/Terra/Luna) — verified against the OpenAI pricing page 2026-08-07
274
- "gpt-5.6": { input: 5.0, output: 30.0 }, // alias of gpt-5.6-sol
275
- "gpt-5.6-sol": { input: 5.0, output: 30.0 },
276
- "gpt-5.6-terra": { input: 2.0, output: 12.0 },
277
- "gpt-5.6-luna": { input: 0.2, output: 1.2 },
290
+ // GPT-5.6 family (Sol/Terra/Luna): standard, short context (up to 272K input), verified against
291
+ // developers.openai.com/api/docs/pricing 2026-10-11. Cached input and cache writes have their own
292
+ // rates; without them every cached token was billed fresh.
293
+ "gpt-5.6": { input: 4.0, cachedInput: 0.4, cacheWrite: 5.0, output: 20.0 }, // alias of gpt-5.6-sol
294
+ "gpt-5.6-sol": { input: 4.0, cachedInput: 0.4, cacheWrite: 5.0, output: 20.0 },
295
+ "gpt-5.6-terra": { input: 2.0, cachedInput: 0.2, cacheWrite: 2.5, output: 12.0 },
296
+ "gpt-5.6-luna": { input: 0.2, cachedInput: 0.02, cacheWrite: 0.25, output: 1.2 },
278
297
 
279
298
  // Z.AI GLM — verified against docs.z.ai/guides/overview/pricing 2026-10-09
280
299
  "glm-5.3": { input: 1.4, cachedInput: 0.26, output: 4.4 },
@@ -22,6 +22,8 @@ export interface ActionContext {
22
22
  /** Where APP state lives, when it is not `store`: a template a website page draws keeps its
23
23
  * own slice in its own store and shares the app's state with everything on the page. */
24
24
  appStore?: ComponentStore;
25
+ /** Print what is on screen (host-provided; the `print` action). Resolves when it is done. */
26
+ print?: () => void | Promise<void>;
25
27
  }
26
28
 
27
29
  /** Resolve a value: a `"{{path}}"` template reads from `scope` (dot-path); anything
@@ -103,6 +105,14 @@ export function dispatchAction(
103
105
  if (ctx.nodeId) ctx.store.apply({ type: "COMPONENT_DATA", chatId: ctx.chatId, nodeId: ctx.nodeId, data: { [field]: scope.text } });
104
106
  else (ctx.appStore ?? ctx.store).mergeAppState({ [field]: scope.text });
105
107
  }
108
+ } else if (spec.type === "print") {
109
+ // PRINT asks the host to print what is on screen (`ctx.print`; the web host waits for a still
110
+ // screen first, dom/element.ts printWhenStill). `then` runs once the print is done. A host
111
+ // that cannot print does nothing.
112
+ if (!ctx.print) return;
113
+ const then = spec.then;
114
+ void Promise.resolve(ctx.print()).then(() => { if (then) dispatchAction(then, scope, ctx); });
115
+ return;
106
116
  } else {
107
117
  // SERVER — narrow the payload by `send` if given, else forward the whole scope.
108
118
  const payload = spec.send ? Object.fromEntries(spec.send.map((k) => [k, scope[k]])) : scope;
@@ -57,7 +57,7 @@ export const cssWidth = (w: unknown): string | undefined => (typeof w === "strin
57
57
  *
58
58
  * ONE function for every caller, because the slot frame once had its own copy without the
59
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).
60
+ * the moment the org's `flex` token went (a bank's comparison, blank, 2026-09-07).
61
61
  */
62
62
  export function placeWidthStyle(declared: unknown, appSize?: Record<string, string>): Record<string, unknown> | null {
63
63
  const width = cssWidth(typeof declared === "string" ? (appSize?.[declared] ?? declared) : undefined);
@@ -377,7 +377,7 @@ export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?
377
377
  // THE MOST RECENT WINS (the `limit: 1` law) for EVERY capped select, not only a state
378
378
  // select. A slot pinned to a component by `type` with `limit: 1` kept the OLDEST card:
379
379
  // the sort lived inside the `where` branch, so a name-pinned cap sliced arrival order
380
- // (SAB voice, two Account Agent cards, 2026-09-10). A cap means "the newest N"; an
380
+ // (a bank's voice app, two Account Agent cards, 2026-09-10). A cap means "the newest N"; an
381
381
  // uncapped flow keeps arrival order.
382
382
  if (sel.where || sel.limit != null) pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
383
383
  if (sel.limit != null) pointers = pointers.slice(0, sel.limit);
@@ -129,6 +129,17 @@ export class UnoverseClient {
129
129
  return this.withSession(() => this.client.callTool({ name, arguments: args }));
130
130
  }
131
131
 
132
+ /**
133
+ * THE TASK A COMPONENT CARRIES (docs/design/lifecycle-hooks.md), run as this viewer: the server
134
+ * runs the hook in the component's own folder with the instance's fields as `props` and answers
135
+ * with the fields it filled. A component with no hook for the phase answers `{}`.
136
+ */
137
+ async runTask(uri: string, phase: "onStart" | "onEnterView", props?: Record<string, unknown>, state?: string): Promise<Record<string, unknown>> {
138
+ const res = (await this.callTool("template_task", { uri, phase, ...(props ? { props } : {}), ...(state ? { state } : {}) })) as { content?: Array<{ text?: string }> };
139
+ const text = res?.content?.[0]?.text;
140
+ return text ? (JSON.parse(text) as Record<string, unknown>) : {};
141
+ }
142
+
132
143
 
133
144
  /** resources/read a definition by URI (e.g. unoverse://components/card). Cached. A hash on
134
145
  * the address names the state it opens in (`stateOfUri`) and is never sent to the server. */
@@ -493,7 +493,7 @@ export class ComponentStore {
493
493
  // recomputed order on EVERY update, which is stable only while one thing is updating.
494
494
  // A held Agent task has two: the task's face ticking through its steps, and the
495
495
  // reply resuming once the result lands. They alternate, so the bottom slot alternated
496
- // with them and the pair visibly swapped places on screen (SAB Account Agent,
496
+ // with them and the pair visibly swapped places on screen (a bank's account agent,
497
497
  // 2026-09-08). The case it was written for is served by arrival order too: text
498
498
  // placed before a form stays above it.
499
499
  this.merge(key, this.shieldGuestFace(key, event.data));
@@ -42,6 +42,8 @@ export interface UnoverseNode {
42
42
  * content grows (new turn or streaming text), unless the user has scrolled up. Generic
43
43
  * scroll-container behavior, opt-in via data (e.g. the conversation state's scroll box). */
44
44
  autoScroll?: boolean;
45
+ /** Drag to move around this box's overflow, keeping the centre when its content changes size. */
46
+ pan?: boolean;
45
47
  /** Box: a sticky bar (its own `position: sticky` + `top` in the design definition) that stays HIDDEN while
46
48
  * the element before it (its previous sibling — e.g. a hero image) is on screen, then
47
49
  * fades in once that sibling scrolls away. Overlays the content (no flow gap). Generic