@k2b/cloud 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/package.json +7 -4
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +17 -5
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/app-skills.ts +1 -7
  8. package/src/ai/approval-routes.ts +40 -4
  9. package/src/ai/approvals.ts +68 -10
  10. package/src/ai/audio-tool.ts +2 -1
  11. package/src/ai/browser.ts +109 -0
  12. package/src/ai/capabilities.ts +6 -2
  13. package/src/ai/chart-block.ts +431 -81
  14. package/src/ai/chat/action-sentences.ts +13 -7
  15. package/src/ai/chat/blocks.tsx +64 -39
  16. package/src/ai/chat/builtin-tools.tsx +9 -7
  17. package/src/ai/chat/chart-block.tsx +218 -44
  18. package/src/ai/chat/explorer-block.tsx +276 -0
  19. package/src/ai/chat/file-tools.tsx +40 -1
  20. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  21. package/src/ai/chat/message-actions.tsx +9 -1
  22. package/src/ai/chat/message-utils.ts +7 -0
  23. package/src/ai/chat/messages.ts +34 -0
  24. package/src/ai/chat/presentation.tsx +13 -3
  25. package/src/ai/chat/reference-text.tsx +34 -0
  26. package/src/ai/chat/tool-groups.ts +67 -12
  27. package/src/ai/chat/tool-names.ts +18 -2
  28. package/src/ai/chat/turn-layout.ts +8 -0
  29. package/src/ai/chat/turn-view.tsx +111 -32
  30. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  31. package/src/ai/chat/view-block.tsx +417 -0
  32. package/src/ai/chat/visual-tools.tsx +32 -53
  33. package/src/ai/chat/web-tools.tsx +95 -1
  34. package/src/ai/client/controller.ts +6 -3
  35. package/src/ai/client-tool-results.ts +9 -0
  36. package/src/ai/code-runtime-tools.ts +50 -17
  37. package/src/ai/code-source-contracts.ts +25 -21
  38. package/src/ai/code-source-tools.ts +3 -1
  39. package/src/ai/default-tool-contracts.ts +334 -91
  40. package/src/ai/default-tools.ts +87 -11
  41. package/src/ai/executor.ts +343 -45
  42. package/src/ai/fetch-file-tool.ts +41 -4
  43. package/src/ai/file-error.ts +2 -0
  44. package/src/ai/file-tools.ts +27 -7
  45. package/src/ai/files-store.ts +60 -0
  46. package/src/ai/firecrawl-tools.ts +87 -4
  47. package/src/ai/html-pdf-tool.ts +3 -2
  48. package/src/ai/http.ts +37 -12
  49. package/src/ai/index.ts +98 -0
  50. package/src/ai/local-tool-contracts.ts +244 -0
  51. package/src/ai/malformed-tool-call-repair.ts +144 -0
  52. package/src/ai/markdown-pdf-tool.ts +2 -1
  53. package/src/ai/migrate.ts +120 -2
  54. package/src/ai/openai-api-messages.ts +53 -0
  55. package/src/ai/openai-api-slots.ts +40 -0
  56. package/src/ai/openai-api.ts +383 -0
  57. package/src/ai/protocol.ts +28 -3
  58. package/src/ai/provider.ts +16 -4
  59. package/src/ai/request-context.ts +33 -0
  60. package/src/ai/routes.ts +99 -49
  61. package/src/ai/runtime.ts +90 -15
  62. package/src/ai/settings.ts +5 -0
  63. package/src/ai/skill-catalog.ts +20 -5
  64. package/src/ai/skill-format.ts +17 -7
  65. package/src/ai/skill-tool.ts +15 -5
  66. package/src/ai/skills-routes.ts +20 -6
  67. package/src/ai/skills.ts +26 -7
  68. package/src/ai/store.ts +91 -1
  69. package/src/ai/system-prompt.ts +69 -10
  70. package/src/ai/timeline.ts +3 -1
  71. package/src/ai/tool-audit.ts +11 -1
  72. package/src/ai/tools.ts +94 -3
  73. package/src/ai/types.ts +48 -0
  74. package/src/ai/view-block.ts +941 -0
  75. package/src/ai/vision-tool.ts +2 -1
  76. package/src/ai/web-provenance.ts +103 -0
  77. package/src/api/admin-identity.ts +6 -1
  78. package/src/api/app-approval-actions.ts +36 -0
  79. package/src/api/app-approval.ts +10 -2
  80. package/src/api/approvals-loader.ts +15 -0
  81. package/src/api/approvals.ts +120 -0
  82. package/src/api/capabilities.ts +3 -2
  83. package/src/api/index.ts +3 -0
  84. package/src/api/me.ts +12 -3
  85. package/src/api/widgets.ts +36 -5
  86. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  87. package/src/approvals/Approvals.tsx +621 -0
  88. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  89. package/src/approvals/messages.ts +95 -0
  90. package/src/approvals/ui.ts +8 -0
  91. package/src/browser/CloudResourceSearch.tsx +1 -1
  92. package/src/browser/app-approval.ts +32 -0
  93. package/src/browser/choose-files.tsx +26 -13
  94. package/src/browser/command-shortcuts.ts +21 -6
  95. package/src/browser/commands.ts +13 -4
  96. package/src/browser/file-provider-error.ts +11 -0
  97. package/src/browser/file-providers.ts +3 -12
  98. package/src/browser/files.tsx +1 -2
  99. package/src/browser/live-connection.ts +2 -3
  100. package/src/browser/provider-list.ts +2 -1
  101. package/src/browser/save-files.tsx +13 -2
  102. package/src/browser/spotlight-position.ts +4 -1
  103. package/src/browser/widgets.ts +7 -5
  104. package/src/capabilities/executions.ts +3 -0
  105. package/src/cli/account.ts +21 -2
  106. package/src/cli/commands.ts +24 -2
  107. package/src/cli/index.ts +16 -0
  108. package/src/cli/plugin.ts +30 -0
  109. package/src/contracts/app-approval.ts +120 -0
  110. package/src/contracts/app.ts +34 -2
  111. package/src/contracts/approval-paths.ts +4 -0
  112. package/src/contracts/approvals.ts +81 -0
  113. package/src/contracts/command-path.ts +17 -0
  114. package/src/contracts/commands.ts +5 -15
  115. package/src/contracts/registry.ts +9 -1
  116. package/src/contracts/shared.ts +5 -0
  117. package/src/contracts/widgets.ts +47 -2
  118. package/src/desktop/solid.tsx +6 -7
  119. package/src/events/live-protocol.ts +2 -1
  120. package/src/events/live-server-message.ts +40 -0
  121. package/src/events/testing.ts +88 -0
  122. package/src/server/index.ts +3 -0
  123. package/src/server/middleware/auth.ts +11 -1
  124. package/src/server/request-timeout.ts +22 -0
  125. package/src/server/services/access.ts +4 -3
  126. package/src/server/widget-request.ts +27 -0
  127. package/src/services/app-approval.ts +103 -15
  128. package/src/services/approvals/cursor.ts +15 -0
  129. package/src/services/approvals/device.ts +112 -0
  130. package/src/services/approvals/index.ts +241 -0
  131. package/src/services/approvals/list.ts +120 -0
  132. package/src/services/approvals/migrate.ts +41 -0
  133. package/src/services/identity/models-only-auth.ts +25 -0
  134. package/src/services/notifications/email-frame.ts +16 -3
  135. package/src/services/service-account-credentials.ts +22 -10
  136. package/src/services/settings/core-settings.ts +27 -0
  137. package/src/shared/ai-platform-prompt.ts +26 -1
  138. package/src/shared/app-presentation.ts +11 -0
  139. package/src/shared/email-html.ts +46 -7
  140. package/src/shared/index.ts +6 -1
  141. package/src/shared/markdown/extensions/code.ts +14 -0
  142. package/src/shared/markdown/index.ts +4 -4
  143. package/src/ssr/AdminLayout.tsx +4 -7
  144. package/src/ssr/AppBadge.tsx +4 -4
  145. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  146. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  147. package/src/ssr/Layout.tsx +38 -14
  148. package/src/ssr/LayoutFooter.tsx +1 -1
  149. package/src/ssr/LayoutHeader.tsx +17 -20
  150. package/src/ssr/LayoutHelp.tsx +1 -1
  151. package/src/ssr/LayoutRail.tsx +1 -2
  152. package/src/ssr/PageTrail.tsx +23 -0
  153. package/src/ssr/PageTrailRow.island.tsx +20 -0
  154. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  155. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  156. package/src/ssr/RailApps.island.tsx +2 -2
  157. package/src/ssr/app-badges.ts +50 -6
  158. package/src/ssr/islands/index.ts +1 -0
  159. package/src/ssr/layout-runtime.ts +60 -0
  160. package/src/ssr/mobile-menu-history.ts +1 -1
  161. package/src/ssr/page-trail-runtime.ts +313 -0
  162. package/src/ssr/page-trail.browser-harness.tsx +265 -0
  163. package/src/ssr/page-trail.ts +59 -0
  164. package/src/ssr/platform-messages.ts +4 -0
  165. package/src/ssr/rail-navigation.ts +5 -3
  166. package/src/ssr/runtime.ts +6 -1
  167. package/src/styles/approvals.css +390 -0
  168. package/src/styles/effects.css +21 -4
  169. package/src/styles/global.css +39 -3
  170. package/src/styles/utilities-layout.css +24 -1
  171. package/src/styles/utilities-navigation.css +6 -18
