@k2b/cloud 0.39.0 → 0.40.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 (194) hide show
  1. package/README.md +8 -0
  2. package/package.json +8 -4
  3. package/src/_internal/app-favicon.ts +4 -2
  4. package/src/_internal/capabilities.ts +72 -48
  5. package/src/_internal/capability-reviews.ts +81 -0
  6. package/src/_internal/capability-schemas.ts +29 -0
  7. package/src/_internal/capability-sentences.ts +216 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +36 -15
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/runtime-watcher.ts +6 -0
  12. package/src/_internal/static-assets.ts +52 -3
  13. package/src/_internal/status-preserving-ssr.ts +2 -0
  14. package/src/access/PermissionEditor.tsx +2 -2
  15. package/src/account/EntitySearch.tsx +1 -1
  16. package/src/ai/action-review.ts +101 -0
  17. package/src/ai/background-review.ts +72 -0
  18. package/src/ai/capabilities.ts +29 -11
  19. package/src/ai/capability-execution.ts +76 -43
  20. package/src/ai/capability-result.ts +95 -0
  21. package/src/ai/chat/action-card.ts +398 -0
  22. package/src/ai/chat/action-messages.ts +129 -0
  23. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  24. package/src/ai/chat/action-question.fixtures.ts +733 -0
  25. package/src/ai/chat/action-question.tsx +669 -0
  26. package/src/ai/chat/blocks.tsx +19 -25
  27. package/src/ai/chat/message-actions.tsx +6 -0
  28. package/src/ai/chat/tool-action.tsx +481 -0
  29. package/src/ai/chat/turn-view.tsx +10 -160
  30. package/src/ai/chat/view-block.tsx +15 -5
  31. package/src/ai/chat-task-contracts.ts +4 -1
  32. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  33. package/src/ai/chat-task-reasons.ts +1 -1
  34. package/src/ai/chat-tasks.ts +55 -25
  35. package/src/ai/client/controller.ts +59 -0
  36. package/src/ai/default-tools.ts +9 -1
  37. package/src/ai/executor.ts +190 -39
  38. package/src/ai/index.ts +2 -0
  39. package/src/ai/migrate.ts +11 -2
  40. package/src/ai/projects-routes.ts +3 -0
  41. package/src/ai/protocol.ts +14 -2
  42. package/src/ai/recent-resources.ts +240 -0
  43. package/src/ai/recent-sent-refs.ts +38 -0
  44. package/src/ai/routes.ts +145 -1
  45. package/src/ai/runtime.ts +1 -0
  46. package/src/ai/skills-routes.ts +3 -0
  47. package/src/ai/skills.ts +10 -1
  48. package/src/ai/store.ts +227 -24
  49. package/src/ai/tool-audit.ts +8 -0
  50. package/src/ai/tool-wording.ts +24 -2
  51. package/src/ai/types.ts +28 -3
  52. package/src/api/accounts-entities.ts +2 -1
  53. package/src/api/admin-ai-projects.ts +2 -0
  54. package/src/api/admin-ai-skills.ts +2 -0
  55. package/src/api/admin-ai-usage.ts +2 -0
  56. package/src/api/admin-geoip.ts +80 -0
  57. package/src/api/admin-identity.ts +3 -0
  58. package/src/api/admin-lifecycle.ts +3 -0
  59. package/src/api/admin-outgoing-mail.ts +2 -0
  60. package/src/api/app-approval.ts +46 -5
  61. package/src/api/approvals-loader.ts +23 -1
  62. package/src/api/approvals.ts +2 -0
  63. package/src/api/auth.ts +25 -6
  64. package/src/api/capabilities.ts +44 -11
  65. package/src/api/capability-streams.ts +12 -0
  66. package/src/api/help.ts +4 -28
  67. package/src/api/index.ts +6 -1
  68. package/src/api/lasting-access-messages.ts +17 -0
  69. package/src/api/mcp.ts +3 -0
  70. package/src/api/me-panel-consent.ts +71 -0
  71. package/src/api/me.ts +80 -33
  72. package/src/api/search.ts +3 -1
  73. package/src/api/sync-ops.ts +2 -0
  74. package/src/api/widgets.ts +3 -0
  75. package/src/approvals/Approvals.tsx +180 -238
  76. package/src/approvals/messages.ts +12 -24
  77. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  78. package/src/browser/app-approval.ts +54 -5
  79. package/src/browser/commands.ts +22 -6
  80. package/src/browser/composer-catalog.ts +45 -3
  81. package/src/browser/composer-insert.ts +10 -0
  82. package/src/browser/composer-open-messages.ts +17 -0
  83. package/src/browser/composer-open.ts +110 -0
  84. package/src/browser/discoverable-command.ts +6 -0
  85. package/src/browser/hints.ts +6 -0
  86. package/src/browser/live-connection.ts +26 -2
  87. package/src/browser/navigation-apps.ts +7 -0
  88. package/src/browser/panel-context-bridge.ts +30 -0
  89. package/src/browser/panel-context-receiver.ts +53 -0
  90. package/src/browser/panel-menu.tsx +56 -0
  91. package/src/browser/panel-messages.ts +11 -0
  92. package/src/browser/panel-shortcuts.ts +31 -0
  93. package/src/browser/panel-slot.ts +412 -10
  94. package/src/browser/panel.ts +37 -0
  95. package/src/browser/search-commands.ts +6 -3
  96. package/src/capabilities/client.ts +8 -2
  97. package/src/capabilities/reviews.ts +205 -0
  98. package/src/capabilities/server.ts +10 -3
  99. package/src/capabilities/testing.ts +47 -0
  100. package/src/capabilities/types.ts +2 -2
  101. package/src/cli/account.ts +13 -3
  102. package/src/cli/admin/geoip.ts +115 -0
  103. package/src/cli/admin/index.ts +3 -0
  104. package/src/contracts/app-approval.ts +120 -13
  105. package/src/contracts/approvals.ts +2 -2
  106. package/src/contracts/capabilities.ts +410 -18
  107. package/src/contracts/geoip.ts +42 -0
  108. package/src/contracts/index.ts +3 -0
  109. package/src/contracts/panel-context-plain.ts +99 -0
  110. package/src/contracts/panel-context.ts +93 -0
  111. package/src/contracts/seen-hints.ts +35 -0
  112. package/src/server/client-ip.ts +5 -0
  113. package/src/server/help-payload.ts +38 -0
  114. package/src/server/middleware/auth.ts +11 -1
  115. package/src/server/middleware/middleware.ts +9 -0
  116. package/src/server/middleware/no-transform.ts +19 -0
  117. package/src/server/middleware/rate-limit.ts +4 -6
  118. package/src/server/request-context/address.ts +45 -0
  119. package/src/server/request-context/geoip.ts +380 -0
  120. package/src/server/request-context/index.ts +33 -0
  121. package/src/server/request-context/user-agent.ts +95 -0
  122. package/src/services/app-approval.ts +382 -59
  123. package/src/services/approvals/device.ts +7 -5
  124. package/src/services/approvals/index.ts +6 -5
  125. package/src/services/approvals/list.ts +26 -5
  126. package/src/services/auth-flows/notification-sender.ts +3 -0
  127. package/src/services/geoip/index.ts +164 -0
  128. package/src/services/geoip/store.ts +30 -0
  129. package/src/services/geoip/upload.ts +150 -0
  130. package/src/services/identity/routes.ts +5 -1
  131. package/src/services/index.ts +11 -0
  132. package/src/services/known-browsers.ts +312 -0
  133. package/src/services/login-address-store.ts +51 -0
  134. package/src/services/login-context.ts +118 -0
  135. package/src/services/mandates/index.ts +3 -0
  136. package/src/services/mandates/policy.ts +15 -3
  137. package/src/services/notifications/email-frame.ts +1 -1
  138. package/src/services/oauth-tokens.ts +16 -2
  139. package/src/services/panel-context-consent.ts +58 -0
  140. package/src/services/service-account-credentials.ts +21 -0
  141. package/src/services/session/index.ts +97 -16
  142. package/src/services/session/recent.ts +13 -0
  143. package/src/services/webauthn.ts +12 -0
  144. package/src/shared/email-html.ts +2 -0
  145. package/src/shared/help.ts +3 -0
  146. package/src/shared-text/cursor.ts +22 -0
  147. package/src/shared-text/documents.ts +142 -0
  148. package/src/shared-text/events.ts +43 -0
  149. package/src/shared-text/index.ts +12 -0
  150. package/src/shared-text/log.ts +48 -0
  151. package/src/shared-text/text-diff.ts +23 -0
  152. package/src/shared-text/types.ts +41 -0
  153. package/src/ssr/AdminLayout.tsx +2 -0
  154. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  155. package/src/ssr/CloudShell.island.tsx +10 -2
  156. package/src/ssr/Layout.tsx +25 -2
  157. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  158. package/src/ssr/LayoutHelp.tsx +435 -274
  159. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  160. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  161. package/src/ssr/MobileNavigation.tsx +2 -2
  162. package/src/ssr/PanelLayout.tsx +9 -1
  163. package/src/ssr/PanelRuntime.island.tsx +7 -0
  164. package/src/ssr/PanelSlot.tsx +53 -3
  165. package/src/ssr/ShellRuntime.island.tsx +11 -2
  166. package/src/ssr/app-color.ts +14 -9
  167. package/src/ssr/app-navigation.ts +16 -3
  168. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  169. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  170. package/src/ssr/help-messages.ts +4 -0
  171. package/src/ssr/help.ts +20 -0
  172. package/src/ssr/hints.ts +25 -0
  173. package/src/ssr/index.ts +2 -0
  174. package/src/ssr/islands/index.ts +4 -3
  175. package/src/ssr/layout-context.ts +2 -0
  176. package/src/ssr/layout-help-article.server.ts +50 -0
  177. package/src/ssr/layout-help-start.ts +33 -7
  178. package/src/ssr/panel-consent.ts +29 -0
  179. package/src/ssr/panel-context.ts +10 -0
  180. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  181. package/src/ssr/platform-messages.ts +18 -0
  182. package/src/ssr/shell-data.tsx +2 -1
  183. package/src/styles/effects.css +198 -9
  184. package/src/styles/file-chooser.css +2 -1
  185. package/src/styles/global.css +49 -12
  186. package/src/styles/panel.css +47 -4
  187. package/src/styles/resource-search.css +6 -2
  188. package/src/styles/tokens.css +17 -11
  189. package/src/styles/utilities-buttons.css +3 -2
  190. package/src/styles/utilities-data.css +2 -1
  191. package/src/styles/utilities-feedback.css +2 -2
  192. package/src/styles/utilities-layout.css +3 -13
  193. package/src/styles/utilities-markdown-editor.css +6 -6
  194. package/src/styles/utilities-navigation.css +17 -22
