@k2b/cloud 0.6.0-rc.0 → 0.7.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 (315) hide show
  1. package/README.md +2 -2
  2. package/package.json +30 -17
  3. package/scripts/browser-performance.ts +14 -0
  4. package/scripts/build-pdf-renderer.ts +39 -0
  5. package/scripts/build.ts +15 -35
  6. package/scripts/preload.ts +9 -12
  7. package/scripts/sync-recovery-smoke.ts +2 -1
  8. package/src/_internal/app-presentation.ts +14 -2
  9. package/src/_internal/capabilities.ts +108 -7
  10. package/src/_internal/capability-streams.ts +81 -0
  11. package/src/_internal/define-app.ts +62 -36
  12. package/src/_internal/heartbeat.ts +4 -0
  13. package/src/_internal/help-catalog.ts +27 -172
  14. package/src/_internal/help.ts +13 -76
  15. package/src/_internal/page-responses.ts +2 -0
  16. package/src/_internal/postgres-application-name.ts +30 -0
  17. package/src/_internal/process-identity.ts +15 -0
  18. package/src/_internal/process-sync.ts +1 -1
  19. package/src/_internal/registry-validation.ts +29 -25
  20. package/src/_internal/registry.ts +2 -53
  21. package/src/_internal/runtime-context.ts +5 -16
  22. package/src/_internal/server-timing.ts +13 -0
  23. package/src/_internal/status-preserving-ssr.ts +18 -6
  24. package/src/_internal/web-vitals-asset.ts +10 -0
  25. package/src/access/PermissionEditor.tsx +14 -153
  26. package/src/access/PrincipalPicker.tsx +104 -0
  27. package/src/access/ui.ts +2 -0
  28. package/src/ai/admin.ts +14 -13
  29. package/src/ai/assistant-models.ts +12 -1
  30. package/src/ai/audio-format.ts +34 -0
  31. package/src/ai/audio-tool.ts +90 -0
  32. package/src/ai/browser-code-contracts.ts +79 -0
  33. package/src/ai/browser.ts +2 -0
  34. package/src/ai/capabilities.ts +55 -36
  35. package/src/ai/capability-execution.ts +47 -12
  36. package/src/ai/chat/blocks.tsx +157 -95
  37. package/src/ai/chat/capability-result.ts +15 -0
  38. package/src/ai/chat/capability-table.tsx +2 -10
  39. package/src/ai/chat/composer-adapter.ts +48 -3
  40. package/src/ai/chat/message-actions.tsx +8 -7
  41. package/src/ai/chat/message-utils.ts +12 -1
  42. package/src/ai/chat/messages.ts +2 -0
  43. package/src/ai/chat/presentation.tsx +19 -48
  44. package/src/ai/chat/primitives.tsx +43 -1
  45. package/src/ai/chat/tool-disclosure.tsx +17 -2
  46. package/src/ai/chat/tool-groups.ts +52 -0
  47. package/src/ai/chat-quotas.ts +25 -0
  48. package/src/ai/chat-task-contracts.ts +9 -1
  49. package/src/ai/chat-tasks.ts +276 -43
  50. package/src/ai/client/controller.ts +110 -33
  51. package/src/ai/client/file-source.ts +12 -6
  52. package/src/ai/client/live-connection.ts +1 -0
  53. package/src/ai/client/projection.ts +22 -11
  54. package/src/ai/client/transport.ts +4 -0
  55. package/src/ai/code-capability-routes.ts +110 -0
  56. package/src/ai/code-capability-transport.ts +38 -0
  57. package/src/ai/code-mode-skill.ts +114 -0
  58. package/src/ai/code-runtime-tools.ts +166 -0
  59. package/src/ai/code-source-contracts.ts +202 -0
  60. package/src/ai/code-source-tools.ts +145 -0
  61. package/src/ai/compaction.ts +27 -6
  62. package/src/ai/data-analysis-skill.ts +10 -0
  63. package/src/ai/default-tools.ts +25 -2
  64. package/src/ai/dictation-runtime.ts +171 -0
  65. package/src/ai/dictations.ts +188 -0
  66. package/src/ai/draft-content.ts +34 -0
  67. package/src/ai/enrich.ts +6 -0
  68. package/src/ai/executor.ts +191 -31
  69. package/src/ai/file-content-version.ts +22 -0
  70. package/src/ai/file-context.ts +2 -1
  71. package/src/ai/file-media-type.ts +12 -0
  72. package/src/ai/file-reference-contracts.ts +15 -0
  73. package/src/ai/file-tools.ts +2 -1
  74. package/src/ai/files-store.ts +174 -71
  75. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  76. package/src/ai/grids-skill.ts +40 -10
  77. package/src/ai/http.ts +15 -8
  78. package/src/ai/index.ts +26 -2
  79. package/src/ai/inference-calls.ts +198 -0
  80. package/src/ai/live-events.ts +5 -1
  81. package/src/ai/memory-learning.ts +60 -28
  82. package/src/ai/message-queue.ts +167 -0
  83. package/src/ai/migrate.ts +187 -11
  84. package/src/ai/model-pricing.ts +43 -0
  85. package/src/ai/pdf-render-worker.ts +74 -0
  86. package/src/ai/pdf-render.fixture.ts +28 -0
  87. package/src/ai/pdf-render.ts +77 -0
  88. package/src/ai/projects-routes.ts +3 -3
  89. package/src/ai/projects.ts +20 -6
  90. package/src/ai/protocol.ts +3 -0
  91. package/src/ai/provider.ts +1 -8
  92. package/src/ai/quota-provider.ts +191 -0
  93. package/src/ai/quota-report.ts +152 -0
  94. package/src/ai/quotas-migrate.ts +45 -0
  95. package/src/ai/quotas.ts +168 -0
  96. package/src/ai/routes.ts +215 -11
  97. package/src/ai/run-timeout.ts +10 -0
  98. package/src/ai/runtime-tools.ts +3 -0
  99. package/src/ai/runtime.ts +55 -16
  100. package/src/ai/settings.ts +36 -8
  101. package/src/ai/short-id.ts +2 -2
  102. package/src/ai/skill-catalog.ts +0 -10
  103. package/src/ai/skill-search.ts +47 -0
  104. package/src/ai/skill-seeds.ts +105 -20
  105. package/src/ai/skill-tool.ts +16 -5
  106. package/src/ai/skills-routes.ts +32 -4
  107. package/src/ai/skills.ts +324 -155
  108. package/src/ai/store.ts +373 -191
  109. package/src/ai/stream.ts +5 -0
  110. package/src/ai/structured-runs.ts +4 -3
  111. package/src/ai/structured.ts +8 -1
  112. package/src/ai/system-prompt.ts +12 -2
  113. package/src/ai/timeline.ts +2 -5
  114. package/src/ai/todo-contracts.ts +20 -0
  115. package/src/ai/todo-tool.ts +16 -0
  116. package/src/ai/tool-audit.ts +30 -40
  117. package/src/ai/tools.ts +6 -0
  118. package/src/ai/transcription.ts +202 -0
  119. package/src/ai/turn-timing.ts +114 -0
  120. package/src/ai/types.ts +58 -10
  121. package/src/ai/ui.tsx +2 -0
  122. package/src/ai/usage.ts +118 -118
  123. package/src/ai/vision-tool.ts +39 -10
  124. package/src/api/admin-ai-projects.ts +2 -2
  125. package/src/api/admin-ai-quotas.ts +74 -0
  126. package/src/api/admin-ai-skills.ts +48 -5
  127. package/src/api/admin-ai-usage.ts +2 -2
  128. package/src/api/admin-core-settings.ts +25 -0
  129. package/src/api/admin-identity.ts +91 -51
  130. package/src/api/admin-rail.ts +58 -0
  131. package/src/api/announcements.ts +14 -0
  132. package/src/api/capabilities.ts +292 -43
  133. package/src/api/capability-streams.ts +262 -0
  134. package/src/api/help.ts +8 -44
  135. package/src/api/index.ts +6 -0
  136. package/src/api/mcp.ts +44 -26
  137. package/src/api/me-web-vitals.ts +35 -0
  138. package/src/api/me.ts +2 -0
  139. package/src/api/search/schemas.ts +26 -15
  140. package/src/api/search.bench.ts +1 -1
  141. package/src/api/search.ts +54 -17
  142. package/src/browser/CloudResourceSearch.tsx +640 -702
  143. package/src/browser/app-approval-vault.ts +19 -7
  144. package/src/browser/command-bridge.ts +109 -0
  145. package/src/browser/command-shortcuts.ts +76 -0
  146. package/src/browser/commands.ts +66 -0
  147. package/src/browser/navigation-search.ts +48 -0
  148. package/src/browser/notifications.ts +1 -1
  149. package/src/browser/resource-picker.tsx +5 -18
  150. package/src/browser/resource-search-dialog.ts +9 -0
  151. package/src/browser/resource-search-input.ts +46 -0
  152. package/src/browser/resource-search-messages.ts +76 -40
  153. package/src/browser/resource-search.ts +7 -0
  154. package/src/browser/search-bridge.ts +102 -0
  155. package/src/browser/search-commands.ts +59 -0
  156. package/src/browser/search.ts +13 -0
  157. package/src/browser/spotlight-position.ts +208 -0
  158. package/src/browser/testing.ts +5 -0
  159. package/src/browser/web-vitals.ts +25 -0
  160. package/src/capabilities/claims.ts +179 -0
  161. package/src/capabilities/client.ts +34 -0
  162. package/src/capabilities/command-link.ts +16 -0
  163. package/src/capabilities/executions.ts +298 -0
  164. package/src/capabilities/migrate.ts +81 -0
  165. package/src/capabilities/server.ts +114 -34
  166. package/src/capabilities/stream-body.ts +16 -0
  167. package/src/capabilities/streams.ts +28 -0
  168. package/src/cli/admin/ai-quotas.ts +161 -0
  169. package/src/cli/admin/ai-skills.ts +64 -0
  170. package/src/cli/admin/ai-usage.ts +53 -30
  171. package/src/cli/admin/app-credentials.ts +79 -0
  172. package/src/cli/admin/data.ts +17 -0
  173. package/src/cli/admin/index.ts +9 -0
  174. package/src/cli/capabilities.ts +29 -0
  175. package/src/cli/capability-streams.ts +25 -0
  176. package/src/cli/index.ts +3 -2
  177. package/src/config/env.ts +11 -8
  178. package/src/contracts/app.ts +23 -3
  179. package/src/contracts/capabilities.ts +68 -5
  180. package/src/contracts/capability-streams.ts +46 -0
  181. package/src/contracts/commands.ts +49 -0
  182. package/src/contracts/index.ts +6 -1
  183. package/src/contracts/rail-admin.ts +38 -0
  184. package/src/contracts/rail-preferences.ts +16 -0
  185. package/src/contracts/registry.ts +3 -27
  186. package/src/contracts/settings-types.ts +1 -1
  187. package/src/contracts/shared.ts +9 -5
  188. package/src/contracts/web-vitals.ts +27 -0
  189. package/src/desktop/solid.tsx +3 -3
  190. package/src/index.ts +0 -4
  191. package/src/server/index.ts +3 -0
  192. package/src/server/middleware/auth.ts +3 -2
  193. package/src/server/middleware/route-template.ts +9 -3
  194. package/src/server/middleware/runtime.ts +2 -1
  195. package/src/server/middleware/settings.ts +17 -11
  196. package/src/server/services/access-revision.ts +10 -0
  197. package/src/server/services/access.ts +1 -1
  198. package/src/server/services/index.ts +1 -0
  199. package/src/services/account-category-policy.ts +9 -0
  200. package/src/services/account-lifecycle/audit.ts +1 -1
  201. package/src/services/accounts/app.ts +12 -2
  202. package/src/services/accounts/email-write.ts +23 -0
  203. package/src/services/accounts/groups.ts +15 -4
  204. package/src/services/accounts/identities.ts +249 -0
  205. package/src/services/accounts/identity-reconciliation.ts +87 -0
  206. package/src/services/accounts/ipa-data.ts +2 -2
  207. package/src/services/accounts/local-groups.ts +2 -2
  208. package/src/services/accounts/posix.ts +45 -29
  209. package/src/services/accounts/users.ts +2 -2
  210. package/src/services/announcements/index.ts +69 -8
  211. package/src/services/audit/index.ts +1 -1
  212. package/src/services/cache-fill.ts +35 -0
  213. package/src/services/help/index.ts +109 -0
  214. package/src/services/help/maintenance.ts +25 -0
  215. package/src/services/help/store.ts +180 -0
  216. package/src/services/help/types.ts +46 -0
  217. package/src/services/identity/invocation-actor.ts +2 -2
  218. package/src/services/identity/key-config.ts +2 -1
  219. package/src/services/identity/key-ring.ts +1 -1
  220. package/src/services/index.ts +15 -0
  221. package/src/services/ipa/users.ts +268 -221
  222. package/src/services/legal-consent.ts +1 -1
  223. package/src/services/logging/index.ts +15 -13
  224. package/src/services/logging/trace.ts +2 -1
  225. package/src/services/logging/web-vitals.ts +81 -0
  226. package/src/services/mandates/index.ts +18 -4
  227. package/src/services/mandates/policy.ts +65 -2
  228. package/src/services/notifications/batches.ts +124 -57
  229. package/src/services/notifications/browser.ts +12 -0
  230. package/src/services/notifications/index.ts +0 -2
  231. package/src/services/notifications/platform.ts +0 -4
  232. package/src/services/oauth-tokens.ts +3 -3
  233. package/src/services/pdf/gotenberg.ts +107 -84
  234. package/src/services/pdf/index.ts +3 -0
  235. package/src/services/providers/local/users.ts +25 -16
  236. package/src/services/public-http.ts +101 -0
  237. package/src/services/rail-shortcuts.ts +95 -0
  238. package/src/services/rail-snapshot.ts +53 -0
  239. package/src/services/request-cache-redis.ts +28 -0
  240. package/src/services/session/index.ts +15 -2
  241. package/src/services/session/user.ts +62 -37
  242. package/src/services/settings/core-settings.ts +21 -28
  243. package/src/services/settings/defaults.ts +5 -0
  244. package/src/services/settings/store.ts +58 -84
  245. package/src/shared/ai-costs.ts +42 -0
  246. package/src/shared/ai-platform-prompt.ts +1 -1
  247. package/src/shared/ai-quotas.ts +136 -0
  248. package/src/shared/ai-usage.ts +6 -16
  249. package/src/shared/app-presentation.ts +21 -15
  250. package/src/shared/capability-messages.ts +14 -0
  251. package/src/shared/help.ts +6 -42
  252. package/src/shared/index.ts +6 -0
  253. package/src/ssr/AdminLayout.tsx +7 -5
  254. package/src/ssr/AdminSidebar.tsx +29 -15
  255. package/src/ssr/AppLaunchpad.island.tsx +24 -137
  256. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  257. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  258. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  259. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  260. package/src/ssr/Layout.tsx +17 -6
  261. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  262. package/src/ssr/LayoutFooter.tsx +1 -1
  263. package/src/ssr/LayoutHeader.tsx +46 -14
  264. package/src/ssr/LayoutHelp.tsx +23 -22
  265. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  266. package/src/ssr/LayoutRail.tsx +17 -9
  267. package/src/ssr/MobileNavigation.tsx +71 -0
  268. package/src/ssr/MobileProfileActions.tsx +49 -0
  269. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  270. package/src/ssr/RailApps.island.tsx +2 -2
  271. package/src/ssr/RailEditor.tsx +17 -2
  272. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  273. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  274. package/src/ssr/admin-navigation.ts +7 -1
  275. package/src/ssr/help.ts +29 -0
  276. package/src/ssr/islands/SearchBar.tsx +67 -0
  277. package/src/ssr/islands/index.ts +3 -1
  278. package/src/ssr/layout-context.ts +2 -2
  279. package/src/ssr/layout-help-search.ts +15 -0
  280. package/src/ssr/mobile-menu-history.ts +70 -0
  281. package/src/ssr/platform-messages.ts +16 -8
  282. package/src/ssr/profile-actions.ts +42 -0
  283. package/src/ssr/rail-context.ts +8 -4
  284. package/src/ssr/rail-messages.ts +2 -0
  285. package/src/ssr/rail-navigation.ts +3 -3
  286. package/src/ssr/workspace-navigation.ts +75 -0
  287. package/src/styles/effects.css +6 -6
  288. package/src/styles/global.css +54 -0
  289. package/src/styles/input.css +43 -41
  290. package/src/styles/resource-search.css +505 -0
  291. package/src/styles/utilities-feedback.css +13 -0
  292. package/src/styles/utilities-layout.css +1 -5
  293. package/src/styles/utilities-navigation.css +3 -39
  294. package/src/types/ambient.d.ts +5 -0
  295. package/src/workflows/ai/runtime.ts +5 -1
  296. package/src/workflows/ai/store.ts +2 -0
  297. package/src/workflows/ai-actions.ts +16 -13
  298. package/src/workflows/definition.ts +10 -5
  299. package/src/workflows/store/actions.ts +44 -20
  300. package/src/workflows/store/index.ts +2 -0
  301. package/src/workflows/store/runs.ts +36 -0
  302. package/src/workflows/store/worker-pool.ts +55 -0
  303. package/src/workflows/store/worker-runtime.ts +90 -0
  304. package/scripts/app-favicon.test.ts +0 -49
  305. package/src/ai/chat/blocks.render.test.tsx +0 -1108
  306. package/src/ai/kit-skill.ts +0 -22
  307. package/src/ai/usage-migrate.ts +0 -81
  308. package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
  309. package/src/browser/notification-target.ts +0 -27
  310. package/src/contracts/notification-live.ts +0 -66
  311. package/src/services/notifications/live.ts +0 -50
  312. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  313. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
  314. package/src/ssr/Layout.render.test.tsx +0 -148
  315. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -4,6 +4,7 @@ import { type AiAttachmentRef, aiAttachmentMarker } from "../attachments";