@@ -1,8 +1,9 @@
1
- import type { AssistantMessage, CompactEvent, LoopAggregate, NessiLoop, OutboundEvent } from "@k2b/nessi";
1
+ import type { AssistantMessage, CompactEvent, LoopAggregate, Message, NessiLoop, OutboundEvent } from "@k2b/nessi";
2
2
  import { compact, nessi } from "@k2b/nessi";
3
3
  import { listCapabilities } from "../_internal/registry";
4
4
  import type { CapabilityActionReview } from "../contracts/capabilities";
5
5
  import type { AccessSubject, RequestActor } from "../server";
6
+ import { approvalAssurance, approvalScopeFromToolName, approvals } from "../services/approvals";
6
7
  import { createHelpReader } from "../services/help";
7
8
  import { logger } from "../services/logging";
8
9
  import { getMandate } from "../services/mandates";
@@ -10,6 +11,7 @@ import type { MandatePolicyV1 } from "../services/mandates/policy";
10
11
  import { coreSettings } from "../services/settings/api";
11
12
  import { normalizeLocale } from "../shared/locale";
12
13
  import {
14
+ AI_WEBSITE_APPROVAL_TOOL,
13
15
  type AiToolApprovalContext,
14
16
  aiToolAllowsAlways,
15
17
  aiToolApprovalScope,
@@ -24,8 +26,21 @@ import { aiChatMessages } from "./chat/messages";
24
26
  import { aiTurnErrorText } from "./chat/turn-error";
25
27
  import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
26
28
  import { createCloudCompactFn } from "./compaction";
27
- import { createCloudAiCodeTools, createCloudAiLocalBashTool, createConfiguredDefaultCloudAiTools } from "./default-tools";
29
+ import {
30
+ createCloudAiCodeTools,
31
+ createCloudAiLocalBashTool,
32
+ createCloudAiLocalTools,
33
+ createConfiguredDefaultCloudAiTools,
34
+ } from "./default-tools";
28
35
  import { aiFileStore } from "./files-store";
36
+ import { AI_LOCAL_TOOL_NAMES } from "./local-tool-contracts";
37
+ import {
38
+ countMalformedToolCallRepairs,
39
+ createMalformedToolCallRepair,
40
+ isMalformedToolCallId,
41
+ withMalformedToolCallRepair,
42
+ withMalformedToolCallResults,
43
+ } from "./malformed-tool-call-repair";
29
44
  import { aiMemories } from "./memories";
30
45
  import { createCloudAiMemoryTool } from "./memory-tool";
31
46
  import { recordAiMemoryWorkflowEvidence } from "./memory-workflow-evidence";
@@ -40,6 +55,7 @@ import {
40
55
  type AiWireEvent,
41
56
  applyWireEventToBlocks,
42
57
  buildBlocksFromMessages,
58
+ canonicalToolName,
43
59
  compactionBlockId,
44
60
  reconcileResolvedTurnActions,
45
61
  steerAppliedBlockId,
@@ -52,16 +68,16 @@ import { assistantQuotaProvider, inferenceProvider } from "./quota-provider";
52
68
  import { collectConversationResourceObservations } from "./resource-refs";
53
69
  import { AiRunTimeout } from "./run-timeout";
54
70
  import { isAiVisionModelConfigured, type resolveAiModel } from "./settings";
55
- import { selectAiSkillCatalog } from "./skill-catalog";
71
+ import { mergeAiSkillCatalog, selectAiSkillCatalog } from "./skill-catalog";
56
72
  import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool, loadSelectedAiSkills } from "./skill-tool";
57
73
  import { aiSkills } from "./skills";
58
- import { aiConversations } from "./store";
74
+ import { aiConversations, recordAiWebsiteReceipts } from "./store";
59
75
  import { AI_LIVE_SNAPSHOT_INTERVAL_MS, publishAiWireEvent } from "./stream";
60
76
  import { composeAiSystemPrompt } from "./system-prompt";
61
77
  import { aiToolAudit } from "./tool-audit";
62
78
  import { acceptCanonicalToolNames } from "./tool-call-names";
63
79
  import { resolveAiToolResultMaxChars } from "./tool-result-budget";
64
- import { aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
80
+ import { type AiApprovalUse, aiApprovalAskedLater, aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
65
81
  import { AiTranscriptionError } from "./transcription";
66
82
  import {
67
83
  AiTurnFailure,
@@ -75,6 +91,7 @@ import { type AiTurnGuidance, loadAiTurnGuidance } from "./turn-guidance";
75
91
  import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
76
92
  import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
77
93
  import type {
94
+ AiApprovalTarget,
78
95
  AiChatTurnRunConfig,
79
96
  AiFrontendToolMode,
80
97
  AiPendingTurnActionRecord,
@@ -85,6 +102,7 @@ import type {
85
102
  AiTurnFinalizedEvent,
86
103
  AiTurnRunConfig,
87
104
  AiTurnSteer,
105
+ AiWebsiteReceipt,
88
106
  } from "./types";
89
107
  import { isAiImageMediaType } from "./types";
90
108
  import { validateAiTurnRequest } from "./validate";
@@ -96,17 +114,18 @@ const AI_COALESCE_MAX_CHARS = 512;
96
114
  const AI_ACTION_BUDGET_MS = 24 * 60 * 60_000;
97
115
 
98
116
  // Failed generation can contain calls that nessi deliberately never executes or resumes.
117
+ // Cloud's repair calls are skipped too: the live run never counts them.
99
118
  const isToolRound = (message: AssistantMessage): boolean =>
100
119
  message.stopReason !== "error" &&
101
120
  message.stopReason !== "interrupted" &&
102
121
  message.stopReason !== "aborted" &&
103
- message.content.some((block) => block.type === "tool_call");
122
+ message.content.some((block) => block.type === "tool_call" && !isMalformedToolCallId(block.id));
104
123
 
105
124
  const toolRoundState = (messages: AiStoredMessage[]): { issued: number; completed: number } => {
106
125
  const completedCallIds = new Set(messages.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
107
126
  const rounds = messages.flatMap(({ message }) => {
108
127
  if (message.role !== "assistant" || !isToolRound(message)) return [];
109
- const callIds = message.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : []));
128
+ const callIds = message.content.flatMap((block) => (block.type === "tool_call" && !isMalformedToolCallId(block.id) ? [block.id] : []));
110
129
  return callIds.length > 0 ? [callIds] : [];
111
130
  });
112
131
  return {
@@ -267,6 +286,7 @@ export type ExecutorConfig = {
267
286
  validateTurn?: typeof validateAiTurnRequest;
268
287
  /** Runs after the durable turn state and final wire event are flushed. */
269
288
  onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>;
289
+ onApprovalRequested?: (event: { approvalId: string }) => Promise<void>;
270
290
  /** Waits before retrying a transient provider failure without Retry-After; tests shorten them. */
271
291
  providerRetryDelaysMs?: readonly number[];
272
292
  };
@@ -274,6 +294,29 @@ export type ExecutorConfig = {
274
294
  type ResolvedModel = Awaited<ReturnType<typeof resolveAiModel>>;
275
295
  type ValidatedTurn = { settings: Awaited<ReturnType<typeof validateAiTurnRequest>>["settings"]; resolved: ResolvedModel };
276
296
 
297
+ // One entry per steering text returned to nessi, including repairs that have no user steer to acknowledge.
298
+ const createChatSteering =
299
+ (input: {
300
+ repair: ReturnType<typeof createMalformedToolCallRepair>;
301
+ appliedSteers: (AiTurnSteer | null)[];
302
+ takePendingSteers: () => Promise<AiTurnSteer[]>;
303
+ noteSteering: () => void;
304
+ }) =>
305
+ async ({ signal }: { signal: AbortSignal }) => {
306
+ if (signal.aborted) return undefined;
307
+ // Exhaustion fails before consuming any pending user input.
308
+ const repairText = input.repair.takeSteering();
309
+ const steers = await input.takePendingSteers();
310
+ input.appliedSteers.push(...steers);
311
+ if (steers.length > 0) input.noteSteering();
312
+ const texts = steers.map((steer) => steer.text);
313
+ if (repairText) {
314
+ texts.push(repairText);
315
+ input.appliedSteers.push(null);
316
+ }
317
+ return texts.length > 0 ? texts : undefined;
318
+ };
319
+
277
320
  // ---------------------------------------------------------------------------
278
321
  // Baseline rebuild — reconstruct the full active-turn view from persisted rounds
279
322
  // ---------------------------------------------------------------------------
@@ -296,7 +339,15 @@ const rebuildBlocksFromMessages = (
296
339
  callId: action.callId,
297
340
  status: action.kind === "client_tool" ? "awaiting_client" : "awaiting_approval",
298
341
  approval:
299
- action.kind === "client_tool" ? undefined : { message: action.message, review: action.review, allowAlways: action.allowAlways },
342
+ action.kind === "client_tool"
343
+ ? undefined
344
+ : {
345
+ message: action.message,
346
+ review: action.review,
347
+ allowAlways: action.allowAlways,
348
+ allowChat: action.allowChat ?? action.allowAlways,
349
+ ...(action.rememberToolName === AI_WEBSITE_APPROVAL_TOOL ? { website: action.approvalScope } : {}),
350
+ },
300
351
  frontendMode: action.frontendMode,
301
352
  };
302
353
  }
@@ -381,9 +432,25 @@ const approvalReviewForCallId = (
381
432
  */
382
433
  const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowRememberedApprovals = true) => {
383
434
  const toolBlocks = new Map<string, Extract<AiTurnBlock, { kind: "tool" }>>();
435
+ /** Website receipts by the call that shows them and their index; every later state of that call keeps them. */
436
+ const receiptsByCall = new Map<string, Map<number, { method: string; url: string }>>();
384
437
  for (const block of seedBlocks) {
385
- if (block.kind === "tool") toolBlocks.set(block.callId, block);
438
+ if (block.kind !== "tool") continue;
439
+ toolBlocks.set(block.callId, block);
440
+ // A pending nested approval shows on its parent call's block; receipts belong to that block.
441
+ const shownCallId = block.id === toolBlockId(block.callId) ? block.callId : (customApprovalParentCallId(block.callId) ?? block.callId);
442
+ if (block.receipts?.length) receiptsByCall.set(shownCallId, new Map(block.receipts.map((receipt, index) => [index, receipt])));
386
443
  }
444
+ const receiptsOf = (callId: string) => {
445
+ const receipts = receiptsByCall.get(callId);
446
+ return receipts ? [...receipts].sort(([a], [b]) => a - b).map(([, receipt]) => receipt) : undefined;
447
+ };
448
+ const addReceipts = (callId: string, receipts: AiWebsiteReceipt[]) => {
449
+ const known = receiptsByCall.get(callId) ?? new Map<number, { method: string; url: string }>();
450
+ for (const { index, method, url } of receipts) known.set(index, { method, url });
451
+ receiptsByCall.set(callId, known);
452
+ return receiptsOf(callId) ?? [];
453
+ };
387
454
  /** Real frontend mode per tool name — set once the turn's tools are prepared.
388
455
  * Getting this wrong is not cosmetic: the client auto-resolves plain "client"
389
456
  * blocks, so a mislabeled client_interaction tool (survey) would be answered
@@ -408,6 +475,10 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
408
475
  const setApprovalPolicies = (items: PreparedAiTools["approvalPolicies"]) => {
409
476
  approvalPolicies = items;
410
477
  };
478
+ let approvalTargets: ReadonlyMap<string, AiApprovalTarget> = new Map();
479
+ const setApprovalTargets = (items: ReadonlyMap<string, AiApprovalTarget>) => {
480
+ approvalTargets = items;
481
+ };
411
482
  let rejectedCallIds = new Set<string>();
412
483
  const setRejectedCallIds = (items: Set<string>) => {
413
484
  rejectedCallIds = items;
@@ -433,12 +504,14 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
433
504
  name,
434
505
  args: "args" in patch ? patch.args : existing?.args,
435
506
  status: patch.status ?? existing?.status ?? "running",
507
+ at: patch.at ?? existing?.at,
436
508
  result: "result" in patch ? patch.result : existing?.result,
437
509
  isError: "isError" in patch ? patch.isError : existing?.isError,
438
- approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false } : approval,
510
+ approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false, allowChat: false } : approval,
439
511
  frontendMode: patch.frontendMode ?? existing?.frontendMode,
440
512
  presentation: patch.presentation ?? existing?.presentation ?? presentations.get(rawName) ?? presentations.get(name),
441
513
  ...(approvedCallIds.has(callId) || approvedCallIds.has(displayCallId) || existing?.approved ? { approved: true } : {}),
514
+ ...(receiptsOf(displayCallId) ? { receipts: receiptsOf(displayCallId) } : {}),
442
515
  };
443
516
  toolBlocks.set(callId, block);
444
517
  return { type: "block_set", block };
@@ -484,6 +557,10 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
484
557
  case "tool_action_request":
485
558
  const displayCallId = event.kind === "custom_approval" ? (customApprovalParentCallId(event.callId) ?? event.callId) : event.callId;
486
559
  const review = approvalReviewForCallId(approvalReviews, event.callId);
560
+ const target = event.kind === "custom_approval" ? approvalTargets.get(event.callId) : undefined;
561
+ const allowAlways = target
562
+ ? target.always
563
+ : review?.approvalScope !== undefined || aiToolAllowsAlways(approvalPolicies.get(event.name));
487
564
  return [
488
565
  setTool(
489
566
  event.callId,
@@ -497,7 +574,9 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
497
574
  : {
498
575
  message: event.message,
499
576
  review,
500
- allowAlways: review?.approvalScope !== undefined || aiToolAllowsAlways(approvalPolicies.get(event.name)),
577
+ allowAlways,
578
+ allowChat: target !== undefined || allowAlways,
579
+ ...(target?.toolName === AI_WEBSITE_APPROVAL_TOOL ? { website: target.approvalScope } : {}),
501
580
  },
502
581
  frontendMode: event.kind === "client_tool" ? (frontendModes.get(event.name) ?? "client") : undefined,
503
582
  },
@@ -509,6 +588,7 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
509
588
  setTool(event.callId, {
510
589
  name: event.name,
511
590
  status: rejectedCallIds.has(event.callId) ? "rejected" : event.isError ? "failed" : "completed",
591
+ at: new Date().toISOString(),
512
592
  result: event.result,
513
593
  isError: Boolean(event.isError),
514
594
  clearApproval: true,
@@ -520,7 +600,15 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
520
600
  if (callId && toolBlocks.has(callId)) {
521
601
  const existing = toolBlocks.get(callId);
522
602
  if (existing && existing.status !== "completed" && existing.status !== "failed") {
523
- return [setTool(callId, { status: "failed", result: event.issue.message, isError: true, clearApproval: true })];
603
+ return [
604
+ setTool(callId, {
605
+ status: "failed",
606
+ at: new Date().toISOString(),
607
+ result: event.issue.message,
608
+ isError: true,
609
+ clearApproval: true,
610
+ }),
611
+ ];
524
612
  }
525
613
  }
526
614
  return [];
@@ -534,16 +622,34 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
534
622
  }
535
623
  };
536
624
 
625
+ const storedMessage = (message: Message, at: string): BlockOp[] =>
626
+ message.role === "tool_result" && isMalformedToolCallId(message.callId)
627
+ ? [
628
+ setTool(message.callId, {
629
+ name: canonicalToolName(message.name),
630
+ args: {},
631
+ status: "failed",
632
+ at,
633
+ result: message.result,
634
+ isError: true,
635
+ clearApproval: true,
636
+ }),
637
+ ]
638
+ : [];
639
+
537
640
  return {
538
641
  translate,
642
+ storedMessage,
539
643
  compaction,
540
644
  setFrontendModes,
541
645
  setPresentations,
542
646
  setCanonicalNames,
543
647
  setApprovalPolicies,
544
648
  setApprovalReviews,
649
+ setApprovalTargets,
545
650
  setRejectedCallIds,
546
651
  setApprovedCallIds,
652
+ addReceipts,
547
653
  };
548
654
  };
549
655
 
@@ -573,7 +679,7 @@ const materializeChatConfig = async (config: AiChatTurnRunConfig, signal: AbortS
573
679
  allowedDataBoundaries: config.modelPolicy?.allowedDataBoundaries,
574
680
  })),
575
681
  // Cloud runs the code tools itself; only tools a client must run wait for that client to declare them.
576
- ...[createCloudAiLocalBashTool(), ...createCloudAiCodeTools()].filter(
682
+ ...[createCloudAiLocalBashTool(), ...createCloudAiLocalTools(), ...createCloudAiCodeTools()].filter(
577
683
  (tool) => tool.location === "server" || config.clientToolIds?.some((name) => name === tool.def.name),
578
684
  ),
579
685
  ]
@@ -825,7 +931,22 @@ export class AiTurnExecutor {
825
931
  const skillSubject =
826
932
  defaultToolSource && resolved.profile.capabilities.includes("tools") ? accessSubjectForActor(material.actor) : null;
827
933
  const availableSkills = skillSubject ? (await aiSkills.list(skillSubject)).filter((skill) => skill.enabled) : [];
828
- const skillCatalog = selectAiSkillCatalog(availableSkills, resolved.provider.contextWindow ?? 0, query);
934
+ const localClient = !config.background && !config.mandate ? config.client : undefined;
935
+ const localContext = localClient ? config.localContext : undefined;
936
+ const localSkills = (localContext?.skills ?? []).map((skill) => ({
937
+ name: skill.name,
938
+ description: skill.description,
939
+ manualOnly: skill.manualOnly,
940
+ source: "local" as const,
941
+ }));
942
+ const skillCatalog = selectAiSkillCatalog(
943
+ mergeAiSkillCatalog(
944
+ availableSkills.map(({ name, description, manualOnly }) => ({ name, description, manualOnly })),
945
+ localSkills,
946
+ ),
947
+ resolved.provider.contextWindow ?? 0,
948
+ query,
949
+ );
829
950
  const projectFiles =
830
951
  project && resolvedProjectId && projectSubject
831
952
  ? {
@@ -854,7 +975,7 @@ export class AiTurnExecutor {
854
975
  ...material.tools,
855
976
  ...(memoryActive ? [createCloudAiMemoryTool(query)] : []),
856
977
  ...(skillSubject && availableSkills.length ? [createCloudAiLoadSkillTool(skillSubject)] : []),
857
- ...(skillSubject && skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(skillSubject)] : []),
978
+ ...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(skillSubject, localSkills)] : []),
858
979
  ...(config.project && resolvedProjectId && projectSubject
859
980
  ? [
860
981
  createCloudAiSearchProjectTool(resolvedProjectId, projectSubject),
@@ -887,7 +1008,13 @@ export class AiTurnExecutor {
887
1008
  // Built-ins that exist but this turn does not offer: client tools without their client, tools a
888
1009
  // task cannot use, and tools outside the conversation's fixed scope. load_tools explains each one.
889
1010
  const unofferedTools = [
890
- ...new Set([...sourceToolNames, ...runtimeTools.map((tool) => tool.def.name), ...CODE_RUNTIME_TOOL_NAMES, "local_bash"]),
1011
+ ...new Set([
1012
+ ...sourceToolNames,
1013
+ ...runtimeTools.map((tool) => tool.def.name),
1014
+ ...CODE_RUNTIME_TOOL_NAMES,
1015
+ "local_bash",
1016
+ ...AI_LOCAL_TOOL_NAMES,
1017
+ ]),
891
1018
  ].filter((name) => !offeredToolNames.has(name));
892
1019
  const memoryToolEnabled = activeTools.some((tool) => tool.def.name === "memory");
893
1020
  const projectToolEnabled = activeTools.some((tool) => tool.def.name === "search_project");
@@ -900,9 +1027,14 @@ export class AiTurnExecutor {
900
1027
  });
901
1028
  }
902
1029
 
1030
+ const approvalTargets = new Map<string, AiApprovalTarget>();
1031
+ const linkedApprovals = new Map<string, string>();
1032
+ const freshApprovalRequired = new Set<string>();
903
1033
  const dynamicToolRuntimeContext = {
904
1034
  turnId,
905
1035
  reportToolProgress: (callId: string, message: string) => pipeline.reportToolProgress(callId, message),
1036
+ reportWebsiteReceipts: (callId: string, receipts: AiWebsiteReceipt[]) => pipeline.reportWebsiteReceipts(callId, receipts),
1037
+ localToolsEnabled: activeTools.some((tool) => tool.def.name === "local_upload"),
906
1038
  attachedFilePaths: new Set(config.files?.attached.map((file) => file.path) ?? []),
907
1039
  allowedDataBoundaries: material.modelPolicy?.allowedDataBoundaries,
908
1040
  projectFiles,
@@ -910,6 +1042,35 @@ export class AiTurnExecutor {
910
1042
  selectedModel: resolved,
911
1043
  locale: promptLocale,
912
1044
  timeZone,
1045
+ linkApproval: (approvalCallId: string, approvalId: string) => {
1046
+ linkedApprovals.set(approvalCallId, approvalId);
1047
+ },
1048
+ consumeApproval: async (approvalCallId: string, input: unknown): Promise<AiApprovalUse> => {
1049
+ const pending = await aiConversations.getPendingTurnAction({ conversationId, turnId, callId: approvalCallId });
1050
+ if (!pending?.approvalId) return "approved";
1051
+ const result = await approvals.consume(pending.approvalId, {
1052
+ input,
1053
+ review: approvalReviewForCallId(capabilityActionReviews, approvalCallId) ?? null,
1054
+ requester: { kind: "assistant_turn", ref: `${turnId}/${approvalCallId}` },
1055
+ });
1056
+ if (result.ok) return "approved";
1057
+ if (result.code === "ALREADY_CONSUMED") {
1058
+ // A later question of the same call proves this run resumes after it, not after a crash during the effect.
1059
+ const [open, resolved] = await Promise.all([
1060
+ aiConversations.listPendingActionRecords({ conversationId, turnId }),
1061
+ aiConversations.listResolvedPendingActions({ conversationId, turnId }),
1062
+ ]);
1063
+ return aiApprovalAskedLater(
1064
+ approvalCallId,
1065
+ [...open, ...resolved].map((action) => action.callId),
1066
+ )
1067
+ ? "approved"
1068
+ : "used";
1069
+ }
1070
+ freshApprovalRequired.add(customApprovalParentCallId(approvalCallId) ?? approvalCallId);
1071
+ return "changed";
1072
+ },
1073
+ describeApproval: (approvalCallId: string, target: AiApprovalTarget) => approvalTargets.set(approvalCallId, target),
913
1074
  };
914
1075
  const prepared = prepareAiTools({
915
1076
  tools: activeTools,
@@ -920,6 +1081,7 @@ export class AiTurnExecutor {
920
1081
  let backgroundError: string | null = null;
921
1082
  const rememberableCapabilityApprovals = new Map<string, string>();
922
1083
  const capabilityActionReviews = new Map<string, CapabilityActionReview>();
1084
+ pipeline.setApprovalTargets(approvalTargets);
923
1085
  pipeline.setFrontendModes(prepared.frontendModes);
924
1086
  pipeline.setCanonicalNames(prepared.canonicalNames);
925
1087
  pipeline.setApprovalPolicies(prepared.approvalPolicies);
@@ -989,7 +1151,7 @@ export class AiTurnExecutor {
989
1151
  await pipeline.emitBaseline();
990
1152
  }
991
1153
 
992
- const appliedSteers: AiTurnSteer[] = [];
1154
+ const appliedSteers: (AiTurnSteer | null)[] = [];
993
1155
 
994
1156
  let mandatePolicy: MandatePolicyV1 | null | undefined;
995
1157
  if (config.background && config.mandate) {
@@ -1043,6 +1205,7 @@ export class AiTurnExecutor {
1043
1205
  conversationId,
1044
1206
  actor: capabilityAuthority?.actor ?? toolActor,
1045
1207
  staticTools: activeTools,
1208
+ ...(activeTools.some((tool) => tool.def.name === "load_skill") ? { skillNames: availableSkills.map((skill) => skill.name) } : {}),
1046
1209
  allowedTools,
1047
1210
  mandatePolicy,
1048
1211
  unofferedTools,
@@ -1151,8 +1314,8 @@ export class AiTurnExecutor {
1151
1314
  );
1152
1315
  const chat = await aiConversations.getConversation({ conversationId });
1153
1316
  const workingPlan = chat?.todoPlan;
1154
- const skillCatalogOffered = activeTools.some((tool) => tool.def.name === "load_skill");
1155
- const skillCreatorAvailable = availableSkills.some((skill) => skill.name === "skill-creator");
1317
+ const skillCatalogOffered = activeTools.some((tool) => ["load_skill", "load_local_skill"].includes(tool.def.name));
1318
+ const skillCreatorAvailable = availableSkills.some((skill) => skill.name === "skill-creator" && !skill.manualOnly);
1156
1319
  // Offer hints and the recent-work summary only guide a fresh turn a person follows; a resumed
1157
1320
  // attempt after an approval or a scheduled run gets neither.
1158
1321
  let guidance: AiTurnGuidance = {};
@@ -1181,7 +1344,16 @@ export class AiTurnExecutor {
1181
1344
  }
1182
1345
  const systemPrompt = composeAiSystemPrompt({
1183
1346
  globalInstructions: settings.globalInstructions,
1184
- loadedSkills,
1347
+ loadedSkills: [...loadedSkills, ...(localContext?.selectedSkills ?? []).map((skill) => ({ ...skill, source: "local" as const }))],
1348
+ localClient: localClient
1349
+ ? {
1350
+ client: localClient,
1351
+ cloudUrl: (await coreSettings.get<string>("app.url")) || "",
1352
+ instructions: settings.localClientInstructions,
1353
+ }
1354
+ : undefined,
1355
+ workspaceInstructions: localContext?.instructions,
1356
+ localSkillsAvailable: skillCatalogOffered && localSkills.some((skill) => !skill.manualOnly),
1185
1357
  turnInstructions: [
1186
1358
  material.systemPrompt,
1187
1359
  audioUnavailableInstruction,
@@ -1217,8 +1389,9 @@ export class AiTurnExecutor {
1217
1389
  skillCreatorAvailable,
1218
1390
  recentWork: guidance.recentWork,
1219
1391
  });
1220
- // The turn policy counts the whole turn, including rounds that compaction archived.
1392
+ // The turn policy and repair budget count the whole turn, including rounds that compaction archived.
1221
1393
  const turnMessages = await aiConversations.listTurnMessages({ conversationId, loopId: turnId, includeCompacted: true });
1394
+ const malformedToolCallRepair = createMalformedToolCallRepair(countMalformedToolCallRepairs(turnMessages));
1222
1395
  const turnBlocks = buildBlocksFromMessages(turnMessages);
1223
1396
  const providerNamesByCallId = new Map(
1224
1397
  turnMessages.flatMap(({ message }) =>
@@ -1261,7 +1434,11 @@ export class AiTurnExecutor {
1261
1434
  runBudgetMs: claim.turn.runBudgetMs ?? null,
1262
1435
  finishedToolCalls: turnBlocks
1263
1436
  .slice(turnBlocks.findLastIndex((block) => block.kind === "steer_applied") + 1)
1264
- .flatMap((block) => (block.kind === "tool" ? [{ ...block, name: providerNamesByCallId.get(block.callId) ?? block.name }] : [])),
1437
+ .flatMap((block) =>
1438
+ block.kind === "tool" && !isMalformedToolCallId(block.callId)
1439
+ ? [{ ...block, name: providerNamesByCallId.get(block.callId) ?? block.name }]
1440
+ : [],
1441
+ ),
1265
1442
  onDecision: (decision) =>
1266
1443
  decision.kind === "hint"
1267
1444
  ? log.warn("AI turn got a loop hint", { conversationId, turnId, hints: decision.hints })
@@ -1271,21 +1448,20 @@ export class AiTurnExecutor {
1271
1448
  agentId: "cloud",
1272
1449
  loopId: turnId,
1273
1450
  ...(isFresh ? { input: turnInput } : {}),
1274
- provider: turnPolicy.provider,
1451
+ provider: withMalformedToolCallRepair(turnPolicy.provider, malformedToolCallRepair),
1275
1452
  systemPrompt,
1276
- store,
1277
- steering: async ({ signal: steeringSignal }) => {
1278
- if (steeringSignal.aborted) return undefined;
1279
- const steers = await aiConversations.takePendingTurnSteers({
1280
- conversationId,
1281
- turnId,
1282
- leaseOwner: this.config.leaseOwner,
1283
- });
1284
- appliedSteers.push(...steers);
1285
- if (steers.length === 0) return undefined;
1286
- turnPolicy.noteSteering();
1287
- return steers.map((steer) => steer.text);
1288
- },
1453
+ store: withMalformedToolCallResults(store, malformedToolCallRepair),
1454
+ steering: createChatSteering({
1455
+ repair: malformedToolCallRepair,
1456
+ appliedSteers,
1457
+ takePendingSteers: () =>
1458
+ aiConversations.takePendingTurnSteers({
1459
+ conversationId,
1460
+ turnId,
1461
+ leaseOwner: this.config.leaseOwner,
1462
+ }),
1463
+ noteSteering: turnPolicy.noteSteering,
1464
+ }),
1289
1465
  tools: async () => {
1290
1466
  try {
1291
1467
  return await turnPolicy.tools();
@@ -1332,6 +1508,9 @@ export class AiTurnExecutor {
1332
1508
  allowRememberedApprovals: aiTurnAllowsRememberedApprovals(config),
1333
1509
  rememberableCapabilityApprovals,
1334
1510
  capabilityActionReviews,
1511
+ approvalTargets,
1512
+ linkedApprovals,
1513
+ freshApprovalRequired,
1335
1514
  appliedSteers,
1336
1515
  noteToolRound: turnPolicy.noteToolRound,
1337
1516
  noteToolCall: turnPolicy.noteToolCall,
@@ -1401,7 +1580,10 @@ export class AiTurnExecutor {
1401
1580
  allowRememberedApprovals: boolean;
1402
1581
  rememberableCapabilityApprovals: ReadonlyMap<string, string>;
1403
1582
  capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
1404
- appliedSteers: AiTurnSteer[];
1583
+ approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
1584
+ linkedApprovals: ReadonlyMap<string, string>;
1585
+ freshApprovalRequired?: ReadonlySet<string>;
1586
+ appliedSteers: (AiTurnSteer | null)[];
1405
1587
  noteToolRound: () => void;
1406
1588
  noteToolCall: (call: AiTurnPolicyToolCall) => void;
1407
1589
  /**
@@ -1423,6 +1605,7 @@ export class AiTurnExecutor {
1423
1605
  allowRememberedApprovals,
1424
1606
  rememberableCapabilityApprovals,
1425
1607
  capabilityActionReviews,
1608
+ approvalTargets,
1426
1609
  appliedSteers,
1427
1610
  noteToolRound,
1428
1611
  noteToolCall,
@@ -1446,7 +1629,11 @@ export class AiTurnExecutor {
1446
1629
  allowRememberedApprovals,
1447
1630
  rememberableCapabilityApprovals,
1448
1631
  capabilityActionReviews,
1632
+ approvalTargets,
1633
+ linkedApprovals: input.linkedApprovals,
1634
+ freshApprovalRequired: input.freshApprovalRequired,
1449
1635
  onBackgroundBlocked: input.onBackgroundBlocked,
1636
+ signal: abortController.signal,
1450
1637
  });
1451
1638
  if (suspended) {
1452
1639
  abortController.abort();
@@ -1549,7 +1736,11 @@ export class AiTurnExecutor {
1549
1736
  allowRememberedApprovals: boolean;
1550
1737
  rememberableCapabilityApprovals: ReadonlyMap<string, string>;
1551
1738
  capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
1739
+ approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
1740
+ linkedApprovals: ReadonlyMap<string, string>;
1741
+ freshApprovalRequired?: ReadonlySet<string>;
1552
1742
  onBackgroundBlocked?: (message: string) => void;
1743
+ signal: AbortSignal;
1553
1744
  }): Promise<boolean> {
1554
1745
  const {
1555
1746
  event,
@@ -1562,6 +1753,7 @@ export class AiTurnExecutor {
1562
1753
  allowRememberedApprovals,
1563
1754
  rememberableCapabilityApprovals,
1564
1755
  capabilityActionReviews,
1756
+ approvalTargets,
1565
1757
  } = input;
1566
1758
  const approvalPolicy = prepared.approvalPolicies.get(event.name);
1567
1759
  const toolName = prepared.canonicalNames.get(event.name) ?? event.name;
@@ -1571,8 +1763,13 @@ export class AiTurnExecutor {
1571
1763
  event.kind === "custom_approval"
1572
1764
  ? rememberableCapabilityApprovals.get(customApprovalParentCallId(event.callId) ?? event.callId)
1573
1765
  : undefined;
1574
- const approvalScope = capabilityApprovalScope ?? aiToolApprovalScope(toolName, approvalPolicy);
1575
- const allowAlways = allowRememberedApprovals && (capabilityApprovalScope !== undefined || aiToolAllowsAlways(approvalPolicy));
1766
+ // A tool that names what it asks for (a code run's HTTP request or Action, a web page to read) already looked up
1767
+ // its own remembered approvals under its own rules; the approval is remembered for that target, not the tool.
1768
+ const target = event.kind === "custom_approval" ? approvalTargets.get(event.callId) : undefined;
1769
+ const approvalScope = target?.approvalScope ?? capabilityApprovalScope ?? aiToolApprovalScope(toolName, approvalPolicy);
1770
+ const allowAlways =
1771
+ allowRememberedApprovals && (target ? target.always : capabilityApprovalScope !== undefined || aiToolAllowsAlways(approvalPolicy));
1772
+ const allowChat = allowRememberedApprovals && (target !== undefined || allowAlways);
1576
1773
 
1577
1774
  // Display-only client_view tools (cards, charts) need neither a browser nor input: Cloud answers them itself, in
1578
1775
  // background runs too, whose transcript shows them later. The block goes from running straight to completed:
@@ -1609,8 +1806,15 @@ export class AiTurnExecutor {
1609
1806
  }
1610
1807
 
1611
1808
  // Remembered approvals resolve inline too.
1612
- if (event.kind !== "client_tool" && allowAlways && approvalContext) {
1613
- const remembered = await hasRememberedAiToolApproval(approvalContext, { toolName, approvalScope }).catch(() => false);
1809
+ if (
1810
+ event.kind !== "client_tool" &&
1811
+ !target &&
1812
+ allowAlways &&
1813
+ approvalContext &&
1814
+ // A stale decided snapshot needs another human decision, even if the first response also remembered it.
1815
+ !input.freshApprovalRequired?.has(customApprovalParentCallId(event.callId) ?? event.callId)
1816
+ ) {
1817
+ const remembered = await hasRememberedAiToolApproval(approvalContext, { toolName, approvalScope, conversationId }).catch(() => false);
1614
1818
  if (remembered) {
1615
1819
  await aiToolAudit
1616
1820
  .noteApprovalResolved({ turnId, callId: event.callId, approvalState: "approved_by_preference" })
@@ -1620,7 +1824,40 @@ export class AiTurnExecutor {
1620
1824
  }
1621
1825
  }
1622
1826
 
1827
+ const review = event.kind === "custom_approval" ? approvalReviewForCallId(capabilityActionReviews, event.callId) : undefined;
1828
+ let approvalId: string | undefined;
1829
+ let presented = event;
1830
+ if (event.kind !== "client_tool") {
1831
+ const conversation = await aiConversations.getConversation({ conversationId });
1832
+ const owner = conversation?.createdByUserId;
1833
+ if (!owner) throw new Error("Approval requires a conversation owner");
1834
+ const linked = input.linkedApprovals.get(event.callId);
1835
+ if (linked) {
1836
+ const record = await approvals.get(linked);
1837
+ if (!record || record.status !== "pending" || record.requesterKind !== "studio_app" || record.approverUserId !== owner)
1838
+ throw new Error("Approval does not belong to an open Studio request of the conversation owner");
1839
+ approvalId = linked;
1840
+ // The card shows the question the effect's own record holds, never the text the relay sent.
1841
+ presented = { ...event, message: record.sentence };
1842
+ } else {
1843
+ const record = await approvals.request({
1844
+ ...approvalScopeFromToolName(toolName),
1845
+ approvalScope,
1846
+ requester: { kind: "assistant_turn", ref: `${turnId}/${event.callId}`, appId: "assistant", title: conversation.title },
1847
+ subjectId: owner,
1848
+ approverUserId: owner,
1849
+ assurance: approvalAssurance(target !== undefined || capabilityApprovalScope !== undefined || aiToolAllowsAlways(approvalPolicy)),
1850
+ sentence: event.message ?? toolName,
1851
+ review,
1852
+ input: event.args,
1853
+ expiresInMs: AI_ACTION_BUDGET_MS,
1854
+ });
1855
+ if (!record.ok) throw new Error("This approval was already decided or has expired.");
1856
+ approvalId = record.id;
1857
+ }
1858
+ }
1623
1859
  await aiConversations.savePendingTurnAction({
1860
+ approvalId,
1624
1861
  turnId,
1625
1862
  conversationId,
1626
1863
  callId: event.callId,
@@ -1628,10 +1865,12 @@ export class AiTurnExecutor {
1628
1865
  status: "pending",
1629
1866
  name: toolName,
1630
1867
  args: event.args,
1631
- message: event.message,
1632
- review: event.kind === "custom_approval" ? approvalReviewForCallId(capabilityActionReviews, event.callId) : undefined,
1868
+ message: presented.message,
1869
+ review,
1633
1870
  approvalScope,
1634
1871
  allowAlways,
1872
+ allowChat,
1873
+ ...(target ? { rememberToolName: target.toolName } : {}),
1635
1874
  frontendMode,
1636
1875
  resolvedEvent: null,
1637
1876
  });
@@ -1653,7 +1892,33 @@ export class AiTurnExecutor {
1653
1892
  .catch(() => undefined);
1654
1893
  }
1655
1894
 
1656
- await pipeline.apply(event);
1895
+ await pipeline.apply(presented);
1896
+ if (
1897
+ event.kind === "client_tool" &&
1898
+ frontendMode === "client" &&
1899
+ (toolName === "local_bash" || AI_LOCAL_TOOL_NAMES.some((name) => name === toolName))
1900
+ ) {
1901
+ await pipeline.flush();
1902
+ // One wait stays inside a heartbeat interval, keeping lease renewal and abort detection on their cadence.
1903
+ const deadline = performance.now() + this.config.heartbeatMs / 2;
1904
+ while (!input.signal.aborted && performance.now() < deadline) {
1905
+ const action = await aiConversations.getPendingTurnAction({ conversationId, turnId, callId: event.callId });
1906
+ if (action?.status === "resolved" && action.resolvedEvent) {
1907
+ await aiConversations.markPendingTurnActionConsumedInline({ conversationId, turnId, callId: event.callId });
1908
+ loop.push(action.resolvedEvent);
1909
+ return false;
1910
+ }
1911
+ await new Promise<void>((resolve) => {
1912
+ const done = () => {
1913
+ clearTimeout(timer);
1914
+ input.signal.removeEventListener("abort", done);
1915
+ resolve();
1916
+ };
1917
+ const timer = setTimeout(done, 10);
1918
+ input.signal.addEventListener("abort", done, { once: true });
1919
+ });
1920
+ }
1921
+ }
1657
1922
  const suspended = await aiConversations.suspendTurn({
1658
1923
  conversationId,
1659
1924
  turnId,
@@ -1663,6 +1928,16 @@ export class AiTurnExecutor {
1663
1928
  waitingBudgetMs: AI_ACTION_BUDGET_MS,
1664
1929
  });
1665
1930
  await pipeline.flush().catch(() => undefined);
1931
+ if (suspended && approvalId && this.config.onApprovalRequested) {
1932
+ await this.config.onApprovalRequested({ approvalId }).catch((error) => {
1933
+ log.warn("AI approval requested hook failed", {
1934
+ conversationId,
1935
+ turnId,
1936
+ approvalId,
1937
+ error: error instanceof Error ? error.message : "AI approval requested hook failed",
1938
+ });
1939
+ });
1940
+ }
1666
1941
  return suspended;
1667
1942
  }
1668
1943
 
@@ -1871,7 +2146,9 @@ class StreamPipeline {
1871
2146
  this.blocks = this.allowRememberedApprovals
1872
2147
  ? blocks
1873
2148
  : blocks.map((block) =>
1874
- block.kind === "tool" && block.approval ? { ...block, approval: { ...block.approval, allowAlways: false } } : block,
2149
+ block.kind === "tool" && block.approval
2150
+ ? { ...block, approval: { ...block.approval, allowAlways: false, allowChat: false } }
2151
+ : block,
1875
2152
  );
1876
2153
  }
1877
2154
 
@@ -1895,6 +2172,10 @@ class StreamPipeline {
1895
2172
  this.mapper.setApprovalPolicies(policies);
1896
2173
  }
1897
2174
 
2175
+ setApprovalTargets(targets: ReadonlyMap<string, AiApprovalTarget>): void {
2176
+ this.mapper.setApprovalTargets(targets);
2177
+ }
2178
+
1898
2179
  setRejectedCallIds(callIds: Set<string>): void {
1899
2180
  this.mapper.setRejectedCallIds(callIds);
1900
2181
  }
@@ -1913,6 +2194,8 @@ class StreamPipeline {
1913
2194
  }
1914
2195
 
1915
2196
  async emitMessage(message: AiStoredMessage): Promise<void> {
2197
+ // A malformed call shows once its error result is saved, at the end of its response as in history.
2198
+ for (const op of this.mapper.storedMessage(message.message, message.createdAt)) await this.emitOp(op);
1916
2199
  const seq = this.nextSeq();
1917
2200
  await this.publish(this.envelope({ type: "message_saved" as const, seq, message }));
1918
2201
  await this.maybeSnapshot();
@@ -1950,6 +2233,20 @@ class StreamPipeline {
1950
2233
  await this.maybeSnapshot();
1951
2234
  }
1952
2235
 
2236
+ /**
2237
+ * Shows requests a website approval let through on the call's block, and records them on its message before they go
2238
+ * out, so the chat keeps each receipt and its revoke action whether the call succeeds, fails, or the turn stops.
2239
+ */
2240
+ async reportWebsiteReceipts(callId: string, receipts: AiWebsiteReceipt[]): Promise<void> {
2241
+ if (!receipts.length) return;
2242
+ await recordAiWebsiteReceipts({ conversationId: this.conversationId, turnId: this.turnId, callId, receipts });
2243
+ const merged = this.mapper.addReceipts(callId, receipts);
2244
+ const block = this.blocks.find((block) => block.kind === "tool" && block.id === toolBlockId(callId));
2245
+ if (block?.kind !== "tool") return;
2246
+ await this.emitOp({ type: "block_set", block: { ...block, receipts: merged } });
2247
+ await this.maybeSnapshot();
2248
+ }
2249
+
1953
2250
  async reportToolProgress(callId: string, progress: string): Promise<void> {
1954
2251
  const block = this.blocks.find((block) => block.kind === "tool" && block.callId === callId);
1955
2252
  if (!block || block.kind !== "tool" || block.progress === progress) return;
@@ -2040,6 +2337,7 @@ class StreamPipeline {
2040
2337
  }
2041
2338
 
2042
2339
  export const __aiExecutorTest = {
2340
+ createChatSteering,
2043
2341
  indexConversationToolSource,
2044
2342
  StreamPipeline,
2045
2343
  createEventMapper,