@@ -1,7 +1,9 @@
1
1
  import type { AssistantMessage, CompactEvent, LoopAggregate, Message, NessiLoop, OutboundEvent } from "@k2b/nessi";
2
2
  import { compact, nessi } from "@k2b/nessi";
3
+ import { capabilityReviewRememberScope } from "../_internal/capability-reviews";
3
4
  import { listCapabilities } from "../_internal/registry";
4
- import type { CapabilityActionReview } from "../contracts/capabilities";
5
+ import type { CapabilityActionReviewData } from "../contracts/capabilities";
6
+ import { capabilityActionPolicy } from "../contracts/capabilities";
5
7
  import type { AccessSubject, RequestActor } from "../server";
6
8
  import { approvalAssurance, approvalScopeFromToolName, approvals } from "../services/approvals";
7
9
  import { createHelpReader } from "../services/help";
@@ -10,6 +12,7 @@ import { getMandate } from "../services/mandates";
10
12
  import type { MandatePolicyV1 } from "../services/mandates/policy";
11
13
  import { coreSettings } from "../services/settings/api";
12
14
  import { normalizeLocale } from "../shared/locale";
15
+ import { type AiActionCheck, type AiStoredActionReview, storedActionReview } from "./action-review";
13
16
  import {
14
17
  AI_WEBSITE_APPROVAL_TOOL,
15
18
  type AiToolApprovalContext,
@@ -19,20 +22,23 @@ import {
19
22
  hasRememberedAiToolApproval,
20
23
  } from "./approvals";
21
24
  import { aiChatAccessSubject, isAssistantChatTurn, resolveAssistantAudioModel } from "./assistant-models";
25
+ import { authorizeBackgroundActionWith } from "./background-review";
22
26
  import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
23
- import { type AiCapabilityCatalogEntry, createAiToolResolver, createRunToolStore } from "./capabilities";
24
- import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
27
+ import { type AiCapabilityCatalogEntry, buildAiCapabilityCatalog, createAiToolResolver, createRunToolStore } from "./capabilities";
28
+ import {
29
+ AiCapabilityExecutionError,
30
+ aiCapabilityCheckResult,
31
+ checkAiCapability,
32
+ executeAiCapability,
33
+ resolveAiCapabilityActor,
34
+ reviewAiCapability,
35
+ } from "./capability-execution";
25
36
  import { aiChatMessages } from "./chat/messages";
26
37
  import { isFailedTool } from "./chat/tool-groups";
27
38
  import { aiTurnErrorText } from "./chat/turn-error";
28
39
  import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
29
40
  import { createCloudCompactFn } from "./compaction";
30
- import {
31
- createCloudAiCodeTools,
32
- createCloudAiLocalBashTool,
33
- createCloudAiLocalTools,
34
- createConfiguredDefaultCloudAiTools,
35
- } from "./default-tools";
41
+ import { createAiTurnCodeAndClientTools, createConfiguredDefaultCloudAiTools } from "./default-tools";
36
42
  import { aiFileStore } from "./files-store";
37
43
  import { AI_LOCAL_TOOL_NAMES } from "./local-tool-contracts";
38
44
  import {
@@ -72,7 +78,15 @@ import { isAiVisionModelConfigured, type resolveAiModel } from "./settings";
72
78
  import { mergeAiSkillCatalog, selectAiSkillCatalog } from "./skill-catalog";
73
79
  import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool, loadSelectedAiSkills } from "./skill-tool";
74
80
  import { aiSkills } from "./skills";
75
- import { aiConversations, recordAiWebsiteReceipts } from "./store";
81
+ import {
82
+ AI_TOOL_CHECK_STALE_MS,
83
+ aiConversations,
84
+ claimAiToolCheck,
85
+ readAiToolCheck,
86
+ recordAiToolReview,
87
+ recordAiWebsiteReceipts,
88
+ settleAiToolCheck,
89
+ } from "./store";
76
90
  import { AI_LIVE_SNAPSHOT_INTERVAL_MS, publishAiWireEvent } from "./stream";
77
91
  import { composeAiSystemPrompt } from "./system-prompt";
78
92
  import { aiToolAudit } from "./tool-audit";
@@ -427,9 +441,9 @@ type ToolBlockPatch = Partial<Extract<AiTurnBlock, { kind: "tool" }>> & { name?:
427
441
  const customApprovalParentCallId = (callId: string): string | undefined => /^(.*)-approval-\d+$/.exec(callId)?.[1];
428
442
 
429
443
  const approvalReviewForCallId = (
430
- reviews: ReadonlyMap<string, CapabilityActionReview>,
444
+ reviews: ReadonlyMap<string, CapabilityActionReviewData>,
431
445
  callId: string,
432
- ): CapabilityActionReview | undefined => {
446
+ ): CapabilityActionReviewData | undefined => {
433
447
  const direct = reviews.get(callId);
434
448
  if (direct) return direct;
435
449
  const parentCallId = customApprovalParentCallId(callId);
@@ -479,9 +493,11 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
479
493
  const setCanonicalNames = (items: Map<string, string>) => {
480
494
  canonicalNames = items;
481
495
  };
482
- let approvalReviews = new Map<string, CapabilityActionReview>();
483
- const setApprovalReviews = (items: Map<string, CapabilityActionReview>) => {
496
+ let approvalReviews = new Map<string, CapabilityActionReviewData>();
497
+ let capabilityScopes: ReadonlyMap<string, string> = new Map();
498
+ const setApprovalReviews = (items: Map<string, CapabilityActionReviewData>, scopes: ReadonlyMap<string, string> = new Map()) => {
484
499
  approvalReviews = items;
500
+ capabilityScopes = scopes;
485
501
  };
486
502
  let approvalPolicies: PreparedAiTools["approvalPolicies"] = new Map();
487
503
  const setApprovalPolicies = (items: PreparedAiTools["approvalPolicies"]) => {
@@ -505,7 +521,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
505
521
  const streamKinds = new Map<string, "text" | "thinking">();
506
522
 
507
523
  const setTool = (callId: string, patch: ToolBlockPatch, displayCallId = callId): BlockOp => {
508
- const existing = toolBlocks.get(callId);
524
+ const existing = toolBlocks.get(callId) ?? toolBlocks.get(displayCallId);
509
525
  const rawName = patch.name ?? existing?.name ?? "tool";
510
526
  const name = canonicalNames.get(rawName) ?? rawName;
511
527
  const approval = patch.clearApproval ? undefined : "approval" in patch ? patch.approval : existing?.approval;
@@ -520,6 +536,8 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
520
536
  result: "result" in patch ? patch.result : existing?.result,
521
537
  isError: "isError" in patch ? patch.isError : existing?.isError,
522
538
  approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false, allowChat: false } : approval,
539
+ review: patch.review ?? existing?.review,
540
+ check: patch.check ?? existing?.check,
523
541
  frontendMode: patch.frontendMode ?? existing?.frontendMode,
524
542
  presentation: patch.presentation ?? existing?.presentation ?? presentations.get(rawName) ?? presentations.get(name),
525
543
  ...(approvedCallIds.has(callId) || approvedCallIds.has(displayCallId) || existing?.approved ? { approved: true } : {}),
@@ -572,7 +590,8 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
572
590
  const target = event.kind === "custom_approval" ? approvalTargets.get(event.callId) : undefined;
573
591
  const allowAlways = target
574
592
  ? target.always
575
- : review?.approvalScope !== undefined || aiToolAllowsAlways(approvalPolicies.get(event.name));
593
+ : capabilityScopes.has(customApprovalParentCallId(event.callId) ?? event.callId) ||
594
+ aiToolAllowsAlways(approvalPolicies.get(event.name));
576
595
  return [
577
596
  setTool(
578
597
  event.callId,
@@ -651,6 +670,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
651
670
 
652
671
  return {
653
672
  translate,
673
+ setTool,
654
674
  storedMessage,
655
675
  compaction,
656
676
  setFrontendModes,
@@ -690,10 +710,7 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
690
710
  accessSubject: aiChatAccessSubject(config.actor),
691
711
  allowedDataBoundaries: config.modelPolicy?.allowedDataBoundaries,
692
712
  })),
693
- // Cloud runs the code tools itself; only tools a client must run wait for that client to declare them.
694
- ...[createCloudAiLocalBashTool(), ...createCloudAiLocalTools(), ...createCloudAiCodeTools()].filter(
695
- (tool) => tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name),
696
- ),
713
+ ...createAiTurnCodeAndClientTools(config),
697
714
  ]
698
715
  : [],
699
716
  toolApprovalContext: config.toolApprovalContext,
@@ -997,11 +1014,7 @@ export class AiTurnExecutor {
997
1014
  ];
998
1015
  const toolsSupported = resolved.profile.capabilities.includes("tools");
999
1016
  const allowed = allowedTools === null ? null : new Set(allowedTools);
1000
- const activeTools = toolsSupported
1001
- ? runtimeTools.filter(
1002
- (tool) => (!allowed || allowed.has(tool.def.name)) && !(config.mandate && ["code_open", "code_secret"].includes(tool.def.name)),
1003
- )
1004
- : [];
1017
+ const activeTools = toolsSupported ? runtimeTools.filter((tool) => !allowed || allowed.has(tool.def.name)) : [];
1005
1018
  let audioUnavailableInstruction: string | undefined;
1006
1019
  if (
1007
1020
  defaultToolSource &&
@@ -1092,7 +1105,23 @@ export class AiTurnExecutor {
1092
1105
  });
1093
1106
  let backgroundError: string | null = null;
1094
1107
  const rememberableCapabilityApprovals = new Map<string, string>();
1095
- const capabilityActionReviews = new Map<string, CapabilityActionReview>();
1108
+ const capabilityActionReviews = new Map<string, CapabilityActionReviewData>();
1109
+ const storedReviews = new Map<string, AiStoredActionReview>();
1110
+ const onCapabilityReview = async (
1111
+ callId: string,
1112
+ review: CapabilityActionReviewData,
1113
+ approval: "none" | "rememberable" | undefined,
1114
+ entry: AiCapabilityCatalogEntry,
1115
+ ) => {
1116
+ capabilityActionReviews.set(callId, review);
1117
+ const scope = capabilityReviewRememberScope(review, approval);
1118
+ if (scope) rememberableCapabilityApprovals.set(callId, scope);
1119
+ else rememberableCapabilityApprovals.delete(callId);
1120
+ const stored = storedActionReview(review, capabilityActionPolicy(entry.app.manifest, entry.operation.localId));
1121
+ await recordAiToolReview({ conversationId, turnId, callId, review: stored });
1122
+ storedReviews.set(callId, stored);
1123
+ await pipeline.setToolReview(callId, stored);
1124
+ };
1096
1125
  pipeline.setApprovalTargets(approvalTargets);
1097
1126
  pipeline.setFrontendModes(prepared.frontendModes);
1098
1127
  pipeline.setCanonicalNames(prepared.canonicalNames);
@@ -1101,7 +1130,7 @@ export class AiTurnExecutor {
1101
1130
  const rejectedToolCallIds = new Set<string>();
1102
1131
  const approvedToolCallIds = new Set<string>();
1103
1132
  pipeline.setPresentations(toolPresentations);
1104
- pipeline.setApprovalReviews(capabilityActionReviews);
1133
+ pipeline.setApprovalReviews(capabilityActionReviews, rememberableCapabilityApprovals);
1105
1134
  let turnInput = config.input;
1106
1135
  try {
1107
1136
  if (resolved.profile.capabilities.includes("vision") && config.files?.attached.some((file) => isAiImageMediaType(file.mediaType))) {
@@ -1192,7 +1221,7 @@ export class AiTurnExecutor {
1192
1221
  );
1193
1222
  return;
1194
1223
  }
1195
- const authorizeBackground =
1224
+ const authorizeBackgroundMandate =
1196
1225
  config.background && config.mandate
1197
1226
  ? async (entry: AiCapabilityCatalogEntry, args: unknown) => {
1198
1227
  try {
@@ -1202,6 +1231,7 @@ export class AiTurnExecutor {
1202
1231
  capabilityId: entry.operation.localId,
1203
1232
  kind: entry.kind,
1204
1233
  input: args,
1234
+ policy: capabilityActionPolicy(entry.app.manifest, entry.operation.localId),
1205
1235
  approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
1206
1236
  });
1207
1237
  } catch (error) {
@@ -1245,16 +1275,51 @@ export class AiTurnExecutor {
1245
1275
  }),
1246
1276
  }
1247
1277
  : {}),
1248
- authorizeBackground,
1249
- onReview: (callId, review) => {
1250
- capabilityActionReviews.set(callId, review);
1251
- if (review.approvalScope) rememberableCapabilityApprovals.set(callId, review.approvalScope);
1252
- },
1278
+ authorizeBackground: authorizeBackgroundMandate
1279
+ ? async (entry, args, context) => {
1280
+ const review = await authorizeBackgroundActionWith(
1281
+ {
1282
+ turnId,
1283
+ mandate: config.mandate!,
1284
+ appId: entry.appId,
1285
+ capabilityId: entry.operation.localId,
1286
+ kind: entry.kind,
1287
+ args,
1288
+ policy: capabilityActionPolicy(entry.app.manifest, entry.operation.localId),
1289
+ approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
1290
+ hasReview: entry.kind === "action" && "review" in entry.operation && entry.operation.review === true,
1291
+ action: entry.display.title,
1292
+ review: () =>
1293
+ capabilityAuthority
1294
+ ? reviewAiCapability({
1295
+ conversationId,
1296
+ authority: capabilityAuthority,
1297
+ mandate: config.mandate,
1298
+ locale: promptLocale,
1299
+ entry,
1300
+ args,
1301
+ context,
1302
+ })
1303
+ : Promise.resolve(null),
1304
+ signal: context.signal,
1305
+ },
1306
+ () => authorizeBackgroundMandate(entry, args),
1307
+ );
1308
+ if (review && context.callId)
1309
+ await onCapabilityReview(
1310
+ context.callId,
1311
+ review,
1312
+ "approval" in entry.operation ? entry.operation.approval : undefined,
1313
+ entry,
1314
+ );
1315
+ }
1316
+ : undefined,
1317
+ onReview: onCapabilityReview,
1253
1318
  ...(capabilityAuthority
1254
1319
  ? {
1255
1320
  execute: async (entry, args, context) => {
1256
1321
  try {
1257
- await authorizeBackground?.(entry, args);
1322
+ await authorizeBackgroundMandate?.(entry, args);
1258
1323
  const result = await executeAiCapability({
1259
1324
  conversationId,
1260
1325
  turnId,
@@ -1268,9 +1333,78 @@ export class AiTurnExecutor {
1268
1333
  entry,
1269
1334
  args,
1270
1335
  context,
1336
+ }).catch(async (error: unknown) => {
1337
+ const review = context.callId ? storedReviews.get(context.callId) : undefined;
1338
+ if (entry.kind !== "action" || !(error instanceof AiCapabilityExecutionError)) throw error;
1339
+ // Only an answer from the app about the request itself (a 4xx) proves that nothing happened. A 5xx,
1340
+ // a timeout, or a cancellation leaves the outcome open only once Cloud sent the request to the app;
1341
+ // before that, nothing ran and the error stays as it is. The idempotency codes speak of an earlier
1342
+ // attempt with the same key, so they leave it open even when this one sent nothing.
1343
+ const uncertain =
1344
+ ["ACTION_OUTCOME_UNKNOWN", "IDEMPOTENCY_UNCERTAIN", "IDEMPOTENCY_IN_PROGRESS"].includes(error.code) ||
1345
+ (error.dispatched && (error.status >= 500 || error.status === 499));
1346
+ if (!uncertain) throw error;
1347
+ const unknown =
1348
+ error.code === "ACTION_OUTCOME_UNKNOWN"
1349
+ ? error
1350
+ : new AiCapabilityExecutionError(
1351
+ "ACTION_OUTCOME_UNKNOWN",
1352
+ error.status,
1353
+ `The outcome is unknown after ${error.code}; do not retry automatically.`,
1354
+ error.dispatched,
1355
+ );
1356
+ if (!review || !("sentence" in review) || !review.check || !context.callId) throw unknown;
1357
+ let check: AiActionCheck;
1358
+ try {
1359
+ // Durable first: the claim stores `checking` before the Query runs. If this worker stops, the
1360
+ // claim goes stale and the person can check again; while it holds, no re-check runs beside it.
1361
+ const target = { conversationId, turnId, callId: context.callId };
1362
+ const claimedAt = new Date().toISOString();
1363
+ const claimed = await claimAiToolCheck({
1364
+ ...target,
1365
+ at: claimedAt,
1366
+ staleBefore: new Date(Date.now() - AI_TOOL_CHECK_STALE_MS).toISOString(),
1367
+ first: true,
1368
+ });
1369
+ if (!claimed) {
1370
+ // A person's check holds or settled the call already; its stored result is the outcome.
1371
+ const stored = await readAiToolCheck(target);
1372
+ const known = stored && stored.state !== "checking" ? stored : { state: "pending" as const };
1373
+ check = known;
1374
+ } else {
1375
+ await pipeline.setToolCheck(context.callId, { state: "checking" });
1376
+ const answered = await checkAiCapability({
1377
+ conversationId,
1378
+ turnId,
1379
+ authority: capabilityAuthority!,
1380
+ mandate: config.mandate,
1381
+ // The receipt joins the review's sentence, so it is asked for in the review's language.
1382
+ locale: review.locale,
1383
+ entry,
1384
+ check: review.check,
1385
+ catalog: buildAiCapabilityCatalog([entry.app], promptLocale),
1386
+ context,
1387
+ });
1388
+ // A claim that went stale and was taken over loses to the newer check's stored result.
1389
+ check = (await settleAiToolCheck({ ...target, claimedAt, check: answered }))
1390
+ ? answered
1391
+ : ((await readAiToolCheck(target)) ?? answered);
1392
+ }
1393
+ await pipeline.setToolCheck(context.callId, check);
1394
+ } catch (recoveryError) {
1395
+ log.warn("AI Action outcome recovery failed", {
1396
+ code: "action_outcome_recovery_failed",
1397
+ turnId,
1398
+ callId: context.callId,
1399
+ error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError),
1400
+ });
1401
+ throw unknown;
1402
+ }
1403
+ return aiCapabilityCheckResult(check);
1271
1404
  });
1272
1405
  const resources = collectConversationResourceObservations(args, result);
1273
1406
  if (resources.length) {
1407
+ // Bookkeeping after a call that ran never turns its outcome into a failure: the effect happened.
1274
1408
  await Promise.all([
1275
1409
  indexConversationResources({ conversationId, turnId, callId: context.callId, resources }),
1276
1410
  recordMemoryWorkflowEvidence({
@@ -1280,7 +1414,14 @@ export class AiTurnExecutor {
1280
1414
  capabilityId: entry.name,
1281
1415
  resources,
1282
1416
  }),
1283
- ]);
1417
+ ]).catch((error: unknown) =>
1418
+ log.warn("AI capability bookkeeping failed after the call ran", {
1419
+ code: "capability_bookkeeping_failed",
1420
+ turnId,
1421
+ callId: context.callId,
1422
+ error: error instanceof Error ? error.message : String(error),
1423
+ }),
1424
+ );
1284
1425
  }
1285
1426
  return result;
1286
1427
  } catch (error) {
@@ -1593,7 +1734,7 @@ export class AiTurnExecutor {
1593
1734
  approvalContext?: AiToolApprovalContext;
1594
1735
  allowRememberedApprovals: boolean;
1595
1736
  rememberableCapabilityApprovals: ReadonlyMap<string, string>;
1596
- capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
1737
+ capabilityActionReviews: ReadonlyMap<string, CapabilityActionReviewData>;
1597
1738
  approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
1598
1739
  linkedApprovals: ReadonlyMap<string, string>;
1599
1740
  freshApprovalRequired?: ReadonlySet<string>;
@@ -1778,7 +1919,7 @@ export class AiTurnExecutor {
1778
1919
  approvalContext?: AiToolApprovalContext;
1779
1920
  allowRememberedApprovals: boolean;
1780
1921
  rememberableCapabilityApprovals: ReadonlyMap<string, string>;
1781
- capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
1922
+ capabilityActionReviews: ReadonlyMap<string, CapabilityActionReviewData>;
1782
1923
  approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
1783
1924
  linkedApprovals: ReadonlyMap<string, string>;
1784
1925
  freshApprovalRequired?: ReadonlySet<string>;
@@ -2207,8 +2348,8 @@ class StreamPipeline {
2207
2348
  this.mapper.setCanonicalNames(names);
2208
2349
  }
2209
2350
 
2210
- setApprovalReviews(reviews: Map<string, CapabilityActionReview>): void {
2211
- this.mapper.setApprovalReviews(reviews);
2351
+ setApprovalReviews(reviews: Map<string, CapabilityActionReviewData>, scopes: ReadonlyMap<string, string>): void {
2352
+ this.mapper.setApprovalReviews(reviews, scopes);
2212
2353
  }
2213
2354
 
2214
2355
  setApprovalPolicies(policies: PreparedAiTools["approvalPolicies"]): void {
@@ -2290,6 +2431,16 @@ class StreamPipeline {
2290
2431
  await this.maybeSnapshot();
2291
2432
  }
2292
2433
 
2434
+ async setToolReview(callId: string, review: AiStoredActionReview): Promise<void> {
2435
+ await this.emitOp(this.mapper.setTool(callId, { review }));
2436
+ await this.maybeSnapshot();
2437
+ }
2438
+
2439
+ async setToolCheck(callId: string, check: AiActionCheck): Promise<void> {
2440
+ await this.emitOp(this.mapper.setTool(callId, { check, status: "running" }));
2441
+ await this.maybeSnapshot();
2442
+ }
2443
+
2293
2444
  async reportToolProgress(callId: string, progress: string): Promise<void> {
2294
2445
  const block = this.blocks.find((block) => block.kind === "tool" && block.callId === callId);
2295
2446
  if (!block || block.kind !== "tool" || block.progress === progress) return;
package/src/ai/index.ts CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  export { assistantAiSettingsState, listAssistantAiModels, selectAssistantAiModelId } from "./assistant-models";
23
23
  export { type AiAttachmentRef, aiAttachmentMarker, formatAiFileSize, parseAiAttachmentMarkers } from "./attachments";
24
24
  export { CloudAiTranscribeAudioInputSchema, CloudAiTranscribeAudioOutputSchema, createCloudAiTranscribeAudioTool } from "./audio-tool";
25
+ export { AiBackgroundApprovalRequiredError, authorizeBackgroundAction } from "./background-review";
25
26
  export { CODE_RUNTIME_TOOL_NAMES, CodeResourceId, CodeRuntimeInput, parseCodeToolInput } from "./browser-code-contracts";
26
27
  export { aiCapabilityId, aiCapabilityToolName } from "./capabilities";
27
28
  export { getAiChatQuotas } from "./chat-quotas";
@@ -419,6 +420,7 @@ export type {
419
420
  AiProjectPromptSnapshot,
420
421
  AiProviderId,
421
422
  AiPublicModelProfile,
423
+ AiRecentResource,
422
424
  AiResolvedModel,
423
425
  AiRuntimeTool,
424
426
  AiSettingsError,
package/src/ai/migrate.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { sql } from "bun";
2
- import { CapabilityActionReviewSchema } from "../contracts/capabilities";
2
+ import { CapabilityActionReviewDataSchema } from "../contracts/capabilities";
3
3
  import { APPROVAL_MAX_WAIT_MS, approvalAssurance, approvalScopeFromToolName, approvals, migrateApprovals } from "../services/approvals";
4
4
  import { migrateChatTaskReasons } from "./chat-task-reasons-migrate";
5
5
  import { migrateAiMessageQueue } from "./message-queue";
@@ -404,6 +404,15 @@ export const migrateCloudAi = async (): Promise<void> => {
404
404
  ON ai.messages(conversation_id, loop_id, seq ASC)
405
405
  WHERE loop_id IS NOT NULL
406
406
  `.simple();
407
+ // "Recent" in the slash menu reads the newest messages a person sent in each of their latest chats. Without it the
408
+ // read walks every Assistant and tool row of those chats. The predicate matches the store query word for word.
409
+ await sql`
410
+ CREATE INDEX IF NOT EXISTS idx_ai_messages_sent_by_person
411
+ ON ai.messages(conversation_id, seq DESC) INCLUDE (created_at, id)
412
+ WHERE role = 'user' AND kind = 'message'
413
+ AND NOT (COALESCE(meta, '{}'::jsonb) ? 'scheduledTask')
414
+ AND NOT (COALESCE(meta, '{}'::jsonb) ? 'agentMessage')
415
+ `.simple();
407
416
 
408
417
  // Optional pg_textsearch ranking. Native weighted FTS above is always the
409
418
  // correctness path; missing extension support must never block startup.
@@ -2667,7 +2676,7 @@ const backfillApprovalRequests = async (): Promise<void> => {
2667
2676
  WHERE r.requester_kind='assistant_turn' AND r.requester_ref=a.turn_id::text || '/' || a.call_id)
2668
2677
  ORDER BY a.created_at, a.id LIMIT 200 FOR UPDATE OF a SKIP LOCKED`;
2669
2678
  for (const row of rows) {
2670
- const review = CapabilityActionReviewSchema.safeParse(row.review == null ? null : decoded(row.review));
2679
+ const review = CapabilityActionReviewDataSchema.safeParse(row.review == null ? null : decoded(row.review));
2671
2680
  // request computes both canonical digests; preserve the original wait deadline, including expiry.
2672
2681
  const record = await approvals.request(
2673
2682
  {
@@ -4,6 +4,7 @@ import { getCapability } from "../_internal/registry";
4
4
  import { CloudResourceRefSchema, cloudResourceRefAppId, resolveCapabilityResourceReader } from "../contracts/capabilities";
5
5
  import { AuthenticatedPrincipalSchema } from "../contracts/shared";
6
6
  import { type AuthContext, auth, err, fail, getLocale, ok, rateLimit, respond, v } from "../server";
7
+ import { noTransform } from "../server/middleware/no-transform";
7
8
  import { ImageMetadataError } from "../services/image-metadata";
8
9
  import { aiChatMessages } from "./chat/messages";
9
10
  import { decodeAiFileContent } from "./files-store";
@@ -71,6 +72,8 @@ type AiProjectsRouteDependencies = {
71
72
 
72
73
  const buildAiProjectsRoutes = (dependencies: AiProjectsRouteDependencies = {}) =>
73
74
  new Hono<AuthContext>()
75
+ // The list combines projects from separate grants; the writer of one can rename it next to another project's instructions.
76
+ .use("*", noTransform())
74
77
  .use(dependencies.limit ?? rateLimit())
75
78
  .use("*", dependencies.authenticate ?? auth.requireRole("authenticated"))
76
79
  .get("/", async (c) => respond(c, ok({ projects: (await aiProjects.list(c.get("accessSubject") ?? null)).map(publicProject) })))
@@ -1,5 +1,6 @@
1
1
  import type { CompactResult, InboundEvent, Message } from "@k2b/nessi";
2
- import type { CapabilityActionReview } from "../contracts/capabilities";
2
+ import type { CapabilityActionReviewData } from "../contracts/capabilities";
3
+ import type { AiActionCheck, AiStoredActionReview } from "./action-review";
3
4
  import type { AiConversation, AiFrontendToolMode, AiStoredMessage, AiToolPresentation, AiTurnStatus } from "./types";
4
5
 
5
6
  /**
@@ -48,7 +49,7 @@ export type AiTurnBlock =
48
49
  /** Present while status is awaiting_approval. */
49
50
  approval?: {
50
51
  message?: string;
51
- review?: CapabilityActionReview;
52
+ review?: CapabilityActionReviewData;
52
53
  allowAlways: boolean;
53
54
  /** Whether the approval can be remembered for this chat. */
54
55
  allowChat?: boolean;
@@ -66,6 +67,13 @@ export type AiTurnBlock =
66
67
  presentation?: AiToolPresentation;
67
68
  /** Requests a website approval let through without asking, in order; shown whatever the call's outcome. */
68
69
  receipts?: { method: string; url: string }[];
70
+ /**
71
+ * The call's review as Cloud stored it when the app reviewed it. It stays after the decision, so the question,
72
+ * its receipt, and a reload all read the same sentence.
73
+ */
74
+ review?: AiStoredActionReview;
75
+ /** The app's `check` after an outcome Cloud could not see. */
76
+ check?: AiActionCheck;
69
77
  }
70
78
  | { id: string; kind: "compaction"; status: "running" | "completed" | "skipped" | "failed"; result?: CompactResult };
71
79
 
@@ -248,6 +256,8 @@ export const buildBlocksFromMessages = (
248
256
  meta?: {
249
257
  steerId?: string;
250
258
  toolPresentations?: Record<string, AiToolPresentation>;
259
+ toolReviews?: Record<string, AiStoredActionReview>;
260
+ toolChecks?: Record<string, AiActionCheck>;
251
261
  toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
252
262
  websiteReceipts?: Record<string, Record<string, { method: string; url: string }>>;
253
263
  } | null;
@@ -275,6 +285,8 @@ export const buildBlocksFromMessages = (
275
285
  args: block.args,
276
286
  status: "running",
277
287
  presentation: meta?.toolPresentations?.[block.id],
288
+ review: meta?.toolReviews?.[block.id],
289
+ check: meta?.toolChecks?.[block.id],
278
290
  // A turn that ended before an approved call returned records the approval on the call's message, and an
279
291
  // approval it left waiting as expired.
280
292
  ...(outcome === "approved" || outcome === "expired" ? { approved: outcome === "approved" } : {}),