4
4
  import { type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
5
5
  import { type AiResourceMarker, aiResourceMarker } from "../resource-markers";
6
6
  import type {
7
+ AiClientToolId,
7
8
  AiConversation,
8
9
  AiConversationTimelineEntry,
9
10
  AiDraftContentPart,
@@ -18,6 +19,7 @@ import {
18
19
  activeTurnFromSnapshot,
19
20
  emptyProjection,
20
21
  mergeActiveTurn,
22
+ messagesWithPendingSend,
21
23
  reconcileActiveTurnActions,
22
24
  reduceProjection,
23
25
  visibleMessages,
@@ -25,6 +27,10 @@ import {
25
27
  import { type AiConversationStreamTransport, type AiStreamHandle, aiSseConversationStreamTransport } from "./transport";
26
28
 
27
29
  type ComposerDraftInput = {
30
+ draftContent?: AiDraftContentPart[];
31
+ /** Bind local content to the server draft it was actually based on. */
32
+ conversationId?: string;
33
+ expectedDraftRevision?: number;
28
34
  message?: string;
29
35
  content?: AiUserContentPart[];
30
36
  files?: File[];
@@ -36,6 +42,7 @@ export type AiChatRunStatus = "idle" | "streaming" | "waiting_for_action" | "sto
36
42
  export type AiStreamStatus = "idle" | "connecting" | "open" | "reconnecting";
37
43
 
38
44
  export type AiFrontendToolHandler = (request: {
45
+ conversationId: string;
39
46
  name: string;
40
47
  callId: string;
41
48
  args: unknown;
@@ -99,6 +106,8 @@ export type CreateAiChatControllerOptions = {
99
106
  /** Persist owner read state for conversation-list unread indicators. */
100
107
  trackViewedState?: boolean;
101
108
  frontendTools?: Record<string, AiFrontendToolHandler>;
109
+ /** Explicitly advertise only client tools with a connected handler. */
110
+ clientToolIds?: AiClientToolId[];
102
111
  /** Overrides the default SSE transport, for example with a shared WebSocket channel. */
103
112
  streamTransport?: AiConversationStreamTransport;
104
113
  };
@@ -141,6 +150,13 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
141
150
  const [streamStatus, setStreamStatus] = createSignal<AiStreamStatus>("idle");
142
151
  const initialProjection = options.initialDetail ? detailToProjection(options.initialDetail) : emptyProjection();
143
152
  const [state, setState] = createStore<AiChatProjection>(initialProjection);
153
+ const completedDuringSend = new Map<string, string>();
154
+ const [pendingSends, setPendingSends] = createSignal<Record<string, AiStoredMessage | undefined>>({});
155
+ const clearPendingSend = (conversationId: string) => setPendingSends(current => {
156
+ const next = { ...current };
157
+ delete next[conversationId];
158
+ return next;
159
+ });
144
160
 
145
161
  // Cache of projections for conversations opened this session (fast switching).
146
162
  const cache = new Map<string, AiChatProjection>();
@@ -207,7 +223,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
207
223
  const error = () => globalError() ?? runError();
208
224
 
209
225
  const activeTurn = () => state.activeTurn;
210
- const messages = createMemo(() => visibleMessages(state));
226
+ const messages = createMemo(() => messagesWithPendingSend(visibleMessages(state), pendingSends()[activeConversationId() ?? ""]));
211
227
  const runStatus = (): AiChatRunStatus => {
212
228
  const override = runStatusRaw();
213
229
  if (override) return override;
@@ -226,6 +242,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
226
242
  const next = reduceProjection({ conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn }, event);
227
243
  setState(reconcile(next, { key: "id", merge: true }));
228
244
  cache.set(conversationId, next);
245
+ if (event.type === "turn_finished" && pendingSends()[conversationId]?.loopId === event.turnId) clearPendingSend(conversationId);
229
246
  };
230
247
 
231
248
  /**
@@ -241,6 +258,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
241
258
  setProjection(detailToProjection(detail), session.conversationId);
242
259
  setHasMore(session.conversationId, detail.hasMoreMessages ?? false);
243
260
  if (detail.timeline) setTimeline(session.conversationId, detail.timeline);
261
+ if (event.type === "turn_finished" && pendingSends()[session.conversationId]?.loopId === event.turnId) clearPendingSend(session.conversationId);
244
262
  } else reduceEvent(session.conversationId, event);
245
263
  setRunStatusRaw(null);
246
264
  void markConversationViewed(session.conversationId);
@@ -249,6 +267,9 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
249
267
  const applyEvent = (session: AiStreamSession, event: AiStreamEvent) => {
250
268
  if (!isCurrentStreamSession(streamSession, session)) return;
251
269
  const conversationId = session.conversationId;
270
+ if (event.type === "turn_finished" && pendingSends()[conversationId]) {
271
+ completedDuringSend.set(conversationId, event.turnId);
272
+ }
252
273
 
253
274
  if (event.type === "state") setHasMore(conversationId, event.hasMoreMessages ?? false);
254
275
  const nextRunError = runErrorFromEvent(event, state.activeTurn?.turnId);
@@ -265,7 +286,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
265
286
 
266
287
  reduceEvent(conversationId, event);
267
288
  if (event.type === "state" && loadingConversationId() === conversationId) setLoadingConversationId(null);
268
- if (runStatusRaw() && runStatusRaw() !== "stopping") setRunStatusRaw(null);
289
+ if (runStatusRaw() && (runStatusRaw() !== "stopping" || !state.activeTurn)) setRunStatusRaw(null);
269
290
 
270
291
  if (event.type === "turn_finished") {
271
292
  setRunStatusRaw(null);
@@ -338,7 +359,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
338
359
  return false;
339
360
  }
340
361
  try {
341
- const result = await handler({ name, callId, args, turnId });
362
+ const result = await handler({ name, callId, args, turnId, conversationId });
342
363
  return submitTurnActionForConversation(conversationId, turnId, callId, { type: "tool_result", result });
343
364
  } catch (toolError) {
344
365
  return submitTurnActionForConversation(conversationId, turnId, callId, {
@@ -394,6 +415,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
394
415
  };
395
416
  cache.set(conversationId, next);
396
417
  setState(reconcile(next, { key: "id", merge: true }));
418
+ if (!next.activeTurn && runStatusRaw() === "stopping") setRunStatusRaw(null);
397
419
  };
398
420
 
399
421
  const openConversation = async (conversationId: string) => {
@@ -436,15 +458,25 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
436
458
  return generation === conversationOpenGeneration ? ("failed" as const) : ("stale" as const);
437
459
  };
438
460
 
461
+ let conversationRefreshGeneration = 0;
439
462
  const refreshActiveConversation = async (): Promise<void> => {
440
463
  const conversationId = activeConversationId();
441
464
  if (!conversationId) return;
465
+ const refreshGeneration = ++conversationRefreshGeneration;
466
+ const openGeneration = conversationOpenGeneration;
442
467
  const detail = await request<AiConversationDetail>(
443
468
  `/conversations/${conversationId}`,
444
469
  { method: "GET" },
445
470
  "Failed to refresh conversation",
446
471
  );
447
- if (!isActiveConversation(conversationId)) return;
472
+ if (
473
+ !isActiveConversation(conversationId) ||
474
+ refreshGeneration !== conversationRefreshGeneration ||
475
+ openGeneration !== conversationOpenGeneration
476
+ )
477
+ return;
478
+ const currentDraft = state.conversation?.draft;
479
+ if (currentDraft && currentDraft.revision > detail.conversation.draft.revision) detail.conversation.draft = currentDraft;
448
480
  const windowOldest = detail.messages[0]?.seq;
449
481
  const preservedOlder = windowOldest === undefined ? [] : state.messages.filter((message) => message.seq < windowOldest);
450
482
  setProjection({ ...detailToProjection(detail), messages: [...preservedOlder, ...detail.messages] }, conversationId);
@@ -653,7 +685,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
653
685
  }
654
686
  }
655
687
  const content: AiDraftContentPart[] = [
656
- ...textParts,
688
+ ...(input.draftContent ?? textParts),
657
689
  ...(input.resources ?? []).map((resource) => ({ type: "resource" as const, ...resource })),
658
690
  ...(input.storedFiles ?? []).map((file) => ({ type: "file" as const, ...file })),
659
691
  ...uploaded.map((file) => ({ type: "file" as const, ...file })),
@@ -661,10 +693,14 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
661
693
  try {
662
694
  const draft = await request<AiConversation["draft"]>(
663
695
  `/conversations/${conversationId}/draft`,
664
- { method: "PUT", body: JSON.stringify({ expectedRevision: conversation.draft.revision, content }) },
696
+ { method: "PUT", body: JSON.stringify({ expectedRevision: input.expectedDraftRevision ?? conversation.draft.revision, content }) },
665
697
  "Failed to save conversation draft",
666
698
  );
667
- const nextConversation = { ...conversation, draft };
699
+ const current = isActiveConversation(conversationId) ? state.conversation : cache.get(conversationId)?.conversation;
700
+ const nextConversation = {
701
+ ...(current ?? conversation),
702
+ draft: current && current.draft.revision > draft.revision ? current.draft : draft,
703
+ };
668
704
  const projection = isActiveConversation(conversationId)
669
705
  ? { conversation: nextConversation, messages: state.messages, activeTurn: state.activeTurn }
670
706
  : { ...(cache.get(conversationId) ?? emptyProjection(nextConversation)), conversation: nextConversation };
@@ -687,36 +723,56 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
687
723
  };
688
724
 
689
725
  const saveDraft = (input: ComposerDraftInput) => {
690
- const targetConversation = ensureConversation();
726
+ const targetConversation = input.conversationId ? Promise.resolve(input.conversationId) : ensureConversation();
691
727
  return queueDraftOperation(async () => persistComposerDraft(input, await targetConversation));
692
728
  };
693
729
 
730
+ const queuedAttempts = new Map<string, {key:string;files?:File[];id:string;saved:{revision:number}}>();
731
+ const queueMessage = (input: ComposerDraftInput & { modelProfileId?: string }): Promise<boolean> => {
732
+ if (!input.conversationId || !isComposerDraftSendable(input)) return Promise.resolve(false);
733
+ const conversationId = input.conversationId;
734
+ const key = JSON.stringify({message:input.message,content:input.content,draftContent:input.draftContent,resources:input.resources,storedFiles:input.storedFiles,model:input.modelProfileId});
735
+ return queueDraftOperation(async () => {
736
+ try {
737
+ const previous = queuedAttempts.get(conversationId);
738
+ const same = previous?.key === key && (previous.files ?? []).length === (input.files ?? []).length && (previous.files ?? []).every((file,index)=>file === input.files?.[index]);
739
+ const saved = same ? previous.saved : await persistComposerDraft(input, conversationId);
740
+ if (!saved) return false;
741
+ const queueId = same ? previous.id : crypto.randomUUID();
742
+ queuedAttempts.set(conversationId,{key,files:input.files,id:queueId,saved});
743
+ await request(`/conversations/${conversationId}/turns`, {method:"POST", body:JSON.stringify({
744
+ draftRevision:saved.revision, queueId, modelProfileId:input.modelProfileId,
745
+ clientToolIds:options.clientToolIds?.filter(id => options.frontendTools?.[id]),
746
+ })}, "Failed to queue message");
747
+ if (isActiveConversation(conversationId)) {
748
+ if ((state.conversation?.draft.revision ?? 0) <= saved.revision) setState("conversation", "draft", {content:[],revision:saved.revision+1,updatedAt:new Date().toISOString()});
749
+ } else invalidateInactiveCache(conversationId);
750
+ queuedAttempts.delete(conversationId);
751
+ if (isActiveConversation(conversationId)) setGlobalError(null);
752
+ return true;
753
+ } catch (error) {
754
+ setConversationError(conversationId,error instanceof Error ? error.message : "Failed to queue message");
755
+ return false;
756
+ }
757
+ });
758
+ };
759
+
694
760
  const send = (input: ComposerDraftInput & { modelProfileId?: string }): Promise<boolean> => {
695
761
  if (!isComposerDraftSendable(input)) return Promise.resolve(false);
696
- const targetConversation = ensureConversation();
762
+ const targetConversation = input.conversationId ? Promise.resolve(input.conversationId) : ensureConversation();
697
763
  return queueDraftOperation(async () => {
698
- const savedDraft = await persistComposerDraft(input, await targetConversation);
699
- if (!savedDraft) return false;
700
- const conversationId = savedDraft.conversationId;
701
- if (isActiveConversation(conversationId) && running()) return false;
702
-
764
+ const conversationId = await targetConversation;
765
+ if (!conversationId || (isActiveConversation(conversationId) && running())) return false;
703
766
  if (isActiveConversation(conversationId)) clearErrors();
704
767
  const baseProjection = isActiveConversation(conversationId)
705
768
  ? { conversation: state.conversation, messages: [...state.messages], activeTurn: state.activeTurn }
706
769
  : (cache.get(conversationId) ?? emptyProjection());
707
-
708
- // Optimistic view renders attachments through the same marker format the server persists.
709
770
  const optimisticContent: AiUserContentPart[] = [
710
- ...savedDraft.content.flatMap((part) => {
711
- if (part.type === "text") return [{ type: "text" as const, text: part.text }];
712
- if (part.type === "file") return [{ type: "text" as const, text: aiAttachmentMarker(part) }];
713
- return [
714
- {
715
- type: "text" as const,
716
- text: aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }),
717
- },
718
- ];
719
- }),
771
+ ...(input.content ?? []),
772
+ ...(!input.content?.length && input.message ? [{ type: "text" as const, text: input.message }] : []),
773
+ ...(input.resources ?? []).map(ref => ({ type: "text" as const, text: aiResourceMarker(ref) })),
774
+ ...(input.storedFiles ?? []).map(file => ({ type: "text" as const, text: aiAttachmentMarker(file) })),
775
+ ...(input.files ?? []).map(file => ({ type: "text" as const, text: aiAttachmentMarker({ path: `/${file.name}`, mediaType: file.type, size: file.size }) })),
720
776
  ];
721
777
 
722
778
  // Optimistic: show the user message immediately.
@@ -738,39 +794,55 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
738
794
  meta: null,
739
795
  createdAt: new Date().toISOString(),
740
796
  };
741
- const optimisticProjection = { ...baseProjection, messages: [...baseProjection.messages, optimistic] };
742
- cache.set(conversationId, optimisticProjection);
797
+ setPendingSends(current => ({ ...current, [conversationId]: optimistic }));
743
798
  if (isActiveConversation(conversationId)) {
744
- setState("messages", (prev) => [...prev, optimistic]);
745
799
  setRunStatusRaw("streaming");
746
800
  }
747
801
 
748
802
  try {
803
+ const savedDraft = await persistComposerDraft(input, conversationId);
804
+ if (!savedDraft) {
805
+ clearPendingSend(conversationId);
806
+ if (isActiveConversation(conversationId)) setRunStatusRaw("failed");
807
+ return false;
808
+ }
809
+ setPendingSends(current => ({ ...current, [conversationId]: {
810
+ ...optimistic,
811
+ meta: { submittedDraftRevision: savedDraft.revision },
812
+ message: { role: "user", content: savedDraft.content.map(part => ({
813
+ type: "text", text: part.type === "text" ? part.text : part.type === "file" ? aiAttachmentMarker(part) : part.type === "project-file" ? `Project file: ${part.path}` : aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }),
814
+ })) },
815
+ } }));
749
816
  const result = await request<SubmitTurnResult>(
750
817
  `/conversations/${conversationId}/turns`,
751
818
  {
752
819
  method: "POST",
753
820
  body: JSON.stringify({
754
821
  draftRevision: savedDraft.revision,
822
+ clientToolIds: options.clientToolIds?.filter((id) => options.frontendTools?.[id]),
755
823
  modelProfileId: input.modelProfileId,
756
824
  }),
757
825
  },
758
826
  "AI request failed",
759
827
  );
828
+ const alreadyFinished = completedDuringSend.get(conversationId) === result.turn.id;
829
+ completedDuringSend.delete(conversationId);
830
+ if (alreadyFinished) clearPendingSend(conversationId);
831
+ else setPendingSends(current => ({ ...current, [conversationId]: result.message }));
760
832
  if (invalidateInactiveCache(conversationId)) return true;
761
833
  // Replace the optimistic message with the persisted one.
762
- setState("messages", (prev) => prev.map((message) => (message.id === optimistic.id ? result.message : message)));
834
+ setState("messages", (prev) => [...prev.filter(message => message.id !== result.message.id), result.message]);
763
835
  setState(
764
836
  "activeTurn",
765
837
  (current) =>
766
- current ?? {
838
+ current ?? (alreadyFinished ? null : {
767
839
  turnId: result.turn.id,
768
840
  attempt: 0,
769
841
  seq: 0,
770
842
  status: "running",
771
843
  blocks: [],
772
844
  modelProfileId: result.turn.modelProfileId,
773
- },
845
+ }),
774
846
  );
775
847
  if (state.conversation) {
776
848
  setState("conversation", "draft", {
@@ -787,6 +859,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
787
859
  }
788
860
  return true;
789
861
  } catch (sendError) {
862
+ completedDuringSend.delete(conversationId);
863
+ clearPendingSend(conversationId);
790
864
  if (invalidateInactiveCache(conversationId)) return false;
791
865
  setState("messages", (prev) => prev.filter((message) => message.id !== optimistic.id));
792
866
  cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
@@ -1157,6 +1231,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1157
1231
  conversation: () => state.conversation,
1158
1232
  setActiveConversationId,
1159
1233
  messages,
1234
+ /** Includes persisted model rounds still represented by live blocks in the timeline. */
1235
+ usageMessages: () => state.messages,
1160
1236
  activeTurn,
1161
1237
  hasMoreHistory,
1162
1238
  loadingOlder: () => isActiveConversationLoading(activeConversationId(), loadingOlderConversationId()),
@@ -1175,6 +1251,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1175
1251
  refreshActiveConversation,
1176
1252
  createConversation,
1177
1253
  saveDraft,
1254
+ queueMessage,
1178
1255
  send,
1179
1256
  steer,
1180
1257
  retrySteer,
@@ -1194,7 +1271,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1194
1271
  export type AiChatController = ReturnType<typeof createAiChatController>;
1195
1272
 
1196
1273
  const isComposerDraftSendable = (input: ComposerDraftInput): boolean =>
1197
- Boolean(input.message?.trim() || input.content?.length || input.files?.length || input.resources?.length || input.storedFiles?.length);
1274
+ Boolean(input.draftContent?.length || input.message?.trim() || input.content?.length || input.files?.length || input.resources?.length || input.storedFiles?.length);
1198
1275
 
1199
1276
  export const __aiControllerTest = {
1200
1277
  claimFrontendCall,
@@ -30,7 +30,7 @@ const bytesToBase64 = (bytes: Uint8Array): string => {
30
30
  return btoa(binary);
31
31
  };
32
32
 
33
- export const conversationFileSource = (baseUrl: string, conversationId: string): FileSource => {
33
+ export const conversationFileSource = (baseUrl: string, conversationId: string): FileSource & { listFiles(): Promise<AiFileStat[]> } => {
34
34
  const filesUrl = (suffix = "", params?: Record<string, string>) => {
35
35
  const query = params ? `?${new URLSearchParams(params).toString()}` : "";
36
36
  return `${baseUrl}/conversations/${conversationId}/files${suffix}${query}`;
@@ -45,15 +45,19 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
45
45
  return (await response.json()) as T;
46
46
  };
47
47
 
48
+ const listFiles = async (): Promise<AiFileStat[]> =>
49
+ (await request<{ files: AiFileStat[] }>(filesUrl(), { method: "GET" }, "Failed to load files")).files;
50
+
48
51
  return {
52
+ previewPreferencesKey: "assistant.csv-preview",
53
+ listFiles,
49
54
  async list(): Promise<FileTreeEntry[]> {
50
- const result = await request<{ files: AiFileStat[] }>(filesUrl(), { method: "GET" }, "Failed to load files");
51
- return result.files.map((file) => ({
55
+ return (await listFiles()).map((file) => ({
52
56
  path: file.path,
53
57
  size: file.size,
54
58
  mediaType: file.mediaType,
55
59
  updatedAt: file.updatedAt,
56
- badge: file.origin === "user" ? "upload" : undefined,
60
+ badge: file.dictationRecordedAt ? undefined : file.origin === "user" ? "upload" : undefined,
57
61
  }));
58
62
  },
59
63
 
@@ -62,7 +66,8 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
62
66
  if (!response.ok) throw new Error(await readError(response, "Failed to load file"));
63
67
  const mediaType = response.headers.get("Content-Type")?.split(";")[0]?.trim() || "application/octet-stream";
64
68
  const bytes = new Uint8Array(await response.arrayBuffer());
65
- return isTextMediaType(mediaType)
69
+ const delimited = /\.(csv|tsv)$/i.test(path) || mediaType === "text/csv" || mediaType === "text/tab-separated-values";
70
+ return isTextMediaType(mediaType) && !delimited
66
71
  ? { encoding: "utf8", content: new TextDecoder().decode(bytes), mediaType }
67
72
  : { encoding: "base64", content: bytesToBase64(bytes), mediaType };
68
73
  },
@@ -79,10 +84,11 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
79
84
  await request(filesUrl("/rename"), { method: "POST", body: JSON.stringify({ from, to }) }, "Failed to rename file");
80
85
  },
81
86
 
82
- async upload(_dirPath, files) {
87
+ async upload(dirPath, files) {
83
88
  for (const file of files) {
84
89
  const form = new FormData();
85
90
  form.append("file", file);
91
+ form.append("directory", dirPath);
86
92
  const response = await fetch(filesUrl(), { method: "POST", body: form });
87
93
  if (!response.ok) throw new Error(await readError(response, `Failed to upload ${file.name}`));
88
94
  }
@@ -63,6 +63,7 @@ export const createAiLiveConnection = (options: CreateAiLiveConnectionOptions):
63
63
 
64
64
  socket = createLiveWebSocket<AiLiveServerMessage>({
65
65
  url: options.url ?? "/api/ai/live",
66
+ activity: "always",
66
67
  initialCursor: options.initialCursor,
67
68
  subscribe: (cursor) => ({
68
69
  type: AI_LIVE_WS_TYPE.subscribe,
@@ -23,6 +23,15 @@ export const emptyProjection = (conversation: AiConversation | null = null): AiC
23
23
  activeTurn: null,
24
24
  });
25
25
 
26
+ /** A local send is an overlay, never part of a replaceable server snapshot. */
27
+ export const messagesWithPendingSend = (messages: AiStoredMessage[], pending?: AiStoredMessage): AiStoredMessage[] => {
28
+ if (!pending) return messages;
29
+ const revision = pending.meta?.submittedDraftRevision;
30
+ const confirmed = messages.some(message => message.id === pending.id ||
31
+ (revision !== undefined && message.message.role === "user" && message.meta?.submittedDraftRevision === revision));
32
+ return confirmed ? messages : [...messages, pending];
33
+ };
34
+
26
35
  const mergeMessages = (existing: AiStoredMessage[], incoming: AiStoredMessage[]): AiStoredMessage[] => {
27
36
  if (incoming.length === 0) return existing;
28
37
  const byId = new Map(existing.map((message) => [message.id, message]));
@@ -69,19 +78,16 @@ const normalizeCustomApprovalBlocks = (blocks: AiTurnBlock[]): AiTurnBlock[] =>
69
78
  return normalized;
70
79
  };
71
80
 
72
- /** Never let a same-turn DB/SSE snapshot remove blocks the browser already observed. */
81
+ /** A complete snapshot replaces its baseline; stale snapshots cannot resurrect old block IDs. */
73
82
  export const mergeActiveTurn = (previous: AiActiveTurn | null, incoming: AiActiveTurn | null): AiActiveTurn | null => {
74
83
  if (!previous || !incoming || previous.turnId !== incoming.turnId) return incoming;
75
84
  if (previous.attempt !== incoming.attempt) return incoming.attempt > previous.attempt ? incoming : previous;
76
-
77
- const incomingIsNewer = incoming.seq >= previous.seq;
78
- const blocks = incomingIsNewer ? overlayBlocks(previous.blocks, incoming.blocks) : overlayBlocks(incoming.blocks, previous.blocks);
79
- return {
80
- ...(incomingIsNewer ? incoming : previous),
81
- seq: Math.max(previous.seq, incoming.seq),
82
- blocks,
83
- status: deriveStatus(blocks),
84
- };
85
+ if (incoming.seq < previous.seq) return previous;
86
+ // Locally submitted steering may not have reached the snapshot yet.
87
+ const pendingSteers = previous.blocks.filter(block => block.kind === "steer_message" && block.status !== "consumed");
88
+ const known = new Set(incoming.blocks.map(block => block.id));
89
+ const blocks = [...incoming.blocks, ...pendingSteers.filter(block => !known.has(block.id))];
90
+ return { ...incoming, blocks, status: deriveStatus(blocks) };
85
91
  };
86
92
 
87
93
  export const reconcileActiveTurnActions = (
@@ -141,7 +147,7 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
141
147
  const active = state.activeTurn;
142
148
 
143
149
  if (event.type === "turn_started") {
144
- if (active && active.turnId === event.turnId && event.attempt < active.attempt) return state;
150
+ if (active && active.turnId === event.turnId && !isNewerWireEvent(event, active)) return state;
145
151
  const pendingSteers =
146
152
  active?.turnId === event.turnId ? active.blocks.filter((block) => block.kind === "steer_message" && block.status !== "consumed") : [];
147
153
  const blocks = event.blocks ? overlayBlocks(normalizeCustomApprovalBlocks(event.blocks), pendingSteers) : pendingSteers;
@@ -163,6 +169,11 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
163
169
  return { ...state, messages: mergeMessages(state.messages, event.messages ?? []), activeTurn: null };
164
170
  }
165
171
 
172
+ if (event.type === "message_saved") {
173
+ if (!active || active.turnId !== event.turnId || !isNewerWireEvent(event, active)) return state;
174
+ return { ...state, messages: mergeMessages(state.messages, [event.message]), activeTurn: { ...active, seq: event.seq, attempt: event.attempt } };
175
+ }
176
+
166
177
  // block_set / block_delta
167
178
  if (!active || active.turnId !== event.turnId) return state;
168
179
  if (!isNewerWireEvent(event, active)) return state;
@@ -21,6 +21,9 @@ const CONNECT_TIMEOUT_MS = 10_000;
21
21
  export async function* parseAiSse(response: Response, signal: AbortSignal): AsyncGenerator<AiStreamEvent> {
22
22
  const reader = response.body?.getReader();
23
23
  if (!reader) return;
24
+ const cancel = () => { void reader.cancel().catch(() => undefined); };
25
+ signal.addEventListener("abort", cancel, {once:true});
26
+ if (signal.aborted) cancel();
24
27
  const decoder = new TextDecoder();
25
28
  let buffer = "";
26
29
  try {
@@ -40,6 +43,7 @@ export async function* parseAiSse(response: Response, signal: AbortSignal): Asyn
40
43
  }
41
44
  }
42
45
  } finally {
46
+ signal.removeEventListener("abort", cancel);
43
47
  await reader.cancel().catch(() => undefined);
44
48
  }
45
49
  }
@@ -0,0 +1,110 @@
1
+ import { type Context, Hono } from "hono";
2
+ import { z } from "zod";
3
+ import { readBoundedJson } from "../_internal/bounded-json";
4
+ import { dispatchCapability } from "../api/capabilities";
5
+ import { dispatchCapabilityStream } from "../api/capability-streams";
6
+ import { CAPABILITY_MAX_REQUEST_BYTES } from "../contracts/capabilities";
7
+ import { type AuthContext, getLocale, type RequestAuthority } from "../server";
8
+ import { requireInvocation } from "../server/middleware/invocation";
9
+ import { codeCapabilityOperation } from "./code-capability-transport";
10
+ import { aiConversations } from "./store";
11
+
12
+ const Input = z
13
+ .object({ input: z.unknown() })
14
+ .strict()
15
+ .refine((value) => Object.hasOwn(value, "input"));
16
+ const denied = () => Response.json({ code: "ACCESS_DENIED", message: "Code execution is no longer authorized" }, { status: 403 });
17
+
18
+ /** Core-only continuation of a foreground code turn, not a general invocation exchange. */
19
+ export function createCodeCapabilityRoutes(
20
+ dependencies: {
21
+ invocation?: Parameters<typeof requireInvocation>[1];
22
+ store?: Pick<typeof aiConversations, "getConversation" | "getActiveTurn" | "getTurnRunConfig">;
23
+ dispatch?: typeof dispatchCapability;
24
+ stream?: typeof dispatchCapabilityStream;
25
+ } = {},
26
+ ) {
27
+ const store = dependencies.store ?? aiConversations;
28
+ // This Core-only exchange resumes the verified foreground user turn. Ordinary
29
+ // app invocations remain non-exchangeable; this is not an interactive cookie.
30
+ const foregroundTurnAuthority = (c: Context<AuthContext>): RequestAuthority => ({
31
+ actor: c.get("actor"),
32
+ accessSubject: c.get("accessSubject"),
33
+ credentialKind: "session",
34
+ scopes: [],
35
+ });
36
+ const invoke = async (c: Context<AuthContext>, review = false) => {
37
+ const body = await readBoundedJson(c.req.raw, CAPABILITY_MAX_REQUEST_BYTES);
38
+ const input = body.ok ? Input.safeParse(body.data) : null;
39
+ if (!input?.success) return Response.json({ code: "VALIDATION_FAILED", message: "Invalid capability input" }, { status: 400 });
40
+ const conversation = await store.getConversation({ conversationId: c.req.param("conversationId")! });
41
+ const appId = c.req.param("appId")!;
42
+ const capabilityId = c.req.param("capabilityId")!;
43
+ if (conversation?.allowedTools && !conversation.allowedTools.includes(`${appId}.${capabilityId}`)) return denied();
44
+ return (dependencies.dispatch ?? dispatchCapability)({
45
+ request: c.req.raw,
46
+ authority: foregroundTurnAuthority(c),
47
+ origin: "assistant",
48
+ continuation: codeCapabilityOperation(c.req.param("conversationId")!, c.req.param("turnId")!),
49
+ review,
50
+ kind: review || c.req.param("kind") === "actions" ? "actions" : "queries",
51
+ appId,
52
+ capabilityId,
53
+ input: input.data.input,
54
+ locale: getLocale(c),
55
+ });
56
+ };
57
+ return new Hono<AuthContext>()
58
+ .use("/:conversationId/:turnId/*", async (c, next) => {
59
+ await next();
60
+ c.header("Cache-Control", "no-store");
61
+ })
62
+ .use(
63
+ "/:conversationId/:turnId/*",
64
+ requireInvocation((c) => {
65
+ const conversationId = z.uuid().safeParse(c.req.param("conversationId"));
66
+ const turnId = z.uuid().safeParse(c.req.param("turnId"));
67
+ return conversationId.success && turnId.success
68
+ ? { targetAppId: "core", operation: codeCapabilityOperation(conversationId.data, turnId.data), schemaHash: null }
69
+ : null;
70
+ }, dependencies.invocation),
71
+ )
72
+ .use("/:conversationId/:turnId/*", async (c, next) => {
73
+ const actor = c.get("actor");
74
+ if (actor.kind !== "user" || actor.delegation?.callingAppId !== "assistant" || actor.delegation.credentialKind !== "session")
75
+ return denied();
76
+ const conversationId = c.req.param("conversationId")!;
77
+ const turnId = c.req.param("turnId")!;
78
+ const [conversation, active, config] = await Promise.all([
79
+ store.getConversation({ conversationId, ownerUserId: actor.user.id }),
80
+ store.getActiveTurn({ conversationId }),
81
+ store.getTurnRunConfig({ conversationId, turnId }),
82
+ ]);
83
+ if (
84
+ !conversation ||
85
+ conversation.createdByUserId !== actor.user.id ||
86
+ conversation.archivedAt ||
87
+ !active ||
88
+ active.turn.id !== turnId ||
89
+ active.turn.cancelRequestedAt ||
90
+ !["running", "waiting_for_action"].includes(active.turn.status) ||
91
+ !config ||
92
+ config.kind === "compact" ||
93
+ config.background ||
94
+ config.mandate
95
+ )
96
+ return denied();
97
+ await next();
98
+ })
99
+ .post("/:conversationId/:turnId/capabilities/v1/actions/:appId/:capabilityId/review", (c) => invoke(c, true))
100
+ .post("/:conversationId/:turnId/capabilities/v1/:kind{queries|actions}/:appId/:capabilityId", (c) => invoke(c))
101
+ .post("/:conversationId/:turnId/capabilities/v1/streams/:verb", (c) =>
102
+ (dependencies.stream ?? dispatchCapabilityStream)(c.req.raw, foregroundTurnAuthority(c), c.req.param("verb"), {
103
+ continuation: codeCapabilityOperation(c.req.param("conversationId")!, c.req.param("turnId")!),
104
+ allow: async ({ appId, capabilityId }) => {
105
+ const conversation = await store.getConversation({ conversationId: c.req.param("conversationId")! });
106
+ return !!conversation && (!conversation.allowedTools || conversation.allowedTools.includes(`${appId}.${capabilityId}`));
107
+ },
108
+ }),
109
+ );
110
+ }
@@ -0,0 +1,38 @@
1
+ import type { CapabilityCaller } from "../capabilities/server";
2
+
3
+ export const CODE_CAPABILITY_TOKEN_HEADER = "x-cloud-code-capability-token";
4
+ export const codeCapabilityOperation = (conversationId: string, turnId: string) => `code-capabilities:${conversationId}:${turnId}`;
5
+ export const codeCapabilityPath = (conversationId: string, turnId: string) => `/api/_internal/ai/code/${conversationId}/${turnId}`;
6
+
7
+ /** Framework-owned callback; tokens stay in the host, never the sandbox or tool arguments. */
8
+ export function createCodeCapabilityTransport(
9
+ current: () => { conversationId: string; turnId: string; token: string },
10
+ ): NonNullable<CapabilityCaller["transport"]> {
11
+ const configured = process.env.CLOUD_CORE_INTERNAL_ORIGIN;
12
+ if (!configured) throw new Error("CLOUD_CORE_INTERNAL_ORIGIN is required for the code host");
13
+ const origin = new URL(configured);
14
+ if (!["http:", "https:"].includes(origin.protocol)) throw new Error("Invalid Core origin");
15
+ return async (request) => {
16
+ const target = new URL(origin);
17
+ const context = current();
18
+ if (!context.token) throw new Error("Code capability authority is unavailable");
19
+ const source = new URL(request.url);
20
+ if (!source.pathname.startsWith("/api/capabilities/v1/")) throw new Error("Invalid code capability route");
21
+ target.pathname = codeCapabilityPath(context.conversationId, context.turnId) + source.pathname.slice("/api".length);
22
+ target.search = "";
23
+ const headers = new Headers(request.headers);
24
+ headers.delete("cookie");
25
+ headers.set("authorization", `Bearer ${context.token}`);
26
+ return fetch(
27
+ new Request(target, {
28
+ method: request.method,
29
+ headers,
30
+ body: request.body,
31
+ signal: request.signal,
32
+ redirect: "error",
33
+ // @ts-expect-error Streaming server request bodies require duplex.
34
+ duplex: request.body ? "half" : undefined,
35
+ }),
36
+ );
37
+ };
38
+ }