@k2b/cloud 0.33.0 → 0.34.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.
Files changed (65) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/app-presentation.ts +40 -11
  3. package/src/_internal/define-app.ts +1 -0
  4. package/src/_internal/registry.ts +10 -1
  5. package/src/_internal/widget-declarations.ts +16 -3
  6. package/src/_internal/widget-settings.ts +93 -0
  7. package/src/ai/capabilities.ts +8 -5
  8. package/src/ai/resource-refs.ts +35 -3
  9. package/src/ai/system-prompt.ts +3 -2
  10. package/src/api/index.ts +2 -0
  11. package/src/api/mcp.ts +48 -17
  12. package/src/api/pwa.ts +7 -2
  13. package/src/api/resource-links.ts +43 -0
  14. package/src/api/widgets.ts +163 -82
  15. package/src/browser/CloudResourceSearch.tsx +6 -6
  16. package/src/browser/command-shortcuts.ts +2 -11
  17. package/src/browser/resource-picker.tsx +1 -0
  18. package/src/browser/resource-search.ts +7 -2
  19. package/src/browser/widgets.ts +23 -5
  20. package/src/capabilities/testing.ts +13 -1
  21. package/src/cli/capabilities.ts +61 -17
  22. package/src/cli/index.ts +2 -0
  23. package/src/cli/resource-links.ts +13 -0
  24. package/src/contracts/app.ts +7 -1
  25. package/src/contracts/capabilities.ts +25 -5
  26. package/src/contracts/registry.ts +3 -1
  27. package/src/contracts/resource-href.ts +7 -0
  28. package/src/contracts/widgets.ts +80 -6
  29. package/src/desktop/index.ts +3 -2
  30. package/src/desktop/solid.tsx +7 -5
  31. package/src/server/widget-request.ts +34 -5
  32. package/src/services/app-approval.ts +4 -2
  33. package/src/services/approvals/index.ts +40 -11
  34. package/src/services/approvals/list.ts +1 -0
  35. package/src/services/approvals/migrate.ts +10 -0
  36. package/src/services/pdf/markdown.ts +11 -7
  37. package/src/services/pwa-devices.ts +68 -15
  38. package/src/shared/app-presentation.ts +49 -1
  39. package/src/shared/index.ts +1 -0
  40. package/src/shared/markdown/callout-sanitizer.ts +37 -0
  41. package/src/shared/markdown/index.ts +8 -2
  42. package/src/ssr/Layout.tsx +3 -15
  43. package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
  44. package/src/ssr/LayoutHelp.tsx +319 -183
  45. package/src/ssr/LayoutHelpTrigger.tsx +1 -3
  46. package/src/ssr/LayoutPreferences.tsx +15 -5
  47. package/src/ssr/MinimalLayoutPreferences.island.tsx +3 -9
  48. package/src/ssr/MobileProfileActions.tsx +3 -8
  49. package/src/ssr/ProfilePreferences.tsx +8 -10
  50. package/src/ssr/ShellRuntime.island.tsx +1 -5
  51. package/src/ssr/ThemeCycleButton.tsx +46 -0
  52. package/src/ssr/app-appearance.ts +6 -1
  53. package/src/ssr/layout-help-url.ts +39 -0
  54. package/src/ssr/platform-messages.ts +0 -8
  55. package/src/ssr/preference-controller.ts +53 -19
  56. package/src/ssr/profile-actions.ts +12 -16
  57. package/src/ssr/profile-preferences-messages.ts +4 -2
  58. package/src/ssr/rail-navigation.ts +3 -0
  59. package/src/ssr/shell-data.tsx +2 -2
  60. package/src/styles/effects.css +13 -3
  61. package/src/styles/global.css +27 -1
  62. package/src/styles/input.css +0 -8
  63. package/src/styles/tokens.css +0 -2
  64. package/src/styles/utilities-navigation.css +10 -13
  65. package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -106,8 +106,8 @@
106
106
  "@tabler/icons": "3.48.0",
107
107
  "@tailwindcss/typography": "0.5.20",
108
108
  "@k2b/nessi": "0.18.0",
109
- "@k2b/ssr": "0.15.1",
110
- "@k2b/ui": "0.24.0",
109
+ "@k2b/ssr": "0.16.0",
110
+ "@k2b/ui": "0.25.0",
111
111
  "@k2b/stdlib": "0.29.0",
112
112
  "@k2b/sync": "7.0.0",
113
113
  "@nats-io/jetstream": "3.4.0",
@@ -1,4 +1,4 @@
1
- import type { AppMeta, AppPresentationCatalog, AppPresentationTranslation, AppWidgetTranslation } from "../contracts/app";
1
+ import type { AppMeta, AppPresentationCatalog, AppPresentationTranslation, AppWidgetTranslation, WidgetEndpoint } from "../contracts/app";
2
2
  import { WIDGET_DESCRIPTION_MAX_LENGTH, WIDGET_TITLE_MAX_LENGTH } from "../contracts/widgets";
3
3
  import { canonicalLocale } from "../shared/locale";
4
4
 
