@k2b/cloud 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.
Files changed (171) hide show
  1. package/package.json +7 -4
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +17 -5
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/app-skills.ts +1 -7
  8. package/src/ai/approval-routes.ts +40 -4
  9. package/src/ai/approvals.ts +68 -10
  10. package/src/ai/audio-tool.ts +2 -1
  11. package/src/ai/browser.ts +109 -0
  12. package/src/ai/capabilities.ts +6 -2
  13. package/src/ai/chart-block.ts +431 -81
  14. package/src/ai/chat/action-sentences.ts +13 -7
  15. package/src/ai/chat/blocks.tsx +64 -39
  16. package/src/ai/chat/builtin-tools.tsx +9 -7
  17. package/src/ai/chat/chart-block.tsx +218 -44
  18. package/src/ai/chat/explorer-block.tsx +276 -0
  19. package/src/ai/chat/file-tools.tsx +40 -1
  20. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  21. package/src/ai/chat/message-actions.tsx +9 -1
  22. package/src/ai/chat/message-utils.ts +7 -0
  23. package/src/ai/chat/messages.ts +34 -0
  24. package/src/ai/chat/presentation.tsx +13 -3
  25. package/src/ai/chat/reference-text.tsx +34 -0
  26. package/src/ai/chat/tool-groups.ts +67 -12
  27. package/src/ai/chat/tool-names.ts +18 -2
  28. package/src/ai/chat/turn-layout.ts +8 -0
  29. package/src/ai/chat/turn-view.tsx +111 -32
  30. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  31. package/src/ai/chat/view-block.tsx +417 -0
  32. package/src/ai/chat/visual-tools.tsx +32 -53
  33. package/src/ai/chat/web-tools.tsx +95 -1
  34. package/src/ai/client/controller.ts +6 -3
  35. package/src/ai/client-tool-results.ts +9 -0
  36. package/src/ai/code-runtime-tools.ts +50 -17
  37. package/src/ai/code-source-contracts.ts +25 -21
  38. package/src/ai/code-source-tools.ts +3 -1
  39. package/src/ai/default-tool-contracts.ts +334 -91
  40. package/src/ai/default-tools.ts +87 -11
  41. package/src/ai/executor.ts +343 -45
  42. package/src/ai/fetch-file-tool.ts +41 -4
  43. package/src/ai/file-error.ts +2 -0
  44. package/src/ai/file-tools.ts +27 -7
  45. package/src/ai/files-store.ts +60 -0
  46. package/src/ai/firecrawl-tools.ts +87 -4
  47. package/src/ai/html-pdf-tool.ts +3 -2
  48. package/src/ai/http.ts +37 -12
  49. package/src/ai/index.ts +98 -0
  50. package/src/ai/local-tool-contracts.ts +244 -0
  51. package/src/ai/malformed-tool-call-repair.ts +144 -0
  52. package/src/ai/markdown-pdf-tool.ts +2 -1
  53. package/src/ai/migrate.ts +120 -2
  54. package/src/ai/openai-api-messages.ts +53 -0
  55. package/src/ai/openai-api-slots.ts +40 -0
  56. package/src/ai/openai-api.ts +383 -0
  57. package/src/ai/protocol.ts +28 -3
  58. package/src/ai/provider.ts +16 -4
  59. package/src/ai/request-context.ts +33 -0
  60. package/src/ai/routes.ts +99 -49
  61. package/src/ai/runtime.ts +90 -15
  62. package/src/ai/settings.ts +5 -0
  63. package/src/ai/skill-catalog.ts +20 -5
  64. package/src/ai/skill-format.ts +17 -7
  65. package/src/ai/skill-tool.ts +15 -5
  66. package/src/ai/skills-routes.ts +20 -6
  67. package/src/ai/skills.ts +26 -7
  68. package/src/ai/store.ts +91 -1
  69. package/src/ai/system-prompt.ts +69 -10
  70. package/src/ai/timeline.ts +3 -1
  71. package/src/ai/tool-audit.ts +11 -1
  72. package/src/ai/tools.ts +94 -3
  73. package/src/ai/types.ts +48 -0
  74. package/src/ai/view-block.ts +941 -0
  75. package/src/ai/vision-tool.ts +2 -1
  76. package/src/ai/web-provenance.ts +103 -0
  77. package/src/api/admin-identity.ts +6 -1
  78. package/src/api/app-approval-actions.ts +36 -0
  79. package/src/api/app-approval.ts +10 -2
  80. package/src/api/approvals-loader.ts +15 -0
  81. package/src/api/approvals.ts +120 -0
  82. package/src/api/capabilities.ts +3 -2
  83. package/src/api/index.ts +3 -0
  84. package/src/api/me.ts +12 -3
  85. package/src/api/widgets.ts +36 -5
  86. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  87. package/src/approvals/Approvals.tsx +621 -0
  88. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  89. package/src/approvals/messages.ts +95 -0
  90. package/src/approvals/ui.ts +8 -0
  91. package/src/browser/CloudResourceSearch.tsx +1 -1
  92. package/src/browser/app-approval.ts +32 -0
  93. package/src/browser/choose-files.tsx +26 -13
  94. package/src/browser/command-shortcuts.ts +21 -6
  95. package/src/browser/commands.ts +13 -4
  96. package/src/browser/file-provider-error.ts +11 -0
  97. package/src/browser/file-providers.ts +3 -12
  98. package/src/browser/files.tsx +1 -2
  99. package/src/browser/live-connection.ts +2 -3
  100. package/src/browser/provider-list.ts +2 -1
  101. package/src/browser/save-files.tsx +13 -2
  102. package/src/browser/spotlight-position.ts +4 -1
  103. package/src/browser/widgets.ts +7 -5
  104. package/src/capabilities/executions.ts +3 -0
  105. package/src/cli/account.ts +21 -2
  106. package/src/cli/commands.ts +24 -2
  107. package/src/cli/index.ts +16 -0
  108. package/src/cli/plugin.ts +30 -0
  109. package/src/contracts/app-approval.ts +120 -0
  110. package/src/contracts/app.ts +34 -2
  111. package/src/contracts/approval-paths.ts +4 -0
  112. package/src/contracts/approvals.ts +81 -0
  113. package/src/contracts/command-path.ts +17 -0
  114. package/src/contracts/commands.ts +5 -15
  115. package/src/contracts/registry.ts +9 -1
  116. package/src/contracts/shared.ts +5 -0
  117. package/src/contracts/widgets.ts +47 -2
  118. package/src/desktop/solid.tsx +6 -7
  119. package/src/events/live-protocol.ts +2 -1
  120. package/src/events/live-server-message.ts +40 -0
  121. package/src/events/testing.ts +88 -0
  122. package/src/server/index.ts +3 -0
  123. package/src/server/middleware/auth.ts +11 -1
  124. package/src/server/request-timeout.ts +22 -0
  125. package/src/server/services/access.ts +4 -3
  126. package/src/server/widget-request.ts +27 -0
  127. package/src/services/app-approval.ts +103 -15
  128. package/src/services/approvals/cursor.ts +15 -0
  129. package/src/services/approvals/device.ts +112 -0
  130. package/src/services/approvals/index.ts +241 -0
  131. package/src/services/approvals/list.ts +120 -0
  132. package/src/services/approvals/migrate.ts +41 -0
  133. package/src/services/identity/models-only-auth.ts +25 -0
  134. package/src/services/notifications/email-frame.ts +16 -3
  135. package/src/services/service-account-credentials.ts +22 -10
  136. package/src/services/settings/core-settings.ts +27 -0
  137. package/src/shared/ai-platform-prompt.ts +26 -1
  138. package/src/shared/app-presentation.ts +11 -0
  139. package/src/shared/email-html.ts +46 -7
  140. package/src/shared/index.ts +6 -1
  141. package/src/shared/markdown/extensions/code.ts +14 -0
  142. package/src/shared/markdown/index.ts +4 -4
  143. package/src/ssr/AdminLayout.tsx +4 -7
  144. package/src/ssr/AppBadge.tsx +4 -4
  145. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  146. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  147. package/src/ssr/Layout.tsx +38 -14
  148. package/src/ssr/LayoutFooter.tsx +1 -1
  149. package/src/ssr/LayoutHeader.tsx +17 -20
  150. package/src/ssr/LayoutHelp.tsx +1 -1
  151. package/src/ssr/LayoutRail.tsx +1 -2
  152. package/src/ssr/PageTrail.tsx +23 -0
  153. package/src/ssr/PageTrailRow.island.tsx +20 -0
  154. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  155. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  156. package/src/ssr/RailApps.island.tsx +2 -2
  157. package/src/ssr/app-badges.ts +50 -6
  158. package/src/ssr/islands/index.ts +1 -0
  159. package/src/ssr/layout-runtime.ts +60 -0
  160. package/src/ssr/mobile-menu-history.ts +1 -1
  161. package/src/ssr/page-trail-runtime.ts +313 -0
  162. package/src/ssr/page-trail.browser-harness.tsx +265 -0
  163. package/src/ssr/page-trail.ts +59 -0
  164. package/src/ssr/platform-messages.ts +4 -0
  165. package/src/ssr/rail-navigation.ts +5 -3
  166. package/src/ssr/runtime.ts +6 -1
  167. package/src/styles/approvals.css +390 -0
  168. package/src/styles/effects.css +21 -4
  169. package/src/styles/global.css +39 -3
  170. package/src/styles/utilities-layout.css +24 -1
  171. package/src/styles/utilities-navigation.css +6 -18
