@hosty-sdk/app 0.30.0 → 0.32.0

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/README.md CHANGED
@@ -460,11 +460,20 @@ a configurable default or URL field.
460
460
 
461
461
  ```ts
462
462
  const result = await ui.navigate(entry.key, { requestId: crypto.randomUUID() });
463
+ // Explicitly promote a declared page into the embedder's main application area.
464
+ const main = await ui.navigate(entry.key, {
465
+ requestId: crypto.randomUUID(), placement: "main", search: "?session=selected-session",
466
+ });
463
467
  const picker = await ui.openToolPicker({ requestId: crypto.randomUUID() });
464
468
  // accepted means navigation was accepted / the chooser became visible.
465
469
  // Retain request IDs for uncertain retries; an accepted replay does not repeat the action.
466
470
  ```
467
471
 
472
+ Hosts opt in with `host.setMainNavigator(async surface => launchMainPage(surface))`. The SDK resolves
473
+ that caller's entry through Core before invoking it. Main placement preserves the originating panel;
474
+ only the destination query can be overridden. Legacy/standalone hosts report it unavailable instead
475
+ of silently navigating the panel. Normal navigation keeps its existing current-surface behavior.
476
+
468
477
  Document hints are `hosty_navigation=show|hide`, `hosty_tools=enabled|disabled` and
469
478
  `hosty_tool_launcher=show|hide`. The SDK removes them from the visible URL, preserving history
470
479
  state. They are not persisted. Verified host overrides take precedence, then hints, app defaults,
@@ -1,5 +1,5 @@
1
1
  import { AssistantClient } from "@hosty-sdk/app/assistant";
2
- import { type AppPermissionState, type ProviderDescriptor, type ProviderInvocation, type ProviderKind, type SpeechCapabilities, type SpeechResult } from "@hosty-sdk/app/providers";
2
+ import { type AppPermissionState, type ProviderDescriptor, type ProviderInvocation, type ProviderKind, type SpeechCapabilities, type SpeechResult, type TranslationRequest, type TranslationResult, type TranslationCapabilities } from "@hosty-sdk/app/providers";
3
3
  export type ProviderServerOptions = {
4
4
  appId?: string;
5
5
  coreOrigin?: string;
@@ -23,6 +23,9 @@ export declare class ProviderClient {
23
23
  language?: string;
24
24
  signal?: AbortSignal;
25
25
  }): Promise<SpeechResult>;
26
+ translationCapabilities(provider: Pick<ProviderDescriptor, "appId" | "key">, signal?: AbortSignal): Promise<TranslationCapabilities>;
27
+ /** Translate text or Markdown through the explicitly selected provider; never retries or falls back. */
28
+ translate(provider: Pick<ProviderDescriptor, "appId" | "key">, input: TranslationRequest, signal?: AbortSignal): Promise<TranslationResult>;
26
29
  /** User-attributed requests preserve the assistant's existing handoff and dispatch policy. */
