@k2b/cloud 0.39.0 → 0.41.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 (237) hide show
  1. package/README.md +8 -0
  2. package/package.json +11 -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 +217 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +49 -18
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/heartbeat.ts +203 -14
  12. package/src/_internal/registry.ts +28 -1
  13. package/src/_internal/runtime-watcher.ts +6 -0
  14. package/src/_internal/setup-lock.ts +103 -0
  15. package/src/_internal/static-assets.ts +52 -3
  16. package/src/_internal/status-preserving-ssr.ts +2 -0
  17. package/src/access/PermissionEditor.tsx +2 -2
  18. package/src/account/EntitySearch.tsx +1 -1
  19. package/src/ai/action-review.ts +101 -0
  20. package/src/ai/background-review.ts +72 -0
  21. package/src/ai/capabilities.ts +29 -11
  22. package/src/ai/capability-execution.ts +76 -43
  23. package/src/ai/capability-result.ts +95 -0
  24. package/src/ai/chat/action-card.ts +464 -0
  25. package/src/ai/chat/action-messages.ts +129 -0
  26. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  27. package/src/ai/chat/action-question.fixtures.ts +733 -0
  28. package/src/ai/chat/action-question.tsx +680 -0
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/blocks.tsx +19 -25
  31. package/src/ai/chat/message-actions.tsx +6 -0
  32. package/src/ai/chat/tool-action-words.ts +31 -0
  33. package/src/ai/chat/tool-action.tsx +456 -0
  34. package/src/ai/chat/turn-view.tsx +10 -160
  35. package/src/ai/chat/view-block.tsx +15 -5
  36. package/src/ai/chat-task-contracts.ts +4 -1
  37. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  38. package/src/ai/chat-task-reasons.ts +1 -1
  39. package/src/ai/chat-tasks.ts +55 -25
  40. package/src/ai/client/controller.ts +59 -0
  41. package/src/ai/default-tools.ts +9 -1
  42. package/src/ai/executor.ts +198 -39
  43. package/src/ai/firecrawl-tools.ts +15 -2
  44. package/src/ai/index.ts +3 -0
  45. package/src/ai/migrate.ts +26 -2
  46. package/src/ai/open-questions.ts +172 -0
  47. package/src/ai/projects-routes.ts +3 -0
  48. package/src/ai/protocol.ts +14 -2
  49. package/src/ai/question-text.ts +8 -0
  50. package/src/ai/recent-resources.ts +240 -0
  51. package/src/ai/recent-sent-refs.ts +38 -0
  52. package/src/ai/routes.ts +195 -5
  53. package/src/ai/runtime.ts +1 -0
  54. package/src/ai/skills-routes.ts +3 -0
  55. package/src/ai/skills.ts +10 -1
  56. package/src/ai/store.ts +245 -24
  57. package/src/ai/testing.ts +36 -0
  58. package/src/ai/tool-audit.ts +8 -0
  59. package/src/ai/tool-sentences.ts +152 -34
  60. package/src/ai/tool-wording.ts +36 -8
  61. package/src/ai/tools.ts +8 -3
  62. package/src/ai/types.ts +43 -4
  63. package/src/api/accounts-entities.ts +2 -1
  64. package/src/api/admin-ai-projects.ts +2 -0
  65. package/src/api/admin-ai-skills.ts +2 -0
  66. package/src/api/admin-ai-usage.ts +2 -0
  67. package/src/api/admin-geoip.ts +80 -0
  68. package/src/api/admin-identity.ts +3 -0
  69. package/src/api/admin-lifecycle.ts +3 -0
  70. package/src/api/admin-outgoing-mail.ts +2 -0
  71. package/src/api/app-approval-actions.ts +31 -7
  72. package/src/api/app-approval.ts +46 -5
  73. package/src/api/approvals-loader.ts +23 -1
  74. package/src/api/approvals.ts +2 -0
  75. package/src/api/auth/schemas.ts +3 -0
  76. package/src/api/auth.ts +67 -12
  77. package/src/api/capabilities.ts +44 -11
  78. package/src/api/capability-streams.ts +12 -0
  79. package/src/api/help.ts +4 -28
  80. package/src/api/index.ts +6 -1
  81. package/src/api/lasting-access-messages.ts +17 -0
  82. package/src/api/mcp.ts +30 -3
  83. package/src/api/me-panel-consent.ts +71 -0
  84. package/src/api/me.ts +159 -36
  85. package/src/api/search.ts +3 -1
  86. package/src/api/sync-ops.ts +2 -0
  87. package/src/api/widgets.ts +3 -0
  88. package/src/approvals/Approvals.tsx +166 -262
  89. package/src/approvals/approval-card.ts +68 -0
  90. package/src/approvals/messages.ts +12 -24
  91. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  92. package/src/browser/app-approval.ts +54 -5
  93. package/src/browser/commands.ts +22 -6
  94. package/src/browser/composer-catalog.ts +45 -3
  95. package/src/browser/composer-insert.ts +10 -0
  96. package/src/browser/composer-open-messages.ts +17 -0
  97. package/src/browser/composer-open.ts +110 -0
  98. package/src/browser/discoverable-command.ts +6 -0
  99. package/src/browser/hints.ts +6 -0
  100. package/src/browser/live-connection.ts +26 -2
  101. package/src/browser/navigation-apps.ts +7 -0
  102. package/src/browser/panel-context-bridge.ts +30 -0
  103. package/src/browser/panel-context-receiver.ts +53 -0
  104. package/src/browser/panel-menu.tsx +56 -0
  105. package/src/browser/panel-messages.ts +11 -0
  106. package/src/browser/panel-shortcuts.ts +31 -0
  107. package/src/browser/panel-slot.ts +412 -10
  108. package/src/browser/panel.ts +37 -0
  109. package/src/browser/search-commands.ts +6 -3
  110. package/src/capabilities/client.ts +8 -2
  111. package/src/capabilities/reviews.ts +205 -0
  112. package/src/capabilities/server.ts +10 -3
  113. package/src/capabilities/testing.ts +47 -0
  114. package/src/capabilities/types.ts +2 -2
  115. package/src/cli/account.ts +13 -3
  116. package/src/cli/admin/geoip.ts +115 -0
  117. package/src/cli/admin/index.ts +3 -0
  118. package/src/contracts/app-approval.ts +123 -16
  119. package/src/contracts/approvals.ts +2 -2
  120. package/src/contracts/capabilities.ts +410 -18
  121. package/src/contracts/geoip.ts +42 -0
  122. package/src/contracts/index.ts +3 -0
  123. package/src/contracts/panel-context-plain.ts +99 -0
  124. package/src/contracts/panel-context.ts +93 -0
  125. package/src/contracts/seen-hints.ts +35 -0
  126. package/src/contracts/shared.ts +3 -0
  127. package/src/server/client-ip.ts +5 -0
  128. package/src/server/help-payload.ts +38 -0
  129. package/src/server/middleware/auth.ts +13 -1
  130. package/src/server/middleware/middleware.ts +9 -0
  131. package/src/server/middleware/no-transform.ts +19 -0
  132. package/src/server/middleware/rate-limit.ts +4 -6
  133. package/src/server/request-context/address.ts +45 -0
  134. package/src/server/request-context/geoip.ts +380 -0
  135. package/src/server/request-context/index.ts +33 -0
  136. package/src/server/request-context/user-agent.ts +95 -0
  137. package/src/services/accounts/app.ts +23 -3
  138. package/src/services/accounts/users.ts +12 -1
  139. package/src/services/app-approval.ts +382 -59
  140. package/src/services/approvals/device.ts +49 -31
  141. package/src/services/approvals/index.ts +7 -5
  142. package/src/services/approvals/list.ts +27 -6
  143. package/src/services/approvals/question.ts +68 -0
  144. package/src/services/audit/index.ts +1 -0
  145. package/src/services/auth-flows/ipa.ts +39 -2
  146. package/src/services/auth-flows/notification-sender.ts +3 -0
  147. package/src/services/auth-flows/password-reset.ts +32 -6
  148. package/src/services/geoip/index.ts +164 -0
  149. package/src/services/geoip/store.ts +30 -0
  150. package/src/services/geoip/upload.ts +150 -0
  151. package/src/services/identity/routes.ts +5 -1
  152. package/src/services/index.ts +13 -0
  153. package/src/services/known-browsers.ts +312 -0
  154. package/src/services/login-address-store.ts +51 -0
  155. package/src/services/login-context.ts +118 -0
  156. package/src/services/mandates/index.ts +3 -0
  157. package/src/services/mandates/policy.ts +15 -3
  158. package/src/services/notifications/email-frame.ts +1 -1
  159. package/src/services/oauth-tokens.ts +16 -2
  160. package/src/services/panel-context-consent.ts +58 -0
  161. package/src/services/pdf/markdown.ts +160 -2
  162. package/src/services/service-account-credentials.ts +21 -0
  163. package/src/services/session/index.ts +296 -41
  164. package/src/services/session/recent.ts +13 -0
  165. package/src/services/webauthn.ts +12 -0
  166. package/src/shared/email-html.ts +2 -0
  167. package/src/shared/help.ts +3 -0
  168. package/src/shared-text/cursor.ts +22 -0
  169. package/src/shared-text/documents.ts +142 -0
  170. package/src/shared-text/events.ts +43 -0
  171. package/src/shared-text/index.ts +12 -0
  172. package/src/shared-text/log.ts +48 -0
  173. package/src/shared-text/text-diff.ts +23 -0
  174. package/src/shared-text/types.ts +41 -0
  175. package/src/ssr/AdminLayout.tsx +2 -0
  176. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  177. package/src/ssr/CloudShell.island.tsx +10 -2
  178. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  179. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  180. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  181. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  182. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  183. package/src/ssr/HelpPanelView.island.tsx +27 -0
  184. package/src/ssr/Layout.tsx +25 -2
  185. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  186. package/src/ssr/LayoutHelp.tsx +222 -419
  187. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
  188. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  189. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  190. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  191. package/src/ssr/MobileNavigation.tsx +2 -2
  192. package/src/ssr/PanelLayout.tsx +9 -1
  193. package/src/ssr/PanelRuntime.island.tsx +7 -0
  194. package/src/ssr/PanelSlot.tsx +53 -3
  195. package/src/ssr/ShellRuntime.island.tsx +11 -2
  196. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  197. package/src/ssr/app-color.ts +14 -9
  198. package/src/ssr/app-navigation.ts +16 -3
  199. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  200. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  201. package/src/ssr/help-messages.ts +4 -0
  202. package/src/ssr/help.ts +21 -0
  203. package/src/ssr/hints.ts +25 -0
  204. package/src/ssr/index.ts +3 -0
  205. package/src/ssr/layout-context.ts +2 -0
  206. package/src/ssr/layout-help-article.server.ts +50 -0
  207. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  208. package/src/ssr/layout-help-panel-document.ts +67 -0
  209. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  210. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  211. package/src/ssr/layout-help-panel-modules.ts +7 -0
  212. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  213. package/src/ssr/layout-help-panel-search.tsx +69 -0
  214. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  215. package/src/ssr/layout-help-panel-state.ts +141 -0
  216. package/src/ssr/layout-help-panel-types.ts +19 -0
  217. package/src/ssr/layout-help-start.ts +33 -7
  218. package/src/ssr/layout-help-views.tsx +484 -0
  219. package/src/ssr/panel-consent.ts +29 -0
  220. package/src/ssr/panel-context.ts +10 -0
  221. package/src/ssr/panel-idle.ts +9 -0
  222. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  223. package/src/ssr/platform-messages.ts +18 -0
  224. package/src/ssr/shell-data.tsx +2 -1
  225. package/src/styles/approvals.css +6 -0
  226. package/src/styles/effects.css +213 -9
  227. package/src/styles/file-chooser.css +2 -1
  228. package/src/styles/global.css +49 -12
  229. package/src/styles/panel.css +47 -4
  230. package/src/styles/resource-search.css +6 -2
  231. package/src/styles/tokens.css +17 -11
  232. package/src/styles/utilities-buttons.css +3 -2
  233. package/src/styles/utilities-data.css +2 -1
  234. package/src/styles/utilities-feedback.css +2 -2
  235. package/src/styles/utilities-layout.css +3 -13
  236. package/src/styles/utilities-markdown-editor.css +6 -6
  237. package/src/styles/utilities-navigation.css +17 -22
