@k2b/cloud 0.30.0 → 0.31.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 (76) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +14 -4
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/approval-routes.ts +40 -4
  8. package/src/ai/approvals.ts +68 -10
  9. package/src/ai/chat/blocks.tsx +53 -32
  10. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  11. package/src/ai/chat/message-actions.tsx +3 -1
  12. package/src/ai/chat/messages.ts +18 -0
  13. package/src/ai/chat/tool-groups.ts +66 -12
  14. package/src/ai/chat/tool-names.ts +4 -2
  15. package/src/ai/chat/turn-layout.ts +8 -0
  16. package/src/ai/chat/turn-view.tsx +94 -25
  17. package/src/ai/chat/web-tools.tsx +95 -1
  18. package/src/ai/client/controller.ts +6 -3
  19. package/src/ai/code-runtime-tools.ts +50 -17
  20. package/src/ai/code-source-contracts.ts +25 -21
  21. package/src/ai/code-source-tools.ts +3 -1
  22. package/src/ai/executor.ts +170 -14
  23. package/src/ai/fetch-file-tool.ts +41 -4
  24. package/src/ai/firecrawl-tools.ts +87 -4
  25. package/src/ai/index.ts +8 -0
  26. package/src/ai/migrate.ts +113 -2
  27. package/src/ai/protocol.ts +22 -1
  28. package/src/ai/routes.ts +11 -0
  29. package/src/ai/runtime.ts +46 -7
  30. package/src/ai/store.ts +66 -1
  31. package/src/ai/tool-audit.ts +11 -1
  32. package/src/ai/tools.ts +91 -3
  33. package/src/ai/types.ts +39 -0
  34. package/src/ai/web-provenance.ts +103 -0
  35. package/src/api/capabilities.ts +3 -2
  36. package/src/api/widgets.ts +36 -5
  37. package/src/browser/CloudResourceSearch.tsx +1 -1
  38. package/src/browser/command-shortcuts.ts +21 -6
  39. package/src/browser/spotlight-position.ts +4 -1
  40. package/src/browser/widgets.ts +7 -5
  41. package/src/capabilities/executions.ts +3 -0
  42. package/src/cli/commands.ts +20 -0
  43. package/src/cli/index.ts +16 -0
  44. package/src/cli/plugin.ts +30 -0
  45. package/src/contracts/app.ts +34 -2
  46. package/src/contracts/registry.ts +9 -1
  47. package/src/contracts/widgets.ts +47 -2
  48. package/src/desktop/solid.tsx +6 -7
  49. package/src/server/index.ts +1 -0
  50. package/src/server/widget-request.ts +27 -0
  51. package/src/services/approvals/index.ts +225 -0
  52. package/src/services/approvals/migrate.ts +33 -0
  53. package/src/shared/app-presentation.ts +11 -0
  54. package/src/shared/email-html.ts +43 -8
  55. package/src/ssr/AdminLayout.tsx +4 -7
  56. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  57. package/src/ssr/Layout.tsx +38 -14
  58. package/src/ssr/LayoutFooter.tsx +1 -1
  59. package/src/ssr/LayoutHeader.tsx +17 -20
  60. package/src/ssr/LayoutHelp.tsx +1 -1
  61. package/src/ssr/LayoutRail.tsx +1 -2
  62. package/src/ssr/PageTrail.tsx +23 -0
  63. package/src/ssr/PageTrailRow.island.tsx +20 -0
  64. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  65. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  66. package/src/ssr/islands/index.ts +1 -0
  67. package/src/ssr/layout-runtime.ts +60 -0
  68. package/src/ssr/mobile-menu-history.ts +1 -1
  69. package/src/ssr/page-trail-runtime.ts +313 -0
  70. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  71. package/src/ssr/page-trail.ts +59 -0
  72. package/src/ssr/platform-messages.ts +4 -0
  73. package/src/styles/effects.css +21 -4
  74. package/src/styles/global.css +35 -2
  75. package/src/styles/utilities-layout.css +24 -1
  76. package/src/styles/utilities-navigation.css +6 -18
package/src/ai/tools.ts CHANGED
@@ -4,6 +4,7 @@ import type { z } from "zod";
4
4
  import type { RequestActor } from "../server";
5
5
  import { aiToolNeedsApproval } from "./approvals";
