@k2b/cloud 0.38.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 (244) hide show
  1. package/README.md +8 -0
  2. package/package.json +12 -5
  3. package/scripts/build-canvas-workers.ts +109 -29
  4. package/scripts/build.ts +3 -5
  5. package/src/_internal/app-favicon.ts +4 -2
  6. package/src/_internal/app-path.ts +25 -0
  7. package/src/_internal/app-presentation.ts +4 -0
  8. package/src/_internal/canvas-worker.ts +5 -3
  9. package/src/_internal/capabilities.ts +72 -48
  10. package/src/_internal/capability-reviews.ts +81 -0
  11. package/src/_internal/capability-schemas.ts +29 -0
  12. package/src/_internal/capability-sentences.ts +216 -1
  13. package/src/_internal/capability-streams.ts +7 -0
  14. package/src/_internal/define-app.ts +63 -16
  15. package/src/_internal/font-preloads.ts +2 -1
  16. package/src/_internal/panel-cookie.ts +39 -0
  17. package/src/_internal/registry-validation.ts +21 -26
  18. package/src/_internal/runtime-context.ts +1 -0
  19. package/src/_internal/runtime-watcher.ts +6 -0
  20. package/src/_internal/static-assets.ts +52 -3
  21. package/src/_internal/status-preserving-ssr.ts +2 -0
  22. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  23. package/src/access/PermissionEditor.tsx +32 -7
  24. package/src/access/ResourceApiKeys.tsx +5 -1
  25. package/src/account/EntitySearch.tsx +1 -1
  26. package/src/ai/action-review.ts +101 -0
  27. package/src/ai/approval-routes.ts +5 -2
  28. package/src/ai/approvals.ts +139 -28
  29. package/src/ai/background-review.ts +72 -0
  30. package/src/ai/browser.ts +10 -0
  31. package/src/ai/capabilities.ts +29 -11
  32. package/src/ai/capability-execution.ts +76 -43
  33. package/src/ai/capability-result.ts +95 -0
  34. package/src/ai/chat/action-card.ts +398 -0
  35. package/src/ai/chat/action-messages.ts +129 -0
  36. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  37. package/src/ai/chat/action-question.fixtures.ts +733 -0
  38. package/src/ai/chat/action-question.tsx +669 -0
  39. package/src/ai/chat/action-sentences.ts +1 -44
  40. package/src/ai/chat/blocks.tsx +19 -25
  41. package/src/ai/chat/message-actions.tsx +6 -0
  42. package/src/ai/chat/tool-action.tsx +481 -0
  43. package/src/ai/chat/turn-view.tsx +45 -163
  44. package/src/ai/chat/view-block.tsx +22 -7
  45. package/src/ai/chat-task-contracts.ts +31 -3
  46. package/src/ai/chat-task-reasons-migrate.ts +75 -0
  47. package/src/ai/chat-task-reasons.ts +92 -0
  48. package/src/ai/chat-tasks.ts +438 -78
  49. package/src/ai/client/controller.ts +59 -0
  50. package/src/ai/default-tools.ts +9 -1
  51. package/src/ai/executor.ts +190 -39
  52. package/src/ai/index.ts +14 -0
  53. package/src/ai/migrate.ts +39 -4
  54. package/src/ai/projects-routes.ts +3 -0
  55. package/src/ai/protocol.ts +14 -2
  56. package/src/ai/recent-resources.ts +240 -0
  57. package/src/ai/recent-sent-refs.ts +38 -0
  58. package/src/ai/request-context.ts +7 -1
  59. package/src/ai/routes.ts +172 -2
  60. package/src/ai/runtime.ts +13 -27
  61. package/src/ai/skills-routes.ts +3 -0
  62. package/src/ai/skills.ts +10 -1
  63. package/src/ai/store.ts +232 -27
  64. package/src/ai/timeline.ts +9 -1
  65. package/src/ai/tool-audit.ts +8 -0
  66. package/src/ai/tool-wording.ts +67 -0
  67. package/src/ai/turn-abort.ts +33 -0
  68. package/src/ai/types.ts +40 -4
  69. package/src/api/accounts-entities.ts +2 -1
  70. package/src/api/admin-ai-projects.ts +2 -0
  71. package/src/api/admin-ai-skills.ts +2 -0
  72. package/src/api/admin-ai-usage.ts +2 -0
  73. package/src/api/admin-core-settings.ts +190 -139
  74. package/src/api/admin-geoip.ts +80 -0
  75. package/src/api/admin-identity.ts +3 -0
  76. package/src/api/admin-lifecycle.ts +3 -0
  77. package/src/api/admin-outgoing-mail.ts +6 -2
  78. package/src/api/app-approval.ts +54 -6
  79. package/src/api/approvals-loader.ts +23 -1
  80. package/src/api/approvals.ts +3 -1
  81. package/src/api/auth.ts +25 -6
  82. package/src/api/capabilities.ts +44 -11
  83. package/src/api/capability-streams.ts +12 -0
  84. package/src/api/help.ts +4 -28
  85. package/src/api/index.ts +7 -1
  86. package/src/api/lasting-access-messages.ts +17 -0
  87. package/src/api/mcp.ts +3 -0
  88. package/src/api/me-panel-consent.ts +71 -0
  89. package/src/api/me.ts +80 -33
  90. package/src/api/search.ts +3 -1
  91. package/src/api/sync-ops.ts +2 -0
  92. package/src/api/widgets.ts +3 -0
  93. package/src/approvals/Approvals.tsx +180 -238
  94. package/src/approvals/messages.ts +12 -24
  95. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  96. package/src/browser/CloudResourceSearch.tsx +10 -6
  97. package/src/browser/app-approval.ts +60 -6
  98. package/src/browser/commands.ts +22 -6
  99. package/src/browser/composer-catalog.ts +145 -0
  100. package/src/browser/composer-insert-messages.ts +31 -0
  101. package/src/browser/composer-insert.ts +160 -0
  102. package/src/browser/composer-open-messages.ts +17 -0
  103. package/src/browser/composer-open.ts +110 -0
  104. package/src/browser/discoverable-command.ts +6 -0
  105. package/src/browser/hints.ts +6 -0
  106. package/src/browser/live-connection.ts +26 -2
  107. package/src/browser/navigation-apps.ts +7 -0
  108. package/src/browser/panel-context-bridge.ts +30 -0
  109. package/src/browser/panel-context-receiver.ts +53 -0
  110. package/src/browser/panel-menu.tsx +56 -0
  111. package/src/browser/panel-messages.ts +35 -0
  112. package/src/browser/panel-shortcuts.ts +31 -0
  113. package/src/browser/panel-slot.ts +756 -0
  114. package/src/browser/panel.ts +65 -0
  115. package/src/browser/resource-picker.tsx +12 -1
  116. package/src/browser/search-commands.ts +6 -3
  117. package/src/capabilities/client.ts +8 -2
  118. package/src/capabilities/reviews.ts +205 -0
  119. package/src/capabilities/server.ts +10 -3
  120. package/src/capabilities/testing.ts +47 -0
  121. package/src/capabilities/types.ts +2 -2
  122. package/src/cli/account.ts +13 -3
  123. package/src/cli/admin/geoip.ts +115 -0
  124. package/src/cli/admin/index.ts +3 -0
  125. package/src/contracts/app-approval.ts +159 -12
  126. package/src/contracts/app.ts +14 -0
  127. package/src/contracts/approvals.ts +2 -2
  128. package/src/contracts/capabilities.ts +410 -18
  129. package/src/contracts/geoip.ts +42 -0
  130. package/src/contracts/index.ts +3 -0
  131. package/src/contracts/panel-context-plain.ts +99 -0
  132. package/src/contracts/panel-context.ts +93 -0
  133. package/src/contracts/registry.ts +3 -1
  134. package/src/contracts/seen-hints.ts +35 -0
  135. package/src/contracts/shared.ts +2 -0
  136. package/src/server/client-ip.ts +5 -0
  137. package/src/server/help-payload.ts +38 -0
  138. package/src/server/middleware/auth.ts +11 -1
  139. package/src/server/middleware/middleware.ts +9 -0
  140. package/src/server/middleware/no-transform.ts +19 -0
  141. package/src/server/middleware/rate-limit.ts +4 -6
  142. package/src/server/request-context/address.ts +45 -0
  143. package/src/server/request-context/geoip.ts +380 -0
  144. package/src/server/request-context/index.ts +33 -0
  145. package/src/server/request-context/user-agent.ts +95 -0
  146. package/src/server/services/access.ts +5 -1
  147. package/src/services/accounts/posix.ts +2 -1
  148. package/src/services/app-approval.ts +533 -90
  149. package/src/services/approvals/device.ts +9 -5
  150. package/src/services/approvals/index.ts +6 -5
  151. package/src/services/approvals/list.ts +26 -5
  152. package/src/services/auth-flows/notification-sender.ts +3 -0
  153. package/src/services/geoip/index.ts +164 -0
  154. package/src/services/geoip/store.ts +30 -0
  155. package/src/services/geoip/upload.ts +150 -0
  156. package/src/services/identity/routes.ts +5 -1
  157. package/src/services/index.ts +32 -3
  158. package/src/services/known-browsers.ts +312 -0
  159. package/src/services/login-address-store.ts +51 -0
  160. package/src/services/login-context.ts +118 -0
  161. package/src/services/mandates/index.ts +3 -0
  162. package/src/services/mandates/policy.ts +15 -3
  163. package/src/services/notifications/browser.ts +16 -5
  164. package/src/services/notifications/email-frame.ts +1 -1
  165. package/src/services/oauth-tokens.ts +16 -2
  166. package/src/services/panel-context-consent.ts +58 -0
  167. package/src/services/pdf/image-protocol.ts +135 -0
  168. package/src/services/pdf/image-variants.ts +124 -0
  169. package/src/services/pdf/image-worker.ts +31 -0
  170. package/src/services/pdf/image.ts +109 -0
  171. package/src/services/pdf/index.ts +9 -0
  172. package/src/services/service-account-credentials.ts +21 -0
  173. package/src/services/session/index.ts +97 -16
  174. package/src/services/session/recent.ts +13 -0
  175. package/src/services/settings/api.ts +11 -6
  176. package/src/services/settings/app.ts +5 -4
  177. package/src/services/settings/core-settings.ts +4 -3
  178. package/src/services/settings/index.ts +19 -17
  179. package/src/services/settings/namespace.ts +2 -1
  180. package/src/services/settings/store.ts +83 -11
  181. package/src/services/webauthn.ts +12 -0
  182. package/src/shared/ai-platform-prompt.ts +1 -1
  183. package/src/shared/app-presentation.ts +1 -0
  184. package/src/shared/email-html.ts +2 -0
  185. package/src/shared/help.ts +3 -0
  186. package/src/shared/redirect.ts +12 -0
  187. package/src/shared-text/cursor.ts +22 -0
  188. package/src/shared-text/documents.ts +142 -0
  189. package/src/shared-text/events.ts +43 -0
  190. package/src/shared-text/index.ts +12 -0
  191. package/src/shared-text/log.ts +48 -0
  192. package/src/shared-text/text-diff.ts +23 -0
  193. package/src/shared-text/types.ts +41 -0
  194. package/src/ssr/AdminLayout.tsx +2 -0
  195. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  196. package/src/ssr/CloudShell.island.tsx +95 -10
  197. package/src/ssr/Layout.tsx +66 -10
  198. package/src/ssr/LayoutHeader.tsx +15 -9
  199. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  200. package/src/ssr/LayoutHelp.tsx +435 -274
  201. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  202. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  203. package/src/ssr/LayoutRail.tsx +2 -2
  204. package/src/ssr/LayoutTabBar.tsx +29 -0
  205. package/src/ssr/MobileNavigation.tsx +2 -2
  206. package/src/ssr/PanelLayout.tsx +35 -0
  207. package/src/ssr/PanelRuntime.island.tsx +94 -0
  208. package/src/ssr/PanelSlot.tsx +158 -0
  209. package/src/ssr/PanelTrigger.tsx +49 -0
  210. package/src/ssr/ShellRuntime.island.tsx +69 -5
  211. package/src/ssr/app-appearance.ts +3 -2
  212. package/src/ssr/app-color.ts +105 -0
  213. package/src/ssr/app-launchpad.tsx +18 -1
  214. package/src/ssr/app-navigation.ts +28 -0
  215. package/src/ssr/compact-shell.baseline.json +5270 -0
  216. package/src/ssr/compact-shell.browser-harness.ts +439 -0
  217. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  218. package/src/ssr/help-messages.ts +4 -0
  219. package/src/ssr/help.ts +20 -0
  220. package/src/ssr/hints.ts +25 -0
  221. package/src/ssr/index.ts +5 -0
  222. package/src/ssr/islands/index.ts +4 -3
  223. package/src/ssr/layout-context.ts +2 -0
  224. package/src/ssr/layout-help-article.server.ts +50 -0
  225. package/src/ssr/layout-help-start.ts +33 -7
  226. package/src/ssr/panel-consent.ts +29 -0
  227. package/src/ssr/panel-context.ts +10 -0
  228. package/src/ssr/panel-slot.browser-harness.tsx +159 -0
  229. package/src/ssr/platform-messages.ts +48 -0
  230. package/src/ssr/rail-navigation.ts +44 -0
  231. package/src/ssr/shell-data.tsx +5 -0
  232. package/src/ssr/shell-shortcuts.ts +13 -2
  233. package/src/styles/effects.css +201 -11
  234. package/src/styles/file-chooser.css +2 -1
  235. package/src/styles/global.css +314 -63
  236. package/src/styles/panel.css +416 -0
  237. package/src/styles/resource-search.css +6 -2
  238. package/src/styles/tokens.css +36 -13
  239. package/src/styles/utilities-buttons.css +3 -2
  240. package/src/styles/utilities-data.css +2 -1
  241. package/src/styles/utilities-feedback.css +2 -2
  242. package/src/styles/utilities-layout.css +3 -13
  243. package/src/styles/utilities-markdown-editor.css +6 -6
  244. package/src/styles/utilities-navigation.css +22 -43