@@ -0,0 +1,95 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+
3
+ export const approvalMessages = i18n.define({
4
+ baseLocale: "en",
5
+ messages: {
6
+ en: {
7
+ title: "Approvals",
8
+ open: ({ count }: { count: number }) => `${count.toLocaleString("en")} open`,
9
+ rejectAll: "Reject all",
10
+ rejectAllTitle: "Reject all open approvals?",
11
+ rejectAllBody: ({ count }: { count: number }) =>
12
+ `${count === 1 ? "The open Action does not run." : `None of the ${count.toLocaleString("en")} open Actions runs.`} Where Cloud asked, it continues with your rejection.`,
13
+ emptyTitle: "Nothing is waiting for you.",
14
+ emptyBody: "When an Action needs your approval, it appears here and in the chat it comes from.",
15
+ loading: "Loading approvals…",
16
+ loadFailed: "Approvals could not be loaded.",
17
+ retry: "Try again",
18
+ showMore: "Show more",
19
+ loadMoreFailed: "More approvals could not be loaded.",
20
+ elsewhere: ({ count }: { count: number }) => `More approvals in other apps (${count.toLocaleString("en")})`,
21
+ allApprovals: "All approvals",
22
+ fromChat: ({ title }: { title: string }) => `from chat “${title}”`,
23
+ fromUntitledChat: "from a chat",
24
+ asksEveryTime: "Asks every time",
25
+ expiresIn: ({ time }: { time: string }) => `Expires ${time}`,
26
+ details: "Details",
27
+ reject: "Reject",
28
+ approve: "Approve",
29
+ approveForChat: "Approve for this chat",
30
+ allowWebsiteForChat: "Allow this website in this chat",
31
+ alwaysApprove: "Always approve",
32
+ moreOptions: "More approval options",
33
+ decidedHere: "Just now, decided here",
34
+ notApplied: "Your decision was not applied",
35
+ approved: "Approved",
36
+ rejected: "Rejected",
37
+ decidedElsewhere: "Already decided elsewhere",
38
+ gone: "Expired or withdrawn",
39
+ decisionFailed: "The decision was not saved. Try again.",
40
+ openIn: ({ app }: { app: string }) => `Open in ${app}`,
41
+ editIn: ({ app }: { app: string }) => `Edit in ${app}`,
42
+ statusIn: ({ app }: { app: string }) => `Status in ${app}`,
43
+ preview: "Preview",
44
+ download: "Download",
45
+ exactlyThis: "This approval applies to exactly this Action. If it changes before it runs, Cloud asks again.",
46
+ approvalFor: ({ title }: { title: string }) => `Approval: ${title}`,
47
+ close: "Close",
48
+ waiting: ({ count }: { count: number }) => (count === 0 ? "nothing waiting" : `${count.toLocaleString("en")} waiting`),
49
+ },
50
+ de: {
51
+ title: "Freigaben",
52
+ open: ({ count }) => `${count.toLocaleString("de")} offen`,
53
+ rejectAll: "Alle ablehnen",
54
+ rejectAllTitle: "Alle offenen Freigaben ablehnen?",
55
+ rejectAllBody: ({ count }) =>
56
+ `${count === 1 ? "Die offene Aktion läuft nicht." : `Keine der ${count.toLocaleString("de")} offenen Aktionen läuft.`} Wo Cloud gefragt hat, geht es mit deiner Ablehnung weiter.`,
57
+ emptyTitle: "Nichts wartet auf dich.",
58
+ emptyBody: "Wenn eine Aktion deine Freigabe braucht, erscheint sie hier und im Chat, aus dem sie kommt.",
59
+ loading: "Freigaben werden geladen …",
60
+ loadFailed: "Die Freigaben konnten nicht geladen werden.",
61
+ retry: "Erneut versuchen",
62
+ showMore: "Mehr anzeigen",
63
+ loadMoreFailed: "Weitere Freigaben konnten nicht geladen werden.",
64
+ elsewhere: ({ count }) => `Weitere Freigaben in anderen Apps (${count.toLocaleString("de")})`,
65
+ allApprovals: "Alle Freigaben",
66
+ fromChat: ({ title }) => `aus Chat „${title}“`,
67
+ fromUntitledChat: "aus einem Chat",
68
+ asksEveryTime: "Fragt immer",
69
+ expiresIn: ({ time }) => `Läuft ab ${time}`,
70
+ details: "Details",
71
+ reject: "Ablehnen",
72
+ approve: "Freigeben",
73
+ approveForChat: "Für diesen Chat freigeben",
74
+ allowWebsiteForChat: "Diese Website in diesem Chat erlauben",
75
+ alwaysApprove: "Immer freigeben",
76
+ moreOptions: "Weitere Freigabeoptionen",
77
+ decidedHere: "Gerade eben, hier entschieden",
78
+ notApplied: "Deine Entscheidung wurde nicht übernommen",
79
+ approved: "Freigegeben",
80
+ rejected: "Abgelehnt",
81
+ decidedElsewhere: "Schon anderswo entschieden",
82
+ gone: "Abgelaufen oder zurückgezogen",
83
+ decisionFailed: "Die Entscheidung wurde nicht gespeichert. Versuche es erneut.",
84
+ openIn: ({ app }) => `In ${app} öffnen`,
85
+ editIn: ({ app }) => `In ${app} bearbeiten`,
86
+ statusIn: ({ app }) => `Status in ${app}`,
87
+ preview: "Vorschau",
88
+ download: "Herunterladen",
89
+ exactlyThis: "Diese Freigabe gilt genau für diese Aktion. Ändert sie sich, bevor sie läuft, fragt Cloud neu.",
90
+ approvalFor: ({ title }) => `Freigabe: ${title}`,
91
+ close: "Schließen",
92
+ waiting: ({ count }) => (count === 0 ? "nichts offen" : `${count.toLocaleString("de")} offen`),
93
+ },
94
+ },
95
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The shared list of pending Action approvals. Every app mounts the same component for its own scope; Core serves the
3
+ * route behind it and decides through the platform's approval requests.
4
+ */
5
+
6
+ export type { ApprovalsProps } from "./Approvals";
7
+ export { default as Approvals } from "./Approvals";
8
+ export { approvalNavCount, default as ApprovalsNavStatus } from "./ApprovalsNavStatus";
@@ -824,7 +824,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
824
824
  focusInput();
825
825
  }}