6
6
  import type {
7
+ AiApprovalTarget,
7
8
  AiDataBoundary,
8
9
  AiFrontendToolMode,
9
10
  AiProjectFileToolSource,
@@ -13,6 +14,7 @@ import type {
13
14
  AiToolApprovalPolicy,
14
15
  AiToolDefinition,
15
16
  AiToolRuntime,
17
+ AiWebsiteReceipt,
16
18
  } from "./types";
17
19
 
18
20
  export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType>(config: {
@@ -60,6 +62,12 @@ export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType
60
62
  timeZone?: string;
61
63
  /** Localized short status; never include payloads or secrets. */
62
64
  reportProgress?: (message: string) => Promise<void>;
65
+ /** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
66
+ requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
67
+ /** Relays a decision for an effect another platform component consumes and executes. */
68
+ requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
69
+ /** Shows requests a website approval let through as they go out, so the chat keeps them whatever the outcome. */
70
+ reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
63
71
  },
64
72
  ) => Promise<z.infer<TOutput>>,
65
73
  ): AiToolRuntime<TInput, TOutput> {
@@ -106,15 +114,93 @@ export type AiToolPreparationContext = {
106
114
  locale?: string;
107
115
  timeZone?: string;
108
116
  reportToolProgress?: (callId: string, message: string) => Promise<void>;
117
+ /** Uses the approved snapshot immediately before the tool executes its effect. */
118
+ consumeApproval?: (approvalCallId: string, input: unknown) => Promise<AiApprovalUse>;
119
+ /** Links a relayed decision to the record the effect owner will consume. */
120
+ linkApproval?: (approvalCallId: string, approvalId: string) => void;
121
+ /** Hears what a nested approval asks for before the approval is requested. */
122
+ describeApproval?: (approvalCallId: string, target: AiApprovalTarget) => void;
123
+ reportWebsiteReceipts?: (callId: string, receipts: AiWebsiteReceipt[]) => Promise<void>;
109
124
  };
110
125
 
126
+ /**
127
+ * What using an approval found: `approved` lets the effect run, `changed` means the approved snapshot no longer
128
+ * matches and asks again, and `used` means an earlier run of the call already used it, so the call must not run again.
129
+ */
130
+ export type AiApprovalUse = "approved" | "changed" | "used";
131
+
132
+ const approvalCallIdPattern = /^(.*)-approval-(\d+)$/;
133
+
134
+ /**
135
+ * Whether `askedCallIds` holds a question of the same tool call that Nessi asked after `approvalCallId`, the call's
136
+ * own approval or one of its numbered questions. A turn that continues after such a question runs the tool from its
137
+ * start again, so the approvals before it are used again by the same call.
138
+ */
139
+ export const aiApprovalAskedLater = (approvalCallId: string, askedCallIds: Iterable<string>): boolean => {
140
+ const own = approvalCallIdPattern.exec(approvalCallId);
141
+ const callId = own ? own[1] : approvalCallId,
142
+ index = own ? Number(own[2]) : -1;
143
+ for (const asked of askedCallIds) {
144
+ const later = approvalCallIdPattern.exec(asked);
145
+ if (later && later[1] === callId && Number(later[2]) > index) return true;
146
+ }
147
+ return false;
148
+ };
149
+
150
+ const approvalUsedError = () =>
151
+ new Error("This approval was already used, so Cloud does not run the call again. Check its outcome before you call again.");
152
+
111
153
  export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRuntimeTool[] }): PreparedAiTools => {
112
154
  const canonicalNames = new Map<string, string>();
113
155
  const approvalPolicies = new Map<string, AiToolApprovalPolicy>();
114
156
  const frontendModes = new Map<string, AiFrontendToolMode>();
115
157
 
158
+ const approvalContext = async (toolInput: unknown, ctx: ToolContext, needsApproval: boolean) => {
159
+ const callId = ctx.callId;
160
+ const use = (approvalCallId: string): Promise<AiApprovalUse> =>
161
+ input.consumeApproval ? input.consumeApproval(approvalCallId, toolInput) : Promise.resolve("approved");
162
+ if (needsApproval && input.consumeApproval) {
163
+ const used = callId ? await use(callId) : "changed";
164
+ if (used === "used") throw approvalUsedError();
165
+ if (used === "changed") throw new Error("This approval no longer matches the call. Ask again.");
166
+ }
167
+ // Nessi numbers the approvals of one execution in request order; count them to name each one's call ID.
168
+ let approvals = 0;
169
+ const requestApproval = async (message: string, target?: AiApprovalTarget): Promise<boolean> => {
170
+ while (true) {
171
+ const approvalCallId = `${callId}-approval-${approvals++}`;
172
+ if (target) input.describeApproval?.(approvalCallId, target);
173
+ if (!(await ctx.requestApproval(message))) return false;
174
+ const used = await use(approvalCallId);
175
+ if (used === "approved") return true;
176
+ if (used === "used") throw approvalUsedError();
177
+ // A changed snapshot requires a new human decision under the next Nessi action ID.
178
+ }
179
+ };
180
+ return {
181
+ ...ctx,
182
+ requestApproval,
183
+ requestApprovalFor: (message: string, target: AiApprovalTarget) => requestApproval(message, target),
184
+ requestRelayedApproval: async (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => {
185
+ const approvalCallId = `${callId}-approval-${approvals++}`;
186
+ if (options.approvalId) input.linkApproval?.(approvalCallId, options.approvalId);
187
+ if (options.target) input.describeApproval?.(approvalCallId, options.target);
188
+ if (!(await ctx.requestApproval(message))) return false;
189
+ // The effect's own record is used where it runs. Without one, as for a website request from code, the relay
190
+ // that hands over the decision uses the chat's record; a decision it cannot use is not handed over as approval.
191
+ return options.approvalId !== undefined || (await use(approvalCallId)) === "approved";
192
+ },
193
+ };
194
+ };
195
+
116
196
  const tools = (input.tools ?? []).map((tool): Tool => {
117
- if (!isCloudAiTool(tool)) return tool;
197
+ if (!isCloudAiTool(tool)) {
198
+ if (tool.kind !== "server") return tool;
199
+ return {
200
+ ...tool,
201
+ execute: async (toolInput, ctx) => tool.execute(toolInput, await approvalContext(toolInput, ctx, tool.def.needsApproval === true)),
202
+ };
203
+ }
118
204
 
119
205
  canonicalNames.set(tool.def.name, tool.def.canonicalName ?? tool.def.name);
120
206
  approvalPolicies.set(tool.def.name, tool.def.approval);
@@ -130,12 +216,12 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
130
216
  });
131
217
 
132
218
  if (tool.location === "server") {
133
- return nessiTool.server((toolInput, ctx) => {
219
+ return nessiTool.server(async (toolInput, ctx) => {
134
220
  if (!input.actor) throw new Error(`AI server tool "${tool.def.name}" requires a request actor.`);
135
221
  const report = input.reportToolProgress;
136
222
  const callId = ctx.callId;
137
223
  return tool.run(toolInput, {
138
- ...ctx,
224
+ ...(await approvalContext(toolInput, ctx, aiToolNeedsApproval(tool.def.approval))),
139
225
  actor: input.actor,
140
226
  conversationId: input.conversationId,
141
227
  turnId: input.turnId,
@@ -147,6 +233,8 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
147
233
  locale: input.locale,
148
234
  timeZone: input.timeZone,
149
235
  reportProgress: callId && report ? (message) => report(callId, message) : undefined,
236
+ reportWebsiteReceipts:
237
+ callId && input.reportWebsiteReceipts ? (receipts) => input.reportWebsiteReceipts!(callId, receipts) : undefined,
150
238
  });
151
239
  });
152
240
  }
package/src/ai/types.ts CHANGED
@@ -303,6 +303,8 @@ export type AiStoredMessage = {
303
303
  toolPresentations?: Record<string, AiToolPresentation>;
304
304
  /** How the person decided an approval; `expired` when the turn ended while it still waited. */
305
305
  toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
306
+ /** Requests a website approval let through, by tool call and then by `AiWebsiteReceipt.index`. */
307
+ websiteReceipts?: Record<string, Record<string, { method: string; url: string }>>;
306
308
  /** Why the turn failed, on the last message of its loop. The chat words it in the reader's language. */
307
309
  turnError?: AiTurnError;
308
310
  } | null;
@@ -420,6 +422,10 @@ export type AiPendingTurnAction =
420
422
  message?: string;
421
423
  review?: CapabilityActionReview;
422
424
  allowAlways: boolean;
425
+ /** Whether the approval can be remembered for this chat. */
426
+ allowChat: boolean;
427
+ /** The website origin a chat approval would allow, for an HTTP request a code run makes. */
428
+ website?: string;
423
429
  }
424
430
  | {
425
431
  type: "frontend_tool";
@@ -441,6 +447,7 @@ export type AiTurnAbortRequest =
441
447
  };
442
448
 
443
449
  export type AiPendingTurnActionRecord = {
450
+ approvalId?: string;
444
451
  turnId: string;
445
452
  conversationId: string;
446
453
  callId: string;
@@ -452,6 +459,10 @@ export type AiPendingTurnActionRecord = {
452
459
  review?: CapabilityActionReview;
453
460
  approvalScope: string;
454
461
  allowAlways: boolean;
462
+ /** Whether the approval can be remembered for this chat; defaults to `allowAlways`. */
463
+ allowChat?: boolean;
464
+ /** Remembered-approval name when it differs from `name`: the website or Action a code run asks for. */
465
+ rememberToolName?: string;
455
466
  frontendMode?: AiFrontendToolMode;
456
467
  resolvedEvent: InboundEvent | null;
457
468
  };
@@ -607,6 +618,11 @@ export type AiChatTurnRunConfig = {
607
618
  selectedSkillIds?: string[];
608
619
  /** Server-owned marker: model grants apply only to interactive Assistant chat. */
609
620
  assistantChat?: true;
621
+ /**
622
+ * Server-owned marker: a person started this turn in a signed-in browser session, not through `cld`, an API key,
623
+ * a delegated account, or a schedule. Only such a turn uses website approvals.
624
+ */
625
+ signedInSession?: true;
610
626
  kind?: "chat";
611
627
  input: Input;
612
628
  /** Stable public ID exposed as runtime context, not instructions. */
@@ -1016,6 +1032,23 @@ export type AiAccessResult<TAccess = unknown> = {
1016
1032
 
1017
1033
  export type AiToolApprovalPolicy = "never" | "once" | "always" | { kind: "user-configurable"; default: "once" | "always"; scope?: string };
1018
1034
 
1035
+ /**
1036
+ * What a server tool asks a person to approve when the approval can be remembered: a website or an Action, not the
1037
+ * tool itself. A target can always be remembered for its chat.
1038
+ */
1039
+ export type AiApprovalTarget = {
1040
+ toolName: string;
1041
+ approvalScope: string;
1042
+ /** Whether the person may also remember it everywhere. */
1043
+ always: boolean;
1044
+ };
1045
+
1046
+ /**
1047
+ * A request a remembered website approval let through without asking. The chat shows it with its full URL and a revoke
1048
+ * action; `index` orders the receipts of one tool call, and a receipt reported again with the same index replaces it.
1049
+ */
1050
+ export type AiWebsiteReceipt = { index: number; method: string; url: string };
1051
+
1019
1052
  export type AiFrontendToolMode = "client" | "client_view" | "client_interaction";
1020
1053
 
1021
1054
  export type AiCapabilityToolPresentation = {
@@ -1071,6 +1104,12 @@ export type AiToolRuntime<TInput extends z.ZodType = z.ZodType, TOutput extends
1071
1104
  timeZone?: string;
1072
1105
  /** Localized short status; never include payloads or secrets. */
1073
1106
  reportProgress?: (message: string) => Promise<void>;
1107
+ /** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
1108
+ requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
1109
+ /** Relays a decision for an effect another platform component consumes and executes. */
1110
+ requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
1111
+ /** Shows requests a website approval let through as they go out, so the chat keeps them whatever the outcome. */
1112
+ reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
1074
1113
  },
1075
1114
  ): Promise<z.infer<TOutput>>;
1076
1115
  }
@@ -0,0 +1,103 @@
1
+ import { domainToUnicode } from "node:url";
2
+ import { sql } from "bun";
3
+ import { toPgTextArray } from "../services/postgres";
4
+
5
+ /** The longest address web_extract and fetch_file accept; a longer one can never be read, so it is never recorded. */
6
+ const WEB_ADDRESS_MAX_CHARS = 2_000;
7
+
8
+ const URL_TOKEN = /https?:\/\/[^\s<>"'`\\^{}|[\]]+/gi;
9
+ /**
10
+ * An address typed without its scheme, such as `example.org/news`. It must start the word: a host inside another
11
+ * address, an email address, or a longer host name is not a typed address of its own.
12
+ */
13
+ const BARE_TOKEN =
14
+ /(?<![\w@.\-/:%+~#=?&])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9](?::\d{1,5})?(?:\/[^\s<>"'`\\^{}|[\]]*)?/gi;
15
+
16
+ /** Drops punctuation that ends the sentence around an address, and a closing bracket the address did not open. */
17
+ const trimToken = (token: string): string => {
18
+ let end = token.length;
19
+ while (end > 0) {
20
+ const last = token[end - 1]!;
21
+ if (".,;:!?*'\"".includes(last)) end--;
22
+ else if (last === ")" && token.slice(0, end).split("(").length <= token.slice(0, end).split(")").length - 1) end--;
23
+ else break;
24
+ }
25
+ return token.slice(0, end);
26
+ };
27
+
28
+ /** The forms of one address that count as the same read: as written, without its fragment, and over HTTPS. */
29
+ const addressForms = (raw: string, add: (address: string) => void) => {
30
+ let url: URL;
31
+ try {
32
+ url = new URL(raw);
33
+ } catch {
34
+ return;
35
+ }
36
+ if (url.protocol !== "https:" && url.protocol !== "http:") return;
37
+ const forms = [url.href];
38
+ if (url.hash) forms.push(url.href.slice(0, -url.hash.length));
39
+ // Upgrading to HTTPS carries no data the address did not already carry.
40
+ if (url.protocol === "http:") forms.push(...forms.map((form) => `https:${form.slice("http:".length)}`));
41
+ for (const form of forms) if (form.length <= WEB_ADDRESS_MAX_CHARS) add(form);
42
+ };
43
+
44
+ /**
45
+ * Every whole address in a text, normalized like a URL the tools read. Only complete tokens count, never a part of a
46
+ * longer address; with `typed`, an address written without its scheme counts too, as a person often types it.
47
+ */
48
+ export const webAddressesIn = (text: string, options: { typed?: boolean } = {}): Set<string> => {
49
+ const addresses = new Set<string>();
50
+ const add = (address: string) => addresses.add(address);
51
+ const rest = text.replace(URL_TOKEN, (token) => {
52
+ addressForms(trimToken(token), add);
53
+ return " ";
54
+ });
55
+ if (options.typed)
56
+ for (const [token] of rest.matchAll(BARE_TOKEN)) {
57
+ const bare = trimToken(token);
58
+ addressForms(`https://${bare}`, add);
59
+ addressForms(`http://${bare}`, add);
60
+ }
61
+ return addresses;
62
+ };
63
+
64
+ /** Records addresses a web search returned or a page read in this chat links to; later reads of them do not ask. */
65
+ export const recordWebAddresses = async (conversationId: string, addresses: Iterable<string>): Promise<void> => {
66
+ const list = [...addresses];
67
+ if (!list.length) return;
68
+ await sql`
69
+ INSERT INTO ai.web_addresses (conversation_id, address)
70
+ SELECT ${conversationId}::uuid, address FROM unnest(${toPgTextArray(list)}::text[]) address
71
+ ON CONFLICT DO NOTHING
72
+ `;
73
+ };
74
+
75
+ /**
76
+ * Whether the chat itself supplied this address: the person wrote it, a web search returned it, or a page read
77
+ * earlier links to it. A message another chat sent counts as neither. Anything else, such as an address the model
78
+ * assembled from other data, has no provenance. `url` is the normalized address the tool reads.
79
+ */
80
+ export const hasWebAddressProvenance = async (conversationId: string, url: string): Promise<boolean> => {
81
+ const [recorded] = await sql<{ found: boolean }[]>`
82
+ SELECT EXISTS (SELECT 1 FROM ai.web_addresses WHERE conversation_id = ${conversationId}::uuid AND address = ${url}) AS found
83
+ `;
84
+ if (recorded?.found) return true;
85
+ // Only the person's own messages that name the host are read, never tool results.
86
+ const { hostname } = new URL(url);
87
+ const hosts = [...new Set([hostname, domainToUnicode(hostname) || hostname])];
88
+ const rows = await sql<{ text: string }[]>`
89
+ SELECT text FROM (
90
+ SELECT message, meta FROM ai.messages WHERE conversation_id = ${conversationId}::uuid AND kind = 'message' AND role = 'user'
91
+ UNION ALL
92
+ SELECT message, meta FROM ai.task_messages WHERE conversation_id = ${conversationId}::uuid AND kind = 'message' AND role = 'user'
93
+ ) entry
94
+ CROSS JOIN LATERAL (
95
+ SELECT string_agg(CASE WHEN jsonb_typeof(part) = 'string' THEN part #>> '{}' ELSE part->>'text' END, chr(10)) AS text
96
+ FROM jsonb_array_elements(CASE WHEN jsonb_typeof(entry.message->'content') = 'array' THEN entry.message->'content' ELSE '[]'::jsonb END) part
97
+ WHERE jsonb_typeof(part) = 'string' OR part->>'type' = 'text'
98
+ ) content
99
+ WHERE NOT COALESCE(entry.meta ? 'agentMessage', false)
100
+ AND EXISTS (SELECT 1 FROM unnest(${toPgTextArray(hosts)}::text[]) host WHERE strpos(lower(content.text), host) > 0)
101
+ `;
102
+ return rows.some((row) => webAddressesIn(row.text, { typed: true }).has(url));
103
+ };
@@ -613,11 +613,12 @@ const runCapabilityDispatch = async (
613
613
  : schemaValidator(`${operation.schemaHash}:result`, capabilityResultJsonSchema(operation.dataSchema));
614
614
  const parsedResult = resultValidator?.safeParse(upstreamBody.data);
615
615
  const parsedReview = params.review ? CapabilityActionReviewSchema.safeParse(upstreamBody.data) : null;
616
+ // A rememberable Action may leave out its scope for arguments that should ask every time; any other Action has none.
616
617
  const reviewApprovalScopeIsValid =
617
618
  !params.review ||
618
- !("approval" in operation) ||
619
619
  !parsedReview?.success ||
620
- (operation.approval === "rememberable" ? parsedReview.data.approvalScope !== undefined : parsedReview.data.approvalScope === undefined);
620
+ ("approval" in operation && operation.approval === "rememberable") ||
621
+ parsedReview.data.approvalScope === undefined;
621
622
  if (!resultValidator || !parsedResult?.success || !reviewApprovalScopeIsValid) {
622
623
  if (actionWithoutRetrySafety) return settle(outcomeUnknown(), "uncertain");
623
624
  const invalid = errorResponse("INVALID_APP_RESPONSE", messages.outsideResultSchema({ appId: params.appId }), 502);
@@ -2,6 +2,9 @@ import { type Context, Hono, type MiddlewareHandler } from "hono";
2
2
  import { readBoundedJson } from "../_internal/bounded-json";
3
3
  import { type DashboardWidget, listWidgets } from "../_internal/registry";
4
4
  import {
5
+ type DashboardWidgetSize,
6
+ fitWidgetSize,
7
+ isDashboardWidgetSize,
5
8
  WIDGET_MAX_RESPONSE_BYTES,
6
9
  type WidgetResponse,
7
10
  WidgetResponseSchema,
@@ -9,6 +12,7 @@ import {
9
12
  widgetKey,
10
13
  } from "../contracts/widgets";
11
14
  import { type AuthContext, auth, preferredLocale, rejectReservedWorkloadCredential } from "../server";
15
+ import { WIDGET_SIZE_PARAM } from "../server/widget-request";
12
16
  import { CLOUD_INVOCATION_TOKEN_TTL_SECONDS } from "../services/identity/constants";
13
17
  import { invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
14
18
  import { widgetInvocationOperation } from "../services/identity/invocation-operations";
@@ -78,6 +82,8 @@ type ProviderOutcome =
78
82
  const callProvider = async (options: {
79
83
  c: Context<AuthContext>;
80
84
  widget: DashboardWidget;
85
+ /** What the browser asked for, fitted to the widget's declared sizes before it reaches the app. */
86
+ size: unknown;
81
87
  token: string;
82
88
  requestId: string | undefined;
83
89
  signal: AbortSignal;
@@ -98,6 +104,7 @@ const callProvider = async (options: {
98
104
  headers.set("x-cloud-invocation-operation", widgetInvocationOperation(widget.widgetId));
99
105
 
100
106
  const targetUrl = new URL(`/api/_internal/widgets/v1/${encodeURIComponent(widget.widgetId)}`, widget.url);
107
+ targetUrl.searchParams.set(WIDGET_SIZE_PARAM, fitWidgetSize(options.size, widget));
101
108
  signal.throwIfAborted();
102
109
  const response = await options.fetch(targetUrl, { headers, signal });
103
110
  if (response.status === 204 || response.status === 403) {
@@ -125,6 +132,17 @@ const callProvider = async (options: {
125
132
  }
126
133
  };
127
134
 
135
+ /**
136
+ * `<appId>/<widgetId>` with an optional `@small`, `@medium`, or `@large`, as the key and the size it asks for. The
137
+ * suffix is a size whenever the key before it is declared; `defineApp()` rejects `@` in widget IDs, so only an app
138
+ * built before sizes existed can declare an ID that ends in such a suffix, and it is still asked by that whole ID.
139
+ */
140
+ const parseRequestedWidget = (value: string, declared: ReadonlySet<string>): [string, DashboardWidgetSize | undefined] => {
141
+ const at = value.lastIndexOf("@");
142
+ const size = at < 0 ? undefined : value.slice(at + 1);
143
+ return isDashboardWidgetSize(size) && declared.has(value.slice(0, at)) ? [value.slice(0, at), size] : [value, undefined];
144
+ };
145
+
128
146
  export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) => {
129
147
  const registry = dependencies.listWidgets ?? listWidgets;
130
148
  const fetchWidget = dependencies.fetch ?? globalThis.fetch;
@@ -155,17 +173,20 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
155
173
  .use(rejectReservedWorkloadCredential)
156
174
  .use(auth.requireOAuthScope("read", "admin"))
157
175
  /**
158
- * Every requested widget (`?widget=<appId>/<widgetId>`, repeatable; all declared widgets without one) as an
159
- * NDJSON stream: one line per widget as soon as it answers, each within its own budget.
176
+ * Every requested widget (`?widget=<appId>/<widgetId>`, repeatable, optionally with `@<size>`; all declared
177
+ * widgets without one) as an NDJSON stream: one line per widget as soon as it answers, each within its own budget.
160
178
  */
161
179
  .get("/widgets/v1", async (c) => {
162
180
  const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
163
- const requested = new Set(c.req.queries("widget") ?? []);
164
181
  const setup = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(timeoutMs)]);
165
182
  let widgets: DashboardWidget[];
183
+ let requested = new Map<string, DashboardWidgetSize | undefined>();
166
184
  try {
167
185
  const keys = new Set<string>();
168
- widgets = (await waitWithin(registry(), setup)).filter((widget) => {
186
+ const declared = await waitWithin(registry(), setup);
187
+ const declaredKeys = new Set(declared.map((widget) => widgetKey(widget.appId, widget.widgetId)));
188
+ requested = new Map((c.req.queries("widget") ?? []).map((value) => parseRequestedWidget(value, declaredKeys)));
189
+ widgets = declared.filter((widget) => {
169
190
  const key = widgetKey(widget.appId, widget.widgetId);
170
191
  if (keys.has(key) || (requested.size > 0 && !requested.has(key))) return false;
171
192
  keys.add(key);
@@ -222,6 +243,7 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
222
243
  signal: AbortSignal.any([fanoutSignal, timeout]),
223
244
  timeout,
224
245
  fetch: fetchWidget,
246
+ size: requested.get(key),
225
247
  });
226
248
  if (outcome.status === "ok") return { type: "widget", key, status: "ok", widget: outcome.widget, ms: elapsed() };
227
249
  // A widget the stream itself stopped has already been settled, and is logged once, below.
@@ -276,7 +298,16 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
276
298
  );
277
299
 
278
300
  phase = "provider";
279
- const outcome = await callProvider({ c, widget, token: signed.token, requestId, signal, timeout, fetch: fetchWidget });
301
+ const outcome = await callProvider({
302
+ c,
303
+ widget,
304
+ size: c.req.query(WIDGET_SIZE_PARAM),
305
+ token: signed.token,
306
+ requestId,
307
+ signal,
308
+ timeout,
309
+ fetch: fetchWidget,
310
+ });
280
311
  if (outcome.status === "ok") return Response.json(outcome.widget, { headers: { "content-type": "application/json" } });
281
312
  if (!("reason" in outcome))
282
313
  return new Response(null, { status: outcome.status === "empty" ? 204 : 403, headers: { "content-type": "application/json" } });
@@ -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" />
@@ -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,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,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, {});