@@ -1,5 +1,8 @@
1
1
  import { type Accessor, createMemo, createSignal, onCleanup } from "solid-js";
2
2
  import { createStore, reconcile } from "solid-js/store";
3
+ import { z } from "zod";
4
+ import { CapabilityActionReceiptSchema } from "../../contracts/capabilities";
5
+ import { type AiActionCheck, preferredActionCheck } from "../action-review";
3
6
  import { type AiAttachmentRef, aiAttachmentMarker } from "../attachments";
4
7
  import { aiChatMessages } from "../chat/messages";
5
8
  import { AI_TURN_LEASE_MS, type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
@@ -1349,6 +1352,61 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1349
1352
  return submitted;
1350
1353
  };
1351
1354
 
1355
+ const checkAction = async (requestInput: { turnId: string; callId: string }): Promise<AiActionCheck> => {
1356
+ const conversationId = activeConversationId();
1357
+ if (!conversationId) throw new Error("No active conversation.");
1358
+ const check = z
1359
+ .object({
1360
+ state: z.enum(["checking", "done", "not_done", "pending", "failed"]),
1361
+ receipt: CapabilityActionReceiptSchema.optional(),
1362
+ at: z.iso.datetime().optional(),
1363
+ })
1364
+ .strict()
1365
+ .parse(
1366
+ await request<unknown>(
1367
+ `/conversations/${encodeURIComponent(conversationId)}/turns/${encodeURIComponent(requestInput.turnId)}/calls/${encodeURIComponent(requestInput.callId)}/check`,
1368
+ { method: "POST" },
1369
+ "Failed to check the Action outcome",
1370
+ ),
1371
+ );
1372
+ const projection = isActiveConversation(conversationId) ? state : cache.get(conversationId);
1373
+ let effective = check;
1374
+ if (projection) {
1375
+ const storedMessage = projection.messages.find(
1376
+ (message) =>
1377
+ message.loopId === requestInput.turnId &&
1378
+ message.message.role === "assistant" &&
1379
+ message.message.content.some((block) => block.type === "tool_call" && block.id === requestInput.callId),
1380
+ );
1381
+ const liveBlock =
1382
+ projection.activeTurn?.turnId === requestInput.turnId
1383
+ ? projection.activeTurn.blocks.find((block) => block.kind === "tool" && block.callId === requestInput.callId)
1384
+ : undefined;
1385
+ effective = preferredActionCheck(storedMessage?.meta?.toolChecks?.[requestInput.callId], effective);
1386
+ effective = preferredActionCheck(liveBlock?.kind === "tool" ? liveBlock.check : undefined, effective);
1387
+ const messages = projection.messages.map((message) =>
1388
+ message.loopId === requestInput.turnId &&
1389
+ message.message.role === "assistant" &&
1390
+ message.message.content.some((block) => block.type === "tool_call" && block.id === requestInput.callId)
1391
+ ? { ...message, meta: { ...message.meta, toolChecks: { ...message.meta?.toolChecks, [requestInput.callId]: effective } } }
1392
+ : message,
1393
+ );
1394
+ const activeTurn =
1395
+ projection.activeTurn?.turnId === requestInput.turnId
1396
+ ? {
1397
+ ...projection.activeTurn,
1398
+ blocks: projection.activeTurn.blocks.map((block) =>
1399
+ block.kind === "tool" && block.callId === requestInput.callId ? { ...block, check: effective } : block,
1400
+ ),
1401
+ }
1402
+ : projection.activeTurn;
1403
+ const next = { ...projection, messages, activeTurn };
1404
+ cache.set(conversationId, next);
1405
+ if (isActiveConversation(conversationId)) setState(reconcile(next));
1406
+ }
1407
+ return effective;
1408
+ };
1409
+
1352
1410
  const submitFrontendToolResult = async (request: { turnId: string; callId: string }, result: unknown) => {
1353
1411
  const conversationId = activeConversationId();
1354
1412
  if (!conversationId) return false;
@@ -1412,6 +1470,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1412
1470
  clearMessageFeedback,
1413
1471
  submitTurnAction,
1414
1472
  respondToApproval,
1473
+ checkAction,
1415
1474
  submitFrontendToolResult,
1416
1475
  fileContentUrl,
1417
1476
  };