27
30
  assistant(provider: Pick<ProviderDescriptor, "appId" | "key"> & {
28
31
  installationId?: string | null;
@@ -54,6 +54,20 @@ export class ProviderClient {
54
54
  return read(await fetch(url, { method: "POST", redirect: "error", signal,
55
55
  headers: { authorization: `Bearer ${binding.token}`, "content-type": audio.type || "audio/wav" }, body: audio }));
56
56
  }
57
+ async translationCapabilities(provider, signal) {
58
+ const binding = await this.connect({ ...provider, kind: "translation" }, signal);
59
+ return read(await fetch(`${binding.url}/capabilities`, { redirect: "error", signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000), headers: { authorization: `Bearer ${binding.token}` } }));
60
+ }
61
+ /** Translate text or Markdown through the explicitly selected provider; never retries or falls back. */
62
+ async translate(provider, input, signal) {
63
+ const deadline = signal ? AbortSignal.any([signal, AbortSignal.timeout(610_000)]) : AbortSignal.timeout(610_000);
64
+ const binding = await this.connect({ ...provider, kind: "translation" }, deadline);
65
+ const result = await read(await fetch(`${binding.url}/translations`, { method: "POST", redirect: "error", signal: deadline,
66
+ headers: { authorization: `Bearer ${binding.token}`, "content-type": "application/json" }, body: JSON.stringify(input) }));
67
+ if (typeof result.text !== "string" || result.targetLanguage !== input.targetLanguage || result.sourceLanguage !== (input.sourceLanguage ?? "auto"))
68
+ throw new ProviderError("provider_response_invalid", "Invalid translation response.", 502);
69
+ return result;
70
+ }
57
71
  /** User-attributed requests preserve the assistant's existing handoff and dispatch policy. */
58
72
  async assistant(provider, userToken) {
59
73
  const target = { ...provider, kind: "assistant" };
@@ -1,5 +1,5 @@
1
1
  /** Provider categories supported by Core. Agent execution is intentionally not part of this contract. */
2
- export type ProviderKind = "speech-to-text" | "assistant";
2
+ export type ProviderKind = "speech-to-text" | "assistant" | "translation";
3
3
  export type ProviderDescriptor = {
4
4
  appId: string;
5
5
  displayName: string;
@@ -48,6 +48,29 @@ export type SpeechCapabilities = {
48
48
  backend: string;
49
49
  ready: boolean;
50
50
  };
51
+ export type TranslationRequest = {
52
+ text: string;
53
+ targetLanguage: string;
54
+ sourceLanguage?: string;
55
+ format?: "text" | "markdown";
56
+ };
57
+ export type TranslationResult = {
58
+ text: string;
59
+ targetLanguage: string;
60
+ sourceLanguage: string;
61
+ };
62
+ export type TranslationCapabilities = {
63
+ version: 1;
64
+ formats: string[];
65
+ maxCharacters: number;
66
+ maxSegmentCharacters: number;
67
+ maxBytes: number;
68
+ maxOutputBytes: number;
69
+ timeoutSeconds: number;
70
+ backend: string;
71
+ ready: boolean;
72
+ reason?: string;
73
+ };
51
74
  /** Errors retain Core/provider codes so consumers can distinguish denial from unavailable service. */
52
75
  export declare class ProviderError extends Error {
53
76
  readonly code: string;
@@ -1,5 +1,5 @@
1
1
  import { UiCompositionModel } from "./ui-composition.js";
2
- import { type UiChromeOverrides, type UiActionOptions, type UiActionResult } from "./ui-types.js";
2
+ import { type UiNavigationOptions, type UiChromeOverrides, type UiActionOptions, type UiActionResult } from "./ui-types.js";
3
3
  import { type UiClient } from "./ui.js";
4
4
  import { type PanelTarget, type PresentationOptions, type PresentationResult, type UiEnvironment, type UiEvent } from "./ui-types.js";
5
5
  /** Direct-child transport. Discovery carries no identity and never creates a fallback host. */
@@ -19,6 +19,7 @@ export declare class EmbeddedUiClient implements UiClient {
19
19
  private state;
20
20
  private active;
21
21
  private offered;
22
+ private mainNavigation;
22
23
  private readonly onMessage;
23
24
  private readonly onFocus;
24
25
  private readonly onKey;
@@ -51,7 +52,7 @@ export declare class EmbeddedUiClient implements UiClient {
51
52
  signal?: AbortSignal;
52
53
  }) => Promise<PresentationResult>;
53
54
  openToolPicker: (options: UiActionOptions) => Promise<UiActionResult>;
54
- navigate: (entryKey: string, options: UiActionOptions) => Promise<UiActionResult>;
55
+ navigate: (entryKey: string, options: UiNavigationOptions) => Promise<UiActionResult>;
55
56
  private reportRoute;
56
57
  reportSurfaceState: (state: {
57
58
  route: string;
@@ -1,5 +1,5 @@
1
1
  import { activeNavigationKey, UiCompositionModel } from "./ui-composition.js";
2
- import { UI_COMPOSITION_CAPABILITY } from "./ui-types.js";
2
+ import { UI_COMPOSITION_CAPABILITY, UI_MAIN_NAVIGATION_CAPABILITY, navigationSearch } from "./ui-types.js";
3
3
  import { createPresentationRequestId } from "./ui.js";
4
4
  import { UiError } from "./ui-types.js";
5
5
  import { canonicalSurfaceRoute, DISCOVERY_TIMEOUT, failure, readUiMessage, uiRequest, validId, validTarget, waitForPresentation } from "./ui-transport.js";
@@ -20,6 +20,7 @@ export class EmbeddedUiClient {
20
20
  state = null;
21
21
  active = true;
22
22
  offered = false;
23
+ mainNavigation = false;
23
24
  onMessage = (event) => { void this.receive(event); };
24
25
  onFocus = () => { void this.recheck(); };
25
26
  onKey = (event) => { if (this.active && !event.defaultPrevented && event.key === "Escape")
@@ -200,6 +201,7 @@ export class EmbeddedUiClient {
200
201
  return;
201
202
  }
202
203
  clearTimeout(this.deadline);
204
+ this.mainNavigation = Array.isArray(msg.capabilities) && msg.capabilities.includes(UI_MAIN_NAVIGATION_CAPABILITY);
203
205
  this.setEnvironment("connected");
204
206
  if (Array.isArray(msg.capabilities) && msg.capabilities.includes(UI_COMPOSITION_CAPABILITY)) {
205
207
  this.composition.setHostPolicy(channel.generation, msg.revision, msg.chrome, msg.rootDisabled === true, msg.endpoint);
@@ -234,7 +236,7 @@ export class EmbeddedUiClient {
234
236
  if (signal?.aborted)
235
237
  return { status: "uncertain", requestId, code: "presentation_unknown" };
236
238
  const target = message.target;
237
- const input = message.type === "hosty:ui:status" ? null : JSON.stringify([message.type, target?.panel ?? null, target?.route ?? null, message.reuse ?? "exact", message.entryKey ?? null]);
239
+ const input = message.type === "hosty:ui:status" ? null : JSON.stringify([message.type, target?.panel ?? null, target?.route ?? null, message.reuse ?? "exact", message.entryKey ?? null, message.search ?? null]);
238
240
  if (this.localActions.has(requestId))
239
241
  return { status: "rejected", requestId, code: "request_conflict" };
240
242
  const existing = this.pending.get(requestId);
@@ -255,7 +257,7 @@ export class EmbeddedUiClient {
255
257
  return { status: "blocked", requestId, code: "presentation_busy" };
256
258
  let settle;
257
259
  const operation = new Promise(resolve => { settle = resolve; });
258
- const entry = { kind: message.type === "hosty:ui:picker" || message.type === "hosty:ui:navigate" ? "action" : "presentation", input, operation, waiters: 0, sent: false, resolve: result => {
260
+ const entry = { kind: message.type === "hosty:ui:picker" || message.type === "hosty:ui:navigate" || message.type === "hosty:ui:navigate-main" ? "action" : "presentation", input, operation, waiters: 0, sent: false, resolve: result => {
259
261
  if (this.pending.get(requestId) === entry)
260
262
  this.pending.delete(requestId);
261
263
  settle(result);
@@ -302,6 +304,13 @@ export class EmbeddedUiClient {
302
304
  return this.request(options.requestId, { type: "hosty:ui:picker" }, options.signal);
303
305
  };
304
306
  navigate = (entryKey, options) => {
307
+ if (options.placement === "main") {
308
+ if (!navigationSearch(options.search))
309
+ return Promise.resolve({ status: "rejected", requestId: options.requestId, code: "destination_invalid" });
310
+ if (!this.mainNavigation)
311
+ return Promise.resolve({ status: "unsupported", requestId: options.requestId, code: "main_navigation_unavailable" });
312
+ return this.request(options.requestId, { type: "hosty:ui:navigate-main", entryKey, search: options.search }, options.signal);
313
+ }
305
314
  const id = options.requestId, existing = this.localActions.get(id);
306
315
  if (!validId(id))
307
316
  return Promise.resolve({ status: "rejected", requestId: id, code: "invalid_presentation" });
package/dist/ui-host.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { UiCompositionModel } from "./ui-composition.js";
2
2
  import { type UiPresentation, type UiWorkspaceSnapshot, type UiWorkspaceRenderer, type UiViewDescriptor, type UiPlacementResolver } from "./ui-workspaces.js";
3
3
  export type { UiWorkspaceRenderer, UiViewDescriptor, UiPlacementResolver, UiPresentation } from "./ui-workspaces.js";
4
- import { type UiChromeOverrides, type UiActionOptions, type UiActionResult } from "./ui-types.js";
4
+ import { type UiNavigationOptions, type UiChromeOverrides, type UiActionOptions, type UiActionResult } from "./ui-types.js";
5
5
  import { type UiClient } from "./ui.js";
6
6
  import { type PanelRef, type PanelTarget, type PresentationOptions, type PresentationResult, type UiBootstrap, type UiEnvironment, type UiEvent, type UiFrameSurface, type UiPanel } from "./ui-types.js";
7
7
  export type UiTool = UiPresentation & {
@@ -45,6 +45,9 @@ export declare class UiHost implements UiClient {
45
45
  readonly composition: UiCompositionModel;
46
46
  private readonly workspaces;
47
47
  private placement?;
48
+ private mainNavigator?;
49
+ /** The host chooses where its primary application area lives. */
50
+ setMainNavigator(navigate?: (surface: UiFrameSurface) => Promise<boolean>): void;
48
51
  private readonly actions;
49
52
  private focusSequence;
50
53
  private activityGeneration;
@@ -100,9 +103,10 @@ export declare class UiHost implements UiClient {
100
103
  private trimLedger;
101
104
  private peerResult;
102
105
  private action;
103
- navigate: (entryKey: string, options: UiActionOptions) => Promise<UiActionResult>;
106
+ navigate: (entryKey: string, options: UiNavigationOptions) => Promise<UiActionResult>;
104
107
  openToolPicker: (options: UiActionOptions) => Promise<UiActionResult>;
105
108
  private pickTools;
109
+ private navigateMain;
106
110
  private navigateFrame;
107
111
  getPresentation: (id: string, options?: {
108
112
  signal?: AbortSignal;
package/dist/ui-host.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { UiCompositionModel, validChromeOverrides, withChromeHints } from "./ui-composition.js";
2
2
  import { DEFAULT_WORKSPACE, ROOT_VIEW, workspace } from "./ui-workspaces.js";
3
- import { UI_COMPOSITION_CAPABILITY } from "./ui-types.js";
3
+ import { UI_COMPOSITION_CAPABILITY, UI_MAIN_NAVIGATION_CAPABILITY, navigationSearch } from "./ui-types.js";
4
4
  import { createPresentationRequestId } from "./ui.js";
5
5
  import { UiError, panelKey, routeWithin } from "./ui-types.js";
6
6
  import { canonicalSurfaceRoute, DISCOVERY_TIMEOUT, failure, readUiMessage, uiPath, uiRequest, validId, validTarget, waitForPresentation } from "./ui-transport.js";
@@ -40,6 +40,9 @@ export class UiHost {
40
40
  composition;
41
41
  workspaces = new Map([[DEFAULT_WORKSPACE, workspace(DEFAULT_WORKSPACE)]]);
42
42
  placement;
43
+ mainNavigator;
44
+ /** The host chooses where its primary application area lives. */
45
+ setMainNavigator(navigate) { this.mainNavigator = navigate; }
43
46
  actions = new Map();
44
47
  focusSequence = 0;
45
48
  activityGeneration = 0;
@@ -628,7 +631,9 @@ export class UiHost {
628
631
  void operation.then(result => { entry.pending = undefined; entry.result = result; this.trimLedger(source); });
629
632
  return waitForPresentation(operation, id, options.signal);
630
633
  }
631
- navigate = (entryKey, options) => this.action("navigate", entryKey, options, async (check) => {
634
+ navigate = (entryKey, options) => this.action("navigate", [entryKey, options.placement, options.search], options, async (check) => {
635
+ if (options.placement === "main")
636
+ throw new UiError("main_navigation_unavailable", "This document has no parent main surface.");
632
637
  const catalog = this.getNavigation().catalog, entry = catalog?.entries.find(e => e.key === entryKey);
633
638
  if (!entry || !entry.route)
634
639
  throw new UiError("destination_changed", "Refresh this app's navigation.");
@@ -666,6 +671,31 @@ export class UiHost {
666
671
  checkRenderer();
667
672
  }, frame);
668
673
  }
674
+ navigateMain(entryKey, search, options, frame) {
675
+ return this.action("navigate-main", [entryKey, search], options, async (check) => {
676
+ if (!navigationSearch(search))
677
+ throw new UiError("destination_invalid", "Invalid navigation query.");
678
+ const navigate = this.mainNavigator;
679
+ if (!navigate)
680
+ throw new UiError("main_navigation_unavailable", "This host has no main application surface.");
681
+ await this.bootstrap();
682
+ check();
683
+ const target = { appId: frame.surface.appId, installationId: frame.surface.installationId, entryKey };
684
+ const resolved = await uiRequest(this.path, "navigation/resolve", target);
685
+ check();
686
+ if (!resolved.available || !resolved.surface)
687
+ throw new UiError(resolved.state, "This page is unavailable.");
688
+ const destination = { ...resolved.surface };
689
+ if (search !== undefined) {
690
+ const url = new URL(destination.route, destination.origin);
691
+ url.search = search;
692
+ destination.route = url.pathname + url.search + url.hash;
693
+ }
694
+ if (!await this.mainNavigator?.(destination))
695
+ throw new UiError("destination_changed", "Refresh this app's navigation.");
696
+ check();
697
+ }, frame);
698
+ }
669
699
  navigateFrame(entryKey, options, frame) {
670
700
  return this.action("navigate", entryKey, options, async (check) => {
671
701
  await this.bootstrap();
@@ -951,7 +981,7 @@ export class UiHost {
951
981
  }
952
982
  this.post(frame, { type: "hosty:ui:connected", coreOrigin: own.coreOrigin, userId: own.userId,
953
983
  appId: own.app.appId, installationId: own.app.installationId, code: frame.connected ? null : "actor_mismatch", route: frame.surface.route,
954
- ...(frame.composition ? { capabilities: [UI_COMPOSITION_CAPABILITY], chrome: frame.chrome, revision: ++frame.policyRevision,
984
+ ...(frame.composition ? { capabilities: [UI_COMPOSITION_CAPABILITY, UI_MAIN_NAVIGATION_CAPABILITY], chrome: frame.chrome, revision: ++frame.policyRevision,
955
985
  rootDisabled: !this.getChrome().tools.enabled, endpoint: frame.surface.endpoint } : {}) });
956
986
  if (frame.connected !== wasConnected)
957
987
  frame.onConnection?.(frame.connected);
@@ -962,9 +992,11 @@ export class UiHost {
962
992
  if (!frame.connected)
963
993
  return;
964
994
  const source = `${frame.surface.appId}:${frame.generation}:${frame.nonce}`;
965
- if (frame.composition && (msg.type === "hosty:ui:picker" || msg.type === "hosty:ui:navigate") && validId(msg.requestId)) {
995
+ if (frame.composition && (msg.type === "hosty:ui:picker" || msg.type === "hosty:ui:navigate" || msg.type === "hosty:ui:navigate-main") && validId(msg.requestId)) {
966
996
  const result = msg.type === "hosty:ui:picker" ? await this.pickTools({ requestId: msg.requestId }, frame)
967
- : typeof msg.entryKey === "string" ? await this.navigateFrame(msg.entryKey, { requestId: msg.requestId }, frame)
997
+ : typeof msg.entryKey === "string" ? msg.type === "hosty:ui:navigate-main"
998
+ ? await this.navigateMain(msg.entryKey, msg.search, { requestId: msg.requestId }, frame)
999
+ : await this.navigateFrame(msg.entryKey, { requestId: msg.requestId }, frame)
968
1000
  : { status: "rejected", requestId: msg.requestId, code: "destination_invalid" };
969
1001
  if (source === `${frame.surface.appId}:${frame.generation}:${frame.nonce}`)
970
1002
  this.post(frame, { type: "hosty:ui:action-result", requestId: msg.requestId, result });
@@ -165,10 +165,17 @@ export type UiResolvedNavigation = {
165
165
  state: string;
166
166
  standaloneHref: string | null;
167
167
  };
168
+ export declare const UI_MAIN_NAVIGATION_CAPABILITY = "ui.main-navigation.v1";
168
169
  export type UiActionOptions = {
169
170
  requestId: string;
170
171
  signal?: AbortSignal;
171
172
  };
173
+ /** Main placement is explicit; only the resolved navigation entry's query can be overridden. */
174
+ export type UiNavigationOptions = UiActionOptions & {
175
+ placement?: "main";
176
+ search?: string;
177
+ };
178
+ export declare function navigationSearch(value: unknown): value is string | undefined;
172
179
  export type UiActionResult = {
173
180
  status: "accepted";
174
181
  requestId: string;
package/dist/ui-types.js CHANGED
@@ -42,3 +42,8 @@ export function buildSurfaceOpenUrl(coreOrigin, destination) {
42
42
  return url.href;
43
43
  }
44
44
  export const UI_COMPOSITION_CAPABILITY = "ui.composition.v1";
45
+ export const UI_MAIN_NAVIGATION_CAPABILITY = "ui.main-navigation.v1";
46
+ export function navigationSearch(value) {
47
+ return value === undefined || typeof value === "string" && value.length <= 4096
48
+ && (value === "" || value.startsWith("?")) && !/[#\u0000-\u001f\u007f]/.test(value);
49
+ }
package/dist/ui.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { UiChromeSnapshot, UiNavigationSnapshot, UiActionOptions, UiActionResult, PanelTarget, PresentationOptions, PresentationResult, UiEnvironment, UiEvent } from "@hosty-sdk/app/ui/types";
1
+ import type { UiChromeSnapshot, UiNavigationSnapshot, UiActionOptions, UiNavigationOptions, UiActionResult, PanelTarget, PresentationOptions, PresentationResult, UiEnvironment, UiEvent } from "@hosty-sdk/app/ui/types";
2
2
  export * from "@hosty-sdk/app/ui/types";
3
3
  export interface UiClient {
4
4
  getEnvironment(): UiEnvironment;
@@ -6,7 +6,7 @@ export interface UiClient {
6
6
  subscribeChrome(listener: () => void): () => void;
7
7
  getNavigation(): UiNavigationSnapshot;
8
8
  subscribeNavigation(listener: () => void): () => void;
9
- navigate(entryKey: string, options: UiActionOptions): Promise<UiActionResult>;
9
+ navigate(entryKey: string, options: UiNavigationOptions): Promise<UiActionResult>;
10
10
  openToolPicker(options: UiActionOptions): Promise<UiActionResult>;
11
11
  openSurface(target: PanelTarget, options: PresentationOptions): Promise<PresentationResult>;
12
12
  getPresentation(requestId: string, options?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hosty-sdk/app",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -139,7 +139,7 @@
139
139
  "server-only": "^0.0.1"
140
140
  },
141
141
  "peerDependencies": {
142
- "next": "^16.3.8",
142
+ "next": "^16.4.0",
143
143
  "react": "^19.2.8",
144
144
  "react-dom": "^19.2.8"
145
145
  },
@@ -155,10 +155,10 @@
155
155
  }
156
156
  },
157
157
  "devDependencies": {
158
- "@types/node": "^24.19.1",
158
+ "@types/node": "^24.19.2",
159
159
  "@types/react": "^19.3.0",
160
160
  "@types/react-dom": "^19.3.0",
161
- "jsdom": "^30.1.1",
161
+ "jsdom": "^30.1.2",
162
162
  "react": "^19.3.0",
163
163
  "react-dom": "^19.3.0",
164
164
  "typescript": "^6",