@@ -14,23 +14,53 @@ const text = (value: string, label: string, max = MAX_SHORT_TEXT): string => {
14
14
 
15
15
  const labels = (values: Readonly<Record<string, string>> | undefined, allowed: Set<string>, label: string, max = MAX_SHORT_TEXT) => {
16
16
  if (!values) return undefined;
17
- const normalized: Record<string, string> = {};
18
- for (const [key, value] of Object.entries(values)) {
19
- if (!allowed.has(key)) throw new Error(`${label} contains unknown key ${JSON.stringify(key)}`);
20
- normalized[key] = text(value, `${label}.${key}`, max);
21
- }
22
- return normalized;
17
+ return Object.fromEntries(
18
+ Object.entries(values).map(([key, value]) => {
19
+ if (!allowed.has(key)) throw new Error(`${label} contains unknown key ${JSON.stringify(key)}`);
20
+ return [key, text(value, `${label}.${key}`, max)];
21
+ }),
22
+ );
23
23
  };
24
24
 
25
25
  const widgetTexts = (
26
26
  values: Readonly<Record<string, AppWidgetTranslation>>,
27
- allowed: Set<string>,
27
+ widgets: readonly WidgetEndpoint[],
28
28
  label: string,
29
29
  ): Record<string, AppWidgetTranslation> => {
30
30
  const normalized: Record<string, AppWidgetTranslation> = {};
31
31
  for (const [id, value] of Object.entries(values)) {
32
- if (!allowed.has(id)) throw new Error(`${label} contains unknown key ${JSON.stringify(id)}`);
32
+ const widget = widgets.find((widget) => widget.id === id);
33
+ if (!widget) throw new Error(`${label} contains unknown key ${JSON.stringify(id)}`);
34
+ const settings: NonNullable<AppWidgetTranslation["settings"]> = Object.fromEntries(
35
+ Object.entries(value.settings ?? {}).map(([name, translation]) => {
36
+ const field = widget.settings?.[name];
37
+ const settingLabel = `${label}.${id}.settings.${name}`;
38
+ if (!field) throw new Error(`${settingLabel} is not declared`);
39
+ return [
40
+ name,
41
+ {
42
+ ...(translation.label !== undefined
43
+ ? { label: text(translation.label, `${settingLabel}.label`, WIDGET_TITLE_MAX_LENGTH) }
44
+ : {}),
45
+ ...(translation.placeholder !== undefined
46
+ ? { placeholder: text(translation.placeholder, `${settingLabel}.placeholder`, WIDGET_TITLE_MAX_LENGTH) }
47
+ : {}),
48
+ ...(translation.options
49
+ ? {
50
+ options: labels(
51
+ translation.options,
52
+ new Set(field.kind === "choice" ? field.options.map((option) => option.value) : []),
53
+ `${settingLabel}.options`,
54
+ WIDGET_TITLE_MAX_LENGTH,
55
+ ),
56
+ }
57
+ : {}),
58
+ },
59
+ ];
60
+ }),
61
+ );
33
62
  normalized[id] = {
63
+ ...(value.settings ? { settings } : {}),
34
64
  ...(value.title !== undefined ? { title: text(value.title, `${label}.${id}.title`, WIDGET_TITLE_MAX_LENGTH) } : {}),
35
65
  ...(value.description !== undefined
36
66
  ? { description: text(value.description, `${label}.${id}.description`, WIDGET_DESCRIPTION_MAX_LENGTH) }
@@ -55,7 +85,6 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
55
85
  const adminHrefs = new Set((app.adminNav ?? []).flatMap((group) => group.links.map((link) => link.href)));
56
86
  const searchHrefs = new Set((app.searchLinks ?? []).map((link) => link.href));
57
87
  const legalHrefs = new Set((app.legalLinks ?? []).map((link) => link.href));
58
- const widgetIds = new Set((app.widgets ?? []).map((widget) => widget.id));
59
88
  const translations: Record<string, AppPresentationTranslation> = {};
60
89
 
61
90
  for (const [locale, translation] of Object.entries(catalog.translations)) {
@@ -82,7 +111,7 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
82
111
  }
83
112
  : {}),
84
113
  ...(translation.legalLinks ? { legalLinks: labels(translation.legalLinks, legalHrefs, `${canonical}.legalLinks`) } : {}),
85
- ...(translation.widgets ? { widgets: widgetTexts(translation.widgets, widgetIds, `${canonical}.widgets`) } : {}),
114
+ ...(translation.widgets ? { widgets: widgetTexts(translation.widgets, app.widgets ?? [], `${canonical}.widgets`) } : {}),
86
115
  };
87
116
  }
88
117
 
@@ -629,6 +629,7 @@ export const defineApp = <
629
629
  : undefined,
630
630
  widgets: meta.widgets?.map((widget) => ({
631
631
  ...widget,
632
+ ...(widget.settings ? { settings: structuredClone(widget.settings) } : {}),
632
633
  sizes: widget.sizes ? [...widget.sizes] : undefined,
633
634
  requiresRoles: widget.requiresRoles ? [...widget.requiresRoles] : undefined,
634
635
  })),
@@ -10,12 +10,14 @@ import {
10
10
  resolveWidgetSizes,
11
11
  WIDGET_DESCRIPTION_MAX_LENGTH,
12
12
  WIDGET_TITLE_MAX_LENGTH,
13
+ type WidgetSettingsDeclaration,
13
14
  } from "../contracts/widgets";
14
15
  import { resolveAppPresentations } from "../shared/app-presentation";
15
16
  import { compileCapabilityPresentation, parseCapabilityManifest } from "./capabilities";
16
17
  import { lazySync } from "./process-sync";
17
18
  import { validateAppRegistryEntry } from "./registry-validation";
18
19
  import { watchRegistryChanges } from "./registry-watch";
20
+ import { WidgetSettingsDeclarationSchema } from "./widget-settings";
19
21
 
20
22
  /**
21
23
  * Shared app registry: two @k2b/sync ephemerals on the process Sync
@@ -356,6 +358,8 @@ export type DashboardWidget = {
356
358
  description: string;
357
359
  sizes: DashboardWidgetSize[];
358
360
  defaultSize: DashboardWidgetSize;
361
+ /** Validated setting presentation in the requested locale, absent when none is valid. */
362
+ settings?: WidgetSettingsDeclaration;
359
363
  suggest: boolean;
360
364
  /** Visibility only, like `nav.requiresRoles`; empty means everyone. */
361
365
  requiresRoles: Role[];
@@ -381,9 +385,13 @@ const declaredText = (value: unknown, max: number): string | undefined =>
381
385
  /** The widgets `apps` declare, resolved as `listWidgets()` returns them. */
382
386
  export const dashboardWidgetsOf = (apps: readonly AppRegistryEntry[], locale?: string): DashboardWidget[] => {
383
387
  const out: DashboardWidget[] = [];
384
- for (const app of locale ? resolveAppPresentations(apps, locale) : apps) {
388
+ for (const [index, app] of (locale ? resolveAppPresentations(apps, locale) : apps).entries()) {
385
389
  for (const w of app.widgets ?? []) {
386
390
  const { sizes, defaultSize } = resolveWidgetSizes(w);
391
+ const translated = WidgetSettingsDeclarationSchema.safeParse(w.settings);
392
+ const original = translated.success
393
+ ? translated
394
+ : WidgetSettingsDeclarationSchema.safeParse(apps[index]?.widgets?.find((widget) => widget.id === w.id)?.settings);
387
395
  out.push({
388
396
  appId: app.id,
389
397
  appName: app.name,
@@ -394,6 +402,7 @@ export const dashboardWidgetsOf = (apps: readonly AppRegistryEntry[], locale?: s
394
402
  description: declaredText(w.description, WIDGET_DESCRIPTION_MAX_LENGTH) ?? app.description,
395
403
  sizes,
396
404
  defaultSize,
405
+ ...(original.success ? { settings: original.data } : {}),
397
406
  suggest: w.suggest === true,
398
407
  requiresRoles: Array.isArray(w.requiresRoles) ? w.requiresRoles.filter((role) => RoleSchema.safeParse(role).success) : [],
399
408
  presentation: legacyPresentation(w.presentation),
@@ -4,8 +4,10 @@ import {
4
4
  DASHBOARD_WIDGET_SIZES,
5
5
  isDashboardWidgetSize,
6
6
  WIDGET_DESCRIPTION_MAX_LENGTH,
7
+ WIDGET_ID_MAX_LENGTH,
7
8
  WIDGET_TITLE_MAX_LENGTH,
8
9
  } from "../contracts/widgets";
10
+ import { WidgetSettingsDeclarationSchema } from "./widget-settings";
9
11
 
10
12
  const text = (value: string | undefined, label: string, max: number): string | undefined => {
11
13
  if (value === undefined) return undefined;
@@ -16,9 +18,9 @@ const text = (value: string | undefined, label: string, max: number): string | u
16
18
 
17
19
  /**
18
20
  * Validates and copies an app's widget declarations at `defineApp()`, so a typo fails at startup instead of a widget
19
- * silently missing from the gallery: unique ids without `@`, which separates a widget's key from the size the
20
- * dashboard asks for, bounded titles and descriptions, known sizes with the default among them, a boolean `suggest`,
21
- * and known roles.
21
+ * silently missing from the gallery: unique ids that fit Core's `widget.read:<id>` invocation, without `@`, which
22
+ * separates a widget's key from the requested size, bounded titles and descriptions, known sizes with the default
23
+ * among them, a boolean `suggest`, known roles, and a bounded settings declaration.
22
24
  */
23
25
  export const compileWidgetDeclarations = (
24
26
  appId: string,
@@ -30,6 +32,11 @@ export const compileWidgetDeclarations = (
30
32
  const label = `Widget ${JSON.stringify(widget.id)} of app ${JSON.stringify(appId)}`;
31
33
  if (!widget.id.trim()) throw new Error(`Widgets of app ${JSON.stringify(appId)} need a non-empty id`);
32
34
  if (widget.id.includes("@")) throw new Error(`${label} must not contain "@"`);
35
+ if (!/^[a-z0-9:._-]+$/.test(widget.id) || widget.id.length > WIDGET_ID_MAX_LENGTH) {
36
+ throw new Error(
37
+ `${label} id must use 1 to ${WIDGET_ID_MAX_LENGTH} lowercase letters, digits, ".", "_", "-", or ":" because Core invokes it as widget.read:<id>`,
38
+ );
39
+ }
33
40
  if (ids.has(widget.id)) throw new Error(`${label} is declared twice`);
34
41
  ids.add(widget.id);
35
42
  const sizes = widget.sizes ? [...widget.sizes] : undefined;
@@ -42,8 +49,14 @@ export const compileWidgetDeclarations = (
42
49
  if (widget.suggest !== undefined && typeof widget.suggest !== "boolean")
43
50
  throw new Error(`${label} must declare suggest as true or false`);
44
51
  if (widget.requiresRoles?.some((role) => !RoleSchema.safeParse(role).success)) throw new Error(`${label} requires an unknown role`);
52
+ const settings = widget.settings === undefined ? undefined : WidgetSettingsDeclarationSchema.safeParse(widget.settings);
53
+ if (settings && !settings.success) {
54
+ const issue = settings.error.issues[0]!;
55
+ throw new Error(`${label} setting ${JSON.stringify(issue.path[0] ?? "settings")}: ${issue.message}`);
56
+ }
45
57
  return {
46
58
  ...widget,
59
+ ...(settings?.success ? { settings: settings.data } : {}),
47
60
  title: text(widget.title, `${label} title`, WIDGET_TITLE_MAX_LENGTH),
48
61
  description: text(widget.description, `${label} description`, WIDGET_DESCRIPTION_MAX_LENGTH),
49
62
  sizes: sizes ? DASHBOARD_WIDGET_SIZES.filter((size) => sizes.includes(size)) : undefined,
@@ -0,0 +1,93 @@
1
+ import { z } from "zod";
2
+ import { CapabilityQualifiedIdSchema, CloudResourceRefSchema } from "../contracts/capabilities";
3
+ import {
4
+ WIDGET_SETTING_MAX_OPTIONS,
5
+ WIDGET_SETTING_NAME_MAX_LENGTH,
6
+ WIDGET_SETTING_TEXT_MAX_LENGTH,
7
+ WIDGET_SETTINGS_MAX_FIELDS,
8
+ WIDGET_TITLE_MAX_LENGTH,
9
+ type WidgetSettings,
10
+ type WidgetSettingsDeclaration,
11
+ type WidgetSettingValue,
12
+ } from "../contracts/widgets";
13
+
14
+ const controlCharacters = /[\u0000-\u001f\u007f-\u009f]/u;
15
+ const settingLabel = z.string().trim().min(1).max(WIDGET_TITLE_MAX_LENGTH);
16
+ /** The single declaration schema used at startup and for tolerant registry reads. */
17
+ export const WidgetSettingsDeclarationSchema = z
18
+ .record(
19
+ z.string().regex(new RegExp(`^[a-z][a-zA-Z0-9]{0,${WIDGET_SETTING_NAME_MAX_LENGTH - 1}}$`)),
20
+ z.discriminatedUnion("kind", [
21
+ z
22
+ .object({
23
+ kind: z.literal("choice"),
24
+ label: settingLabel,
25
+ options: z
26
+ .array(
27
+ z.object({
28
+ label: settingLabel,
29
+ value: z
30
+ .string()
31
+ .min(1)
32
+ .max(100)
33
+ .refine((value) => !controlCharacters.test(value)),
34
+ }),
35
+ )
36
+ .min(1)
37
+ .max(WIDGET_SETTING_MAX_OPTIONS),
38
+ default: z.string().optional(),
39
+ })
40
+ .superRefine((field, ctx) => {
41
+ if (new Set(field.options.map((option) => option.value)).size !== field.options.length)
42
+ ctx.addIssue({ code: "custom", path: ["options"], message: "Choice option values must be distinct" });
43
+ if (field.default !== undefined && !field.options.some((option) => option.value === field.default))
44
+ ctx.addIssue({ code: "custom", path: ["default"], message: "Choice default must be a declared option" });
45
+ }),
46
+ z.object({ kind: z.literal("resource"), label: settingLabel, type: CapabilityQualifiedIdSchema }),
47
+ z.object({ kind: z.literal("toggle"), label: settingLabel, default: z.boolean().optional() }),
48
+ z.object({
49
+ kind: z.literal("text"),
50
+ label: settingLabel,
51
+ placeholder: settingLabel.optional(),
52
+ maxLength: z.number().int().min(1).max(WIDGET_SETTING_TEXT_MAX_LENGTH).optional(),
53
+ }),
54
+ ]),
55
+ )
56
+ .superRefine((fields, ctx) => {
57
+ for (const name of Object.keys(fields)) {
58
+ if (name in Object.prototype)
59
+ ctx.addIssue({ code: "custom", path: [name], message: "Setting names must not be Object.prototype members" });
60
+ }
61
+ })
62
+ .refine((fields) => Object.keys(fields).length <= WIDGET_SETTINGS_MAX_FIELDS, `Declare at most ${WIDGET_SETTINGS_MAX_FIELDS} settings`);
63
+
64
+ /** Drop invalid or undeclared values and fill choice and toggle defaults without reporting input errors. */
65
+ export const parseWidgetSettings = (declaration: WidgetSettingsDeclaration | undefined, raw: unknown): WidgetSettings => {
66
+ const values = z.record(z.string(), z.unknown()).safeParse(raw);
67
+ const settings: Record<string, WidgetSettingValue> = {};
68
+ for (const [name, field] of Object.entries(declaration ?? {})) {
69
+ const value = values.success && Object.hasOwn(values.data, name) ? values.data[name] : undefined;
70
+ switch (field.kind) {
71
+ case "choice":
72
+ if (typeof value === "string" && field.options.some((option) => option.value === value)) settings[name] = value;
73
+ else if (field.default !== undefined) settings[name] = field.default;
74
+ break;
75
+ case "toggle":
76
+ if (typeof value === "boolean") settings[name] = value;
77
+ else if (field.default !== undefined) settings[name] = field.default;
78
+ break;
79
+ case "text": {
80
+ if (typeof value !== "string" || controlCharacters.test(value)) break;
81
+ const text = value.trim();
82
+ if (text && text.length <= (field.maxLength ?? WIDGET_SETTING_TEXT_MAX_LENGTH)) settings[name] = text;
83
+ break;
84
+ }
85
+ case "resource": {
86
+ const ref = CloudResourceRefSchema.safeParse(value);
87
+ if (ref.success && ref.data.type === field.type) settings[name] = ref.data;
88
+ break;
89
+ }
90
+ }
91
+ }
92
+ return settings;
93
+ };
@@ -15,8 +15,8 @@ import {
15
15
  type CapabilityActionManifest,
16
16
  type CapabilityActionReview,
17
17
  type CapabilityQueryManifest,
18
- CloudResourceRefSchema,
19
18
  cloudResourceRefAppId,
19
+ LinkedCloudResourceReferenceSchema,
20
20
  resolveCapabilityResourceReader,
21
21
  } from "../contracts/capabilities";
22
22
  import type { CapabilityRegistryEntry } from "../contracts/registry";
@@ -29,6 +29,7 @@ import { recordRejectedAiCapability } from "./capability-execution";
29
29
  import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
30
30
  import { createCodeSourceTool } from "./code-source-tools";
31
31
  import { CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES } from "./default-tools";
32
+ import { addCloudResourceHrefs } from "./resource-refs";
32
33
  import { type AiToolPreparationContext, defineAiTool, type PreparedAiTools, prepareAiTools } from "./tools";
33
34
  import type { AiConversationService, AiRuntimeTool, AiToolPresentation } from "./types";
34
35
 
@@ -638,6 +639,7 @@ export const createAiToolMetaTools = (input: {
638
639
  };
639
640
 
640
641
  export const createLoadedAiCapabilityTools = (input: {
642
+ apps: readonly CapabilityRegistryEntry[];
641
643
  catalog: readonly AiCapabilityCatalogEntry[];
642
644
  loadedNames: readonly string[];
643
645
  actor: RequestActor;
@@ -686,7 +688,7 @@ export const createLoadedAiCapabilityTools = (input: {
686
688
  throw new Error("Capability Action was rejected by the user.");
687
689
  }
688
690
  }
689
- return input.execute(entry, args, context);
691
+ return addCloudResourceHrefs(await input.execute(entry, args, context), input.apps);
690
692
  }),
691
693
  ];
692
694
  });
@@ -701,14 +703,14 @@ export const createAiResourceReaderTool = (input: {
701
703
  name: "read_cloud_resource",
702
704
  description:
703
705
  "Read a Cloud resource from its structured reference using the resource type's current canonical reader. Pass refs returned by search, Projects, or other capabilities unchanged; never substitute an id from another resource type.",
704
- inputSchema: CloudResourceRefSchema,
706
+ inputSchema: LinkedCloudResourceReferenceSchema,
705
707
  outputSchema: z.unknown(),
706
708
  approval: "never",
707
709
  }).server(async (ref, context) => {
708
710
  if (ref.type === "assistant.artifact") {
709
711
  const tool = createCodeSourceTool("code_read");
710
712
  if (tool.location !== "server") throw new Error("Code reader must execute on the server.");
711
- return tool.run(CODE_SOURCE_TOOLS.code_read.input.parse({ id: ref.id }), context);
713
+ return addCloudResourceHrefs(await tool.run(CODE_SOURCE_TOOLS.code_read.input.parse({ id: ref.id }), context), input.apps);
712
714
  }
713
715
  const appId = cloudResourceRefAppId(ref);
714
716
  const app = input.apps.find((candidate) => candidate.appId === appId);
@@ -718,7 +720,7 @@ export const createAiResourceReaderTool = (input: {
718
720
  (candidate) => candidate.appId === appId && candidate.kind === "query" && candidate.operation.localId === reader.localId,
719
721
  );
720
722
  if (!entry) throw new Error(`Cloud resource reader ${appId}.${reader.localId} is unavailable.`);
721
- return input.execute(entry, { id: ref.id }, context);
723
+ return addCloudResourceHrefs(await input.execute(entry, { id: ref.id }, context), input.apps);
722
724
  });
723
725
 
724
726
  /** Nessi resolver: one registry/load-state snapshot drives discovery, loading, schemas, and execution per model turn. */
@@ -860,6 +862,7 @@ export const createAiToolResolver =
860
862
  ...activeBuiltIns,
861
863
  ...(input.execute
862
864
  ? createLoadedAiCapabilityTools({
865
+ apps: registry,
863
866
  catalog: capabilityCatalog,
864
867
  loadedNames,
865
868
  actor: input.actor,
@@ -1,12 +1,43 @@
1
1
  import {
2
+ CloudResourceHrefSchema,
2
3
  type CloudResourceRef,
3
- type CloudResourceReference,
4
4
  CloudResourceReferenceSchema,
5
5
  type CloudResourceView,
6
6
  CloudResourceViewSchema,
7
+ cloudResourceHref,
8
+ cloudResourceRefAppId,
9
+ resolveCapabilityResourceReader,
7
10
  } from "../contracts/capabilities";
11
+ import type { CapabilityRegistryEntry } from "../contracts/registry";
8
12
  import type { AiConversationResourceObservation } from "./types";
9
13
 
14
+ const LinkedResourceReferenceSchema = CloudResourceReferenceSchema.extend({
15
+ href: CloudResourceHrefSchema.optional(),
16
+ });
17
+
18
+ export const addCloudResourceHrefs = <T>(
19
+ value: T,
20
+ apps: readonly Pick<CapabilityRegistryEntry, "appId" | "manifest">[],
21
+ origin?: string,
22
+ ) => {
23
+ if (!value || typeof value !== "object" || !("refs" in value) || !Array.isArray(value.refs)) return value;
24
+ return {
25
+ ...value,
26
+ refs: value.refs.map((reference: unknown) => {
27
+ if (reference && typeof reference === "object" && "href" in reference) return reference;
28
+ const parsed = CloudResourceReferenceSchema.safeParse(reference);
29
+ if (!parsed.success) return reference;
30
+ const ref = parsed.data;
31
+ if (!ref.id.isWellFormed()) return reference;
32
+ const app = apps.find((candidate) => candidate.appId === cloudResourceRefAppId(ref));
33
+ if (!app || !resolveCapabilityResourceReader(app.manifest, ref)) return reference;
34
+ const href = CloudResourceHrefSchema.safeParse(cloudResourceHref(ref));
35
+ if (!href.success) return reference;
36
+ return { ...ref, href: origin ? new URL(href.data, origin).href : href.data };
37
+ }),
38
+ };
39
+ };
40
+
10
41
  const observationKey = (ref: CloudResourceRef): string => `${ref.type}\0${ref.id}`;
11
42
 
12
43
  export const isConversationResourceCursor = (value: string, scope: "conversation" | "user"): boolean => {
@@ -47,9 +78,9 @@ export const collectConversationResourceObservations = (...values: unknown[]): A
47
78
  href: resource.links.find((link) => link.rel === "open")?.href,
48
79
  });
49
80
  } else {
50
- const reference = CloudResourceReferenceSchema.safeParse(value);
81
+ const reference = LinkedResourceReferenceSchema.safeParse(value);
51
82
  if (reference.success) {
52
- const resource: CloudResourceReference = reference.data;
83
+ const resource = reference.data;
53
84
  const ref: CloudResourceRef = { type: resource.type, id: resource.id };
54
85
  const previous = observations.get(observationKey(ref));
55
86
  observations.set(observationKey(ref), {
@@ -58,6 +89,7 @@ export const collectConversationResourceObservations = (...values: unknown[]): A
58
89
  ...(resource.title !== undefined ? { title: resource.title } : {}),
59
90
  ...(resource.preview !== undefined ? { preview: resource.preview } : {}),
60
91
  ...(resource.icon !== undefined ? { icon: resource.icon } : {}),
92
+ ...(resource.href !== undefined ? { href: resource.href } : {}),
61
93
  });
62
94
  }
63
95
  }
@@ -171,6 +171,7 @@ export const composeAiSystemPrompt = (input: AiSystemPromptInput): string => {
171
171
  ? [
172
172
  "# Local client",
173
173
  'This turn comes from a local client. Where this section describes what the user sees, it replaces "What the user sees" above; it cannot override the other platform rules.',
174
+ "Offer the supplied link for every Cloud resource you create, change, or name, because cld and editors show these links as full Cloud addresses.",
174
175
  `Client: ${input.localClient.client.kind === "cli" ? "terminal (cld)" : `editor via ACP${input.localClient.client.name ? ` (${input.localClient.client.name})` : ""}`}`,
175
176
  input.localClient.cloudUrl.trim() ? `Cloud: ${withoutTrailingSlashes(input.localClient.cloudUrl.trim())}` : undefined,
176
177
  `Workspace: ${input.localClient.client.cwd}`,
@@ -244,8 +245,8 @@ export const composeAiSystemPrompt = (input: AiSystemPromptInput): string => {
244
245
  ].join("\n"),
245
246
  [
246
247
  "# Cloud resource links",
247
- "When the answer mentions a Cloud resource and its result or supplied context includes an open or edit href, make the resource's human-readable title a Markdown link using that exact href. Use the title exactly as supplied, even when answering in another language. Apply this to every mentioned resource, including list items and headings. Prefer open over edit. Without a supplied href, use plain text. Never construct a Cloud URL.",
248
- "Example: [Urgent invoice review 001](/app/mail/5guDsC?conversation=nTf34n) — payment deadline approaching.",
248
+ "Cloud adds an href to resource refs it can open. When the answer mentions a Cloud resource, use its ref's href or the result's open or edit link to make the resource's human-readable title a Markdown link using that exact href. Use the title exactly as supplied, even when answering in another language. Apply this to every mentioned resource, including list items and headings. Prefer open over edit. Without a supplied href, use plain text. Never construct a Cloud URL.",
249
+ "Example: [Urgent invoice review 001](/open/mail.draft/~AbC123) — payment deadline approaching.",
249
250
  ].join("\n"),
250
251
  ];
251
252
 
package/src/api/index.ts CHANGED
@@ -121,3 +121,5 @@ export {
121
121
  createMcpRoutes,
122
122
  publicCloudOrigin,
123
123
  } from "./mcp";
124
+
125
+ export { resolveCloudResourceHref } from "./resource-links";
package/src/api/mcp.ts CHANGED
@@ -17,6 +17,7 @@ import { z } from "zod";
17
17
  import { readBoundedJson } from "../_internal/bounded-json";
18
18
  import { HELP_SEARCH_MAX_LIMIT, helpResourceUri, parseHelpResourceUri, readHelpArticle } from "../_internal/help-catalog";
19
19
  import { getCapability, listApps } from "../_internal/registry";
20
+ import { addCloudResourceHrefs } from "../ai/resource-refs";
20
21
  import { capabilityValueMeta, recordCapabilityExecution } from "../capabilities/executions";
21
22
  import {
22
23
  CAPABILITY_FRAMEWORK_ERROR_CODES,
@@ -26,9 +27,11 @@ import {
26
27
  type CapabilityActionManifest,
27
28
  type CapabilityQueryManifest,
28
29
  CapabilitySemanticLinkSchema,
29
- CloudResourceRefSchema,
30
+ type CloudResourceRef,
30
31
  capabilityResultJsonSchema,
32
+ capabilityResultSchema,
31
33
  cloudResourceRefAppId,
34
+ LinkedCloudResourceReferenceSchema,
32
35
  resolveCapabilityResourceReader,
33
36
  } from "../contracts/capabilities";
34
37
  import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
@@ -121,7 +124,7 @@ const platformTools: Tool[] = [
121
124
  title: "Read Cloud resource",
122
125
  description:
123
126
  "Read one Cloud resource through its current canonical reader. Pass a typed ref returned by search, list, lookup, or another capability unchanged; never guess a reader name or substitute an id from another resource type.",
124
- inputSchema: structuredClone(z.toJSONSchema(CloudResourceRefSchema)) as Tool["inputSchema"],
127
+ inputSchema: structuredClone(z.toJSONSchema(LinkedCloudResourceReferenceSchema)) as Tool["inputSchema"],
125
128
  annotations: { title: "Read Cloud resource", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
126
129
  _meta: { "cloud/kind": "resource-read" },
127
130
  },
@@ -206,7 +209,7 @@ const projectTool = (
206
209
  title: `${app.appName}: ${operation.title}`,
207
210
  description: operation.description,
208
211
  inputSchema: action ? actionInputSchema(action) : cloneObjectSchema(operation.inputSchema),
209
- outputSchema: cloneObjectSchema(capabilityResultJsonSchema(operation.dataSchema)),
212
+ outputSchema: cloneObjectSchema(capabilityResultJsonSchema(operation.dataSchema, { consumer: true })),
210
213
  annotations: {
211
214
  title: operation.title,
212
215
  readOnlyHint: !isAction,
@@ -300,7 +303,7 @@ const findCapabilityTool = async (
300
303
  };
301
304
 
302
305
  const findResourceReaderTool = async (
303
- ref: z.infer<typeof CloudResourceRefSchema>,
306
+ ref: CloudResourceRef,
304
307
  lookup: (appId: string) => Promise<CapabilityRegistryEntry | null>,
305
308
  registry: () => Promise<AppRegistryEntry[]>,
306
309
  ): Promise<CapabilityTool | null> => {
@@ -333,15 +336,20 @@ const responseTooLargeToolResult = (): CallToolResult => {
333
336
  return { isError: true, structuredContent: error, content: [{ type: "text", text: JSON.stringify(error) }] };
334
337
  };
335
338
 
339
+ const toolResult = (body: Record<string, unknown>, isError: boolean): CallToolResult => ({
340
+ ...(isError ? { isError: true } : {}),
341
+ structuredContent: body,
342
+ content: [{ type: "text", text: JSON.stringify(body) }],
343
+ });
344
+
345
+ const fitsToolResult = (result: CallToolResult): boolean =>
346
+ new TextEncoder().encode(JSON.stringify(result)).byteLength <= CAPABILITY_MAX_RESULT_BYTES;
347
+
336
348
  const ensureBoundedToolResult = (result: CallToolResult): CallToolResult =>
337
- new TextEncoder().encode(JSON.stringify(result)).byteLength > CAPABILITY_MAX_RESULT_BYTES ? responseTooLargeToolResult() : result;
349
+ fitsToolResult(result) ? result : responseTooLargeToolResult();
338
350
 
339
351
  const boundedToolResult = (body: Record<string, unknown>, isError: boolean): CallToolResult =>
340
- ensureBoundedToolResult({
341
- ...(isError ? { isError: true } : {}),
342
- structuredContent: body,
343
- content: [{ type: "text", text: JSON.stringify(body) }],
344
- });
352
+ ensureBoundedToolResult(toolResult(body, isError));
345
353
 
346
354
  const semanticResourceLinks = (body: Record<string, unknown>, origin: string): CallToolResult["content"] => {
347
355
  const found = new Map<string, z.infer<typeof CapabilitySemanticLinkSchema>>();
@@ -573,14 +581,14 @@ const createMcpServer = (
573
581
  let args: Record<string, unknown>;
574
582
  try {
575
583
  if (message.params.name === RESOURCE_READ_TOOL) {
576
- const parsed = CloudResourceRefSchema.safeParse(message.params.arguments);
584
+ const parsed = LinkedCloudResourceReferenceSchema.safeParse(message.params.arguments);
577
585
  if (!parsed.success) {
578
586
  const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; ");
579
587
  return validationToolError(
580
588
  `cloud__resource__read requires one exact typed ref returned by a current Cloud capability (${issues})`,
581
589
  );
582
590
  }
583
- selected = await findResourceReaderTool(parsed.data, capabilityLookup, registry);
591
+ selected = await findResourceReaderTool({ type: parsed.data.type, id: parsed.data.id }, capabilityLookup, registry);
584
592
  args = { id: parsed.data.id };
585
593
  if (!selected) {
586
594
  return boundedToolResult(
@@ -646,12 +654,35 @@ const createMcpServer = (
646
654
  authority,
647
655
  dependencies,
648
656
  });
649
- const body = await parseResponse(response);
650
- const result = boundedToolResult(body, !response.ok);
651
- if (response.ok) {
652
- result.content.push(...semanticResourceLinks(body, publicCloudOrigin(await (dependencies.getAppUrl ?? defaultAppUrl)())));
657
+ const providerBody = await parseResponse(response);
658
+ let body = providerBody;
659
+ const origin = response.ok ? publicCloudOrigin(await (dependencies.getAppUrl ?? defaultAppUrl)()) : undefined;
660
+ if (origin) {
661
+ const parsed = capabilityResultSchema(z.unknown()).safeParse(body);
662
+ const appIds = new Set(parsed.success ? parsed.data.refs?.map(cloudResourceRefAppId) : []);
663
+ const apps = appIds.size ? await registry().catch(() => []) : [];
664
+ const capabilities = await Promise.all(
665
+ apps
666
+ .filter((app) => appIds.has(app.id) && app.capabilities?.protocolVersion === CAPABILITY_PROTOCOL_VERSION)
667
+ .map(async (app) => {
668
+ const entry = await capabilityLookup(app.id).catch(() => null);
669
+ return entry?.appId === app.id && entry.manifest.manifestHash === app.capabilities?.manifestHash ? entry : null;
670
+ }),
671
+ );
672
+ body = addCloudResourceHrefs(
673
+ body,
674
+ capabilities.filter((entry) => entry !== null),
675
+ origin,
676
+ );
653
677
  }
654
- return ensureBoundedToolResult(result);
678
+ const resultFor = (value: Record<string, unknown>): CallToolResult => {
679
+ const result = toolResult(value, !response.ok);
680
+ if (origin) result.content.push(...semanticResourceLinks(value, origin));
681
+ return result;
682
+ };
683
+ // Added hrefs are optional: they never turn a result that fits into RESPONSE_TOO_LARGE.
684
+ const result = resultFor(body);
685
+ return ensureBoundedToolResult(fitsToolResult(result) || body === providerBody ? result : resultFor(providerBody));
655
686
  });
656
687
 
657
688
  return server;
package/src/api/pwa.ts CHANGED
@@ -134,11 +134,16 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
134
134
  describeRoute({
135
135
  tags,
136
136
  summary: "Read a pairing",
137
- description: "Only for the web session that started it. Never returns the code.",
137
+ description:
138
+ "Only for the web session that started it. Never returns the code. A pending read is held for up to one poll interval and answers as soon as the phone claims the link.",
138
139
  responses: { 200: jsonResponse(PwaPairingStatusSchema, "Pairing state") },
139
140
  }),
140
141
  v("param", IdParamSchema, pwaInvalidRequest),
141
- async (c) => c.json(await service.inspectPairing(await webActor(c), c.req.valid("param").id)),
142
+ async (c) => {
143
+ const actor = await webActor(c);
144
+ const hold = { ms: PWA_LIMITS.pollSeconds * 1000, signal: c.req.raw.signal };
145
+ return c.json(await service.inspectPairing(actor, c.req.valid("param").id, hold));
146
+ },
142
147
  )
143
148
  .post(
144
149
  "/pairings/:id/confirm",