@@ -59,7 +59,7 @@ import {
59
59
  import { createCloudAiMarkdownToPdfTool } from "./markdown-pdf-tool";
60
60
  import { createAiTodoTool } from "./todo-tool";
61
61
  import { defineAiTool } from "./tools";
62
- import type { AiDataBoundary, AiRuntimeTool } from "./types";
62
+ import type { AiChatTurnRunConfig, AiDataBoundary, AiRuntimeTool } from "./types";
63
63
  import { createCloudAiViewImageTool } from "./vision-tool";
64
64
 
65
65
  export {
@@ -276,6 +276,14 @@ export const createCloudAiCodeTools = () => [
276
276
  }).server(runManagedCodeTool("code_export")),
277
277
  ];
278
278
 
279
+ /** Server-run Code Mode and declared client tools, excluding app opening and secret entry under a mandate. */
280
+ export const createAiTurnCodeAndClientTools = (config: Pick<AiChatTurnRunConfig, "clientToolIds" | "mandate">) =>
281
+ [createCloudAiLocalBashTool(), ...createCloudAiLocalTools(), ...createCloudAiCodeTools()].filter(
282
+ (tool) =>
283
+ (tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name)) &&
284
+ !(config.mandate && ["code_open", "code_secret"].includes(tool.def.name)),
285
+ );
286
+
279
287
  export const createDefaultCloudAiTools = () => [
280
288
  createAiTodoTool(),
281
289
  createCloudAiSurveyTool(),
@@ -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
@@ -2,6 +2,7 @@ export { type AiAppSkillIssue, aiAppSkills, appSkillsRegistryState, reconcileApp
2
2
  export { type AiApprovalPreferenceRoutes, type AiApprovalPreferenceView, createAiApprovalPreferenceRoutes } from "./approval-routes";
3
3
  export {
4
4
  AI_WEBSITE_APPROVAL_TOOL,
5
+ type AiToolApprovalChangeContext,
5
6
  type AiToolApprovalContext,
6
7
  type AiToolApprovalPreference,
7
8
  type AiToolApprovalRemember,
@@ -21,6 +22,7 @@ export {
21
22
  export { assistantAiSettingsState, listAssistantAiModels, selectAssistantAiModelId } from "./assistant-models";
22
23
  export { type AiAttachmentRef, aiAttachmentMarker, formatAiFileSize, parseAiAttachmentMarkers } from "./attachments";
23
24
  export { CloudAiTranscribeAudioInputSchema, CloudAiTranscribeAudioOutputSchema, createCloudAiTranscribeAudioTool } from "./audio-tool";
25
+ export { AiBackgroundApprovalRequiredError, authorizeBackgroundAction } from "./background-review";
24
26
  export { CODE_RUNTIME_TOOL_NAMES, CodeResourceId, CodeRuntimeInput, parseCodeToolInput } from "./browser-code-contracts";
25
27
  export { aiCapabilityId, aiCapabilityToolName } from "./capabilities";
26
28
  export { getAiChatQuotas } from "./chat-quotas";
@@ -33,11 +35,22 @@ export {
33
35
  ChatTaskOccurrenceIdSchema,
34
36
  ChatTaskScheduleInputSchema,
35
37
  chatTaskCreateFingerprint,
38
+ chatTaskName,
36
39
  getChatTaskTimezone,
37
40
  normalizeChatTaskSchedule,
38
41
  toAiChatTaskOccurrenceView,
39
42
  toAiChatTaskView,
40
43
  } from "./chat-task-contracts";
44
+ export {
45
+ CHAT_TASK_REASON_CODES,
46
+ type ChatTaskReason,
47
+ type ChatTaskReasonCode,
48
+ chatTaskLocale,
49
+ chatTaskNeedsNewTime,
50
+ chatTaskReasonSentence,
51
+ chatTaskReasonState,
52
+ chatTaskStopSentence,
53
+ } from "./chat-task-reasons";
41
54
  export {
42
55
  type AiChatTask,
43
56
  AiChatTaskIdempotencyConflictError,
@@ -407,6 +420,7 @@ export type {
407
420
  AiProjectPromptSnapshot,
408
421
  AiProviderId,
409
422
  AiPublicModelProfile,
423
+ AiRecentResource,
410
424
  AiResolvedModel,
411
425
  AiRuntimeTool,
412
426
  AiSettingsError,
package/src/ai/migrate.ts CHANGED
@@ -1,6 +1,7 @@
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
+ import { migrateChatTaskReasons } from "./chat-task-reasons-migrate";
4
5
  import { migrateAiMessageQueue } from "./message-queue";
5
6
  import { migrateAiModelAccess } from "./model-access-migrate";
6
7
  import { migrateAiQuotas } from "./quotas-migrate";
@@ -403,6 +404,15 @@ export const migrateCloudAi = async (): Promise<void> => {
403
404
  ON ai.messages(conversation_id, loop_id, seq ASC)
404
405
  WHERE loop_id IS NOT NULL
405
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();
406
416
 
407
417
  // Optional pg_textsearch ranking. Native weighted FTS above is always the
408
418
  // correctness path; missing extension support must never block startup.
@@ -885,6 +895,8 @@ export const migrateCloudAi = async (): Promise<void> => {
885
895
  )
886
896
  `.simple();
887
897
 
898
+ await sql.begin(migrateChatTaskReasons);
899
+
888
900
  await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS idempotency_fingerprint TEXT`.simple();
889
901
  await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 0`.simple();
890
902
  await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS mandate_id UUID`.simple();
@@ -998,6 +1010,17 @@ export const migrateCloudAi = async (): Promise<void> => {
998
1010
  )
999
1011
  `.simple();
1000
1012
 
1013
+ await sql`
1014
+ DO $$ BEGIN
1015
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_task_occurrences_state_check'
1016
+ AND pg_get_constraintdef(oid) LIKE '%stopped%') THEN
1017
+ ALTER TABLE ai.chat_task_occurrences DROP CONSTRAINT IF EXISTS ai_chat_task_occurrences_state_check;
1018
+ ALTER TABLE ai.chat_task_occurrences ADD CONSTRAINT ai_chat_task_occurrences_state_check
1019
+ CHECK (state IN ('queued', 'running', 'completed', 'failed', 'stopped'));
1020
+ END IF;
1021
+ END $$
1022
+ `.simple();
1023
+
1001
1024
  await sql`ALTER TABLE ai.chat_task_occurrences ADD COLUMN IF NOT EXISTS task_revision BIGINT NOT NULL DEFAULT 0`.simple();
1002
1025
 
1003
1026
  await sql`
@@ -1016,10 +1039,18 @@ export const migrateCloudAi = async (): Promise<void> => {
1016
1039
  WHERE state = 'queued'
1017
1040
  `.simple();
1018
1041
 
1042
+ await sql`
1043
+ DO $$ BEGIN
1044
+ IF EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'ai' AND indexname = 'idx_ai_chat_task_occurrences_finished'
1045
+ AND indexdef NOT LIKE '%stopped%') THEN
1046
+ DROP INDEX ai.idx_ai_chat_task_occurrences_finished;
1047
+ END IF;
1048
+ END $$
1049
+ `.simple();
1019
1050
  await sql`
1020
1051
  CREATE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_finished
1021
1052
  ON ai.chat_task_occurrences(completed_at, id)
1022
- WHERE state IN ('completed', 'failed')
1053
+ WHERE state IN ('completed', 'failed', 'stopped')
1023
1054
  `.simple();
1024
1055
 
1025
1056
  await sql`
@@ -2468,7 +2499,11 @@ export const migrateCloudAi = async (): Promise<void> => {
2468
2499
  {
2469
2500
  const { coreSettings } = await import("../services");
2470
2501
  const { setAiCredential, splitAiProfileCredentials } = await import("./credentials");
2471
- const split = splitAiProfileCredentials((await coreSettings.get<string>("ai.model_profiles_json")) ?? "[]");
2502
+ const profiles = await coreSettings.read<string>("ai.model_profiles_json");
2503
+ if (profiles.stored === "unreadable") {
2504
+ console.warn('[ai] cannot read setting "ai.model_profiles_json"; skipping credential split');
2505
+ }
2506
+ const split = profiles.stored === "unreadable" ? null : splitAiProfileCredentials(profiles.value ?? "[]");
2472
2507
  if (split && split.credentials.length > 0) {
2473
2508
  for (const { profileId, secret } of split.credentials) await setAiCredential(profileId, secret);
2474
2509
  await coreSettings.set("ai.model_profiles_json", split.profilesJson);
@@ -2641,7 +2676,7 @@ const backfillApprovalRequests = async (): Promise<void> => {
2641
2676
  WHERE r.requester_kind='assistant_turn' AND r.requester_ref=a.turn_id::text || '/' || a.call_id)
2642
2677
  ORDER BY a.created_at, a.id LIMIT 200 FOR UPDATE OF a SKIP LOCKED`;
2643
2678
  for (const row of rows) {
2644
- const review = CapabilityActionReviewSchema.safeParse(row.review == null ? null : decoded(row.review));
2679
+ const review = CapabilityActionReviewDataSchema.safeParse(row.review == null ? null : decoded(row.review));
2645
2680
  // request computes both canonical digests; preserve the original wait deadline, including expiry.
2646
2681
  const record = await approvals.request(
2647
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) })))