826
826
  >
827
- ← {t().back}
827
+ <i class="ti ti-arrow-left" aria-hidden="true" /> {t().back}
828
828
  </Button>
829
829
  <div class="cloud-resource-search__preview-app">
830
830
  <i class={item().icon ?? item().appIcon} aria-hidden="true" />
@@ -4,9 +4,12 @@ export { type AppVaultBlob, type AppVaultConfig, AppVaultError, type AppVaultMet
4
4
 
5
5
  import { z } from "zod";
6
6
  import {
7
+ APP_ACTION_LIMITS,
7
8
  APP_APPROVAL_LIMITS,
8
9
  APP_APPROVAL_PATH,
9
10
  APP_APPROVAL_PROTOCOL,
11
+ type AppActionView,
12
+ AppActionViewSchema,
10
13
  AppDeviceCommandSchema,
11
14
  AppDeviceProofSchema,
12
15
  type AppDevicePublicKey,
@@ -19,6 +22,7 @@ import {
19
22
  AppPairingResultSchema,
20
23
  type AppPendingLogin,
21
24
  AppPendingLoginSchema,
25
+ appActionDigest,
22
26
  appDeviceProofMessage,
23
27
  appPairingLink,
24
28
  appPairingProofMessage,
@@ -84,6 +88,8 @@ const InfoSchema = z.object({
84
88
  appOrigin: z.string(),
85
89
  algorithm: z.literal("ES256"),
86
90
  limits: z.object(Object.fromEntries(Object.entries(APP_APPROVAL_LIMITS).map(([key, value]) => [key, z.literal(value)]))),
91
+ /** Absent on Clouds that serve only the first version's commands. */
92
+ features: z.array(z.string().max(40)).max(16).optional(),
87
93
  });
88
94
 
89
95
  /** Call only after the user explicitly trusts the issuer from a pairing link.
@@ -215,6 +221,31 @@ const connect = async (options: {
215
221
  throw new AppApprovalClientError("INVALID_RESPONSE");
216
222
  return result;
217
223
  },
224
+ /** Whether this Cloud serves `inbox`, `action` and `act`. Older Clouds answer those commands with HTTP 400. */
225
+ actions: info.features?.includes("actions") === true,
226
+ /** Pending sign-ins and the newest open Action approvals in one call; use it instead of `pending` where `actions` is true. */
227
+ inbox: async (device: AppApprovalDevice, signal?: AbortSignal) => {
228
+ const result = await command(device, { operation: "inbox" }, signal);
229
+ if (!("actions" in result)) throw new AppApprovalClientError("INVALID_RESPONSE");
230
+ return result;
231
+ },
232
+ /** One open Action approval. The digest is recomputed here, so a view that does not match it is never returned. */
233
+ action: async (device: AppApprovalDevice, actionId: string, signal?: AbortSignal): Promise<AppActionView> => {
234
+ const result = await command(device, { operation: "action", actionId }, signal);
235
+ if (!("digest" in result) || result.actionId !== actionId || (await appActionDigest(issuer, result)) !== result.digest)
236
+ throw new AppApprovalClientError("INVALID_RESPONSE");
237
+ return result;
238
+ },
239
+ /** Signs the decision over the view's digest. Only call it on an explicit choice of the person. */
240
+ act: async (device: AppApprovalDevice, view: AppActionView, decision: "approve" | "deny", signal?: AbortSignal) => {
241
+ view = AppActionViewSchema.parse(view);
242
+ if (Date.parse(view.expiresAt) <= Date.now() || (await appActionDigest(issuer, view)) !== view.digest)
243
+ throw new AppApprovalClientError("INVALID_INPUT");
244
+ const result = await command(device, { operation: "act", actionId: view.actionId, digest: view.digest, decision }, signal);
245
+ if (!("state" in result) || result.state !== (decision === "approve" ? "approved" : "denied"))
246
+ throw new AppApprovalClientError("INVALID_RESPONSE");
247
+ return result;
248
+ },
218
249
  /** Reads the account this device signs in and the device's own record. Older Clouds answer HTTP 400. */
219
250
  account: async (device: AppApprovalDevice, signal?: AbortSignal) => {
220
251
  const result = await command(device, { operation: "account" }, signal);
@@ -243,4 +274,5 @@ export const appApproval = {
243
274
  createKey,
244
275
  connect,
245
276
  limits: APP_APPROVAL_LIMITS,
277
+ actionLimits: APP_ACTION_LIMITS,
246
278
  };
@@ -1,9 +1,10 @@
1
1
  import { showFileDialog } from "@k2b/stdlib/browser";
2
2
  import { dialogCore, panelDialogWorkspaceOptions } from "@k2b/ui";
3
3
  import { createSignal } from "solid-js";
4
- import { type ChooseFilesOptions, FileChooser, type FileProviderList } from "./FileChooser";
4
+ import type { ChooseFilesOptions, FileProviderList } from "./FileChooser";
5
5
  import type { FileProviderCaller } from "./file-providers";
6
6
  import type { ProviderList } from "./provider-list";
7
+ import { importOnDemand } from "./reload";
7
8
 
8
9
  /**
9
10
  * One page's file choosing over its provider list. `caller` carries the locale and, in tests, the transport.
@@ -39,18 +40,30 @@ export const createFileChoosing = (
39
40
  );
40
41
  };
41
42
  if (!known) load();
42
- return dialogCore
43
- .open<File[]>(
44
- (close) => <FileChooser options={options} providers={providers} retryProviders={load} caller={caller} close={close} />,
45
- {
46
- ...panelDialogWorkspaceOptions,
47
- panelClassName: `${panelDialogWorkspaceOptions.panelClassName} cloud-file-chooser-dialog`,
48
- initialFocus: (dialog) => dialog.querySelector<HTMLElement>('[role="gridcell"][tabindex="0"]'),
49
- signal: options.signal,
50
- history: true,
51
- },
52
- )
53
- .then((files) => files ?? []);
43
+ return (async () => {
44
+ let abort = () => {};
45
+ const chooser = await Promise.race([
46
+ importOnDemand(() => import("./FileChooser")),
47
+ new Promise<undefined>((resolve) => {
48
+ abort = () => resolve(undefined);
49
+ signal?.addEventListener("abort", abort, { once: true });
50
+ if (signal?.aborted) abort();
51
+ }),
52
+ ]).finally(() => signal?.removeEventListener("abort", abort));
53
+ if (!chooser) return [];
54
+ return dialogCore
55
+ .open<File[]>(
56
+ (close) => <chooser.FileChooser options={options} providers={providers} retryProviders={load} caller={caller} close={close} />,
57
+ {
58
+ ...panelDialogWorkspaceOptions,
59
+ panelClassName: `${panelDialogWorkspaceOptions.panelClassName} cloud-file-chooser-dialog`,
60
+ initialFocus: (dialog) => dialog.querySelector<HTMLElement>('[role="gridcell"][tabindex="0"]'),
61
+ signal: options.signal,
62
+ history: true,
63
+ },
64
+ )
65
+ .then((files) => files ?? []);
66
+ })();
54
67
  };
55
68
 
56
69
  return chooseFiles;
@@ -31,12 +31,21 @@ export const shortcutLabel = (shortcut: string): string => {
31
31
  if (!combination) return "";
32
32
  const mac = isMac();
33
33
  const names = { ctrl: mac ? "⌃" : "Ctrl", alt: mac ? "⌥" : "Alt", shift: mac ? "⇧" : "Shift", meta: mac ? "⌘" : "Meta" };
34
- return [...modifiers.filter((key) => combination[key]).map((key) => names[key]), combination.key.toUpperCase()].join(mac ? "" : "+");
34
+ const arrows: Record<string, string> = { arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→" };
35
+ const key = arrows[combination.key] ?? combination.key.toUpperCase();
36
+ return [...modifiers.filter((name) => combination[name]).map((name) => names[name]), key].join(mac ? "" : "+");
35
37
  };
36
38
 
37
- /** One owner in the Cloud layout; app islands only publish commands. */
39
+ const ARROWS = new Set(["arrowup", "arrowdown", "arrowleft", "arrowright"]);
40
+
41
+ /**
42
+ * One owner in the Cloud layout; app islands only publish commands. A command
43
+ * on an arrow key comes last: the focused element sees the key first, and a
44
+ * widget that moves with arrow keys, such as a tree that reorders on Alt+↑ or
45
+ * an open list, keeps it by handling it.
46
+ */
38
47
  export const attachCommandShortcuts = (run: (command: ContextAwareCommand) => void): (() => void) => {
39
- const listener = (event: KeyboardEvent) => {
48
+ const listener = (event: KeyboardEvent, arrows: boolean) => {
40
49
  if (event.defaultPrevented || event.repeat || event.isComposing || (!isMac() && event.getModifierState("AltGraph"))) return;
41
50
  const target = event.target;
42
51
  const editing =
@@ -47,7 +56,7 @@ export const attachCommandShortcuts = (run: (command: ContextAwareCommand) => vo
47
56
  if (!command.shortcut) return false;
48
57
  if (command.id !== "cloud.search" && dialogOpen) return false;
49
58
  const combo = parse(command.shortcut);
50
- if (!combo || (editing && !combo.ctrl && !combo.meta)) return false;
59
+ if (!combo || ARROWS.has(combo.key) !== arrows || (editing && !combo.ctrl && !combo.meta)) return false;
51
60
  // Shift+/ reports '?' on common keyboard layouts.
52
61
  const key = event.key === "?" && combo.key === "/" ? "/" : event.key.toLowerCase();
53
62
  const physicalLetter =
@@ -69,8 +78,14 @@ export const attachCommandShortcuts = (run: (command: ContextAwareCommand) => vo
69
78
  run(matches[0]!);
70
79
  };
71
80
  // Registered app combinations must win over editor bindings such as Mod+Shift+K.
72
- window.addEventListener("keydown", listener, true);
73
- return () => window.removeEventListener("keydown", listener, true);
81
+ const first = (event: KeyboardEvent) => listener(event, false);
82
+ const last = (event: KeyboardEvent) => listener(event, true);
83
+ window.addEventListener("keydown", first, true);
84
+ window.addEventListener("keydown", last);
85
+ return () => {
86
+ window.removeEventListener("keydown", first, true);
87
+ window.removeEventListener("keydown", last);
88
+ };
74
89
  };
75
90
 
76
91
  export { COMMANDS_CHANGED };
@@ -1,12 +1,12 @@
1
1
  import { toast } from "@k2b/ui";
2
- import { resolveCommand } from "../capabilities/client";
3
- import { type CommandOptions, CommandOptionsSchema, clearCommand, readCommand } from "../contracts/commands";
2
+ import type { CommandOptions } from "../contracts/commands";
4
3
  import {
5
4
  type ContextAwareCommand,
6
5
  collectContextAwareCommands,
7
6
  requestCommandHandling,
8
7
  requestContextCommandExecution,
9
8
  } from "./command-bridge";
9
+ import { importOnDemand } from "./reload";
10
10
  import { resourceSearchMessages } from "./resource-search-messages";
11
11
  import { requestGlobalSearch } from "./search-bridge";
12
12
 
@@ -16,17 +16,26 @@ export { registerCommandHandler, registerContextAwareCommand } from "./command-b
16
16
 
17
17
  /** Opens locally only when a mounted owner explicitly accepts; otherwise follows the app's URL. */
18
18
  export const openCommand = async (command: string, input: unknown = {}, options: CommandOptions = {}): Promise<void> => {
19
- const parsedOptions = CommandOptionsSchema.parse(options);
19
+ const modules = await importOnDemand(() => Promise.all([import("../contracts/commands"), import("../capabilities/client")]));
20
+ if (!modules) return;
21
+ const [contracts, capabilities] = modules;
22
+ const parsedOptions = contracts.CommandOptionsSchema.parse(options);
20
23
  const handled = requestCommandHandling({ command, input, options: parsedOptions });
21
24
  if (handled) return handled;
22
- const result = await resolveCommand(command, input, parsedOptions);
25
+ const result = await capabilities.resolveCommand(command, input, parsedOptions);
23
26
  if (!result.ok) throw new Error(result.error.message);
24
27
  window.location.assign(result.data.href);
25
28
  };
26
29
 
27
30
  /** Call after this page's handlers have mounted. Each entry is consumed, including invalid links. */
28
31
  export const consumeCommandLink = async (): Promise<void> => {
32
+ const command = new URL(window.location.href).searchParams.get("command");
33
+ if (!command) return;
34
+ const contracts = await importOnDemand(() => import("../contracts/commands"));
35
+ if (!contracts) return;
29
36
  const url = new URL(window.location.href);
37
+ if (url.searchParams.get("command") !== command) return;
38
+ const { clearCommand, readCommand } = contracts;
30
39
  const messages = resourceSearchMessages.resolve([document.documentElement.lang]).t;
31
40
  let target: ReturnType<typeof readCommand>;
32
41
  try {
@@ -0,0 +1,11 @@
1
+ /** A provider failure the chooser explains; `status` 0 means the browser is offline. */
2
+ export class FileProviderError extends Error {
3
+ constructor(
4
+ readonly code: string,
5
+ message: string,
6
+ readonly status: number,
7
+ ) {
8
+ super(message);
9
+ this.name = "FileProviderError";
10
+ }
11
+ }
@@ -15,6 +15,9 @@ import {
15
15
  fileProviderIssues,
16
16
  } from "../contracts/file-provider";
17
17
  import { LOCALE_HEADER } from "../shared/locale";
18
+ import { FileProviderError } from "./file-provider-error";
19
+
20
+ export { FileProviderError } from "./file-provider-error";
18
21
 
19
22
  /** One live application whose catalog manifest offers files, reduced to what choosing a file needs. */
20
23
  export type FileProviderSource = {
@@ -31,18 +34,6 @@ export type FileProviderSource = {
31
34
 
32
35
  export type FileProviderCaller = Pick<CapabilityHttpOptions, "fetch" | "baseUrl"> & { locale: string };
33
36
 
34
- /** A provider failure the chooser explains; `status` 0 means the browser is offline. */
35
- export class FileProviderError extends Error {
36
- constructor(
37
- readonly code: string,
38
- message: string,
39
- readonly status: number,
40
- ) {
41
- super(message);
42
- this.name = "FileProviderError";
43
- }
44
- }
45
-
46
37
  /** The page size the chooser asks for; the contract allows 1 to 100. */
47
38
  export const FILE_PROVIDER_PAGE_SIZE = 50;
48
39
  /** At most this many provider reads run at the same time. */
@@ -1,5 +1,4 @@
1
1
  import { createFileChoosing } from "./choose-files";
2
- import { loadFileProviders } from "./file-providers";
3
2
  import { createProviderList } from "./provider-list";
4
3
  import { createFileSaving } from "./save-files";
5
4
 
@@ -7,7 +6,7 @@ export type { ChooseFilesOptions } from "./FileChooser";
7
6
  export { SAVE_FILES_ICON, type SavedFile, type SaveFileSource, type SaveFilesOptions } from "./save-files";
8
7
 
9
8
  const pageLocale = () => (typeof document === "undefined" ? "en" : document.documentElement.lang || "en");
10
- const providers = createProviderList(() => loadFileProviders({ locale: pageLocale() }));
9
+ const providers = createProviderList(async () => (await import("./file-providers")).loadFileProviders({ locale: pageLocale() }));
11
10
  const saving = createFileSaving(providers);
12
11
 
13
12
  /**
@@ -1,4 +1,4 @@
1
- import { type LiveServerMessage, LiveServerMessageSchema } from "../events/live-protocol";
1
+ import { type LiveServerMessage, parseLiveServerMessage } from "../events/live-server-message";
2
2
  import { createLiveWebSocket, type LiveWebSocket } from "./live-websocket";
3
3
 
4
4
  export type LiveSubscriptionHandlers<T> = {
@@ -41,8 +41,7 @@ const shared = new Map<string, Shared>();
41
41
 
42
42
  const parseMessage = (raw: string): LiveServerMessage | null => {
43
43
  try {
44
- const parsed = LiveServerMessageSchema.safeParse(JSON.parse(raw));
45
- return parsed.success ? parsed.data : null;
44
+ return parseLiveServerMessage(JSON.parse(raw));
46
45
  } catch {
47
46
  return null;
48
47
  }
@@ -1,5 +1,6 @@
1
1
  import { createSignal } from "solid-js";
2
- import { FileProviderError, type FileProviderSource } from "./file-providers";
2
+ import { FileProviderError } from "./file-provider-error";
3
+ import type { FileProviderSource } from "./file-providers";
3
4
 
4
5
  /** The catalog refuses the caller, such as a visitor of a public page or a session that has just expired. */
5
6
  const refused = (error: unknown) => error instanceof FileProviderError && (error.status === 401 || error.status === 403);
@@ -1,10 +1,11 @@
1
1
  import { dialogCore, IconButton, type IconButtonProps, panelDialogWorkspaceOptions, toast, useLocale } from "@k2b/ui";
2
2
  import { createSignal, type JSX, splitProps } from "solid-js";
3
3
  import type { FileProviderList } from "./FileChooser";
4
- import { FileSaver, type SavedFile } from "./FileSaver";
4
+ import type { SavedFile } from "./FileSaver";
5
5
  import { fileChooserMessages } from "./file-chooser-messages";
6
6
  import type { FileProviderCaller, SaveFileSource } from "./file-providers";
7
7
  import type { ProviderList } from "./provider-list";
8
+ import { importOnDemand } from "./reload";
8
9
 
9
10
  export type { SavedFile } from "./FileSaver";
10
11
  export type { SaveFileSource } from "./file-providers";
@@ -40,9 +41,19 @@ export const createFileSaving = (
40
41
  if (!known) load();
41
42
  const saved: SavedFile[] = [];
42
43
  const running = new Set<Promise<void>>();
44
+ let abort = () => {};
45
+ const saver = await Promise.race([
46
+ importOnDemand(() => import("./FileSaver")),
47
+ new Promise<undefined>((resolve) => {
48
+ abort = () => resolve(undefined);
49
+ options.signal?.addEventListener("abort", abort, { once: true });
50
+ if (options.signal?.aborted) abort();
51
+ }),
52
+ ]).finally(() => options.signal?.removeEventListener("abort", abort));
53
+ if (!saver) return [];
43
54
  await dialogCore.open<void>(
44
55
  (close) => (
45
- <FileSaver
56
+ <saver.FileSaver
46
57
  files={files}
47
58
  providers={providers}
48
59
  retryProviders={load}
@@ -1,4 +1,4 @@
1
- import type { DialogRender } from "@k2b/ui";
1
+ import { type DialogRender, suppressTextSelection } from "@k2b/ui";
2
2
 
3
3
  export type SpotlightPosition = { x: number; y: number };
4
4
  const STORAGE_KEY = "cloud.spotlight.position.v1";
@@ -42,6 +42,7 @@ export const attachSpotlightPosition = (
42
42
  origin: SpotlightPosition;
43
43
  previous: SpotlightPosition | null;
44
44
  handle: HTMLElement;
45
+ releaseTextSelection: () => void;
45
46
  } | null = null;
46
47
 
47
48
  const home = document.createElement("div");
@@ -108,6 +109,7 @@ export const attachSpotlightPosition = (
108
109
  if (!drag) return;
109
110
  const handle = drag.handle;
110
111
  const id = drag.id;
112
+ drag.releaseTextSelection();
111
113
  drag = null;
112
114
  if (handle.hasPointerCapture(id)) handle.releasePointerCapture(id);
113
115
  delete host.dataset.dragging;
@@ -132,6 +134,7 @@ export const attachSpotlightPosition = (
132
134
  origin: { x: rect.x, y: rect.y },
133
135
  previous: position,
134
136
  handle,
137
+ releaseTextSelection: suppressTextSelection(event.pointerId),
135
138
  };
136
139
  handle.setPointerCapture(event.pointerId);
137
140
  };
@@ -1,23 +1,25 @@
1
- import type { WidgetStreamLine } from "../contracts/widgets";
1
+ import type { DashboardWidgetSize, WidgetStreamLine } from "../contracts/widgets";
2
2
  import { LOCALE_HEADER } from "../shared/locale";
3
3
  import { NDJSON_CONTENT_TYPE, readNdjsonLines } from "./ndjson";
4
4
 
5
- export type { WidgetResponse, WidgetStreamLine, WidgetStreamStatus } from "../contracts/widgets";
5
+ export type { DashboardWidgetSize, WidgetResponse, WidgetStreamLine, WidgetStreamStatus } from "../contracts/widgets";
6
6
 
7
7
  /**
8
8
  * Asks Core for dashboard widgets and hands over each one as soon as it answers: `start` names the widgets, one
9
9
  * `widget` line follows per widget in the order they finish, then `done`. `keys` (`<appId>/<widgetId>`) limits the
10
- * request, for example to retry one widget; without it Core asks every declared widget. Aborting `signal` stops every
11
- * widget Core still waits for. Rejects when the request fails or the stream breaks off.
10
+ * request, for example to retry one widget; without it Core asks every declared widget. `sizes` names the size to ask
11
+ * a widget for; a widget without one, or with a size it does not offer, answers in its default size. Aborting `signal`
12
+ * stops every widget Core still waits for. Rejects when the request fails or the stream breaks off.
12
13
  */
13
14
  export const streamWidgets = async (options: {
14
15
  keys?: readonly string[];
16
+ sizes?: Readonly<Record<string, DashboardWidgetSize>>;
15
17
  signal: AbortSignal;
16
18
  locale: string;
17
19
  onLine: (line: WidgetStreamLine) => void;
18
20
  }): Promise<void> => {
19
21
  const query = new URLSearchParams();
20
- for (const key of options.keys ?? []) query.append("widget", key);
22
+ for (const key of options.keys ?? []) query.append("widget", options.sizes?.[key] ? `${key}@${options.sizes[key]}` : key);
21
23
  const search = query.toString();
22
24
  const response = await fetch(`/api/widgets/v1${search ? `?${search}` : ""}`, {
23
25
  signal: options.signal,
@@ -1,5 +1,6 @@
1
1
  import { sql } from "bun";
2
2
  import type { CapabilityOrigin } from "../contracts/capabilities";
3
+ import { pruneApprovalRequests } from "../services/approvals";
3
4
  import { logger } from "../services/logging";
4
5
  import { CAPABILITY_CLAIM_RETENTION_HOURS, pruneCapabilityIdempotencyClaims } from "./claims";
5
6
 
@@ -280,6 +281,8 @@ export const startCapabilityExecutionMaintenance = (): (() => void) => {
280
281
  try {
281
282
  const removed = await pruneCapabilityExecutions(new Date(Date.now() - CAPABILITY_EXECUTION_RETENTION_DAYS * 86_400_000));
282
283
  if (removed > 0) log.info("Pruned expired capability executions", { removed });
284
+ const requests = await pruneApprovalRequests(new Date(Date.now() - CAPABILITY_EXECUTION_RETENTION_DAYS * 86_400_000));
285
+ if (requests > 0) log.info("Pruned expired approval requests", { removed: requests });
283
286
  const claims = await pruneCapabilityIdempotencyClaims(new Date(Date.now() - CAPABILITY_CLAIM_RETENTION_HOURS * 3_600_000));
284
287
  if (claims > 0) log.info("Pruned expired capability idempotency claims", { removed: claims });
285
288
  } catch (error) {
@@ -1,4 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { isModelsOnlyApiKey } from "../contracts/shared";
2
3
  import { arg, type CliInputFlagValue, type CloudCliContext, command, confirmFlag, defineCliCommands, flag, readCliInput } from "./index";
3
4
 
4
5
  type User = {
@@ -31,6 +32,7 @@ type User = {
31
32
  };
32
33
 
33
34
  type ApiKey = {
35
+ scopes: string[];
34
36
  id: string;
35
37
  name: string;
36
38
  status: "active" | "revoked";
@@ -121,6 +123,7 @@ const apiKeyRows = (keys: ApiKey[]) =>
121
123
  id: key.id,
122
124
  name: key.name,
123
125
  prefix: key.tokenPrefix,
126
+ access: isModelsOnlyApiKey(key) ? "models" : "full",
124
127
  expires: key.expiresAt ?? "never",
125
128
  lastUsed: key.lastUsedAt ?? "never",
126
129
  created: key.createdAt,
@@ -283,6 +286,7 @@ export default defineCliCommands({
283
286
  printJsonOrTable(ctx, result, apiKeyRows(result.items), [
284
287
  { key: "name" },
285
288
  { key: "prefix" },
289
+ { key: "access" },
286
290
  { key: "expires" },
287
291
  { key: "lastUsed" },
288
292
  { key: "id" },
@@ -294,15 +298,30 @@ export default defineCliCommands({
294
298
  args: { name: arg.required({ valueLabel: "name", description: "API key name" }) },
295
299
  flags: {
296
300
  expires: flag.string({ description: "ISO timestamp or duration like 90d" }),
301
+ access: flag.enum(["full", "models"], { default: "full", description: "Full access or models-only inference access" }),
297
302
  },
298
303
  run: async ({ ctx, args, flags }) => {
299
304
  const result = await readApi<{ credential: ApiKey; token: string }>(
300
305
  ctx,
301
306
  "/api-keys",
302
- jsonRequest("POST", { name: args.name, expiresAt: parseExpiry(flags.expires) }),
307
+ jsonRequest("POST", { name: args.name, expiresAt: parseExpiry(flags.expires), access: flags.access }),
303
308
  );
309
+ const modelsOnly = isModelsOnlyApiKey(result.credential);
310
+ if (flags.access === "models" && !modelsOnly) {
311
+ try {
312
+ await readApi<{ message: string }>(ctx, `/api-keys/${encodeURIComponent(result.credential.id)}`, jsonRequest("DELETE"));
313
+ } catch {
314
+ throw new Error(
315
+ `The key was created with full access and could not be revoked automatically. Run: cld account api-keys revoke ${result.credential.id} --yes`,
316
+ );
317
+ }
318
+ throw new Error("This Cloud does not support models-only API keys. The full-access key it created was revoked.");
319
+ }
304
320
  if (ctx.options.output === "json") ctx.json(result);
305
- else ctx.print(`Token: ${result.token}\nPrefix: ${result.credential.tokenPrefix}\nStore this token now. It cannot be shown again.`);
321
+ else {
322
+ ctx.print(`Token: ${result.token}\nPrefix: ${result.credential.tokenPrefix}\nStore this token now. It cannot be shown again.`);
323
+ if (modelsOnly) ctx.print("This key works only with the OpenAI-compatible API at /api/ai/openai/v1 on this Cloud.");
324
+ }
306
325
  },
307
326
  }),
308
327
  command("api-keys revoke", {
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import type { CloudCliContext, CloudCliFlagValue, CloudCliModule } from "./index";
3
+ import { isCloudCliAlias, isCloudCliShortcuts } from "./plugin";
3
4
 
4
5
  type FlagKind = "string" | "boolean" | "int" | "enum" | "stringList" | "input";
5
6
  type ArgKind = "required" | "optional" | "rest";
@@ -155,6 +156,8 @@ type CommandNode = {
155
156
  type CliCommandsConfig = {
156
157
  name: string;
157
158
  summary: string;
159
+ alias?: string;
160
+ shortcuts?: Readonly<Record<string, string>>;
158
161
  requiresCloud?: boolean;
159
162
  groupSummaries?: Readonly<Record<string, string>>;
160
163
  commands: readonly CliCommandDefinition[];
@@ -241,10 +244,27 @@ export const readCliInput = async (input: CliInputFlagValue, options: ReadCliInp
241
244
 
242
245
  export const defineCliCommands = (config: CliCommandsConfig): CloudCliModule => {
243
246
  const root = buildCommandTree(config.commands, config.groupSummaries);
247
+ if (config.alias !== undefined && !isCloudCliAlias(config.alias, config.name)) {
248
+ throw new Error("CLI alias must be a lowercase kebab-case name different from the module name");
249
+ }
250
+ let shortcuts: Record<string, string> | undefined;
251
+ if (config.shortcuts !== undefined) {
252
+ if (!isCloudCliShortcuts(config.shortcuts, config.name, config.alias)) {
253
+ throw new Error("CLI shortcuts require unique kebab-case names and non-empty command paths without flags");
254
+ }
255
+ shortcuts = {};
256
+ for (const [name, target] of Object.entries(config.shortcuts)) {
257
+ const segments = target.trim().split(/\s+/);
258
+ if (!findNode(root, segments)?.command) throw new Error(`Unknown CLI shortcut command path: ${target}`);
259
+ shortcuts[name] = segments.join(" ");
260
+ }
261
+ }
244
262
 
245
263
  return {
246
264
  name: config.name,
247
265
  summary: config.summary,
266
+ ...(config.alias !== undefined ? { alias: config.alias } : {}),
267
+ ...(shortcuts !== undefined ? { shortcuts } : {}),
248
268
  requiresCloud: config.requiresCloud,
249
269
  requiresCloudFor: (args) => {
250
270
  const helpRequest = normalizeHelpRequest(args, {});
@@ -597,8 +617,10 @@ const renderFlagUsage = (key: string, spec: CliFlagSpec): string => {
597
617
 
598
618
  const renderFlagDescription = (spec: CliFlagSpec): string => {
599
619
  const parts = [spec.description];
600
- if ("default" in spec && spec.default !== undefined)
601
- parts.push(`Default: ${Array.isArray(spec.default) ? spec.default.join(",") : spec.default}.`);
620
+ const hasDefault = "default" in spec && spec.default !== undefined;
621
+ if ("required" in spec && spec.required && !hasDefault) parts.push("Required.");
622
+ if (hasDefault) parts.push(`Default: ${Array.isArray(spec.default) ? spec.default.join(",") : spec.default}.`);
602
623
  if (spec.kind === "enum") parts.push(`Values: ${spec.values.join(", ")}.`);
624
+ if (parts.length > 1 && parts[0] && !/[.!?:]$/.test(parts[0])) parts[0] += ".";
603
625
  return parts.filter(Boolean).join(" ");
604
626
  };