@@ -39,8 +39,11 @@ import { ReferenceText } from "./reference-text";
39
39
  const VISIBLE_ROWS = 100;
40
40
  /** Rows the viewport shows before it scrolls; it reserves their height from the first frame. */
41
41
  const VIEWPORT_ROWS = 8;
42
- /** A compact row: 12px text on a 16px line, 6px padding above and below, a 1px line. */
43
- const ROW_REM = 1.8125;
42
+ /** A compact DataTable row and its totals row, and the quieter header row above them. */
43
+ const ROW_REM = 2.5;
44
+ const HEADER_REM = 2.125;
45
+ /** DataTable draws a hairline between rows only above this many columns. */
46
+ const HAIRLINE_ABOVE_COLUMNS = 8;
44
47
 
45
48
  /** Loads a view file with the viewer's access and validates it again: a file is untrusted whoever wrote it. */
46
49
  async function loadView(url: string, path: string, signal: AbortSignal): Promise<CloudAiView | null> {
@@ -75,9 +78,16 @@ type ChartSummary = Extract<CloudAiViewSummary, { view: "chart" }>;
75
78
  type GroupSummary = Extract<CloudAiViewSummary, { view: "group" }>;
76
79
  type ExplorerSummary = Extract<CloudAiViewSummary, { view: "explorer" }>;
77
80
 
78
- /** The header row, the rows up to the viewport's, and the totals row when there is one. */
79
- const tableHeight = (summary: TableSummary) =>
80
- `${(Math.max(1, Math.min(summary.rows, VIEWPORT_ROWS)) + 1 + Number(summary.footer)) * ROW_REM}rem`;
81
+ /**
82
+ * The header row, the rows up to the viewport's, and the totals row when there is one, inside the paper's 1px frame.
83
+ * Wide tables add a 1px hairline after each visible body row with a following body row; the footer adds none.
84
+ */
85
+ const tableHeight = (summary: TableSummary) => {
86
+ const bodyRows = Math.max(1, Math.min(summary.rows, VIEWPORT_ROWS));
87
+ const rows = bodyRows + Number(summary.footer);
88
+ const lines = summary.columns > HAIRLINE_ABOVE_COLUMNS ? bodyRows - 1 + Number(summary.rows > VIEWPORT_ROWS) : 0;
89
+ return `calc(${HEADER_REM + rows * ROW_REM}rem + ${2 + lines}px)`;
90
+ };
81
91
 
82
92
  /**
83
93
  * A table view: title, Copy data and a sortable table in a viewport whose height follows the row count of its
@@ -55,7 +55,10 @@ export type AiChatTaskOccurrenceView = {
55
55
  export const toAiChatTaskView = (task: AiChatTask): AiChatTaskView => ({
56
56
  title: task.title,
57
57
  name: task.name,
58
- reason: task.reasonCode && task.reasonAt ? { code: task.reasonCode, at: task.reasonAt } : null,
58
+ reason:
59
+ task.reasonCode && task.reasonAt
60
+ ? { code: task.reasonCode, at: task.reasonAt, ...(task.reasonAction ? { action: task.reasonAction } : {}) }
61
+ : null,
59
62
  id: task.shortId,
60
63
  chatId: task.chatId,
61
64
  chatTitle: task.chatTitle,
@@ -4,6 +4,7 @@ import { canonicalLocale } from "../shared/locale";
4
4
  /** Called in a transaction; new columns and reason repairs commit together. */
5
5
  export const migrateChatTaskReasons = async (sql: SQL): Promise<void> => {
6
6
  await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS reason_code TEXT`.simple();
7
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS reason_action TEXT`.simple();
7
8
  await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS reason_at TIMESTAMPTZ`.simple();
8
9
  await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS title TEXT`.simple();
9
10
  const [column] = await sql`SELECT 1 FROM information_schema.columns
@@ -39,6 +40,11 @@ export const migrateChatTaskReasons = async (sql: SQL): Promise<void> => {
39
40
  ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_reason_code_check
40
41
  CHECK (reason_code IN ('paused', 'chat_archived', 'owner_inactive', 'failed', 'rights_changed', 'needs_approval'));
41
42
  END IF;
43
+ -- Bounded only: an older Core clears reason_code without knowing reason_action, so readers pair the two.
44
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_reason_action_length_check') THEN
45
+ ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_reason_action_length_check
46
+ CHECK (reason_action IS NULL OR char_length(reason_action) BETWEEN 1 AND 200);
47
+ END IF;
42
48
  IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_title_check') THEN
43
49
  ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_title_check CHECK (char_length(btrim(title)) BETWEEN 1 AND 80);
44
50
  END IF;
@@ -49,6 +55,7 @@ export const migrateChatTaskReasons = async (sql: SQL): Promise<void> => {
49
55
  WHEN state = 'paused' THEN 'paused'
50
56
  WHEN last_error IN ('Scheduled task mandate revision changed', 'Scheduled task mandate or sponsor is unavailable') THEN 'rights_changed'
51
57
  ELSE 'failed' END,
58
+ reason_action = NULL,
52
59
  reason_at = CASE WHEN state IN ('paused', 'needs_attention') THEN updated_at ELSE NULL END
53
60
  WHERE NOT (
54
61
  (state IN ('paused', 'needs_attention')) = (reason_code IS NOT NULL)
@@ -28,7 +28,7 @@ const messages = i18n.define({
28
28
  failed: "The last run failed.",
29
29
  rights_changed: "What it may do changed. Please confirm again.",
30
30
  needs_approval: ({ action }: { action?: string }) =>
31
- `${action ? `Tried to ${action}. ` : ""}That needs your approval every time, and routines never ask.`,
31
+ `${action ? `Tried to ${action.replace(/^\p{Lu}(?=\p{Ll})/u, (first) => first.toLowerCase())}. ` : ""}That needs your approval every time, and routines never ask.`,
32
32
  stopped: ({ list }: { list: string }) =>
33
33
  list
34
34
  ? `Stopped while the routine was running. Already done: ${list}. Nothing was undone.`
@@ -1,6 +1,7 @@
1
1
  import type { Message } from "@k2b/nessi";
2
2
  import { type SQL, sql } from "bun";
3
3
  import { getCapability } from "../_internal/registry";
4
+ import { type CapabilityActionPolicy, capabilityActionPolicy } from "../contracts/capabilities";
4
5
  import { logger } from "../services/logging";
5
6
  import {
6
7
  createMandate,
@@ -48,6 +49,7 @@ export type AiChatTask = {
48
49
  locale: string | null;
49
50
  reasonCode: ChatTaskReasonCode | null;
50
51
  reasonAt: string | null;
52
+ reasonAction: string | null;
51
53
  id: string;
52
54
  shortId: string;
53
55
  chatId: string;
@@ -88,6 +90,7 @@ type TaskRow = {
88
90
  locale: string | null;
89
91
  reason_code: ChatTaskReasonCode | null;
90
92
  reason_at: Date | string | null;
93
+ reason_action: string | null;
91
94
  id: string;
92
95
  short_id: string;
93
96
  conversation_id: string;
@@ -172,7 +175,14 @@ const validateTaskGrants = async (input: unknown): Promise<TaskGrant[]> => {
172
175
  (item) => item.localId === grant.capabilityId,
173
176
  );
174
177
  if (!operation) throw new Error(`Capability unavailable: ${grant.appId}.${grant.capabilityId}`);
175
- if (grant.kind === "action" && (!("approval" in operation) || (operation.approval !== "none" && operation.approval !== "rememberable")))
178
+ const policy = entry ? capabilityActionPolicy(entry.manifest, grant.capabilityId) : undefined;
179
+ if (grant.kind === "action" && policy && (!("silent" in policy) || policy.routines === false))
180
+ throw new Error(`Capability requires interactive approval: ${grant.appId}.${grant.capabilityId}`);
181
+ if (
182
+ grant.kind === "action" &&
183
+ !policy &&
184
+ (!("approval" in operation) || (operation.approval !== "none" && operation.approval !== "rememberable"))
185
+ )
176
186
  throw new Error(`Capability always requires interactive approval: ${grant.appId}.${grant.capabilityId}`);
177
187
  const properties = operation.inputSchema.properties ?? {};
178
188
  for (const key of Object.keys(grant.fixedInput))
@@ -255,7 +265,7 @@ const stopTaskAdmission = async (
255
265
  const state = chatTaskReasonState(code);
256
266
  const [stopped] = await db<TaskRow[]>`
257
267
  UPDATE ai.chat_tasks
258
- SET state = ${state}, last_error = ${reason}, reason_code = ${code}, reason_at = now(),
268
+ SET state = ${state}, last_error = ${reason}, reason_code = ${code}, reason_action = NULL, reason_at = now(),
259
269
  mandate_revision = COALESCE(${pausedMandateRevision ?? null}::bigint, mandate_revision),
260
270
  revision = revision + 1, updated_at = now()
261
271
  WHERE id = ${task.id}::uuid AND state = ${task.state} RETURNING *
@@ -387,6 +397,8 @@ const toTask = (row: TaskRow): AiChatTask => ({
387
397
  locale: row.locale,
388
398
  reasonCode: row.reason_code,
389
399
  reasonAt: nullableIso(row.reason_at),
400
+ // An older Core may leave a stale action beside another code; the action belongs only to needs_approval.
401
+ reasonAction: row.reason_code === "needs_approval" ? row.reason_action : null,
390
402
  id: row.id,
391
403
  shortId: row.short_id,
392
404
  chatId: row.conversation_short_id,
@@ -672,6 +684,7 @@ export const aiChatTasks = {
672
684
  kind: "query" | "action";
673
685
  input: unknown;
674
686
  approval?: "none" | "rememberable" | "always";
687
+ policy?: CapabilityActionPolicy;
675
688
  }): Promise<void> => {
676
689
  const result = await validateMandateIssueAuthority({
677
690
  mandateId: input.mandate.id,
@@ -681,6 +694,7 @@ export const aiChatTasks = {
681
694
  operation: `capability.${input.kind === "query" ? "query" : "action.run"}:${input.capabilityId}`,
682
695
  input: input.input,
683
696
  capabilityApproval: input.approval,
697
+ capabilityPolicy: input.policy,
684
698
  });
685
699
  if (!result.ok) {
686
700
  const policyDenied = result.error.code === "FORBIDDEN" && result.error.message === MANDATE_POLICY_DENIED_MESSAGE;
@@ -908,6 +922,7 @@ export const aiChatTasks = {
908
922
  ELSE NULL
909
923
  END,
910
924
  reason_code = CASE WHEN ${authorityStopped} OR task.state = 'paused' OR (task.state = 'completed' AND ${scheduleChanged} = false) THEN task.reason_code ELSE NULL END,
925
+ reason_action = CASE WHEN ${authorityStopped} OR task.state = 'paused' OR (task.state = 'completed' AND ${scheduleChanged} = false) THEN task.reason_action ELSE NULL END,
911
926
  reason_at = CASE WHEN ${authorityStopped} OR task.state = 'paused' OR (task.state = 'completed' AND ${scheduleChanged} = false) THEN task.reason_at ELSE NULL END,
912
927
  revision = task.revision + 1, updated_at = now()
913
928
  WHERE id = ${current.id}::uuid
@@ -970,7 +985,7 @@ export const aiChatTasks = {
970
985
  const [restored] = await tx`SELECT 1 FROM ai.conversations WHERE id = ${input.conversationId}::uuid
971
986
  AND archived_at IS NULL FOR SHARE`;
972
987
  if (current?.reason_code === "chat_archived" && restored)
973
- await tx`UPDATE ai.chat_tasks SET reason_code = 'paused', updated_at = now() WHERE id = ${current.id}::uuid`;
988
+ await tx`UPDATE ai.chat_tasks SET reason_code = 'paused', reason_action = NULL, updated_at = now() WHERE id = ${current.id}::uuid`;
974
989
  });
975
990
  } catch (error) {
976
991
  if (!(error instanceof AiChatTaskAuthorityError)) throw error;
@@ -1008,7 +1023,8 @@ export const aiChatTasks = {
1008
1023
  AND task.updated_at < now() - make_interval(secs => ${graceSeconds}::double precision)
1009
1024
  ORDER BY task.id LIMIT ${limit}
1010
1025
  FOR UPDATE OF task SKIP LOCKED FOR SHARE OF conversation SKIP LOCKED`;
1011
- for (const row of rows) await tx`UPDATE ai.chat_tasks SET reason_code = 'paused', updated_at = now() WHERE id = ${row.id}::uuid`;
1026
+ for (const row of rows)
1027
+ await tx`UPDATE ai.chat_tasks SET reason_code = 'paused', reason_action = NULL, updated_at = now() WHERE id = ${row.id}::uuid`;
1012
1028
  return rows.length;
1013
1029
  });
1014
1030
  },
@@ -1063,6 +1079,7 @@ export const aiChatTasks = {
1063
1079
  UPDATE ai.chat_tasks task
1064
1080
  SET state = ${input.state === "paused" ? chatTaskReasonState("paused") : "active"}, mandate_revision = ${revision},
1065
1081
  reason_code = ${input.state === "paused" ? "paused" : null},
1082
+ reason_action = NULL,
1066
1083
  reason_at = CASE WHEN ${input.state} = 'active' THEN NULL WHEN task.reason_code = 'paused' THEN task.reason_at ELSE now() END,
1067
1084
  last_error = CASE WHEN task.state = ${input.state} THEN task.last_error ELSE NULL END,
1068
1085
  revision = CASE WHEN task.state = ${input.state} THEN task.revision ELSE task.revision + 1 END,
@@ -1278,7 +1295,7 @@ export const aiChatTasks = {
1278
1295
  task.id AS task_row_id, task.short_id AS task_short_id, task.conversation_id, conversation.short_id AS conversation_short_id,
1279
1296
  conversation.title AS conversation_title, task.sponsor_user_id, task.mandate_id, task.mandate_revision,
1280
1297
  (SELECT policy FROM auth.mandates WHERE id = task.mandate_id) AS mandate_policy,
1281
- task.title, task.locale, task.reason_code, task.reason_at, task.prompt, task.schedule_kind, task.run_at, task.cron,
1298
+ task.title, task.locale, task.reason_code, task.reason_at, task.reason_action, task.prompt, task.schedule_kind, task.run_at, task.cron,
1282
1299
  task.timezone, task.state AS task_state, task.revision, task.last_error, task.created_at AS task_created_at, task.updated_at
1283
1300
  FROM (
1284
1301
  SELECT candidate.*,
@@ -1353,7 +1370,7 @@ export const aiChatTasks = {
1353
1370
  const revision = await pauseTerminalTaskMandate(task, tx);
1354
1371
  await tx`
1355
1372
  UPDATE ai.chat_tasks
1356
- SET state = ${chatTaskReasonState(input.code)}, reason_code = ${input.code}, reason_at = CASE WHEN reason_code = ${input.code} THEN COALESCE(reason_at, now()) ELSE now() END, last_error = ${input.error}, mandate_revision = ${revision},
1373
+ SET state = ${chatTaskReasonState(input.code)}, reason_code = ${input.code}, reason_action = NULL, reason_at = CASE WHEN reason_code = ${input.code} THEN COALESCE(reason_at, now()) ELSE now() END, last_error = ${input.error}, mandate_revision = ${revision},
1357
1374
  revision = revision + 1, updated_at = now()
1358
1375
  WHERE id = ${rows[0].task_id}::uuid AND revision = ${rows[0].task_revision}::bigint
1359
1376
  `;
@@ -1481,6 +1498,13 @@ export const aiChatTasks = {
1481
1498
  return { delivered: true as const, conversationId: occurrence.conversation_id, turnId };
1482
1499
  }),
1483
1500
 
1501
+ markNeedsApproval: async (input: { turnId: string; action: string }): Promise<void> => {
1502
+ await sql`UPDATE ai.turns SET run_config = jsonb_set(run_config, '{background,needsApproval}',
1503
+ jsonb_build_object('action', ${input.action.slice(0, 200)}::text))
1504
+ WHERE id = ${input.turnId}::uuid AND run_config ? 'background'
1505
+ AND run_config #> '{background,needsApproval}' IS NULL`;
1506
+ },
1507
+
1484
1508
  finalizeTurn: async (input: {
1485
1509
  turnId: string;
1486
1510
  status: AiTurnStatus;
@@ -1506,6 +1530,8 @@ export const aiChatTasks = {
1506
1530
  return occurrence ? { occurrenceId: occurrence.id, failed: false } : null;
1507
1531
  }
1508
1532
  if (!task) return null;
1533
+ const needsApproval = turn.run_config.background?.needsApproval;
1534
+ const approvalError = needsApproval ? `NEEDS_APPROVAL: ${needsApproval.action} requires approval for every call` : null;
1509
1535
  const rows = await tx<
1510
1536
  {
1511
1537
  occurrence_id: string;
@@ -1517,12 +1543,15 @@ export const aiChatTasks = {
1517
1543
  task_revision: number | bigint;
1518
1544
  current_revision: number | bigint;
1519
1545
  turn_error: string | null;
1546
+ needs_approval: boolean;
1520
1547
  completed_at: Date | string;
1521
1548
  }[]
1522
1549
  >`
1523
1550
  UPDATE ai.chat_task_occurrences occurrence
1524
- SET state = ${input.status === "completed" ? "completed" : "failed"},
1551
+ SET state = CASE WHEN ${approvalError}::text IS NOT NULL AND occurrence.task_revision = task.revision THEN 'failed'
1552
+ ELSE ${input.status === "completed" ? "completed" : "failed"} END,
1525
1553
  error = CASE
1554
+ WHEN ${approvalError}::text IS NOT NULL AND occurrence.task_revision = task.revision THEN ${approvalError}
1526
1555
  WHEN ${input.status} = 'completed' THEN NULL
1527
1556
  ELSE COALESCE(${input.error ?? null}, turn.error, ${`Scheduled turn ${input.status}`})
1528
1557
  END,
@@ -1531,10 +1560,12 @@ export const aiChatTasks = {
1531
1560
  WHERE occurrence.task_id = task.id AND occurrence.turn_id = ${input.turnId}::uuid AND turn.id = occurrence.turn_id
1532
1561
  AND occurrence.state = 'running'
1533
1562
  RETURNING occurrence.id AS occurrence_id, occurrence.task_id, task.schedule_kind, occurrence.trigger,
1534
- occurrence.scheduled_for, task.run_at, occurrence.task_revision, task.revision AS current_revision, turn.error AS turn_error, occurrence.completed_at
1563
+ occurrence.scheduled_for, task.run_at, occurrence.task_revision, task.revision AS current_revision, turn.error AS turn_error, (${approvalError}::text IS NOT NULL AND occurrence.task_revision = task.revision) AS needs_approval, occurrence.completed_at
1535
1564
  `;
1536
1565
  const row = rows[0];
1537
1566
  if (!row) return null;
1567
+ const failed = row.needs_approval || input.status !== "completed";
1568
+ const failureCode = row.needs_approval ? "needs_approval" : "failed";
1538
1569
  const responses = await tx<{ message: Message }[]>`
1539
1570
  SELECT message FROM ai.task_messages WHERE loop_id = ${input.turnId} AND role = 'assistant' ORDER BY seq DESC
1540
1571
  `;
@@ -1549,36 +1580,35 @@ export const aiChatTasks = {
1549
1580
  .join("\n")
1550
1581
  .trim();
1551
1582
  })
1552
- .find(Boolean) || (input.status === "completed" ? "Background task completed." : "");
1583
+ .find(Boolean) || (!failed ? "Background task completed." : "");
1553
1584
  const unchangedSinceStart = Number(row.task_revision) === Number(row.current_revision);
1554
1585
  const currentOnceSlot = row.run_at !== null && iso(row.run_at) === iso(row.scheduled_for);
1555
- if (
1556
- unchangedSinceStart &&
1557
- input.status === "completed" &&
1558
- row.schedule_kind === "once" &&
1559
- row.trigger === "scheduled" &&
1560
- currentOnceSlot
1561
- ) {
1586
+ if (unchangedSinceStart && !failed && row.schedule_kind === "once" && row.trigger === "scheduled" && currentOnceSlot) {
1562
1587
  const revision = await pauseTerminalTaskMandate(task, tx);
1563
1588
  await tx`
1564
1589
  UPDATE ai.chat_tasks
1565
- SET state = 'completed', last_error = NULL, reason_code = NULL, reason_at = NULL, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
1590
+ SET state = 'completed', last_error = NULL, reason_code = NULL, reason_action = NULL, reason_at = NULL, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
1566
1591
  WHERE id = ${row.task_id}::uuid AND revision = ${row.task_revision}::bigint
1567
1592
  `;
1568
- } else if (unchangedSinceStart && input.status !== "completed") {
1569
- const error = input.error || row.turn_error || `Scheduled turn ${input.status}`;
1593
+ } else if (unchangedSinceStart && failed) {
1594
+ const error = (row.needs_approval ? approvalError : null) || input.error || row.turn_error || `Scheduled turn ${input.status}`;
1570
1595
  const revision = await pauseTerminalTaskMandate(task, tx);
1571
1596
  await tx`
1572
1597
  UPDATE ai.chat_tasks
1573
- SET state = ${chatTaskReasonState("failed")}, reason_code = 'failed', reason_at = CASE WHEN reason_code = 'failed' THEN COALESCE(reason_at, now()) ELSE now() END, last_error = ${error}, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
1598
+ SET state = ${chatTaskReasonState(failureCode)}, reason_code = ${failureCode},
1599
+ reason_action = ${row.needs_approval ? needsApproval!.action : null},
1600
+ reason_at = CASE WHEN reason_code = ${failureCode} THEN COALESCE(reason_at, now()) ELSE now() END, last_error = ${error}, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
1574
1601
  WHERE id = ${row.task_id}::uuid AND revision = ${row.task_revision}::bigint
1575
1602
  `;
1576
1603
  }
1577
1604
  const [current] = await tx<TaskRow[]>`SELECT * FROM ai.chat_tasks WHERE id = ${task.id}::uuid`;
1578
- const reason: ChatTaskReason | null =
1579
- input.status !== "completed"
1580
- ? { code: "failed", at: current?.reason_code === "failed" && current.reason_at ? iso(current.reason_at) : iso(row.completed_at) }
1581
- : null;
1605
+ const reason: ChatTaskReason | null = failed
1606
+ ? {
1607
+ code: failureCode,
1608
+ at: current?.reason_code === failureCode && current.reason_at ? iso(current.reason_at) : iso(row.completed_at),
1609
+ ...(row.needs_approval ? { action: needsApproval!.action } : {}),
1610
+ }
1611
+ : null;
1582
1612
  await deliverOccurrenceResult(
1583
1613
  tx,
1584
1614
  task,
@@ -1586,7 +1616,7 @@ export const aiChatTasks = {
1586
1616
  resultText || (reason ? reasonSentence(task, reason, appLocale) : ""),
1587
1617
  reason ? { reason, ...(resultText ? {} : { reasonOnly: true as const }) } : {},
1588
1618
  );
1589
- return { occurrenceId: row.occurrence_id, failed: input.status !== "completed" };
1619
+ return { occurrenceId: row.occurrence_id, failed };
1590
1620
  }),
1591
1621
 
1592
1622
  listTerminalRunningTurns: async (limit = 100): Promise<Array<{ turnId: string; status: "completed" | "failed" | "aborted" }>> => {
@@ -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(),