@opengeni/api-router 3.0.1 → 3.1.2

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 (546) hide show
  1. package/dist/access-grant-rls.d.ts +8 -1
  2. package/dist/admission-trace.d.ts +8 -0
  3. package/dist/app.d.ts +50 -3
  4. package/dist/app.js +10 -1
  5. package/dist/artifact-catalog-cursor.d.ts +1 -0
  6. package/dist/attached-browser-viewer.d.ts +11 -0
  7. package/dist/auth/managed-auth-attempt-context.d.ts +2 -0
  8. package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
  9. package/dist/auth/managed-auth-session-adapter.d.ts +9 -1
  10. package/dist/auth/managed-auth.d.ts +72 -5
  11. package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
  12. package/dist/auth/new-signups-gate.d.ts +23 -0
  13. package/dist/auth/signup-funnel-metrics.d.ts +44 -0
  14. package/dist/azure-live.d.ts +28 -0
  15. package/dist/browser-action-fences.d.ts +13 -0
  16. package/dist/browser-controller-authority.d.ts +1 -61
  17. package/dist/browser-ephemeral.d.ts +5 -0
  18. package/dist/browser-lost-runtime-end.d.ts +24 -0
  19. package/dist/browser-state-authority.d.ts +1 -27
  20. package/dist/chunk-OGQDXVXY.js +18 -0
  21. package/dist/chunk-OGQDXVXY.js.map +1 -0
  22. package/dist/{chunk-AJ62T5QN.js → chunk-OT56OY4V.js} +80503 -39869
  23. package/dist/chunk-OT56OY4V.js.map +1 -0
  24. package/dist/claude-default-models.d.ts +19 -0
  25. package/dist/claude-subscription-core.d.ts +50 -0
  26. package/dist/claude-subscription-oauth.d.ts +36 -0
  27. package/dist/claude-subscription-profile.d.ts +9 -0
  28. package/dist/claude-subscription-usage.d.ts +6 -0
  29. package/dist/claude-workspace-connection.d.ts +15 -0
  30. package/dist/codemode.d.ts +40 -5
  31. package/dist/codex-realtime.d.ts +29 -3
  32. package/dist/codex-session-pointers.d.ts +19 -0
  33. package/dist/connection-ownership.d.ts +6 -3
  34. package/dist/controller-data-plane.d.ts +16 -0
  35. package/dist/credit-promotion-snapshot.d.ts +6 -0
  36. package/dist/editable-artifact-source-session.d.ts +3 -0
  37. package/dist/editable-artifact-websocket.d.ts +5 -3
  38. package/dist/fatal-process-boundary.d.ts +9 -1
  39. package/dist/github-access.d.ts +7 -1
  40. package/dist/github-browser-flow.d.ts +1 -1
  41. package/dist/http/acting-person.d.ts +36 -0
  42. package/dist/http/api-contract-compat.d.ts +19 -0
  43. package/dist/http/api-error.d.ts +50 -1
  44. package/dist/http/auth.d.ts +8 -1
  45. package/dist/http/cors.d.ts +12 -0
  46. package/dist/http/deprecation.d.ts +42 -0
  47. package/dist/http/keyed-admission.d.ts +12 -0
  48. package/dist/http/local-browser-boundary.d.ts +72 -0
  49. package/dist/http/metrics-listener.d.ts +22 -0
  50. package/dist/http/path-identifier.d.ts +17 -0
  51. package/dist/http/registered-route-label.d.ts +16 -0
  52. package/dist/http/rejection-telemetry.d.ts +34 -0
  53. package/dist/http/request-body.d.ts +27 -0
  54. package/dist/http/request-source.d.ts +41 -3
  55. package/dist/http/sse.d.ts +12 -1
  56. package/dist/http/unmatched-route.d.ts +18 -0
  57. package/dist/http/user-content.d.ts +36 -0
  58. package/dist/http/variable-sets-removed.d.ts +41 -0
  59. package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
  60. package/dist/index.js +1279 -108
  61. package/dist/index.js.map +1 -1
  62. package/dist/integration-connect-metrics.d.ts +35 -0
  63. package/dist/integration-endpoint-test.d.ts +20 -0
  64. package/dist/integrations/api-integrations.d.ts +2 -2
  65. package/dist/integrations/atlassian.d.ts +8 -3
  66. package/dist/integrations/fiken.d.ts +3 -1
  67. package/dist/integrations/github-lens-connect.d.ts +37 -66
  68. package/dist/integrations/google-drive.d.ts +2 -0
  69. package/dist/integrations/integration-commit-authority.d.ts +13 -0
  70. package/dist/integrations/marketplace-plugin.d.ts +43 -43
  71. package/dist/integrations/oauth-client.d.ts +171 -12
  72. package/dist/integrations/oauth-profiles.d.ts +42 -13
  73. package/dist/integrations/onepassword-vault-provider.d.ts +9 -0
  74. package/dist/integrations/slack-bot.d.ts +285 -7
  75. package/dist/integrations/slack-file-upload-flow.d.ts +62 -0
  76. package/dist/integrations/slack-interactions.d.ts +89 -10
  77. package/dist/integrations/slack-mrkdwn.d.ts +41 -0
  78. package/dist/integrations/slack-routing.d.ts +1 -1
  79. package/dist/integrations/slack-session-defaults.d.ts +58 -0
  80. package/dist/integrations/slack-task-file-upload.d.ts +36 -0
  81. package/dist/integrations/slack-tool-review.d.ts +5 -0
  82. package/dist/interaction-agent-access.d.ts +13 -8
  83. package/dist/interaction-agent-version.d.ts +21 -0
  84. package/dist/interaction-holder-heartbeat.d.ts +5 -0
  85. package/dist/interaction-metrics.d.ts +27 -2
  86. package/dist/interaction-route-stages.d.ts +25 -0
  87. package/dist/interaction-secrets-vault.d.ts +20 -0
  88. package/dist/interaction-secrets.d.ts +103 -0
  89. package/dist/mcp/action-catalog-types.d.ts +14 -0
  90. package/dist/mcp/action-catalog.gen.d.ts +2 -0
  91. package/dist/mcp/action-descriptions.d.ts +4 -0
  92. package/dist/mcp/action-schema-imports.gen.d.ts +1 -0
  93. package/dist/mcp/capability-account-readiness.d.ts +7 -0
  94. package/dist/mcp/child-read-evidence.d.ts +9 -0
  95. package/dist/mcp/company-profile-agent-admin.d.ts +12 -0
  96. package/dist/mcp/contract-input.d.ts +13 -0
  97. package/dist/mcp/documents.d.ts +2 -0
  98. package/dist/mcp/first-party-tool-permissions.d.ts +3 -606
  99. package/dist/mcp/notification-tools.d.ts +26 -0
  100. package/dist/mcp/orchestration-failure-diagnostic.d.ts +43 -0
  101. package/dist/mcp/scheduled-task-input.d.ts +1309 -0
  102. package/dist/mcp/scheduled-task-view.d.ts +217 -3
  103. package/dist/mcp/secret-tools.d.ts +14 -0
  104. package/dist/mcp/server.d.ts +108 -5
  105. package/dist/mcp/session-admin-access-tools.d.ts +33 -0
  106. package/dist/mcp/session-event-errors.d.ts +28 -0
  107. package/dist/mcp/session-event-view.d.ts +62 -0
  108. package/dist/mcp/session-view.d.ts +15 -0
  109. package/dist/mcp/session-wait.d.ts +18 -0
  110. package/dist/mcp/site-agent-clamp-tools.d.ts +38 -0
  111. package/dist/mcp/site-data-tools.d.ts +22 -0
  112. package/dist/mcp/site-publication-tools.d.ts +19 -0
  113. package/dist/mcp/site-sql-tools.d.ts +27 -0
  114. package/dist/mcp-oauth.d.ts +37 -4
  115. package/dist/model-calls/chat-completions.d.ts +87 -0
  116. package/dist/model-calls/pending-usage-settlements.d.ts +57 -0
  117. package/dist/model-calls/service.d.ts +36 -0
  118. package/dist/model-catalog.d.ts +9 -3
  119. package/dist/native-push-dispatch.d.ts +69 -0
  120. package/dist/observability.d.ts +3 -1
  121. package/dist/organization-mcp.d.ts +82 -0
  122. package/dist/prepared-mcp-actor.d.ts +77 -0
  123. package/dist/prepared-mcp-permissions.d.ts +8 -0
  124. package/dist/routes/analytics-consent.d.ts +26 -0
  125. package/dist/routes/api-integrations.d.ts +11 -0
  126. package/dist/routes/api-keys.d.ts +7 -7
  127. package/dist/routes/automations.d.ts +2 -0
  128. package/dist/routes/billing.d.ts +59 -0
  129. package/dist/routes/browser-sessions.d.ts +65 -2
  130. package/dist/routes/chat-completions.d.ts +11 -0
  131. package/dist/routes/claude-core-usage.d.ts +20 -0
  132. package/dist/routes/claude-subscription-accounts.d.ts +3 -0
  133. package/dist/routes/claude-subscription-oauth.d.ts +3 -0
  134. package/dist/routes/client-errors.d.ts +182 -0
  135. package/dist/routes/codex-core.d.ts +401 -0
  136. package/dist/routes/codex.d.ts +64 -2
  137. package/dist/routes/computer-sessions.d.ts +6 -1
  138. package/dist/routes/desktop-distribution.d.ts +32 -0
  139. package/dist/routes/github-browser-fonts.gen.d.ts +2 -0
  140. package/dist/routes/github-browser-pages.d.ts +27 -0
  141. package/dist/routes/inbox.d.ts +13 -0
  142. package/dist/routes/insights-response-cache.d.ts +10 -0
  143. package/dist/routes/insights-usage.d.ts +12 -0
  144. package/dist/routes/insights.d.ts +28 -0
  145. package/dist/routes/interaction-placement-message.d.ts +7 -0
  146. package/dist/routes/managed-sign-in-methods.d.ts +6 -0
  147. package/dist/routes/model-account-choice.d.ts +13 -0
  148. package/dist/routes/model-account-settings.d.ts +19 -0
  149. package/dist/routes/native-app-auth.d.ts +12 -0
  150. package/dist/routes/organization-integration-policy.d.ts +8 -0
  151. package/dist/routes/organization-integrations.d.ts +6 -0
  152. package/dist/routes/organization-mcp-connections.d.ts +3 -0
  153. package/dist/routes/organization-model-defaults.d.ts +9 -0
  154. package/dist/routes/organization-service-accounts.d.ts +3 -0
  155. package/dist/routes/personal-api-keys.d.ts +3 -0
  156. package/dist/routes/session-admin-access.d.ts +3 -0
  157. package/dist/routes/session-artifact-associations.d.ts +4 -0
  158. package/dist/routes/session-history-imports.d.ts +20 -0
  159. package/dist/routes/sessions.d.ts +38 -2
  160. package/dist/routes/site-data.d.ts +18 -0
  161. package/dist/routes/site-publishing.d.ts +5 -0
  162. package/dist/routes/site-runtime.d.ts +31 -0
  163. package/dist/routes/site-sql.d.ts +16 -0
  164. package/dist/routes/slack-bot-access.d.ts +3 -0
  165. package/dist/routes/subscription-account-pools.d.ts +21 -0
  166. package/dist/routes/subscription-core-http.d.ts +5 -0
  167. package/dist/routes/subscription-pool-access.d.ts +37 -0
  168. package/dist/routes/subscription-pool-core.d.ts +153 -0
  169. package/dist/routes/supergrok.d.ts +6 -6
  170. package/dist/routes/usage-allowances.d.ts +12 -0
  171. package/dist/routes/variable-sets-removed.d.ts +2 -0
  172. package/dist/routes/vault-providers.d.ts +34 -0
  173. package/dist/routes/vaults.d.ts +3 -0
  174. package/dist/routes/workspace-integrations.d.ts +35 -0
  175. package/dist/routes/workspace-model-providers.d.ts +4 -0
  176. package/dist/routes/workspaces.d.ts +11 -2
  177. package/dist/sandbox/agent-update.d.ts +64 -0
  178. package/dist/sandbox/auth-callout.d.ts +8 -0
  179. package/dist/sandbox/channel-a.d.ts +15 -1
  180. package/dist/sandbox/enrollment.d.ts +26 -4
  181. package/dist/sandbox/machine-audience.d.ts +29 -0
  182. package/dist/sandbox/machines.d.ts +15 -2
  183. package/dist/sandbox/metrics-ingestion.d.ts +6 -2
  184. package/dist/sandbox/screen-control.d.ts +85 -0
  185. package/dist/sandbox/viewer.d.ts +5 -1
  186. package/dist/sandbox-file-artifacts.d.ts +17 -1
  187. package/dist/scheduled-task-blockers.d.ts +39 -0
  188. package/dist/scheduled-task-deletion.d.ts +3 -4
  189. package/dist/scheduled-task-lifecycle.d.ts +14 -0
  190. package/dist/secret-answers.d.ts +40 -0
  191. package/dist/session-create-observability.d.ts +5 -0
  192. package/dist/session-event-view-36GO3IL7.js +528 -0
  193. package/dist/session-event-view-36GO3IL7.js.map +1 -0
  194. package/dist/signed-state-secret.d.ts +18 -0
  195. package/dist/site-agent-guard.d.ts +53 -0
  196. package/dist/site-data/operations.d.ts +103 -0
  197. package/dist/site-data/service.d.ts +203 -0
  198. package/dist/site-frame-guard.d.ts +32 -0
  199. package/dist/site-publishing/agent-data-tools.d.ts +84 -0
  200. package/dist/site-publishing/agent-model.d.ts +58 -0
  201. package/dist/site-publishing/agent-session.d.ts +16 -0
  202. package/dist/site-publishing/content-scanner.d.ts +31 -0
  203. package/dist/site-publishing/edge-sync.d.ts +48 -0
  204. package/dist/site-publishing/gateway-signature.d.ts +56 -0
  205. package/dist/site-publishing/limits.d.ts +63 -0
  206. package/dist/site-publishing/page-tool-authority.d.ts +30 -0
  207. package/dist/site-publishing/platform-usage.d.ts +113 -0
  208. package/dist/site-publishing/platform.d.ts +21 -0
  209. package/dist/site-publishing/readiness.d.ts +62 -0
  210. package/dist/site-publishing/runtime.d.ts +179 -0
  211. package/dist/site-publishing/service.d.ts +264 -0
  212. package/dist/site-publishing/session-events.d.ts +22 -0
  213. package/dist/site-publishing/session-views.d.ts +36 -0
  214. package/dist/site-publishing/viewer.d.ts +40 -0
  215. package/dist/site-session-origin.d.ts +31 -6
  216. package/dist/site-sql/format.d.ts +47 -0
  217. package/dist/site-sql/gateway.d.ts +50 -0
  218. package/dist/site-sql/service.d.ts +213 -0
  219. package/dist/temporal-schedule-sync.d.ts +26 -0
  220. package/dist/transcription/billing-refusal.d.ts +8 -0
  221. package/dist/transcription/normalize.d.ts +23 -0
  222. package/dist/transcription/providers/azure-mai.d.ts +12 -0
  223. package/dist/transcription/providers/azure-openai.d.ts +4 -0
  224. package/dist/transcription/providers/openai.d.ts +16 -0
  225. package/dist/transcription/providers/subscription-core.d.ts +39 -0
  226. package/dist/transcription/service.d.ts +10 -1
  227. package/dist/transcription/wav-duration.d.ts +8 -0
  228. package/dist/workspace-tool-gateway-attestations.d.ts +37 -0
  229. package/dist/workspace-tool-gateway-observability.d.ts +16 -0
  230. package/dist/workspace-tool-gateway-secrets.d.ts +27 -0
  231. package/dist/workspace-tool-gateway.d.ts +102 -6
  232. package/dist/workspace-tool-target-fence.d.ts +116 -0
  233. package/dist/workspace-tool-target.d.ts +225 -0
  234. package/dist/workspace-webhook-dispatch.d.ts +26 -0
  235. package/dist/xai-subscription-core.d.ts +60 -0
  236. package/package.json +25 -20
  237. package/src/access-grant-rls.ts +64 -14
  238. package/src/admission-trace.ts +14 -0
  239. package/src/app.ts +1684 -252
  240. package/src/artifact-catalog-cursor.ts +1 -0
  241. package/src/attached-browser-viewer.ts +26 -0
  242. package/src/auth/managed-auth-attempt-context.ts +9 -0
  243. package/src/auth/managed-auth-rate-limits.ts +252 -0
  244. package/src/auth/managed-auth-session-adapter.ts +64 -29
  245. package/src/auth/managed-auth.ts +303 -35
  246. package/src/auth/managed-email.ts +18 -1
  247. package/src/auth/managed-sign-in-notifications.ts +81 -0
  248. package/src/auth/new-signups-gate.ts +49 -0
  249. package/src/auth/organization-user-setup.ts +3 -3
  250. package/src/auth/signup-funnel-metrics.ts +164 -0
  251. package/src/azure-live.ts +103 -0
  252. package/src/browser-action-fences.ts +45 -0
  253. package/src/browser-controller-authority.ts +1 -186
  254. package/src/browser-ephemeral.ts +39 -0
  255. package/src/browser-lost-runtime-end.ts +71 -0
  256. package/src/browser-state-authority.ts +1 -236
  257. package/src/claude-default-models.ts +37 -0
  258. package/src/claude-subscription-core.ts +154 -0
  259. package/src/claude-subscription-oauth.ts +352 -0
  260. package/src/claude-subscription-profile.ts +73 -0
  261. package/src/claude-subscription-usage.ts +51 -0
  262. package/src/claude-workspace-connection.ts +82 -0
  263. package/src/codemode.ts +236 -39
  264. package/src/codex-realtime.ts +181 -53
  265. package/src/codex-session-pointers.ts +80 -0
  266. package/src/company-brain-okf.ts +1 -1
  267. package/src/connected-machine-computer-access.ts +2 -1
  268. package/src/connection-ownership.ts +19 -13
  269. package/src/controller-data-plane.ts +34 -0
  270. package/src/credit-promotion-snapshot.ts +36 -0
  271. package/src/editable-artifact-production.ts +10 -0
  272. package/src/editable-artifact-source-session.ts +123 -0
  273. package/src/editable-artifact-websocket.ts +16 -3
  274. package/src/fatal-process-boundary.ts +67 -3
  275. package/src/gateway-realtime.ts +12 -8
  276. package/src/github-access.ts +31 -5
  277. package/src/github-browser-flow.ts +1 -1
  278. package/src/http/acting-person.ts +105 -0
  279. package/src/http/api-contract-compat.ts +49 -0
  280. package/src/http/api-error.ts +210 -2
  281. package/src/http/auth.ts +68 -4
  282. package/src/http/cors.ts +37 -5
  283. package/src/http/deprecation.ts +118 -0
  284. package/src/http/keyed-admission.ts +29 -0
  285. package/src/http/local-browser-boundary.ts +343 -0
  286. package/src/http/metrics-listener.ts +57 -0
  287. package/src/http/path-identifier.ts +54 -0
  288. package/src/http/registered-route-label.ts +34 -0
  289. package/src/http/rejection-telemetry.ts +120 -0
  290. package/src/http/request-body.ts +127 -0
  291. package/src/http/request-source.ts +154 -13
  292. package/src/http/sse.ts +88 -28
  293. package/src/http/unmatched-route.ts +60 -0
  294. package/src/http/user-content.ts +158 -0
  295. package/src/http/variable-sets-removed.ts +122 -0
  296. package/src/http/websocket-upgrade-dispatch.ts +29 -0
  297. package/src/index.ts +107 -57
  298. package/src/integration-connect-metrics.ts +228 -0
  299. package/src/integration-endpoint-test.ts +291 -0
  300. package/src/integrations/api-integrations.ts +81 -7
  301. package/src/integrations/atlassian.ts +202 -83
  302. package/src/integrations/fiken.ts +66 -38
  303. package/src/integrations/github-app-connect.ts +64 -18
  304. package/src/integrations/github-lens-connect.ts +8 -29
  305. package/src/integrations/google-drive.ts +448 -152
  306. package/src/integrations/integration-commit-authority.ts +69 -0
  307. package/src/integrations/marketplace-plugin.ts +1 -1
  308. package/src/integrations/oauth-client.ts +733 -193
  309. package/src/integrations/oauth-profiles.ts +116 -45
  310. package/src/integrations/onepassword-vault-provider.ts +16 -0
  311. package/src/integrations/personal-github-repositories.ts +1 -1
  312. package/src/integrations/personal-github.ts +61 -35
  313. package/src/integrations/provider-oauth.ts +67 -85
  314. package/src/integrations/slack-app-home.ts +5 -5
  315. package/src/integrations/slack-bot.ts +893 -201
  316. package/src/integrations/slack-file-upload-flow.ts +147 -0
  317. package/src/integrations/slack-install.ts +7 -7
  318. package/src/integrations/slack-interactions.ts +1589 -299
  319. package/src/integrations/slack-mrkdwn.ts +193 -0
  320. package/src/integrations/slack-routing.ts +2 -2
  321. package/src/integrations/slack-session-defaults.ts +224 -0
  322. package/src/integrations/slack-task-file-upload.ts +323 -0
  323. package/src/integrations/slack-tool-review.ts +65 -0
  324. package/src/integrations/social-oauth.ts +90 -58
  325. package/src/interaction-agent-access.ts +40 -20
  326. package/src/interaction-agent-version.ts +85 -0
  327. package/src/interaction-holder-heartbeat.ts +24 -8
  328. package/src/interaction-metrics.ts +164 -2
  329. package/src/interaction-route-stages.ts +94 -0
  330. package/src/interaction-secrets-vault.ts +213 -0
  331. package/src/interaction-secrets.ts +426 -0
  332. package/src/mcp/action-catalog-types.ts +14 -0
  333. package/src/mcp/action-catalog.gen.ts +8014 -0
  334. package/src/mcp/action-descriptions.ts +299 -0
  335. package/src/mcp/action-schema-imports.gen.ts +304 -0
  336. package/src/mcp/capability-account-readiness.ts +17 -0
  337. package/src/mcp/child-read-evidence.ts +19 -0
  338. package/src/mcp/company-profile-agent-admin.ts +13 -0
  339. package/src/mcp/contract-input.ts +165 -0
  340. package/src/mcp/documents.ts +9 -2
  341. package/src/mcp/editable-artifacts.ts +29 -11
  342. package/src/mcp/files.ts +5 -1
  343. package/src/mcp/first-party-tool-permissions.ts +7 -285
  344. package/src/mcp/knowledge-entries.ts +108 -34
  345. package/src/mcp/notification-tools.ts +480 -0
  346. package/src/mcp/orchestration-failure-diagnostic.ts +43 -0
  347. package/src/mcp/scheduled-task-input.ts +89 -0
  348. package/src/mcp/scheduled-task-view.ts +63 -3
  349. package/src/mcp/secret-tools.ts +185 -0
  350. package/src/mcp/server.ts +2213 -1116
  351. package/src/mcp/session-admin-access-tools.ts +163 -0
  352. package/src/mcp/session-event-errors.ts +123 -0
  353. package/src/mcp/session-event-view.ts +395 -13
  354. package/src/mcp/session-view.ts +3 -0
  355. package/src/mcp/session-wait.ts +67 -7
  356. package/src/mcp/site-agent-clamp-tools.ts +140 -0
  357. package/src/mcp/site-data-tools.ts +339 -0
  358. package/src/mcp/site-publication-tools.ts +201 -0
  359. package/src/mcp/site-sql-tools.ts +427 -0
  360. package/src/mcp-oauth.ts +635 -45
  361. package/src/model-calls/chat-completions.ts +352 -0
  362. package/src/model-calls/pending-usage-settlements.ts +125 -0
  363. package/src/model-calls/service.ts +694 -0
  364. package/src/model-catalog.ts +68 -17
  365. package/src/native-push-dispatch.ts +457 -0
  366. package/src/observability.ts +15 -1
  367. package/src/organization-mcp.ts +485 -0
  368. package/src/prepared-mcp-actor.ts +154 -0
  369. package/src/prepared-mcp-permissions.ts +38 -0
  370. package/src/routes/analytics-consent.ts +123 -0
  371. package/src/routes/api-integrations.ts +268 -9
  372. package/src/routes/api-keys.ts +164 -25
  373. package/src/routes/artifact-catalog.ts +111 -0
  374. package/src/routes/automations.ts +163 -90
  375. package/src/routes/billing.ts +535 -59
  376. package/src/routes/browser-sessions.ts +966 -342
  377. package/src/routes/canonical-human-identities.ts +61 -0
  378. package/src/routes/capabilities.ts +112 -5
  379. package/src/routes/channels.ts +4 -3
  380. package/src/routes/chat-completions.ts +235 -0
  381. package/src/routes/claude-core-usage.ts +147 -0
  382. package/src/routes/claude-subscription-accounts.ts +233 -0
  383. package/src/routes/claude-subscription-oauth.ts +175 -0
  384. package/src/routes/client-errors.ts +394 -0
  385. package/src/routes/codex-core.ts +687 -0
  386. package/src/routes/codex.ts +1277 -1119
  387. package/src/routes/company-brain.ts +4 -0
  388. package/src/routes/computer-sessions.ts +319 -85
  389. package/src/routes/connect.ts +277 -175
  390. package/src/routes/connections.ts +690 -363
  391. package/src/routes/desktop-distribution.ts +217 -0
  392. package/src/routes/documents.ts +13 -7
  393. package/src/routes/editable-artifacts.ts +74 -4
  394. package/src/routes/enrollments.ts +110 -16
  395. package/src/routes/external-identity-links.ts +13 -12
  396. package/src/routes/files.ts +41 -16
  397. package/src/routes/github-browser-fonts.gen.ts +12 -0
  398. package/src/routes/github-browser-pages.ts +258 -0
  399. package/src/routes/github.ts +200 -75
  400. package/src/routes/inbox.ts +353 -0
  401. package/src/routes/insights-response-cache.ts +83 -0
  402. package/src/routes/insights-usage.ts +345 -0
  403. package/src/routes/insights.ts +103 -8
  404. package/src/routes/install.ts +51 -10
  405. package/src/routes/integration-facets.ts +70 -28
  406. package/src/routes/interaction-placement-message.ts +12 -0
  407. package/src/routes/knowledge.ts +136 -27
  408. package/src/routes/machines.ts +189 -135
  409. package/src/routes/managed-auth-session-sets.ts +43 -10
  410. package/src/routes/managed-onboarding.ts +39 -19
  411. package/src/routes/managed-sign-in-methods.ts +433 -0
  412. package/src/routes/memory-slack-publications.ts +3 -2
  413. package/src/routes/model-account-choice.ts +194 -0
  414. package/src/routes/model-account-settings.ts +235 -0
  415. package/src/routes/model-connection-access.ts +210 -48
  416. package/src/routes/native-app-auth.ts +296 -0
  417. package/src/routes/organization-integration-policy.ts +116 -0
  418. package/src/routes/organization-integrations.ts +326 -0
  419. package/src/routes/organization-mcp-connections.ts +183 -0
  420. package/src/routes/organization-memberships.ts +205 -19
  421. package/src/routes/organization-model-defaults.ts +110 -0
  422. package/src/routes/organization-model-providers.ts +74 -3
  423. package/src/routes/organization-service-accounts.ts +128 -0
  424. package/src/routes/organization-sessions.ts +21 -5
  425. package/src/routes/personal-api-keys.ts +184 -0
  426. package/src/routes/personal-github-git-broker.ts +2 -2
  427. package/src/routes/personal-github.ts +9 -4
  428. package/src/routes/plugins.ts +46 -7
  429. package/src/routes/pr-review-github.ts +105 -118
  430. package/src/routes/pr-review.ts +88 -82
  431. package/src/routes/preference-registry.ts +18 -0
  432. package/src/routes/rigs.ts +48 -13
  433. package/src/routes/scheduled-tasks.ts +347 -27
  434. package/src/routes/session-admin-access.ts +271 -0
  435. package/src/routes/session-artifact-associations.ts +164 -0
  436. package/src/routes/session-history-imports.ts +305 -0
  437. package/src/routes/sessions.ts +1682 -363
  438. package/src/routes/site-data.ts +340 -0
  439. package/src/routes/site-publishing.ts +584 -0
  440. package/src/routes/site-runtime.ts +1944 -0
  441. package/src/routes/site-sql.ts +263 -0
  442. package/src/routes/skill-content.ts +72 -7
  443. package/src/routes/skills.ts +5 -4
  444. package/src/routes/slack-bot-access.ts +108 -0
  445. package/src/routes/social.ts +27 -15
  446. package/src/routes/subscription-account-pools.ts +209 -0
  447. package/src/routes/subscription-core-http.ts +18 -0
  448. package/src/routes/subscription-pool-access.ts +180 -0
  449. package/src/routes/subscription-pool-core.ts +552 -0
  450. package/src/routes/supergrok.ts +174 -655
  451. package/src/routes/transcription-recordings.ts +71 -4
  452. package/src/routes/transcriptions.ts +26 -2
  453. package/src/routes/usage-allowances.ts +381 -0
  454. package/src/routes/variable-sets-removed.ts +19 -0
  455. package/src/routes/vault-providers.ts +782 -0
  456. package/src/routes/vaults.ts +421 -0
  457. package/src/routes/video-generation.ts +30 -12
  458. package/src/routes/workspace-artifacts.ts +165 -53
  459. package/src/routes/workspace-integrations.ts +576 -0
  460. package/src/routes/workspace-model-providers.ts +148 -0
  461. package/src/routes/workspace-state.ts +4 -0
  462. package/src/routes/workspaces.ts +625 -117
  463. package/src/sandbox/agent-update.ts +315 -0
  464. package/src/sandbox/auth-callout.ts +104 -21
  465. package/src/sandbox/channel-a.ts +165 -55
  466. package/src/sandbox/enrollment.ts +131 -22
  467. package/src/sandbox/machine-audience.ts +192 -0
  468. package/src/sandbox/machines.ts +75 -31
  469. package/src/sandbox/metrics-ingestion.ts +133 -2
  470. package/src/sandbox/rematerialize.ts +51 -21
  471. package/src/sandbox/screen-control.ts +289 -0
  472. package/src/sandbox/viewer.ts +33 -58
  473. package/src/sandbox-file-artifacts.ts +53 -27
  474. package/src/scheduled-task-blockers.ts +293 -0
  475. package/src/scheduled-task-deletion.ts +13 -3
  476. package/src/scheduled-task-lifecycle.ts +84 -0
  477. package/src/secret-answers.ts +119 -0
  478. package/src/session-create-observability.ts +46 -0
  479. package/src/session-realtime-context.ts +26 -4
  480. package/src/signed-state-secret.ts +58 -0
  481. package/src/site-agent-guard.ts +261 -0
  482. package/src/site-data/operations.ts +777 -0
  483. package/src/site-data/service.ts +1043 -0
  484. package/src/site-frame-guard.ts +107 -0
  485. package/src/site-publishing/agent-data-tools.ts +250 -0
  486. package/src/site-publishing/agent-model.ts +127 -0
  487. package/src/site-publishing/agent-session.ts +148 -0
  488. package/src/site-publishing/content-scanner.ts +93 -0
  489. package/src/site-publishing/edge-sync.ts +148 -0
  490. package/src/site-publishing/gateway-signature.ts +198 -0
  491. package/src/site-publishing/limits.ts +315 -0
  492. package/src/site-publishing/page-tool-authority.ts +111 -0
  493. package/src/site-publishing/platform-usage.ts +479 -0
  494. package/src/site-publishing/platform.ts +98 -0
  495. package/src/site-publishing/readiness.ts +330 -0
  496. package/src/site-publishing/runtime.ts +831 -0
  497. package/src/site-publishing/service.ts +1282 -0
  498. package/src/site-publishing/session-events.ts +262 -0
  499. package/src/site-publishing/session-views.ts +262 -0
  500. package/src/site-publishing/viewer.ts +75 -0
  501. package/src/site-session-origin.ts +91 -6
  502. package/src/site-sql/format.ts +134 -0
  503. package/src/site-sql/gateway.ts +168 -0
  504. package/src/site-sql/service.ts +1636 -0
  505. package/src/site-uploads.ts +27 -5
  506. package/src/temporal-schedule-sync.ts +74 -0
  507. package/src/transcription/billing-refusal.ts +14 -0
  508. package/src/transcription/normalize.ts +77 -0
  509. package/src/transcription/providers/azure-mai.ts +98 -0
  510. package/src/transcription/providers/azure-openai.ts +16 -7
  511. package/src/transcription/providers/codex-subscription.ts +190 -67
  512. package/src/transcription/providers/openai.ts +79 -5
  513. package/src/transcription/providers/subscription-core.ts +125 -0
  514. package/src/transcription/providers/xai-subscription.ts +81 -83
  515. package/src/transcription/segmenter.ts +7 -1
  516. package/src/transcription/service.ts +239 -10
  517. package/src/transcription/wav-duration.ts +39 -0
  518. package/src/workspace-deletion.ts +25 -1
  519. package/src/workspace-tool-gateway-attestations.ts +133 -0
  520. package/src/workspace-tool-gateway-observability.ts +64 -0
  521. package/src/workspace-tool-gateway-secrets.ts +104 -0
  522. package/src/workspace-tool-gateway.ts +921 -103
  523. package/src/workspace-tool-target-fence.ts +380 -0
  524. package/src/workspace-tool-target.ts +554 -0
  525. package/src/workspace-webhook-dispatch.ts +225 -0
  526. package/src/xai-realtime.ts +85 -81
  527. package/src/xai-subscription-core.ts +172 -0
  528. package/dist/browser-auth-broker.d.ts +0 -20
  529. package/dist/chunk-AJ62T5QN.js.map +0 -1
  530. package/dist/connection-authority-owner.d.ts +0 -59
  531. package/dist/routes/connection-authorities.d.ts +0 -3
  532. package/dist/routes/environments.d.ts +0 -5
  533. package/dist/routes/host-mcp-bindings.d.ts +0 -5
  534. package/dist/routes/host-mcp-resolvers.d.ts +0 -3
  535. package/dist/routes/packs.d.ts +0 -3
  536. package/dist/session-event-view-MU3HMNV3.js +0 -264
  537. package/dist/session-event-view-MU3HMNV3.js.map +0 -1
  538. package/dist/xai-subscription-auth.d.ts +0 -23
  539. package/src/browser-auth-broker.ts +0 -155
  540. package/src/connection-authority-owner.ts +0 -61
  541. package/src/routes/connection-authorities.ts +0 -139
  542. package/src/routes/environments.ts +0 -388
  543. package/src/routes/host-mcp-bindings.ts +0 -122
  544. package/src/routes/host-mcp-resolvers.ts +0 -41
  545. package/src/routes/packs.ts +0 -784
  546. package/src/xai-subscription-auth.ts +0 -139
@@ -1,7 +1,66 @@
1
+ import {
2
+ legacyVariableSetFields,
3
+ rejectLegacyVariableSetFields,
4
+ variableSetsRemovedResponse,
5
+ withoutEmptyLegacyVariableSetFields,
6
+ } from "../http/variable-sets-removed";
7
+ import { AZURE_LIVE_MODEL_ID, buildSessionAzureLiveBroker } from "../azure-live";
1
8
  import { getRetainedProviderCommand } from "@opengeni/db/retained-provider-commands";
2
- import { scheduledSessionIds } from "@opengeni/db";
3
- import { withSiteSessionOrigin } from "@opengeni/core";
4
- import { resolveSiteSessionOrigin } from "../site-session-origin";
9
+ import { assertGoalResumeAllowed, GoalResumeBlockedError } from "@opengeni/core";
10
+ import {
11
+ agentConfigDeploymentPolicy,
12
+ resolveFirstPartyMcpToolPolicy,
13
+ UnsupportedLatencyModeError,
14
+ } from "@opengeni/config";
15
+ import { siteAgentDataToolNames, siteAgentSiteSqlOn } from "../site-publishing/agent-data-tools";
16
+ import { searchSessionMessagesForSubject, SessionMessageSearchCursorError } from "@opengeni/db";
17
+ import { isVaultError, vaultErrorFromDatabase } from "@opengeni/db";
18
+ import { listSessionEventSlices } from "@opengeni/db/session-event-slices";
19
+ import * as sessionPreviewSchema from "@opengeni/db/schema";
20
+ import { and, eq, sql } from "drizzle-orm";
21
+ import {
22
+ FIRST_PARTY_MCP_TOOL_CAPABILITIES,
23
+ FirstPartyMcpToolName,
24
+ resolveWorkspaceAgentDefaults,
25
+ SessionMessageSearchRequest,
26
+ type AgentCapabilities,
27
+ type ToolRef,
28
+ } from "@opengeni/contracts";
29
+ import {
30
+ SITE_FRAME_SESSION_CREATE_FIELDS,
31
+ siteFrameBodyRefusal,
32
+ } from "@opengeni/contracts/site-session-http";
33
+ import { requireWorkspace, scheduledSessionIds } from "@opengeni/db";
34
+ import {
35
+ currentSiteSessionOrigin,
36
+ isVerifiedDelegatedHumanAuthorization,
37
+ withSiteSessionOrigin,
38
+ } from "@opengeni/core";
39
+ import { freezeSessionRealtimeConnectionAccounts } from "@opengeni/core";
40
+ import {
41
+ createRealtimeVoiceBilling,
42
+ RealtimeVoiceUnavailableError,
43
+ TranscriptionBillingRefusedError,
44
+ } from "@opengeni/core";
45
+ import type { CreditDebitAttribution, SessionForkPointRefusal } from "@opengeni/db";
46
+ import { creditDebitAttributionForGrant } from "../access-grant-rls";
47
+ import { transcriptionBillingRefusal } from "../transcription/billing-refusal";
48
+ import { resolveSiteSessionOrigin, withOptionalSiteCommandOrigin } from "../site-session-origin";
49
+ import { SandboxRecoveryRequest } from "@opengeni/contracts";
50
+ import { getManagedHumanSandboxRecovery, consentManagedHumanSandboxRecovery } from "@opengeni/core";
51
+ import { SandboxRecoveryConflictError } from "@opengeni/db";
52
+ import { codexAccountJson } from "./codex";
53
+ import {
54
+ deliverSubscriptionCoreCodexWake,
55
+ getSubscriptionCoreSessionCodexAccounts,
56
+ pinSubscriptionCoreSessionCodexAccount,
57
+ } from "@opengeni/db";
58
+ import { codexRouteDisposition } from "./codex-core";
59
+ import {
60
+ loadCodexSessionPointerProjection,
61
+ type CodexSessionPointerProjection,
62
+ } from "../codex-session-pointers";
63
+ import { getToolActionReview, getToolReviewDetailsPage } from "@opengeni/db";
5
64
  import {
6
65
  AcknowledgeStreamRequest,
7
66
  ApplySessionGoalRevisionRequest,
@@ -43,6 +102,7 @@ import {
43
102
  RenewSessionRealtimeRequest,
44
103
  SyncSessionRealtimeLedgerRequest,
45
104
  SessionControlRequest,
105
+ SessionRetryRequest,
46
106
  SESSION_EVENT_RAW_DELTA_TYPES,
47
107
  SessionEventPayloadMode,
48
108
  SessionEventReadDirection,
@@ -64,13 +124,18 @@ import {
64
124
  UpdateSessionChannelRequest,
65
125
  UpdateSessionAttentionRequest,
66
126
  UpdateSessionArchiveRequest,
127
+ UpdateSessionRetentionRequest,
128
+ SetSessionUsageLimitRequest,
67
129
  UpdateSessionPinRequest,
68
130
  UpdateSessionGoalRequest,
69
131
  UpdateSessionMcpApprovalPolicyRequest,
70
132
  UpdateSessionRequest,
71
- UpdateSessionVariableSetsRequest,
72
133
  UpdateSessionVisibilityRequest,
134
+ SessionVisibility,
73
135
  UpdateSessionToolPolicyRequest,
136
+ UpdateSessionAgentRequest,
137
+ UpdateSessionSkillsRequest,
138
+ AgentConfigError,
74
139
  ViewerHeartbeatRequest,
75
140
  WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
76
141
  WORK_CLAIM_CANONICAL_KEY_MAX_BYTES,
@@ -98,7 +163,8 @@ import {
98
163
  type TerminalPtyExitedPayload,
99
164
  type TerminalPtyOutputDeltaPayload,
100
165
  type TerminalPtyStartedPayload,
101
- type VariableSet,
166
+ type VaultApiError,
167
+ VAULT_ERROR_HTTP_STATUS,
102
168
  } from "@opengeni/contracts";
103
169
  import { streamTokenDegraded } from "@opengeni/config";
104
170
  import {
@@ -112,6 +178,7 @@ import {
112
178
  getLiveEnrollmentConnection,
113
179
  getRetainedProcess,
114
180
  getSandbox,
181
+ getPublishedSiteVersion,
115
182
  getSession,
116
183
  readActiveSandbox,
117
184
  deleteSessionTreeIfQuiescent,
@@ -131,18 +198,18 @@ import {
131
198
  listSessionGoalRevisions,
132
199
  rejectSessionGoalRevisionWithEvent,
133
200
  listSessionIdsInGroup,
201
+ countAllSessionsInGroup,
134
202
  listSessionDiscoverySummaries,
135
203
  listSessionDiscoveryAncestorPaths,
136
204
  listSessionsForSubject,
205
+ listSessionEntriesForSubject,
137
206
  getLatestStartedSessionTurn,
138
207
  listSessionTurns,
139
208
  projectEffectiveControlForRelatedAccess,
140
209
  projectSessionForRelatedAccess,
141
210
  recordStreamAcknowledgment,
142
211
  requestSessionCompaction,
143
- switchSessionCodexAccount,
144
212
  setSessionChannel,
145
- updateSessionVariableSets,
146
213
  ChannelNotFoundError,
147
214
  setSessionAttention,
148
215
  setSessionArchive,
@@ -160,6 +227,7 @@ import {
160
227
  upsertSessionGoalWithEvent,
161
228
  updatePtySessionActivity,
162
229
  QueueCommandConflictError,
230
+ PersonalSessionInitiatorRequiredError,
163
231
  beginSessionRealtimeInTransaction,
164
232
  activateSessionRealtimeConnectionInTransaction,
165
233
  claimSessionRealtimeConnectionInTransaction,
@@ -173,17 +241,20 @@ import {
173
241
  SessionToolPolicyVersionConflictError,
174
242
  SessionContextBusyError,
175
243
  SessionVariableSetSelectionUnavailableError,
244
+ SessionCreateConnectionSelectionUnavailableError,
176
245
  workspaceControlRequestLockTimeoutMs,
177
246
  SessionTenancyAccessError,
178
247
  SessionTenancyConflictError,
179
248
  SessionTenancyInvalidRequestError,
180
249
  SessionTenancyNotActivatedError,
250
+ SessionVisibilityWorkConflictError,
181
251
  HumanInputResponseValidationError,
182
252
  SkillHumanResponseError,
183
253
  latestWorkspaceCapture,
184
254
  sessionLatestWorkspaceCapture,
185
255
  renewSessionRealtimeInTransaction,
186
256
  syncSessionRealtimeLedgerInTransaction,
257
+ setSessionKeepLive,
187
258
  withWorkspaceSessionActivityRls,
188
259
  withWorkspaceRls,
189
260
  workspaceCaptureAtRevision,
@@ -257,6 +328,7 @@ import {
257
328
  type ResolvedSessionAuthorization,
258
329
  } from "@opengeni/core";
259
330
  import type { ApiRouteDeps } from "@opengeni/core";
331
+ import { SessionRetryConflictError } from "@opengeni/db";
260
332
  import {
261
333
  attachViewer,
262
334
  detachViewer,
@@ -273,7 +345,9 @@ import {
273
345
  import { buildSessionCodexRealtimeBroker, CodexRealtimeBrokerError } from "../codex-realtime";
274
346
  import {
275
347
  acceptSessionUserMessage,
348
+ validateSessionMcpCredentialUpdates,
276
349
  controlHumanSessionWorkstream,
350
+ retryFailedSession,
277
351
  createSessionForRequest,
278
352
  deleteHumanQueuePrompt,
279
353
  editHumanQueuePrompt,
@@ -291,16 +365,29 @@ import {
291
365
  submitComposerDraftForRequest,
292
366
  updateSessionMcpApprovalPolicy,
293
367
  updateManagedHumanSessionVisibility,
368
+ previewManagedHumanSessionVisibilityChange,
294
369
  updateSessionToolPolicy,
370
+ updateSessionAgent,
371
+ updateSessionSkills,
295
372
  updateSessionTitle,
373
+ setSessionRetention,
296
374
  workflowIdForSession,
297
375
  sessionWithEffectiveToolPolicy,
298
- workspaceSessionToolPolicyDefaultServerIds,
299
- workspaceSessionToolPolicyServerIds,
376
+ workspaceSessionEffectiveToolsContext,
377
+ workspaceSessionToolPolicyContext,
300
378
  relayConfigFromSettings,
301
- validateVariableSetAttachment,
379
+ getSessionUsageLimitForAccess,
380
+ SessionUsageLimitHttpError,
381
+ sessionUsageLimitForRead,
382
+ setSessionUsageLimitForAccess,
302
383
  } from "@opengeni/core";
303
384
  import { assertSessionExists, boundedLimit } from "../http/common";
385
+ import { isAgentActingAsPerson } from "../http/acting-person";
386
+ import {
387
+ humanSecretWriteContext,
388
+ prepareSecretAnswers,
389
+ splitSecretAnswers,
390
+ } from "../secret-answers";
304
391
  import { browserSseDeliveryOptions, sseSessionStream } from "../http/sse";
305
392
  import {
306
393
  serveWorkspaceCapture,
@@ -310,6 +397,9 @@ import {
310
397
  import { publishSandboxFileArtifact } from "../sandbox-file-artifacts";
311
398
  import { ApiHttpError } from "../http/api-error";
312
399
  import { observeWorkDiscovery, summarizeWorkDiscoveryRows } from "../work-discovery-observability";
400
+ import { recordAcceptedApiAdmission } from "../admission-trace";
401
+ import { parseRequestBody, parseRequestJson } from "../http/request-body";
402
+ import { measureSessionCreatePhase } from "../session-create-observability";
313
403
 
314
404
  type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
315
405
 
@@ -350,6 +440,66 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
350
440
  observability: deps.observability,
351
441
  };
352
442
  const workspaceCaptureManifestCache = new WorkspaceCaptureManifestCache();
443
+ const realtimeVoiceBilling = createRealtimeVoiceBilling({ db, settings });
444
+ /** Credit refusal / unavailable deployment voice as an HTTP response, else null. */
445
+ const realtimeVoiceRefusalResponse = (c: Context, error: unknown): Response | null => {
446
+ if (error instanceof TranscriptionBillingRefusedError) {
447
+ return transcriptionBillingRefusal(c, error);
448
+ }
449
+ if (error instanceof RealtimeVoiceUnavailableError) {
450
+ return c.json(
451
+ {
452
+ error: {
453
+ status: 409,
454
+ code: "realtime_voice_unavailable",
455
+ message: error.message,
456
+ retryable: false,
457
+ details: { reason: error.code },
458
+ },
459
+ },
460
+ 409,
461
+ );
462
+ }
463
+ return null;
464
+ };
465
+ /**
466
+ * Bill every started minute observed so far for a deployment-funded call.
467
+ * Owner attribution only when the caller is the voice owner. A settlement
468
+ * failure is logged and retried idempotently on the next heartbeat.
469
+ */
470
+ const settleRealtimeVoice = async (input: {
471
+ grant: { subjectId: string };
472
+ attribution: CreditDebitAttribution;
473
+ workspaceId: string;
474
+ sessionId: string;
475
+ realtimeId: string;
476
+ }) => {
477
+ try {
478
+ return await realtimeVoiceBilling.settle({
479
+ workspaceId: input.workspaceId,
480
+ sessionId: input.sessionId,
481
+ realtimeId: input.realtimeId,
482
+ callerSubjectId: input.grant.subjectId,
483
+ attribution: input.attribution,
484
+ });
485
+ } catch (error) {
486
+ console.error("realtime voice settlement failed", {
487
+ workspaceId: input.workspaceId,
488
+ sessionId: input.sessionId,
489
+ realtimeId: input.realtimeId,
490
+ error: error instanceof Error ? error.message : String(error),
491
+ });
492
+ return null;
493
+ }
494
+ };
495
+ const withSiteCommandOrigin = <T>(c: Context, grant: AccessGrant, run: () => Promise<T>) =>
496
+ withOptionalSiteCommandOrigin(
497
+ db,
498
+ grant,
499
+ c.req.header("x-opengeni-site-id"),
500
+ c.req.header("x-opengeni-site-version"),
501
+ run,
502
+ );
353
503
  const ptyIdentity = (pty: SandboxOpenPtySessionRow): SandboxPtyProcessIdentity => ({
354
504
  leaseId: pty.leaseId,
355
505
  sandboxGroupId: pty.sandboxGroupId,
@@ -477,7 +627,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
477
627
  });
478
628
 
479
629
  // Every deployment has one fail-closed authorization seam for every HTTP
480
- // session surface. The core boundary always enforces durable OpenGeni-owned
630
+ // session surface. The core boundary always enforces durable Opengeni-owned
481
631
  // private-session rules; an embedding host port can add narrower policy.
482
632
  // Register it before the routes so a newly added path cannot accidentally
483
633
  // inherit workspace access without an explicit operation classification. The
@@ -557,16 +707,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
557
707
 
558
708
  app.post("/v1/workspaces/:workspaceId/sessions", async (c) => {
559
709
  const workspaceId = c.req.param("workspaceId");
560
- const authorization = await requireAccessGrantAuthorization(
561
- c,
562
- deps,
563
- workspaceId,
564
- "sessions:create",
710
+ const authorization = await measureSessionCreatePhase(deps.observability, "authorization", () =>
711
+ requireAccessGrantAuthorization(c, deps, workspaceId, "sessions:create"),
565
712
  );
566
713
  const grant = authorization.grant;
567
714
  let payload: unknown;
568
715
  try {
569
- payload = await c.req.json();
716
+ payload = await measureSessionCreatePhase(deps.observability, "body_read", () =>
717
+ c.req.json(),
718
+ );
570
719
  } catch {
571
720
  return c.json(
572
721
  {
@@ -578,23 +727,93 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
578
727
  }
579
728
  let session: Session;
580
729
  try {
730
+ const legacyFields = legacyVariableSetFields(payload);
731
+ if (legacyFields.length > 0) return variableSetsRemovedResponse(c, 422, legacyFields);
732
+ payload = withoutEmptyLegacyVariableSetFields(payload);
581
733
  CreateSessionRequest.parse(payload);
582
- const origin = await resolveSiteSessionOrigin(
583
- db,
584
- workspaceId,
585
- c.req.header("x-opengeni-site-id"),
586
- c.req.header("x-opengeni-site-version"),
734
+ // only a person present in the app or the host's server sets a
735
+ // task usage limit; an agent acting for a person cannot, even null.
736
+ if (
737
+ (payload as { usageLimitMicros?: unknown }).usageLimitMicros !== undefined &&
738
+ isAgentActingAsPerson(c)
739
+ ) {
740
+ throw new SessionUsageLimitHttpError(
741
+ 403,
742
+ "session_usage_limit_forbidden",
743
+ "Only a person or the application's server can set a task usage limit.",
744
+ );
745
+ }
746
+ const origin = await measureSessionCreatePhase(deps.observability, "site_origin", () =>
747
+ resolveSiteSessionOrigin(
748
+ db,
749
+ grant,
750
+ c.req.header("x-opengeni-site-id"),
751
+ c.req.header("x-opengeni-site-version"),
752
+ // A frame without a published Site (inline HTML, a preview) is
753
+ // recorded as that frame, so its later requests reach this session.
754
+ {
755
+ marker: c.req.header("x-opengeni-site-frame"),
756
+ frameId: c.req.header("x-opengeni-site-frame-id"),
757
+ },
758
+ ),
587
759
  );
760
+ // A Site page may start a conversation with a message and a model, and
761
+ // nothing else: the session gets the minimal Site profile. Only the Site
762
+ // itself, running its published agent as `site:<siteId>`, chooses tools.
763
+ const siteOrigin = origin ?? currentSiteSessionOrigin();
764
+ if (siteOrigin && grant.subjectId !== `site:${siteOrigin.siteId}`) {
765
+ const refused = siteFrameBodyRefusal("sessions.create", "/sessions", payload);
766
+ if (refused)
767
+ return c.json(
768
+ {
769
+ code: "SITE_SESSION_TOOLS_FORBIDDEN",
770
+ message:
771
+ "A Site page can start a session with a message and a model only, not choose its tools, servers, connections, permissions, vaults, sandbox, resources, Skills or learning",
772
+ details: { code: "site_frame_field_forbidden", field: refused },
773
+ },
774
+ 403,
775
+ );
776
+ payload = await siteFrameSessionProfile(deps, workspaceId, payload, {
777
+ accountId: grant.accountId,
778
+ siteId: siteOrigin.siteId,
779
+ versionId: siteOrigin.versionId ?? null,
780
+ });
781
+ }
588
782
  const create = () =>
589
783
  createSessionForRequest(deps, grant, workspaceId, payload, authorization);
590
- session = await (origin ? withSiteSessionOrigin(origin, create) : create());
784
+ session = await measureSessionCreatePhase(deps.observability, "core_create", () =>
785
+ origin ? withSiteSessionOrigin(origin, create) : create(),
786
+ );
591
787
  } catch (error) {
592
788
  return sessionCreateErrorResponse(c, error);
593
789
  }
790
+ // A creator may exempt its new session from the idle-session archive (a
791
+ // persistent agent, for example). Creation committed; this only marks it.
792
+ if ((payload as { keepLive?: unknown }).keepLive === true) {
793
+ const kept = await setSessionKeepLive(db, {
794
+ workspaceId,
795
+ sessionId: session.id,
796
+ keepLive: true,
797
+ });
798
+ if (kept.status === "updated") session = { ...session, retention: kept.retention };
799
+ }
800
+ // Echo a requested usage limit so clients can tell this server applied it
801
+ // (older servers strip the unknown field and would run unlimited).
802
+ if ((payload as { usageLimitMicros?: unknown }).usageLimitMicros !== undefined) {
803
+ const usageLimit = await sessionUsageLimitForRead(db, session, {
804
+ subjectId: grant.subjectId,
805
+ });
806
+ if (usageLimit) session = { ...session, usageLimit };
807
+ }
594
808
  // Creation has committed by this point. Keep response projection outside
595
809
  // the create-rejection boundary so a post-commit policy read cannot be
596
810
  // misreported as though the session itself was rejected.
597
- return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session), 202);
811
+ return c.json(
812
+ await measureSessionCreatePhase(deps.observability, "response_projection", () =>
813
+ withEffectivePolicy(deps, workspaceId, grant.subjectId, session),
814
+ ),
815
+ 202,
816
+ );
598
817
  });
599
818
 
600
819
  app.get("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
@@ -681,6 +900,59 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
681
900
  }
682
901
  });
683
902
 
903
+ app.get("/v1/workspaces/:workspaceId/session-message-search", async (c) => {
904
+ const workspaceId = c.req.param("workspaceId");
905
+ const authorization = await requireAccessGrantAuthorization(
906
+ c,
907
+ deps,
908
+ workspaceId,
909
+ "sessions:read",
910
+ );
911
+ const grant = authorization.grant;
912
+ // A single-session filter is not authority: both searches use the complete
913
+ // live host/agent list scope, plus ordinary private/archive/RLS boundaries.
914
+ let authorizationScope;
915
+ try {
916
+ authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "http");
917
+ } catch (error) {
918
+ throw sessionAuthorizationHttpError(error);
919
+ }
920
+ const raw = c.req.query();
921
+ const parsed = SessionMessageSearchRequest.safeParse({
922
+ ...raw,
923
+ ...(raw.limit !== undefined ? { limit: Number(raw.limit) } : {}),
924
+ });
925
+ if (!parsed.success)
926
+ throw new HTTPException(400, { message: "Invalid session message search request" });
927
+ try {
928
+ return c.json(
929
+ await searchSessionMessagesForSubject(
930
+ db,
931
+ workspaceId,
932
+ parsed.data,
933
+ {
934
+ subjectId: grant.subjectId,
935
+ ...(authorizationScope ? { authorizationScope } : {}),
936
+ personalWorkspaceOwnerException: hasVerifiedOwningUserAuthorization(authorization),
937
+ },
938
+ { signal: c.req.raw.signal },
939
+ ),
940
+ );
941
+ } catch (error) {
942
+ if (error instanceof SessionListAccessError)
943
+ throw new HTTPException(403, { message: error.message });
944
+ if (error instanceof SessionMessageSearchCursorError)
945
+ throw new HTTPException(400, { message: error.message });
946
+ // Clients are told to cancel superseded searches, so an abort here is a
947
+ // routine client disconnect, never a server failure. Match the existing
948
+ // client-gone convention (mcp/request-abort.ts): 499 with an empty body,
949
+ // which a departed client never reads and monitors never count as 5xx.
950
+ if (c.req.raw.signal.aborted || (error instanceof Error && error.name === "AbortError"))
951
+ return new Response(null, { status: 499 });
952
+ throw error;
953
+ }
954
+ });
955
+
684
956
  app.get("/v1/workspaces/:workspaceId/sessions", async (c) => {
685
957
  const workspaceId = c.req.param("workspaceId");
686
958
  const authorization = await requireAccessGrantAuthorization(
@@ -698,31 +970,51 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
698
970
  }
699
971
  const pageView = c.req.query("view") === "page";
700
972
  const query = sessionListQuery(c.req.query(), pageView);
701
- let page: Awaited<ReturnType<typeof listSessionsForSubject>>;
973
+ const projection = c.req.query("projection");
974
+ if (projection !== undefined && projection !== "summary")
975
+ throw new HTTPException(400, { message: 'projection must be "summary"' });
976
+ if (projection && !pageView)
977
+ throw new HTTPException(400, { message: 'summary projection requires view="page"' });
978
+ const summary = projection === "summary";
979
+ let page: Awaited<
980
+ ReturnType<typeof listSessionsForSubject | typeof listSessionEntriesForSubject>
981
+ >;
702
982
  try {
703
- page = await listSessionsForSubject(db, workspaceId, {
704
- subjectId: grant.subjectId,
705
- limit: boundedLimit(query.limit),
706
- materializeSnapshot: pageView,
707
- ...(query.cursor ? { cursor: query.cursor } : {}),
708
- ...(query.search ? { search: query.search } : {}),
709
- ...(query.pinsOnly ? { pinsOnly: true } : {}),
710
- ...(query.archivedOnly ? { archivedOnly: true } : {}),
711
- ...(query.parentSessionId !== undefined ? { parentSessionId: query.parentSessionId } : {}),
712
- ...(query.channelId !== undefined ? { channelId: query.channelId } : {}),
713
- ...(query.originSiteId ? { originSiteId: query.originSiteId } : {}),
714
- ...(query.createdBy ? { createdBy: query.createdBy } : {}),
715
- ...(query.updatedFrom ? { updatedFrom: query.updatedFrom } : {}),
716
- ...(query.updatedBefore ? { updatedBefore: query.updatedBefore } : {}),
717
- ...(query.createdFrom ? { createdFrom: query.createdFrom } : {}),
718
- ...(query.createdBefore ? { createdBefore: query.createdBefore } : {}),
719
- ...(query.scopeSubjectId ? { scopeSubjectId: query.scopeSubjectId } : {}),
720
- ...(authorizationScope ? { authorizationScope } : {}),
721
- // A managed human's own personal workspace has no membership row, so
722
- // the list's removal fence must fall back to the organization-membership
723
- // pointer, only with verified native or external owning-user provenance.
724
- personalWorkspaceOwnerException: hasVerifiedOwningUserAuthorization(authorization),
725
- });
983
+ page = await (summary ? listSessionEntriesForSubject : listSessionsForSubject)(
984
+ db,
985
+ workspaceId,
986
+ {
987
+ subjectId: grant.subjectId,
988
+ limit: boundedLimit(query.limit),
989
+ materializeSnapshot: pageView,
990
+ ...(query.cursor ? { cursor: query.cursor } : {}),
991
+ ...(query.search ? { search: query.search } : {}),
992
+ ...(query.pinsOnly ? { pinsOnly: true } : {}),
993
+ ...(query.includeTotals ? { includeTotals: true } : {}),
994
+ ...(query.needsYouOnly ? { needsYouOnly: true } : {}),
995
+ ...(query.contentArchivedOnly ? { contentArchivedOnly: true } : {}),
996
+ ...(query.includePinned === false ? { includePinned: false } : {}),
997
+ ...(query.archivedOnly ? { archivedOnly: true } : {}),
998
+ ...(query.sortBy ? { sortBy: query.sortBy } : {}),
999
+ ...(query.archiveStatus ? { archiveStatus: query.archiveStatus } : {}),
1000
+ ...(query.parentSessionId !== undefined
1001
+ ? { parentSessionId: query.parentSessionId }
1002
+ : {}),
1003
+ ...(query.channelId !== undefined ? { channelId: query.channelId } : {}),
1004
+ ...(query.originSiteId ? { originSiteId: query.originSiteId } : {}),
1005
+ ...(query.createdBy ? { createdBy: query.createdBy } : {}),
1006
+ ...(query.updatedFrom ? { updatedFrom: query.updatedFrom } : {}),
1007
+ ...(query.updatedBefore ? { updatedBefore: query.updatedBefore } : {}),
1008
+ ...(query.createdFrom ? { createdFrom: query.createdFrom } : {}),
1009
+ ...(query.createdBefore ? { createdBefore: query.createdBefore } : {}),
1010
+ ...(query.scopeSubjectId ? { scopeSubjectId: query.scopeSubjectId } : {}),
1011
+ ...(authorizationScope ? { authorizationScope } : {}),
1012
+ // A managed human's own personal workspace has no membership row, so
1013
+ // the list's removal fence must fall back to the organization-membership
1014
+ // pointer, only with verified native or external owning-user provenance.
1015
+ personalWorkspaceOwnerException: hasVerifiedOwningUserAuthorization(authorization),
1016
+ },
1017
+ );
726
1018
  } catch (error) {
727
1019
  if (error instanceof SessionListAccessError) {
728
1020
  throw new HTTPException(403, { message: error.message });
@@ -743,7 +1035,6 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
743
1035
  // body for older clients while still making its older-pin omission visible
744
1036
  // to raw HTTP consumers without changing that response shape.
745
1037
  c.header("x-opengeni-pinned-truncated", page.pinnedTruncated === true ? "true" : "false");
746
- const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
747
1038
  const commandActivity = await backgroundCommandActivityForSessions(db, {
748
1039
  accountId: grant.accountId,
749
1040
  workspaceId,
@@ -758,17 +1049,34 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
758
1049
  [...page.pinned, ...page.sessions].map((session) => session.id),
759
1050
  )
760
1051
  : new Set<string>();
1052
+ if ("projection" in page) {
1053
+ const decorateEntry = (session: (typeof page.sessions)[number]) => ({
1054
+ ...session,
1055
+ hasSchedules: scheduleTargets.has(session.id),
1056
+ ...(commandActivity.has(session.id)
1057
+ ? { backgroundCommandActivity: commandActivity.get(session.id)! }
1058
+ : {}),
1059
+ });
1060
+ return c.json({
1061
+ ...page,
1062
+ ...(query.hasPageFilters ? { filtersApplied: true as const } : {}),
1063
+ ...(query.originSiteId ? { originSiteId: query.originSiteId } : {}),
1064
+ pinned: page.pinned.map(decorateEntry),
1065
+ sessions: page.sessions.map(decorateEntry),
1066
+ });
1067
+ }
1068
+ const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId, [
1069
+ ...page.pinned,
1070
+ ...page.sessions,
1071
+ ]);
761
1072
  const decorate = (session: Session): Session => {
762
1073
  const activity = commandActivity.get(session.id);
763
- return sessionWithEffectiveToolPolicy(
764
- {
765
- ...session,
766
- hasSchedules: scheduleTargets.has(session.id),
767
- ...(activity ? { backgroundCommandActivity: activity } : {}),
768
- },
769
- policy.workspaceServerIds,
770
- policy.workspaceDefaultServerIds,
771
- );
1074
+ const decorated = {
1075
+ ...session,
1076
+ hasSchedules: scheduleTargets.has(session.id),
1077
+ ...(activity ? { backgroundCommandActivity: activity } : {}),
1078
+ };
1079
+ return sessionResponse(decorated, policy);
772
1080
  };
773
1081
  if (pageView) {
774
1082
  return c.json({
@@ -937,6 +1245,48 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
937
1245
  }
938
1246
  });
939
1247
 
1248
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/tool-reviews/:approvalId", async (c) => {
1249
+ const workspaceId = c.req.param("workspaceId");
1250
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1251
+ const input = {
1252
+ accountId: grant.accountId,
1253
+ workspaceId,
1254
+ sessionId: c.req.param("sessionId"),
1255
+ approvalId: c.req.param("approvalId"),
1256
+ };
1257
+ const review = await getToolActionReview(db, input);
1258
+ if (!review) throw new HTTPException(404, { message: "Review not found" });
1259
+ c.header("Cache-Control", "private, no-store");
1260
+ return c.json(review);
1261
+ });
1262
+
1263
+ app.get(
1264
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/tool-reviews/:approvalId/details",
1265
+ async (c) => {
1266
+ const workspaceId = c.req.param("workspaceId");
1267
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1268
+ const query = z
1269
+ .object({
1270
+ actionDigest: z.string().regex(/^[0-9a-f]{64}$/),
1271
+ path: z.string().max(2048).default(""),
1272
+ offset: z.coerce.number().int().min(0).max(4_194_304).default(0),
1273
+ })
1274
+ .safeParse(c.req.query());
1275
+ if (!query.success) throw new HTTPException(400, { message: "Invalid review page" });
1276
+ const input = {
1277
+ accountId: grant.accountId,
1278
+ workspaceId,
1279
+ sessionId: c.req.param("sessionId"),
1280
+ approvalId: c.req.param("approvalId"),
1281
+ ...query.data,
1282
+ };
1283
+ const page = await getToolReviewDetailsPage(db, input);
1284
+ if (!page) throw new HTTPException(404, { message: "Review details unavailable" });
1285
+ c.header("Cache-Control", "private, no-store");
1286
+ return c.json(page);
1287
+ },
1288
+ );
1289
+
940
1290
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
941
1291
  const workspaceId = c.req.param("workspaceId");
942
1292
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -954,22 +1304,38 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
954
1304
  if (!session) {
955
1305
  throw new HTTPException(404, { message: "session not found" });
956
1306
  }
957
- const activity = await backgroundCommandActivityForSessions(db, {
958
- accountId: grant.accountId,
959
- workspaceId,
960
- sessionIds: [sessionId],
961
- });
962
- const scheduleTargets =
1307
+ // Independent reads of the already-authorized session: the effective
1308
+ // policy context depends only on the session row, not on the activity or
1309
+ // schedule annotations, so all three are read concurrently.
1310
+ const [activity, scheduleTargets, policy] = await Promise.all([
1311
+ backgroundCommandActivityForSessions(db, {
1312
+ accountId: grant.accountId,
1313
+ workspaceId,
1314
+ sessionIds: [sessionId],
1315
+ }),
963
1316
  hasPermission(grant.permissions, "scheduled_tasks:run") &&
964
1317
  hasPermission(grant.permissions, "sessions:control")
965
- ? await scheduledSessionIds(db, workspaceId, [sessionId])
966
- : new Set<string>();
1318
+ ? scheduledSessionIds(db, workspaceId, [sessionId])
1319
+ : Promise.resolve(new Set<string>()),
1320
+ loadEffectivePolicyContext(deps, workspaceId, grant.subjectId, [session]),
1321
+ ]);
1322
+ // the task's usage projection. A failed read is `unavailable`,
1323
+ // never $0 or unlimited; it does not fail the session read.
1324
+ const usageLimit = await sessionUsageLimitForRead(db, session, {
1325
+ subjectId: grant.subjectId,
1326
+ });
967
1327
  return c.json(
968
- await withEffectivePolicy(deps, workspaceId, grant.subjectId, {
969
- ...session,
970
- hasSchedules: scheduleTargets.has(sessionId),
971
- ...(activity.get(sessionId) ? { backgroundCommandActivity: activity.get(sessionId) } : {}),
972
- }),
1328
+ sessionResponse(
1329
+ {
1330
+ ...session,
1331
+ ...(usageLimit ? { usageLimit } : {}),
1332
+ hasSchedules: scheduleTargets.has(sessionId),
1333
+ ...(activity.get(sessionId)
1334
+ ? { backgroundCommandActivity: activity.get(sessionId) }
1335
+ : {}),
1336
+ },
1337
+ policy,
1338
+ ),
973
1339
  );
974
1340
  });
975
1341
 
@@ -1065,11 +1431,42 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1065
1431
  }
1066
1432
  });
1067
1433
 
1434
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/visibility/preview", async (c) => {
1435
+ const workspaceId = c.req.param("workspaceId");
1436
+ const sessionId = c.req.param("sessionId");
1437
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
1438
+ const target = SessionVisibility.safeParse(c.req.query("visibility"));
1439
+ if (!target.success) {
1440
+ throw new ApiHttpError(422, {
1441
+ code: "validation_failed",
1442
+ message: "Invalid session visibility preview request.",
1443
+ retryable: false,
1444
+ details: { fields: zodErrorFields(target.error) },
1445
+ });
1446
+ }
1447
+ try {
1448
+ return c.json(
1449
+ await previewManagedHumanSessionVisibilityChange(
1450
+ deps,
1451
+ authorization,
1452
+ workspaceId,
1453
+ sessionId,
1454
+ target.data,
1455
+ "http",
1456
+ ),
1457
+ );
1458
+ } catch (error) {
1459
+ throw sessionTenancyHttpError(error);
1460
+ }
1461
+ });
1462
+
1068
1463
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/forks", async (c) => {
1069
1464
  const workspaceId = c.req.param("workspaceId");
1070
1465
  const sessionId = c.req.param("sessionId");
1071
1466
  const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
1072
- const parsed = ForkSessionRequest.safeParse(await c.req.json().catch(() => null));
1467
+ const forkLegacy = rejectLegacyVariableSetFields(c, await c.req.json().catch(() => null));
1468
+ if ("response" in forkLegacy) return forkLegacy.response;
1469
+ const parsed = ForkSessionRequest.safeParse(forkLegacy.payload);
1073
1470
  if (!parsed.success) {
1074
1471
  throw new ApiHttpError(422, {
1075
1472
  code: "validation_failed",
@@ -1131,15 +1528,35 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1131
1528
  });
1132
1529
  }
1133
1530
  try {
1134
- const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
1135
- beginSessionRealtimeInTransaction(scopedDb, {
1531
+ await realtimeVoiceBilling.admit({
1532
+ accountId: grant.accountId,
1533
+ workspaceId,
1534
+ model: parsed.data.model,
1535
+ attribution: creditDebitAttributionForGrant(grant),
1536
+ });
1537
+ } catch (error) {
1538
+ const refused = realtimeVoiceRefusalResponse(c, error);
1539
+ if (refused) return refused;
1540
+ throw error;
1541
+ }
1542
+ try {
1543
+ const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) => {
1544
+ const accounts = await freezeSessionRealtimeConnectionAccounts({
1545
+ db: scopedDb as unknown as Database,
1546
+ settings,
1547
+ grant,
1548
+ workspaceId,
1549
+ sessionId,
1550
+ });
1551
+ return beginSessionRealtimeInTransaction(scopedDb, {
1136
1552
  accountId: grant.accountId,
1137
1553
  workspaceId,
1138
1554
  sessionId,
1139
1555
  ownerSubjectId: grant.subjectId,
1140
1556
  ...parsed.data,
1141
- }),
1142
- );
1557
+ ...accounts,
1558
+ });
1559
+ });
1143
1560
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1144
1561
  c.header("cache-control", "private, no-store");
1145
1562
  return c.json({ mode: result.mode, replay: result.replay }, result.replay ? 200 : 201);
@@ -1170,6 +1587,16 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1170
1587
  });
1171
1588
  }
1172
1589
  try {
1590
+ // Settle observed minutes before renewing; out of credits keeps the
1591
+ // current lease so the call drains and ends instead of running free.
1592
+ const settlement = await settleRealtimeVoice({
1593
+ grant,
1594
+ attribution: creditDebitAttributionForGrant(grant),
1595
+ workspaceId,
1596
+ sessionId,
1597
+ realtimeId,
1598
+ });
1599
+ const stop = settlement?.stop ?? null;
1173
1600
  const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
1174
1601
  renewSessionRealtimeInTransaction(scopedDb, {
1175
1602
  workspaceId,
@@ -1177,11 +1604,18 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1177
1604
  realtimeId,
1178
1605
  ownerSubjectId: grant.subjectId,
1179
1606
  ...parsed.data,
1607
+ extendLease: stop === null,
1180
1608
  }),
1181
1609
  );
1182
1610
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1183
1611
  c.header("cache-control", "private, no-store");
1184
- return c.json({ mode: result.mode, replay: result.replay });
1612
+ return c.json({
1613
+ mode: result.mode,
1614
+ replay: result.replay,
1615
+ ...(stop && result.mode.state === "active"
1616
+ ? { stop: { code: stop.code, message: stop.message } }
1617
+ : {}),
1618
+ });
1185
1619
  } catch (error) {
1186
1620
  throw sessionRealtimeHttpError(error);
1187
1621
  }
@@ -1214,10 +1648,18 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1214
1648
  sessionId,
1215
1649
  realtimeId,
1216
1650
  ownerSubjectId: grant.subjectId,
1651
+ ownerSubjectLabel: grant.subjectLabel ?? null,
1217
1652
  ...parsed.data,
1218
1653
  }),
1219
1654
  );
1220
1655
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1656
+ await settleRealtimeVoice({
1657
+ grant,
1658
+ attribution: creditDebitAttributionForGrant(grant),
1659
+ workspaceId,
1660
+ sessionId,
1661
+ realtimeId,
1662
+ });
1221
1663
  c.header("cache-control", "private, no-store");
1222
1664
  return c.json({ mode: result.mode, replay: result.replay });
1223
1665
  } catch (error) {
@@ -1253,21 +1695,34 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1253
1695
  ...providerRequest
1254
1696
  } = parsed.data;
1255
1697
  const claim = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
1256
- scopedDb.transaction(async (tx) =>
1257
- claimSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
1258
- workspaceId,
1259
- sessionId,
1260
- realtimeId,
1261
- operationId,
1262
- ownerSubjectId: grant.subjectId,
1263
- browserInstanceId,
1264
- ownerKey,
1265
- expectedVersion,
1266
- expectedConnectionEpoch,
1267
- rotate,
1268
- promotionMode: browserActivation === "required" ? "staged" : "legacy",
1269
- }),
1270
- ),
1698
+ scopedDb.transaction(async (tx) => {
1699
+ const claimed = await claimSessionRealtimeConnectionInTransaction(
1700
+ tx as unknown as Database,
1701
+ {
1702
+ workspaceId,
1703
+ sessionId,
1704
+ realtimeId,
1705
+ operationId,
1706
+ ownerSubjectId: grant.subjectId,
1707
+ browserInstanceId,
1708
+ ownerKey,
1709
+ expectedVersion,
1710
+ expectedConnectionEpoch,
1711
+ rotate,
1712
+ promotionMode: browserActivation === "required" ? "staged" : "legacy",
1713
+ },
1714
+ );
1715
+ if (
1716
+ claimed.mode.model !== "gpt-live-1-boulder-alpha" &&
1717
+ claimed.mode.model !== AZURE_LIVE_MODEL_ID
1718
+ ) {
1719
+ throw new CodexRealtimeBrokerError(
1720
+ "invalid_request",
1721
+ "This voice model does not use WebRTC negotiation",
1722
+ );
1723
+ }
1724
+ return claimed;
1725
+ }),
1271
1726
  );
1272
1727
  if (claim.replay) {
1273
1728
  if (
@@ -1302,7 +1757,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1302
1757
  return c.json({
1303
1758
  sdp: claim.connection.sdpAnswer,
1304
1759
  version: "v3" as const,
1305
- model: "gpt-live-1-boulder-alpha" as const,
1760
+ model: claim.mode.model,
1306
1761
  connectionId: claim.connection.id,
1307
1762
  connectionEpoch: claim.connection.connectionEpoch,
1308
1763
  startupFenceSequence: claim.connection.startupFenceSequence,
@@ -1310,13 +1765,40 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1310
1765
  replay: true,
1311
1766
  });
1312
1767
  }
1313
- const broker = buildSessionCodexRealtimeBroker(
1314
- db,
1315
- settings,
1316
- workspaceId,
1317
- sessionId,
1318
- deps.codexFetch,
1319
- );
1768
+ try {
1769
+ await realtimeVoiceBilling.admit({
1770
+ accountId: grant.accountId,
1771
+ workspaceId,
1772
+ model: claim.mode.model,
1773
+ attribution: creditDebitAttributionForGrant(grant),
1774
+ });
1775
+ } catch (error) {
1776
+ const refused = realtimeVoiceRefusalResponse(c, error);
1777
+ if (!refused) throw error;
1778
+ await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
1779
+ scopedDb.transaction(async (tx) =>
1780
+ failSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
1781
+ workspaceId,
1782
+ sessionId,
1783
+ realtimeId,
1784
+ connectionId: claim.connection.id,
1785
+ operationId,
1786
+ connectionEpoch: claim.connection.connectionEpoch,
1787
+ failureCode: "billing_refused",
1788
+ }),
1789
+ ),
1790
+ ).catch(() => undefined);
1791
+ return refused;
1792
+ }
1793
+ const broker =
1794
+ claim.mode.model === AZURE_LIVE_MODEL_ID
1795
+ ? buildSessionAzureLiveBroker(db, settings, workspaceId, sessionId, deps.codexFetch)
1796
+ : buildSessionCodexRealtimeBroker(
1797
+ db,
1798
+ settings,
1799
+ { accountId: grant.accountId, workspaceId, sessionId },
1800
+ deps.codexFetch,
1801
+ );
1320
1802
  try {
1321
1803
  const answer = await broker({
1322
1804
  request: providerRequest,
@@ -1355,6 +1837,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1355
1837
  ),
1356
1838
  )
1357
1839
  : null;
1840
+ await settleRealtimeVoice({
1841
+ grant,
1842
+ attribution: creditDebitAttributionForGrant(grant),
1843
+ workspaceId,
1844
+ sessionId,
1845
+ realtimeId,
1846
+ });
1358
1847
  return c.json({
1359
1848
  ...answer,
1360
1849
  connectionId: completed.connection.id,
@@ -1455,6 +1944,12 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1455
1944
  "Realtime Gateway tokens are single-use; reconnect with a new operation",
1456
1945
  );
1457
1946
  }
1947
+ await realtimeVoiceBilling.admit({
1948
+ accountId: grant.accountId,
1949
+ workspaceId,
1950
+ model: claim.mode.model,
1951
+ attribution: creditDebitAttributionForGrant(grant),
1952
+ });
1458
1953
  const secret = await createGatewayRealtimeConnectionSecret({
1459
1954
  db,
1460
1955
  settings,
@@ -1478,6 +1973,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1478
1973
  ),
1479
1974
  );
1480
1975
  connectionCompleted = true;
1976
+ await settleRealtimeVoice({
1977
+ grant,
1978
+ attribution: creditDebitAttributionForGrant(grant),
1979
+ workspaceId,
1980
+ sessionId,
1981
+ realtimeId,
1982
+ });
1481
1983
  return c.json({
1482
1984
  ...secret,
1483
1985
  connectionId: completed.connection.id,
@@ -1499,11 +2001,18 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1499
2001
  operationId,
1500
2002
  connectionEpoch: claimed.connection.connectionEpoch,
1501
2003
  failureCode:
1502
- error instanceof GatewayRealtimeBrokerError ? error.code : "gateway_error",
2004
+ error instanceof GatewayRealtimeBrokerError
2005
+ ? error.code
2006
+ : error instanceof TranscriptionBillingRefusedError ||
2007
+ error instanceof RealtimeVoiceUnavailableError
2008
+ ? "billing_refused"
2009
+ : "gateway_error",
1503
2010
  }),
1504
2011
  ),
1505
2012
  ).catch(() => undefined);
1506
2013
  }
2014
+ const refused = realtimeVoiceRefusalResponse(c, error);
2015
+ if (refused) return refused;
1507
2016
  if (error instanceof SessionRealtimeConflictError) throw sessionRealtimeHttpError(error);
1508
2017
  if (!(error instanceof GatewayRealtimeBrokerError)) throw error;
1509
2018
  const status = error.code === "credential_unavailable" ? 409 : 502;
@@ -1712,29 +2221,38 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1712
2221
  });
1713
2222
  }
1714
2223
  try {
1715
- const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) => {
1716
- // Acquire origin authority before inference/session locks. A deferred
1717
- // realtime session has no initial worker turn; capture its exact
1718
- // linked actor when the ledger actually admits ordinary agent work.
1719
- await beforeCommit?.(scopedDb as unknown as Database);
1720
- return syncSessionRealtimeLedgerInTransaction(
1721
- scopedDb,
1722
- {
1723
- workspaceId,
1724
- sessionId,
1725
- realtimeId,
1726
- ownerSubjectId: grant.subjectId,
1727
- ...parsed.data,
1728
- controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
1729
- },
1730
- captureLinked
1731
- ? {
1732
- afterDelegationAdmission: async ({ turnId }) =>
1733
- captureLinked(scopedDb as unknown as Database, sessionId, turnId),
1734
- }
1735
- : {},
1736
- );
1737
- });
2224
+ const result = await withWorkspaceSessionActivityRls(
2225
+ db,
2226
+ workspaceId,
2227
+ async (scopedDb) => {
2228
+ // Acquire origin authority before inference/session locks. A deferred
2229
+ // realtime session has no initial worker turn; capture its exact
2230
+ // linked actor when the ledger actually admits ordinary agent work.
2231
+ await beforeCommit?.(scopedDb as unknown as Database);
2232
+ return syncSessionRealtimeLedgerInTransaction(
2233
+ scopedDb,
2234
+ {
2235
+ workspaceId,
2236
+ sessionId,
2237
+ realtimeId,
2238
+ ownerSubjectId: grant.subjectId,
2239
+ ownerSubjectLabel: grant.subjectLabel ?? null,
2240
+ ...parsed.data,
2241
+ controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
2242
+ },
2243
+ captureLinked
2244
+ ? {
2245
+ afterDelegationAdmission: async ({ turnId }) =>
2246
+ captureLinked(scopedDb as unknown as Database, sessionId, turnId),
2247
+ }
2248
+ : {},
2249
+ );
2250
+ },
2251
+ undefined,
2252
+ // External reauthorization takes the organization-membership lock,
2253
+ // which precedes the tenancy fence and the canonical session prefix.
2254
+ Boolean(beforeCommit),
2255
+ );
1738
2256
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1739
2257
  c.header("cache-control", "private, no-store");
1740
2258
  return c.json({ accepted: result.accepted, outbound: result.outbound });
@@ -1858,6 +2376,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1858
2376
 
1859
2377
  // Personal chat organization: archived roots leave the ordinary rail only
1860
2378
  // for this member and remain recoverable through the archived list view.
2379
+ // Archiving a chat you started that still has work also pauses it (see
2380
+ // pauseOwnArchivedWork); archiving anyone else's chat only hides it for you.
1861
2381
  app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/archive", async (c) => {
1862
2382
  const workspaceId = c.req.param("workspaceId");
1863
2383
  const authorization = await requireAccessGrantAuthorization(
@@ -1873,14 +2393,22 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1873
2393
  throw new HTTPException(400, { message: "invalid session archive request" });
1874
2394
  }
1875
2395
  try {
1876
- const session = await setSessionArchive(db, {
2396
+ const archived = await setSessionArchive(db, {
1877
2397
  workspaceId,
1878
2398
  subjectId: grant.subjectId,
1879
2399
  sessionId,
1880
2400
  personalWorkspaceOwnerException: hasVerifiedOwningUserAuthorization(authorization),
1881
2401
  ...parsed.data,
1882
2402
  });
1883
- if (!session) throw new HTTPException(404, { message: "session not found" });
2403
+ if (!archived) throw new HTTPException(404, { message: "session not found" });
2404
+ let session = archived;
2405
+ if (parsed.data.archived) {
2406
+ try {
2407
+ session = await pauseOwnArchivedWork(deps, grant, workspaceId, archived);
2408
+ } catch (error) {
2409
+ return commandConflictResponse(c, error);
2410
+ }
2411
+ }
1884
2412
  return c.json(
1885
2413
  await withEffectivePolicy(
1886
2414
  deps,
@@ -1911,9 +2439,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1911
2439
  c.req.param("sessionId"),
1912
2440
  ...lineage.ancestors.map((session) => session.id),
1913
2441
  ];
2442
+ const lineageSessions = [...lineage.ancestors];
1914
2443
  const collect = (nodes: LineageNode[]) => {
1915
2444
  for (const node of nodes) {
1916
2445
  sessionIds.push(node.session.id);
2446
+ lineageSessions.push(node.session as Session);
1917
2447
  collect(node.children);
1918
2448
  }
1919
2449
  };
@@ -1929,23 +2459,56 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1929
2459
  session: { ...node.session, hasSchedules: targets.has(node.session.id) },
1930
2460
  children: decorateNodes(node.children),
1931
2461
  }));
1932
- const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
2462
+ const policy = await loadEffectivePolicyContext(
2463
+ deps,
2464
+ workspaceId,
2465
+ grant.subjectId,
2466
+ lineageSessions,
2467
+ );
1933
2468
  return c.json({
1934
2469
  ...lineage,
1935
2470
  sessionHasSchedules: targets.has(c.req.param("sessionId")),
1936
2471
  ancestors: lineage.ancestors.map((session) =>
1937
- sessionWithEffectiveToolPolicy(
1938
- { ...session, hasSchedules: targets.has(session.id) },
1939
- policy.workspaceServerIds,
1940
- policy.workspaceDefaultServerIds,
1941
- ),
2472
+ sessionResponse({ ...session, hasSchedules: targets.has(session.id) }, policy),
1942
2473
  ),
1943
2474
  children: decorateNodes(mapLineageNodes(lineage.children, policy)),
1944
2475
  });
1945
2476
  });
1946
2477
 
2478
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/codex-accounts", async (c) => {
2479
+ const workspaceId = c.req.param("workspaceId");
2480
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
2481
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
2482
+ // authorizeSessionHttp has already enforced private-session and agent scope.
2483
+ await codexRouteDisposition(deps, grant.accountId);
2484
+ const projection = await getSubscriptionCoreSessionCodexAccounts(db, {
2485
+ accountId: grant.accountId,
2486
+ workspaceId,
2487
+ sessionId: c.req.param("sessionId"),
2488
+ // A turn on the viewer's own personal connection shows it to them.
2489
+ viewerSubjectId: grant.subjectId,
2490
+ });
2491
+ if (!projection) throw new HTTPException(404, { message: "session not found" });
2492
+ const activeAccountId = projection.rotation?.activeCredentialId ?? null;
2493
+ return c.json({
2494
+ accounts: projection.accounts.map((account) => codexAccountJson(account)),
2495
+ activeAccountId,
2496
+ settings: {
2497
+ rotationEnabled: projection.rotation?.rotationEnabled ?? false,
2498
+ rotationStrategy: "sharded",
2499
+ activeCredentialId: activeAccountId,
2500
+ },
2501
+ currentSelection: projection.currentSelection,
2502
+ currentAccount: projection.currentAccount
2503
+ ? codexAccountJson(projection.currentAccount)
2504
+ : null,
2505
+ pinnedAccountId: projection.pinnedAccountId,
2506
+ lastAccountId: projection.lastAccountId,
2507
+ });
2508
+ });
2509
+
1947
2510
  // Pin (or unpin) the session's Codex account. body { target: "auto" | "<id>" }:
1948
- // "auto" clears the pin (the session follows the workspace active pointer); a
2511
+ // "auto" clears only the Codex pin (shared-core automatic selection); a
1949
2512
  // uuid pins the session to that specific account. Overrides a capacity-blocked
1950
2513
  // turn; a running attempt keeps its account. 404 when the session or the target
1951
2514
  // account id isn't in the workspace.
@@ -1964,7 +2527,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1964
2527
  try {
1965
2528
  await requireSessionAuthorization(deps, grant, {
1966
2529
  sessionId,
1967
- operation: "session.codex_account.write",
2530
+ operation: "session.model_account.write",
1968
2531
  surface: "http",
1969
2532
  });
1970
2533
  } catch (error) {
@@ -1972,42 +2535,23 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1972
2535
  }
1973
2536
  }
1974
2537
  const pinned = target === "auto" ? null : target;
1975
- const mutation = await switchSessionCodexAccount(db, {
2538
+ await codexRouteDisposition(deps, grant.accountId);
2539
+
2540
+ const core = await pinSubscriptionCoreSessionCodexAccount(db, {
2541
+ accountId: grant.accountId,
1976
2542
  workspaceId,
1977
2543
  sessionId,
1978
- credentialId: pinned,
2544
+ connectionId: pinned,
1979
2545
  subjectId: grant.subjectId,
1980
2546
  });
1981
- const ok = mutation.result.changed;
1982
- if (!ok) {
1983
- throw new HTTPException(404, {
1984
- message: "session or codex account not found",
1985
- });
2547
+ if (!core.result.changed) {
2548
+ throw new HTTPException(404, { message: "session or codex account not found" });
1986
2549
  }
1987
- await Promise.allSettled(
1988
- mutation.wakeTargets.map((wake) =>
1989
- workflowClient.signalCodexCapacity
1990
- ? workflowClient.signalCodexCapacity({
1991
- accountId: wake.accountId,
1992
- workspaceId: wake.workspaceId,
1993
- sessionId: wake.sessionId,
1994
- workflowId: wake.workflowId,
1995
- wakeRevision: wake.wakeRevision,
1996
- workflowWakeRevision: wake.workflowWakeRevision,
1997
- })
1998
- : workflowClient.wakeSessionWorkflow({
1999
- accountId: wake.accountId,
2000
- workspaceId: wake.workspaceId,
2001
- sessionId: wake.sessionId,
2002
- workflowId: wake.workflowId,
2003
- wakeRevision: wake.workflowWakeRevision,
2004
- }),
2005
- ),
2006
- );
2007
- await publishDurableSessionEvents(bus, workspaceId, sessionId, mutation.result.events);
2550
+ await deliverSubscriptionCoreCodexWake(db, core.wake);
2551
+ await publishDurableSessionEvents(bus, workspaceId, sessionId, core.result.events);
2008
2552
  return c.json({
2009
2553
  pinned: target === "auto" ? "auto" : target,
2010
- appliedTo: mutation.result.appliedTo,
2554
+ appliedTo: core.result.appliedTo,
2011
2555
  });
2012
2556
  });
2013
2557
 
@@ -2024,7 +2568,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2024
2568
  operation: "session.control",
2025
2569
  surface: "http",
2026
2570
  });
2027
- const payload = UpdateSessionChannelRequest.parse(await c.req.json());
2571
+ const payload = await parseRequestJson(c, UpdateSessionChannelRequest);
2028
2572
  try {
2029
2573
  const updated = await setSessionChannel(db, {
2030
2574
  workspaceId,
@@ -2053,89 +2597,12 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2053
2597
  return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
2054
2598
  });
2055
2599
 
2056
- // Replace the complete ordered Variable Set selection. The DB mutation
2057
- // serializes with turn claim, rejects live/shared sandbox use, and requests a
2058
- // cold rotation before the new environment can be materialized.
2600
+ // Variable Sets were replaced by vaults. The route answers 410 so old
2601
+ // clients learn where secrets moved instead of silently doing nothing.
2059
2602
  app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/variable-sets", async (c) => {
2060
2603
  const workspaceId = c.req.param("workspaceId");
2061
- const sessionId = c.req.param("sessionId");
2062
- const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2063
- try {
2064
- await requireSessionAuthorization(deps, grant, {
2065
- sessionId,
2066
- operation: "session.variable_sets.write",
2067
- surface: "http",
2068
- });
2069
- } catch (error) {
2070
- throw sessionAuthorizationHttpError(error);
2071
- }
2072
- // Detach still requires attach authority: it changes which protected
2073
- // resources the session may materialize. Non-empty selections additionally
2074
- // require use authority through validateVariableSetAttachment.
2075
- requirePermission(grant, "variable-sets:attach");
2076
- const parsed = UpdateSessionVariableSetsRequest.safeParse(await c.req.json().catch(() => null));
2077
- if (!parsed.success) {
2078
- throw new ApiHttpError(422, {
2079
- code: "validation_failed",
2080
- message: "Invalid session Variable Set request.",
2081
- retryable: false,
2082
- details: { fields: zodErrorFields(parsed.error) },
2083
- });
2084
- }
2085
- const variableSets: VariableSet[] = [];
2086
- for (const variableSetId of parsed.data.variableSetIds) {
2087
- variableSets.push(
2088
- await validateVariableSetAttachment({ settings, db }, grant, workspaceId, variableSetId),
2089
- );
2090
- }
2091
- const result = await updateSessionVariableSets(db, {
2092
- accountId: grant.accountId,
2093
- workspaceId,
2094
- sessionId,
2095
- subjectId: grant.subjectId,
2096
- variableSets: variableSets.map((variableSet) => ({
2097
- id: variableSet.id,
2098
- name: variableSet.name,
2099
- scope: variableSet.scope,
2100
- })),
2101
- });
2102
- if (result.status === "not_found") {
2103
- throw new HTTPException(404, { message: "session not found" });
2104
- }
2105
- if (result.status === "invalid_variable_sets") {
2106
- throw new ApiHttpError(422, {
2107
- code: "validation_failed",
2108
- message: "One or more selected Variable Sets are no longer available.",
2109
- retryable: false,
2110
- outcomeUnknown: false,
2111
- details: { variableSetIds: result.variableSetIds },
2112
- });
2113
- }
2114
- if (result.status === "blocked") {
2115
- const messages = {
2116
- turn_in_flight:
2117
- "Variable Sets can be changed only when the session has no accepted, queued, claimed, or pending work.",
2118
- shared_sandbox_group:
2119
- "Variable Sets cannot be changed while this session shares a sandbox; fork it into a separate session first.",
2120
- live_sandbox_holders:
2121
- "Close active terminal, desktop, and sandbox operations before changing Variable Sets.",
2122
- } as const;
2123
- throw new HTTPException(409, { message: messages[result.reason] });
2124
- }
2125
- if (result.status === "updated") {
2126
- await publishDurableSessionEvents(bus, workspaceId, sessionId, [result.event]);
2127
- }
2128
- const session = await getSessionForSubject(
2129
- db,
2130
- workspaceId,
2131
- sessionId,
2132
- grant.subjectId,
2133
- relatedSessionAccessFor(c),
2134
- );
2135
- if (!session) {
2136
- throw new HTTPException(404, { message: "session not found" });
2137
- }
2138
- return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
2604
+ await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2605
+ return variableSetsRemovedResponse(c, 410);
2139
2606
  });
2140
2607
 
2141
2608
  // Manual rename. A user-set title is permanent: the db write is
@@ -2146,7 +2613,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2146
2613
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2147
2614
  const sessionId = c.req.param("sessionId");
2148
2615
  await assertSessionExists(db, workspaceId, sessionId);
2149
- const payload = UpdateSessionRequest.parse(await c.req.json());
2616
+ const payload = await parseRequestJson(c, UpdateSessionRequest);
2150
2617
  const titleUpdate = await updateSessionTitle(deps, grant, sessionId, payload.title, "user");
2151
2618
  // A session-returning member route must preserve the caller's private pin
2152
2619
  // projection. Returning the generic mapSession() default here would reset a
@@ -2164,6 +2631,89 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2164
2631
  return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
2165
2632
  });
2166
2633
 
2634
+ // Workspace-wide keep-live exemption from the idle-session archive. Distinct
2635
+ // from the personal rail archive above; archived sessions are read-only.
2636
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/retention", async (c) => {
2637
+ const workspaceId = c.req.param("workspaceId");
2638
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2639
+ const sessionId = c.req.param("sessionId");
2640
+ const payload = await parseRequestJson(c, UpdateSessionRetentionRequest);
2641
+ const { result, relatedSessionAccess } = await setSessionRetention(
2642
+ deps,
2643
+ grant,
2644
+ sessionId,
2645
+ payload.keepLive,
2646
+ );
2647
+ if (result.status === "not_found") {
2648
+ throw new HTTPException(404, { message: "session not found" });
2649
+ }
2650
+ if (result.status === "archived") {
2651
+ return c.json(
2652
+ {
2653
+ code: "SESSION_ARCHIVED_READ_ONLY",
2654
+ message:
2655
+ "This session is already archived, so its keep-live setting can no longer change.",
2656
+ },
2657
+ 409,
2658
+ );
2659
+ }
2660
+ const session = await getSessionForSubject(
2661
+ db,
2662
+ workspaceId,
2663
+ sessionId,
2664
+ grant.subjectId,
2665
+ relatedSessionAccess,
2666
+ );
2667
+ if (!session) throw new HTTPException(404, { message: "session not found" });
2668
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
2669
+ });
2670
+
2671
+ // Per-task usage limit. Reads follow session read access; a child
2672
+ // returns its root's shared state. Writes are compare-and-set on the ROOT
2673
+ // by a person in the app or the host's server key only, never an agent
2674
+ // (including one acting for a person), and never wake or resume work.
2675
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/usage-limit", async (c) => {
2676
+ const workspaceId = c.req.param("workspaceId");
2677
+ const sessionId = c.req.param("sessionId");
2678
+ if (!z.string().uuid().safeParse(sessionId).success) {
2679
+ throw new HTTPException(404, { message: "session not found" });
2680
+ }
2681
+ const authorization = await requireAccessGrantAuthorization(
2682
+ c,
2683
+ deps,
2684
+ workspaceId,
2685
+ "sessions:read",
2686
+ );
2687
+ return c.json(await getSessionUsageLimitForAccess(deps, authorization, sessionId));
2688
+ });
2689
+
2690
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/usage-limit", async (c) => {
2691
+ const workspaceId = c.req.param("workspaceId");
2692
+ const sessionId = c.req.param("sessionId");
2693
+ if (!z.string().uuid().safeParse(sessionId).success) {
2694
+ throw new HTTPException(404, { message: "session not found" });
2695
+ }
2696
+ // Checked first: an agent acting for a person (connected agent, admin
2697
+ // access, personal API key) is refused whatever access it holds.
2698
+ if (isAgentActingAsPerson(c)) {
2699
+ throw new SessionUsageLimitHttpError(
2700
+ 403,
2701
+ "session_usage_limit_forbidden",
2702
+ "Only a person or the application's server can change a task usage limit.",
2703
+ );
2704
+ }
2705
+ const authorization = await requireAccessGrantAuthorization(
2706
+ c,
2707
+ deps,
2708
+ workspaceId,
2709
+ "sessions:control",
2710
+ );
2711
+ const payload = await parseRequestJson(c, SetSessionUsageLimitRequest);
2712
+ return c.json(
2713
+ await setSessionUsageLimitForAccess(deps, authorization, sessionId, payload, c.req.raw),
2714
+ );
2715
+ });
2716
+
2167
2717
  app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
2168
2718
  const workspaceId = c.req.param("workspaceId");
2169
2719
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
@@ -2179,30 +2729,55 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2179
2729
  case "not_found":
2180
2730
  throw new HTTPException(404, { message: "session not found" });
2181
2731
  case "not_root":
2182
- throw new HTTPException(409, {
2183
- message: "delete the root session to remove the complete workstream",
2732
+ throw new ApiHttpError(409, {
2733
+ code: "conflict",
2734
+ message: "Delete the chat this one belongs to; sub-chats are removed with it.",
2735
+ retryable: false,
2736
+ outcomeUnknown: false,
2737
+ details: { code: "session_delete_not_root" },
2184
2738
  });
2185
2739
  case "active_sessions":
2186
- throw new HTTPException(409, {
2187
- message: "cancel the workstream and wait for active turns to finish before deleting it",
2740
+ throw new ApiHttpError(409, {
2741
+ code: "conflict",
2742
+ message:
2743
+ "This chat is still running. Stop it, then delete it once its current turn has finished.",
2744
+ retryable: true,
2745
+ outcomeUnknown: false,
2746
+ details: { code: "session_delete_active_sessions" },
2188
2747
  });
2189
2748
  case "active_video_generations":
2190
- throw new HTTPException(409, {
2191
- message: "wait for active video generations to finish before deleting this workstream",
2749
+ throw new ApiHttpError(409, {
2750
+ code: "conflict",
2751
+ message: "Wait for this chat's video generations to finish before deleting it.",
2752
+ retryable: false,
2753
+ outcomeUnknown: false,
2754
+ details: { code: "session_delete_active_video_generations" },
2192
2755
  });
2193
2756
  case "active_background_commands":
2194
- throw new HTTPException(409, {
2195
- message: "pause or cancel this workstream's background commands before deleting it",
2757
+ throw new ApiHttpError(409, {
2758
+ code: "conflict",
2759
+ message: "Stop this chat's background commands before deleting it.",
2760
+ retryable: false,
2761
+ outcomeUnknown: false,
2762
+ details: { code: "session_delete_active_background_commands" },
2196
2763
  });
2197
2764
  case "live_sandboxes":
2198
- throw new HTTPException(409, {
2765
+ throw new ApiHttpError(409, {
2766
+ code: "conflict",
2199
2767
  message:
2200
- "wait for the workstream's sandbox activity to finish draining before deleting it",
2768
+ "This chat's computer is still shutting down. Try deleting it again in a moment.",
2769
+ retryable: true,
2770
+ outcomeUnknown: false,
2771
+ details: { code: "session_delete_live_sandboxes" },
2201
2772
  });
2202
2773
  case "externally_referenced":
2203
- throw new HTTPException(409, {
2774
+ throw new ApiHttpError(409, {
2775
+ code: "conflict",
2204
2776
  message:
2205
- "this workstream has durable workspace outputs or independent forks; archive it instead",
2777
+ "This chat has saved workspace outputs or forks that depend on it. Archive it instead.",
2778
+ retryable: false,
2779
+ outcomeUnknown: false,
2780
+ details: { code: "session_delete_externally_referenced" },
2206
2781
  });
2207
2782
  }
2208
2783
  });
@@ -2239,7 +2814,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2239
2814
  const workspaceId = c.req.param("workspaceId");
2240
2815
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2241
2816
  const sessionId = c.req.param("sessionId");
2242
- const payload = UpdateSessionToolPolicyRequest.parse(await c.req.json().catch(() => null));
2817
+ const payload = parseRequestBody(
2818
+ UpdateSessionToolPolicyRequest,
2819
+ await c.req.json().catch(() => null),
2820
+ );
2243
2821
  try {
2244
2822
  const session = await updateSessionToolPolicy(deps, grant, sessionId, payload);
2245
2823
  return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
@@ -2258,6 +2836,63 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2258
2836
  }
2259
2837
  });
2260
2838
 
2839
+ // Replace the session's agent configuration (capabilities, identity,
2840
+ // instructions alias, renderer). Shares the tool-policy version CAS and
2841
+ // applies from the next attempt.
2842
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/agent", async (c) => {
2843
+ const workspaceId = c.req.param("workspaceId");
2844
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2845
+ const sessionId = c.req.param("sessionId");
2846
+ const payload = parseRequestBody(
2847
+ UpdateSessionAgentRequest,
2848
+ await c.req.json().catch(() => null),
2849
+ );
2850
+ try {
2851
+ const session = await updateSessionAgent(deps, grant, sessionId, payload);
2852
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
2853
+ } catch (error) {
2854
+ if (error instanceof SessionToolPolicyVersionConflictError) {
2855
+ return c.json(
2856
+ {
2857
+ code: error.code,
2858
+ message: error.message,
2859
+ currentVersion: error.currentVersion,
2860
+ },
2861
+ 409,
2862
+ );
2863
+ }
2864
+ throw error;
2865
+ }
2866
+ });
2867
+
2868
+ // Replace the Skills the session carries itself. Shares the tool-policy
2869
+ // version CAS and applies from the next attempt.
2870
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/skills", async (c) => {
2871
+ const workspaceId = c.req.param("workspaceId");
2872
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2873
+ const sessionId = c.req.param("sessionId");
2874
+ const payload = parseRequestBody(
2875
+ UpdateSessionSkillsRequest,
2876
+ await c.req.json().catch(() => null),
2877
+ );
2878
+ try {
2879
+ const session = await updateSessionSkills(deps, grant, sessionId, payload);
2880
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
2881
+ } catch (error) {
2882
+ if (error instanceof SessionToolPolicyVersionConflictError) {
2883
+ return c.json(
2884
+ {
2885
+ code: error.code,
2886
+ message: error.message,
2887
+ currentVersion: error.currentVersion,
2888
+ },
2889
+ 409,
2890
+ );
2891
+ }
2892
+ throw error;
2893
+ }
2894
+ });
2895
+
2261
2896
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
2262
2897
  const workspaceId = c.req.param("workspaceId");
2263
2898
  await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -2265,6 +2900,12 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2265
2900
  await assertSessionExists(db, workspaceId, sessionId);
2266
2901
  const goal = await getSessionGoalWithContinuation(db, workspaceId, sessionId);
2267
2902
  if (!goal) {
2903
+ // `?absent=null` opts in to a successful `null` for a goal-less session,
2904
+ // so a browser polling a fresh chat logs no failed request. Without it
2905
+ // the original 404 contract is unchanged for existing clients.
2906
+ if (c.req.query("absent") === "null") {
2907
+ return c.json(null);
2908
+ }
2268
2909
  throw new HTTPException(404, { message: "session goal not found" });
2269
2910
  }
2270
2911
  return c.json(goal);
@@ -2313,7 +2954,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2313
2954
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2314
2955
  const sessionId = c.req.param("sessionId");
2315
2956
  await assertSessionExists(db, workspaceId, sessionId);
2316
- const payload = ApplySessionGoalRevisionRequest.parse(await c.req.json());
2957
+ const payload = await parseRequestJson(c, ApplySessionGoalRevisionRequest);
2317
2958
  const revision = await getSessionGoalRevision(
2318
2959
  db,
2319
2960
  workspaceId,
@@ -2377,7 +3018,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2377
3018
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2378
3019
  const sessionId = c.req.param("sessionId");
2379
3020
  await assertSessionExists(db, workspaceId, sessionId);
2380
- const payload = RejectSessionGoalRevisionRequest.parse(await c.req.json());
3021
+ const payload = await parseRequestJson(c, RejectSessionGoalRevisionRequest);
2381
3022
  try {
2382
3023
  const result = await rejectSessionGoalRevisionWithEvent(db, {
2383
3024
  accountId: grant.accountId,
@@ -2405,7 +3046,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2405
3046
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2406
3047
  const sessionId = c.req.param("sessionId");
2407
3048
  await assertSessionExists(db, workspaceId, sessionId);
2408
- const payload = RollbackSessionGoalRevisionRequest.parse(await c.req.json());
3049
+ const payload = await parseRequestJson(c, RollbackSessionGoalRevisionRequest);
2409
3050
  const revision = await getSessionGoalRevision(
2410
3051
  db,
2411
3052
  workspaceId,
@@ -2457,7 +3098,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2457
3098
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2458
3099
  const sessionId = c.req.param("sessionId");
2459
3100
  await assertSessionExists(db, workspaceId, sessionId);
2460
- const payload = UpdateSessionGoalRequest.parse(await c.req.json());
3101
+ const payload = await parseRequestJson(c, UpdateSessionGoalRequest);
2461
3102
  const existing = await getSessionGoal(db, workspaceId, sessionId);
2462
3103
  if (!existing) {
2463
3104
  throw new HTTPException(404, { message: "session goal not found" });
@@ -2542,6 +3183,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2542
3183
  sessionId,
2543
3184
  {
2544
3185
  status: "active",
3186
+ beforeResume: (tx, session, causalTurn) =>
3187
+ assertGoalResumeAllowed({ ...deps, db: tx }, session, causalTurn).catch(
3188
+ (error: unknown) => {
3189
+ if (error instanceof GoalResumeBlockedError) {
3190
+ throw new HTTPException(422, { message: error.message, cause: error });
3191
+ }
3192
+ throw error;
3193
+ },
3194
+ ),
2545
3195
  event: { type: "goal.resumed", actor: "api" },
2546
3196
  },
2547
3197
  );
@@ -2569,10 +3219,17 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2569
3219
 
2570
3220
  app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
2571
3221
  const workspaceId = c.req.param("workspaceId");
2572
- await requireAccessGrant(c, deps, workspaceId, "sessions:control");
3222
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2573
3223
  const sessionId = c.req.param("sessionId");
2574
3224
  await assertSessionExists(db, workspaceId, sessionId);
2575
- const { event } = await clearSessionGoal(db, workspaceId, sessionId);
3225
+ const { event } = await clearSessionGoal(db, workspaceId, sessionId, {
3226
+ actor: grant.principalKind === "agent_attempt" ? "agent" : "api",
3227
+ }).catch((error: unknown) => {
3228
+ if (error instanceof SessionControlConflictError) {
3229
+ throw new HTTPException(409, { message: error.message, cause: error });
3230
+ }
3231
+ throw error;
3232
+ });
2576
3233
  if (event) {
2577
3234
  try {
2578
3235
  await bus.publish(workspaceId, sessionId, [event]);
@@ -2635,7 +3292,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2635
3292
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2636
3293
  const sessionId = c.req.param("sessionId");
2637
3294
  await assertSessionExists(db, workspaceId, sessionId);
2638
- CompactSessionContextRequest.parse((await c.req.json().catch(() => ({}))) ?? {});
3295
+ parseRequestBody(CompactSessionContextRequest, (await c.req.json().catch(() => ({}))) ?? {});
2639
3296
  // /compact sets one durable request. The worker clears it only in the same
2640
3297
  // fenced transaction that installs replacement history, so failed or stale
2641
3298
  // attempts cannot lose the request.
@@ -2822,9 +3479,93 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2822
3479
  direction === "before" ? "X-OpenGeni-Next-Before" : "X-OpenGeni-Next-After",
2823
3480
  String(page.nextSequence),
2824
3481
  );
2825
- }
2826
- return c.json(page.events);
2827
- });
3482
+ }
3483
+ return c.json(page.events);
3484
+ });
3485
+
3486
+ // Selected search hit only: never use the audit event projection here, even
3487
+ // in summary mode (it can contain modelContext or truncate visible text).
3488
+ app.get(
3489
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/events/:eventId/message-preview",
3490
+ async (c) => {
3491
+ const workspaceId = c.req.param("workspaceId");
3492
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
3493
+ const sessionId = c.req.param("sessionId");
3494
+ await assertSessionExists(db, workspaceId, sessionId);
3495
+ const eventId = c.req.param("eventId");
3496
+ const rawSequence = c.req.query("sequence");
3497
+ const sequence = Number(rawSequence);
3498
+ if (
3499
+ !z.string().uuid().safeParse(eventId).success ||
3500
+ rawSequence === undefined ||
3501
+ !/^\d+$/.test(rawSequence) ||
3502
+ !Number.isSafeInteger(sequence) ||
3503
+ sequence < 1 ||
3504
+ sequence > 2_147_483_647
3505
+ )
3506
+ throw new HTTPException(400, { message: "Invalid message preview reference" });
3507
+
3508
+ const notFound = () => new HTTPException(404, { message: "message not found" });
3509
+ const maxUnits = 12_000;
3510
+ const eventTable = sessionPreviewSchema.sessionEvents;
3511
+ // The slice helper supports structured result views too. Check only the
3512
+ // JSON scalar *kind* before calling it; never fetch a structured text value.
3513
+ const candidates = await withWorkspaceRls(db, workspaceId, (tx) =>
3514
+ tx
3515
+ .select({ kind: sql<string | null>`jsonb_typeof(${eventTable.payload}->'text')` })
3516
+ .from(eventTable)
3517
+ .where(
3518
+ and(
3519
+ eq(eventTable.workspaceId, workspaceId),
3520
+ eq(eventTable.sessionId, sessionId),
3521
+ eq(eventTable.id, eventId),
3522
+ eq(eventTable.sequence, sequence),
3523
+ sql`${eventTable.type} in ('user.message', 'agent.message.completed')`,
3524
+ ),
3525
+ )
3526
+ .limit(1),
3527
+ );
3528
+ if (candidates[0]?.kind !== "string") throw notFound();
3529
+ const read = (offset: number) =>
3530
+ listSessionEventSlices(db, workspaceId, sessionId, {
3531
+ sourceSequence: sequence,
3532
+ sourceOffset: offset,
3533
+ after: sequence - 1,
3534
+ before: sequence + 1,
3535
+ includeTypes: ["user.message", "agent.message.completed"],
3536
+ view: "conversation",
3537
+ });
3538
+ const first = await read(0);
3539
+ const event = first.events[0];
3540
+ const slice = first.slices?.[sequence];
3541
+ // Includes the slice reader's duplicate, late and unclaimed-prompt gates.
3542
+ if (!event || event.id !== eventId || event.sequence !== sequence || !slice || slice.omitted)
3543
+ throw notFound();
3544
+ if (slice.total > maxUnits) return c.json({ status: "unavailable" as const });
3545
+
3546
+ let text = slice.text;
3547
+ let offset = slice.unit === "utf16" ? slice.text.length : Array.from(slice.text).length;
3548
+ if (text.length > maxUnits) return c.json({ status: "unavailable" as const });
3549
+ while (offset < slice.total) {
3550
+ if (offset === 0) throw notFound();
3551
+ const next = await read(offset);
3552
+ const part = next.slices?.[sequence];
3553
+ if (
3554
+ next.events[0]?.id !== eventId ||
3555
+ !part ||
3556
+ part.omitted ||
3557
+ part.unit !== slice.unit ||
3558
+ part.total !== slice.total ||
3559
+ part.offset !== offset
3560
+ )
3561
+ throw notFound();
3562
+ text += part.text;
3563
+ offset += slice.unit === "utf16" ? part.text.length : Array.from(part.text).length;
3564
+ if (text.length > maxUnits) return c.json({ status: "unavailable" as const });
3565
+ }
3566
+ return c.json({ status: "available" as const, text });
3567
+ },
3568
+ );
2828
3569
 
2829
3570
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events/stream", async (c) => {
2830
3571
  const workspaceId = c.req.param("workspaceId");
@@ -2919,7 +3660,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2919
3660
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2920
3661
  const sessionId = c.req.param("sessionId");
2921
3662
  await assertSessionExists(db, workspaceId, sessionId);
2922
- const payload = MoveSessionQueueItemRequest.parse(await c.req.json());
3663
+ const payload = await parseRequestJson(c, MoveSessionQueueItemRequest);
2923
3664
  try {
2924
3665
  const response = await moveHumanQueuePrompt(
2925
3666
  deps,
@@ -2946,7 +3687,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2946
3687
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2947
3688
  const sessionId = c.req.param("sessionId");
2948
3689
  await assertSessionExists(db, workspaceId, sessionId);
2949
- const payload = EditSessionQueueItemRequest.parse(await c.req.json());
3690
+ const payload = await parseRequestJson(c, EditSessionQueueItemRequest);
2950
3691
  try {
2951
3692
  const response = await editHumanQueuePrompt(
2952
3693
  deps,
@@ -2973,7 +3714,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2973
3714
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2974
3715
  const sessionId = c.req.param("sessionId");
2975
3716
  await assertSessionExists(db, workspaceId, sessionId);
2976
- const payload = SteerSessionQueueItemRequest.parse(await c.req.json());
3717
+ const payload = await parseRequestJson(c, SteerSessionQueueItemRequest);
2977
3718
  try {
2978
3719
  const response = await steerHumanQueuePrompt(
2979
3720
  deps,
@@ -3000,7 +3741,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3000
3741
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
3001
3742
  const sessionId = c.req.param("sessionId");
3002
3743
  await assertSessionExists(db, workspaceId, sessionId);
3003
- const payload = DeleteSessionQueueItemRequest.parse(await c.req.json());
3744
+ const payload = await parseRequestJson(c, DeleteSessionQueueItemRequest);
3004
3745
  try {
3005
3746
  const response = await deleteHumanQueuePrompt(
3006
3747
  deps,
@@ -3040,7 +3781,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3040
3781
  const workspaceId = c.req.param("workspaceId");
3041
3782
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
3042
3783
  const sessionId = c.req.param("sessionId");
3043
- const payload = SaveComposerDraftRequest.parse(await c.req.json());
3784
+ const payload = await parseRequestJson(c, SaveComposerDraftRequest);
3044
3785
  try {
3045
3786
  return c.json(
3046
3787
  await saveHumanComposerDraft(
@@ -3059,6 +3800,113 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3059
3800
  }
3060
3801
  });
3061
3802
 
3803
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/sandbox-recovery", async (c) => {
3804
+ const workspaceId = c.req.param("workspaceId");
3805
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
3806
+ try {
3807
+ return c.json(
3808
+ await getManagedHumanSandboxRecovery(
3809
+ deps,
3810
+ authorization,
3811
+ workspaceId,
3812
+ c.req.param("sessionId"),
3813
+ ),
3814
+ );
3815
+ } catch (error) {
3816
+ throw sessionTenancyHttpError(error);
3817
+ }
3818
+ });
3819
+
3820
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/sandbox-recovery", async (c) => {
3821
+ const workspaceId = c.req.param("workspaceId");
3822
+ const sessionId = c.req.param("sessionId");
3823
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
3824
+ const parsed = SandboxRecoveryRequest.safeParse(await c.req.json().catch(() => null));
3825
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid checkpoint consent" });
3826
+ let consentCommitted = false;
3827
+ try {
3828
+ const receipt = await consentManagedHumanSandboxRecovery(
3829
+ deps,
3830
+ authorization,
3831
+ workspaceId,
3832
+ sessionId,
3833
+ parsed.data,
3834
+ );
3835
+ consentCommitted = true;
3836
+ const projection = await getManagedHumanSandboxRecovery(
3837
+ deps,
3838
+ authorization,
3839
+ workspaceId,
3840
+ sessionId,
3841
+ );
3842
+ if (
3843
+ projection.operationId === receipt.operationId &&
3844
+ projection.status === "consent_accepted"
3845
+ ) {
3846
+ // Establish/verify only. No failed-turn Retry, Send, tool callback or
3847
+ // command replay. The ordinary cold->warming election owns creation.
3848
+ const session = await getSession(db, workspaceId, sessionId);
3849
+ if (session) {
3850
+ try {
3851
+ await withChannelARead(
3852
+ channelAServices,
3853
+ {
3854
+ accountId: authorization.grant.accountId,
3855
+ workspaceId,
3856
+ session,
3857
+ subjectId: authorization.grant.subjectId,
3858
+ grant: authorization.grant,
3859
+ },
3860
+ async () => undefined,
3861
+ );
3862
+ } catch {
3863
+ // Consent committed. Never turn a provider failure into a false
3864
+ // rejection of consent or leak native provider bindings/messages.
3865
+ }
3866
+ }
3867
+ }
3868
+ return c.json({
3869
+ ...receipt,
3870
+ recovery: await getManagedHumanSandboxRecovery(deps, authorization, workspaceId, sessionId),
3871
+ });
3872
+ } catch (error) {
3873
+ if (consentCommitted) {
3874
+ // A later authorization/read failure cannot revoke the committed
3875
+ // receipt. Return no session state, checkpoint or provider details.
3876
+ return c.json(
3877
+ {
3878
+ code: "upstream_unavailable",
3879
+ message:
3880
+ "Recovery status could not be confirmed. Check status without resubmitting consent.",
3881
+ outcomeUnknown: true,
3882
+ retryable: false,
3883
+ },
3884
+ 503,
3885
+ );
3886
+ }
3887
+ if (error instanceof SandboxRecoveryConflictError)
3888
+ return c.json({ code: error.code, message: error.message }, 409);
3889
+ if (error instanceof SessionCommandIdempotencyError) return commandConflictResponse(c, error);
3890
+ throw sessionTenancyHttpError(error);
3891
+ }
3892
+ });
3893
+
3894
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/retry", async (c) => {
3895
+ const workspaceId = c.req.param("workspaceId");
3896
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
3897
+ const parsed = SessionRetryRequest.safeParse(await c.req.json().catch(() => null));
3898
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid session retry request" });
3899
+ try {
3900
+ return c.json(
3901
+ await retryFailedSession(deps, grant, workspaceId, c.req.param("sessionId"), parsed.data),
3902
+ );
3903
+ } catch (error) {
3904
+ if (error instanceof SessionRetryConflictError)
3905
+ return c.json({ code: error.code, message: error.message }, 409);
3906
+ return commandConflictResponse(c, error);
3907
+ }
3908
+ });
3909
+
3062
3910
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/control", async (c) => {
3063
3911
  const workspaceId = c.req.param("workspaceId");
3064
3912
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
@@ -3112,34 +3960,34 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3112
3960
  const payload = parseSteerSessionAdmission(await c.req.json().catch(() => null));
3113
3961
  let result: Awaited<ReturnType<typeof acceptSessionUserMessage>>;
3114
3962
  try {
3115
- result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
3116
- text: payload.text,
3117
- annotations: payload.annotations,
3118
- modelContext: payload.modelContext ?? null,
3119
- resources: payload.resources,
3120
- model: payload.model ?? null,
3121
- reasoningEffort: payload.reasoningEffort ?? null,
3122
- latencyMode: payload.latencyMode ?? null,
3123
- mcpCredentialUpdates: payload.mcpCredentialUpdates ?? [],
3124
- connectionAuthorities: payload.connectionAuthorities,
3125
- ...(payload.selectedHostMcpDelegations
3126
- ? { selectedHostMcpDelegations: payload.selectedHostMcpDelegations }
3127
- : {}),
3128
- ...(payload.personalResourceAttachment
3129
- ? { personalResourceAttachment: payload.personalResourceAttachment }
3130
- : {}),
3131
- authorization,
3132
- delivery: "steer",
3133
- origin: "human",
3134
- ...(payload.controlEtag !== undefined ? { controlEtag: payload.controlEtag } : {}),
3135
- ...(payload.expectedDraftRevision !== undefined
3136
- ? { expectedDraftRevision: payload.expectedDraftRevision }
3137
- : {}),
3138
- ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
3139
- });
3963
+ result = await withSiteCommandOrigin(c, grant, () =>
3964
+ acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
3965
+ text: payload.text,
3966
+ annotations: payload.annotations,
3967
+ modelContext: payload.modelContext ?? null,
3968
+ resources: payload.resources,
3969
+ model: payload.model ?? null,
3970
+ reasoningEffort: payload.reasoningEffort ?? null,
3971
+ latencyMode: payload.latencyMode ?? null,
3972
+ mcpCredentialUpdates: payload.mcpCredentialUpdates ?? [],
3973
+ connectionAccounts: payload.connectionAccounts,
3974
+ ...(payload.personalResourceAttachment
3975
+ ? { personalResourceAttachment: payload.personalResourceAttachment }
3976
+ : {}),
3977
+ authorization,
3978
+ delivery: "steer",
3979
+ origin: "human",
3980
+ ...(payload.controlEtag !== undefined ? { controlEtag: payload.controlEtag } : {}),
3981
+ ...(payload.expectedDraftRevision !== undefined
3982
+ ? { expectedDraftRevision: payload.expectedDraftRevision }
3983
+ : {}),
3984
+ ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
3985
+ }),
3986
+ );
3140
3987
  } catch (error) {
3141
3988
  return commandConflictResponse(c, error);
3142
3989
  }
3990
+ recordAcceptedApiAdmission(deps.observability, result);
3143
3991
  return c.json(result, 202);
3144
3992
  });
3145
3993
 
@@ -3154,15 +4002,21 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3154
4002
  const grant = authorization.grant;
3155
4003
  const sessionId = c.req.param("sessionId");
3156
4004
  await assertSessionExists(db, workspaceId, sessionId);
3157
- const payload = SubmitComposerDraftRequest.parse(await c.req.json().catch(() => null));
4005
+ const payload = parseRequestBody(
4006
+ SubmitComposerDraftRequest,
4007
+ await c.req.json().catch(() => null),
4008
+ );
3158
4009
  let result: Awaited<ReturnType<typeof submitComposerDraftForRequest>>;
3159
4010
  try {
3160
- result = await submitComposerDraftForRequest(deps, grant, workspaceId, sessionId, payload, {
3161
- authorization,
3162
- });
4011
+ result = await withSiteCommandOrigin(c, grant, () =>
4012
+ submitComposerDraftForRequest(deps, grant, workspaceId, sessionId, payload, {
4013
+ authorization,
4014
+ }),
4015
+ );
3163
4016
  } catch (error) {
3164
4017
  return commandConflictResponse(c, error);
3165
4018
  }
4019
+ recordAcceptedApiAdmission(deps.observability, result);
3166
4020
  return c.json(result, 202);
3167
4021
  });
3168
4022
 
@@ -3197,47 +4051,73 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3197
4051
  if (event.type === "user.message") {
3198
4052
  let result: Awaited<ReturnType<typeof acceptSessionUserMessage>>;
3199
4053
  try {
3200
- result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
3201
- text: event.payload.text,
3202
- annotations: event.payload.annotations,
3203
- modelContext: event.payload.modelContext ?? null,
3204
- resources: event.payload.resources ?? [],
3205
- model: event.payload.model ?? null,
3206
- reasoningEffort: event.payload.reasoningEffort ?? null,
3207
- latencyMode: event.payload.latencyMode ?? null,
3208
- mcpCredentialUpdates: event.payload.mcpCredentialUpdates ?? [],
3209
- connectionAuthorities: event.payload.connectionAuthorities,
3210
- ...(event.payload.selectedHostMcpDelegations
3211
- ? { selectedHostMcpDelegations: event.payload.selectedHostMcpDelegations }
3212
- : {}),
3213
- ...(event.payload.personalResourceAttachment
3214
- ? { personalResourceAttachment: event.payload.personalResourceAttachment }
3215
- : {}),
3216
- authorization,
3217
- ...(event.payload.controlEtag !== undefined
3218
- ? { controlEtag: event.payload.controlEtag }
3219
- : {}),
3220
- ...(event.payload.expectedDraftRevision !== undefined
3221
- ? { expectedDraftRevision: event.payload.expectedDraftRevision }
3222
- : {}),
3223
- ...(event.clientEventId ? { clientEventId: event.clientEventId } : {}),
3224
- });
4054
+ result = await withSiteCommandOrigin(c, grant, () =>
4055
+ acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
4056
+ text: event.payload.text,
4057
+ annotations: event.payload.annotations,
4058
+ modelContext: event.payload.modelContext ?? null,
4059
+ resources: event.payload.resources ?? [],
4060
+ model: event.payload.model ?? null,
4061
+ reasoningEffort: event.payload.reasoningEffort ?? null,
4062
+ latencyMode: event.payload.latencyMode ?? null,
4063
+ mcpCredentialUpdates: event.payload.mcpCredentialUpdates ?? [],
4064
+ connectionAccounts: event.payload.connectionAccounts,
4065
+ ...(event.payload.personalResourceAttachment
4066
+ ? { personalResourceAttachment: event.payload.personalResourceAttachment }
4067
+ : {}),
4068
+ authorization,
4069
+ ...(event.payload.controlEtag !== undefined
4070
+ ? { controlEtag: event.payload.controlEtag }
4071
+ : {}),
4072
+ ...(event.payload.expectedDraftRevision !== undefined
4073
+ ? { expectedDraftRevision: event.payload.expectedDraftRevision }
4074
+ : {}),
4075
+ ...(event.clientEventId ? { clientEventId: event.clientEventId } : {}),
4076
+ }),
4077
+ );
3225
4078
  } catch (error) {
3226
4079
  return commandConflictResponse(c, error);
3227
4080
  }
4081
+ recordAcceptedApiAdmission(deps.observability, result);
3228
4082
  return c.json(result.accepted, 202);
3229
4083
  }
3230
4084
 
3231
- if (event.type === "user.approvalDecision") {
3232
- const accepted = await acceptSessionApprovalDecision(db, {
3233
- accountId: grant.accountId,
3234
- workspaceId,
3235
- sessionId,
3236
- subjectId: grant.subjectId,
3237
- respondedByKind: childRequiresActionRespondedByKindForGrant(grant),
3238
- payload: event.payload,
3239
- clientEventId: event.clientEventId ?? null,
4085
+ // Responses use the same header-only validation, permission and encryption
4086
+ // boundary as Send/Steer. Persistence belongs to the response transaction,
4087
+ // never a separate rotation that could commit without accepting the reply.
4088
+ const updates = event.payload.mcpCredentialUpdates ?? [];
4089
+ let mcpCredentialUpdates = [] as ReturnType<typeof validateSessionMcpCredentialUpdates>;
4090
+ if (updates.length) {
4091
+ const session = await getSession(db, workspaceId, sessionId);
4092
+ if (!session) throw new HTTPException(404, { message: "session not found" });
4093
+ mcpCredentialUpdates = validateSessionMcpCredentialUpdates({
4094
+ settings,
4095
+ grant,
4096
+ session,
4097
+ updates,
3240
4098
  });
4099
+ }
4100
+
4101
+ if (event.type === "user.approvalDecision") {
4102
+ const { mcpCredentialUpdates: _writeOnlyCredentials, ...payload } = event.payload;
4103
+ let accepted;
4104
+ try {
4105
+ accepted = await acceptSessionApprovalDecision(db, {
4106
+ accountId: grant.accountId,
4107
+ workspaceId,
4108
+ sessionId,
4109
+ subjectId: grant.subjectId,
4110
+ respondedByKind: childRequiresActionRespondedByKindForGrant(grant),
4111
+ payload,
4112
+ mcpCredentialUpdates,
4113
+ clientEventId: event.clientEventId ?? null,
4114
+ });
4115
+ } catch (error) {
4116
+ if (error instanceof Error && error.message.startsWith("Unknown session MCP server")) {
4117
+ throw new HTTPException(422, { message: error.message });
4118
+ }
4119
+ throw error;
4120
+ }
3241
4121
  if (accepted.action === "conflict") {
3242
4122
  throw new HTTPException(409, {
3243
4123
  message: `session is ${accepted.sessionStatus}; no unhandled approval is pending`,
@@ -3257,21 +4137,45 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3257
4137
  }
3258
4138
 
3259
4139
  if (event.type === "user.humanInputResponse") {
4140
+ // Secret values are write-only: split them off before anything else sees
4141
+ // the response, generate/encrypt them, and let the accept transaction
4142
+ // write them to vaults. The stored response and event get references.
4143
+ const { response, secretAnswers: rawSecretAnswers } = splitSecretAnswers(
4144
+ event.payload.response,
4145
+ event.payload.secretAnswers,
4146
+ );
3260
4147
  let accepted;
3261
4148
  try {
4149
+ const secretAnswers = prepareSecretAnswers(settings, rawSecretAnswers, "human");
3262
4150
  accepted = await acceptSessionHumanInputResponse(db, {
3263
4151
  accountId: grant.accountId,
3264
4152
  workspaceId,
3265
4153
  sessionId,
3266
4154
  requestId: event.payload.requestId,
3267
- response: event.payload.response,
4155
+ response,
4156
+ ...(secretAnswers.length
4157
+ ? {
4158
+ secretAnswers,
4159
+ secretWriteContext: humanSecretWriteContext({
4160
+ accountId: grant.accountId,
4161
+ workspaceId,
4162
+ subjectId: grant.subjectId,
4163
+ }),
4164
+ }
4165
+ : {}),
3268
4166
  respondedBy: grant.subjectId,
3269
4167
  canonicalHumanSession:
3270
- authorization.canonicalManagedHumanSession || authorization.canonicalLocalHumanSession,
4168
+ authorization.canonicalManagedHumanSession ||
4169
+ authorization.canonicalLocalHumanSession ||
4170
+ isVerifiedDelegatedHumanAuthorization(authorization),
3271
4171
  respondedByKind: childRequiresActionRespondedByKindForGrant(grant),
3272
4172
  clientEventId: event.clientEventId ?? null,
4173
+ mcpCredentialUpdates,
3273
4174
  });
3274
4175
  } catch (error) {
4176
+ if (error instanceof Error && error.message.startsWith("Unknown session MCP server")) {
4177
+ throw new HTTPException(422, { message: error.message });
4178
+ }
3275
4179
  if (error instanceof SkillHumanResponseError) {
3276
4180
  throw new HTTPException(
3277
4181
  error.code === "conflict" ? 409 : error.code === "forbidden" ? 403 : 422,
@@ -3292,6 +4196,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3292
4196
  },
3293
4197
  );
3294
4198
  }
4199
+ const vaultError = isVaultError(error) ? error : vaultErrorFromDatabase(error);
4200
+ if (vaultError) {
4201
+ const body: VaultApiError = {
4202
+ error: vaultError.code,
4203
+ message: vaultError.message,
4204
+ ...(vaultError.details ? { details: vaultError.details } : {}),
4205
+ };
4206
+ return c.json(body, VAULT_ERROR_HTTP_STATUS[vaultError.code] as ContentfulStatusCode);
4207
+ }
3295
4208
  throw error;
3296
4209
  }
3297
4210
  if (accepted.action === "not_found") {
@@ -3382,13 +4295,18 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3382
4295
  // Resolve the shared-exposure disclosure for a session's group: `shared` when
3383
4296
  // the group has >1 session (addendum E.1), and the OTHER sessions' ids ONLY
3384
4297
  // (never their conversation/metadata; the query selects only id — stress g).
4298
+ // `shared` counts every member, including Only-me sessions the caller cannot
4299
+ // read, because sharing is a property of the box; the ids stay actor-scoped.
3385
4300
  async function resolveSharedExposure(
3386
4301
  workspaceId: string,
3387
4302
  session: { id: string; sandboxGroupId: string },
3388
4303
  ): Promise<{ shared: boolean; sharedSessionIds: string[] }> {
3389
- const ids = await listSessionIdsInGroup(db, workspaceId, session.sandboxGroupId);
4304
+ const [ids, memberCount] = await Promise.all([
4305
+ listSessionIdsInGroup(db, workspaceId, session.sandboxGroupId),
4306
+ countAllSessionsInGroup(db, workspaceId, session.sandboxGroupId),
4307
+ ]);
3390
4308
  const others = ids.filter((id) => id !== session.id);
3391
- return { shared: others.length > 0, sharedSessionIds: others };
4309
+ return { shared: memberCount > 1 || others.length > 0, sharedSessionIds: others };
3392
4310
  }
3393
4311
 
3394
4312
  // GET .../stream-capabilities — the capability-negotiation read. Returns the
@@ -3755,6 +4673,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3755
4673
  workspaceId,
3756
4674
  session,
3757
4675
  viewerSubjectId: grant.subjectId,
4676
+ grant,
3758
4677
  waitSignal: c.req.raw.signal,
3759
4678
  ...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
3760
4679
  });
@@ -4008,6 +4927,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
4008
4927
  return c.json(out);
4009
4928
  });
4010
4929
 
4930
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/read-workspace", async (c) => {
4931
+ const ctx = await channelAPreamble(c, "files:read", "fs.read");
4932
+ const req = await parseChannelABody(c, FsReadRequest);
4933
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
4934
+ service.fsRead({ ...req, workspaceOnly: true }),
4935
+ );
4936
+ return c.json(out);
4937
+ });
4938
+
4011
4939
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/artifacts/publish", async (c) => {
4012
4940
  const workspaceId = c.req.param("workspaceId");
4013
4941
  const authorization = await requireAccessGrantAuthorization(
@@ -4517,11 +5445,19 @@ function codexRealtimeHttpFailure(error: CodexRealtimeBrokerError): {
4517
5445
  }
4518
5446
  }
4519
5447
 
4520
- function sessionRealtimeHttpError(error: unknown): HTTPException {
5448
+ /** Conflicts a person can hit by starting voice; the browser shows these verbatim. */
5449
+ const SESSION_REALTIME_USER_MESSAGES: Partial<Record<string, string>> = {
5450
+ REALTIME_ACTIVE:
5451
+ "Voice is already on for this session in another tab or window. End it there, or try again in a minute.",
5452
+ CONTROL_NOT_ACTIVE: "Resume this session to start voice.",
5453
+ SESSION_CANCELLED: "This session was cancelled, so voice can't start.",
5454
+ };
5455
+
5456
+ export function sessionRealtimeHttpError(error: unknown): HTTPException {
4521
5457
  if (error instanceof HTTPException) return error;
4522
5458
  if (error instanceof SessionRealtimeConflictError) {
4523
5459
  return new HTTPException(error.code === "REALTIME_NOT_FOUND" ? 404 : 409, {
4524
- message: error.message,
5460
+ message: SESSION_REALTIME_USER_MESSAGES[error.code] ?? error.message,
4525
5461
  cause: error,
4526
5462
  });
4527
5463
  }
@@ -4553,11 +5489,28 @@ export function sessionAuthorizationOperationForHttp(
4553
5489
  if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
4554
5490
  if (suffix === "/attention" && verb === "PUT") return "session.attention.write";
4555
5491
  if (suffix === "/archive" && verb === "PUT") return "session.archive.write";
5492
+ if (suffix === "/retention" && verb === "PUT") return "session.retention.write";
5493
+ // Task usage limit: read with the session; change as a control write
5494
+ // (the route additionally admits only a person or the host server key).
5495
+ if (suffix === "/usage-limit") {
5496
+ if (verb === "GET") return "session.read";
5497
+ if (verb === "PUT") return "session.control";
5498
+ return null;
5499
+ }
4556
5500
  if (suffix === "/visibility" && verb === "PUT") return "session.visibility.write";
5501
+ // Owner-only preview of the same change: identical authority to making it.
5502
+ if (suffix === "/visibility/preview" && verb === "GET") return "session.visibility.write";
4557
5503
  if (suffix === "/forks" && verb === "POST") return "session.fork.create";
4558
5504
  if (suffix === "/channel" && verb === "PUT") return "session.channel.write";
4559
5505
  if (suffix === "/variable-sets" && verb === "PUT") return "session.variable_sets.write";
4560
5506
  if (suffix === "/tool-policy" && verb === "PUT") return "session.tool_policy.write";
5507
+ if (suffix === "/agent" && verb === "PUT") return "session.tool_policy.write";
5508
+ if (suffix === "/skills" && verb === "PUT") return "session.tool_policy.write";
5509
+ if (suffix === "/admin-access") {
5510
+ if (verb === "GET") return "session.read";
5511
+ if (verb === "PUT" || verb === "DELETE") return "session.tool_policy.write";
5512
+ return null;
5513
+ }
4561
5514
  if (suffix === "/mcp-credentials/rotate" && verb === "POST")
4562
5515
  return "session.mcp.credentials.rotate";
4563
5516
  if (/^\/mcp-servers\/[^/]+\/approval-policy$/.test(suffix) && verb === "PATCH") {
@@ -4566,11 +5519,21 @@ export function sessionAuthorizationOperationForHttp(
4566
5519
  if (suffix === "/lineage" && verb === "GET") return "session.lineage.read";
4567
5520
  if (suffix === "/background-commands" && verb === "GET") return "session.read";
4568
5521
  if (suffix === "/model-context" && verb === "GET") return "session.read";
5522
+ if (/^\/tool-reviews\/[^/]+(?:\/details)?$/.test(suffix) && verb === "GET") return "session.read";
5523
+ if (suffix === "/codex-accounts" && verb === "GET") return "session.read";
4569
5524
  if (/^\/background-commands\/[^/]+$/.test(suffix) && verb === "DELETE") {
4570
5525
  return "session.control";
4571
5526
  }
4572
5527
  if (suffix === "/codex-account" && verb === "POST") {
4573
- return "session.codex_account.write";
5528
+ return "session.model_account.write";
5529
+ }
5530
+ // The provider-neutral account choice (any provider) and the older
5531
+ // per-provider pin share one internal operation. Core adapts its name at the
5532
+ // host boundary according to the authorization port's explicit capability.
5533
+ if (suffix === "/model-account") {
5534
+ if (verb === "GET") return "session.read";
5535
+ if (verb === "PUT") return "session.model_account.write";
5536
+ return null;
4574
5537
  }
4575
5538
  if (suffix === "/realtime/webrtc" && verb === "POST") {
4576
5539
  return "session.realtime.start";
@@ -4615,6 +5578,8 @@ export function sessionAuthorizationOperationForHttp(
4615
5578
  return verb === "POST" ? "session.context.write" : null;
4616
5579
  }
4617
5580
  if (suffix === "/events/stream" && verb === "GET") return "session.stream.read";
5581
+ if (/^\/events\/[^/]+\/message-preview$/.test(suffix) && verb === "GET")
5582
+ return "session.events.read";
4618
5583
  if (suffix === "/events") {
4619
5584
  if (verb === "GET") return "session.events.read";
4620
5585
  if (verb === "POST") return "session.append";
@@ -4632,6 +5597,8 @@ export function sessionAuthorizationOperationForHttp(
4632
5597
  // acceptSessionUserMessageWithOutcome after the body is parsed.
4633
5598
  if (suffix === "/composer-draft/submit" && verb === "POST") return "session.append";
4634
5599
  if (suffix === "/control" && verb === "POST") return "session.control";
5600
+ if (suffix === "/retry" && verb === "POST") return "session.control";
5601
+ if (suffix === "/sandbox-recovery" && ["GET", "POST"].includes(verb)) return "session.control";
4635
5602
  if (suffix === "/steer" && verb === "POST") return "session.steer";
4636
5603
  if (suffix === "/human-input-requests" && verb === "GET") {
4637
5604
  return "session.human_input.read";
@@ -4647,10 +5614,16 @@ export function sessionAuthorizationOperationForHttp(
4647
5614
  if (suffix.startsWith("/viewers/") && ["POST", "DELETE"].includes(verb)) {
4648
5615
  return "session.viewer.control";
4649
5616
  }
4650
- if (suffix === "/fs/list" || suffix === "/fs/list-batch" || suffix === "/fs/read") {
5617
+ if (
5618
+ suffix === "/fs/list" ||
5619
+ suffix === "/fs/list-batch" ||
5620
+ suffix === "/fs/read" ||
5621
+ suffix === "/fs/read-workspace"
5622
+ ) {
4651
5623
  return verb === "POST" ? "session.files.read" : null;
4652
5624
  }
4653
5625
  if (suffix === "/artifacts/publish" && verb === "POST") return "session.files.write";
5626
+ if (suffix.startsWith("/artifact-associations/") && verb === "GET") return "session.read";
4654
5627
  if (["/fs/write", "/fs/delete", "/fs/move", "/fs/mkdir"].includes(suffix)) {
4655
5628
  return verb === "POST" ? "session.files.write" : null;
4656
5629
  }
@@ -4679,6 +5652,17 @@ function sessionAuthorizationHttpError(error: unknown): HTTPException {
4679
5652
  throw error;
4680
5653
  }
4681
5654
 
5655
+ const SESSION_FORK_POINT_REFUSAL_MESSAGES: Record<SessionForkPointRefusal, string> = {
5656
+ message_unavailable:
5657
+ "Only your messages and the agent's replies can be fork points. Choose one of those, or fork the whole session.",
5658
+ boundary_not_retained:
5659
+ "This message is from before the conversation was compacted, so it can't be a fork point. Choose a more recent message, or fork the whole session.",
5660
+ history_compacted:
5661
+ "The conversation up to this message was compacted, so it can't be copied as it was then. Choose a more recent message, or fork the whole session.",
5662
+ unfinished_tool_exchange:
5663
+ "The agent was still waiting for a tool result at this message. Choose a later message, or fork the whole session.",
5664
+ };
5665
+
4682
5666
  export function sessionTenancyHttpError(error: unknown): Error {
4683
5667
  if (error instanceof SessionTenancyManagedHumanRequiredError) {
4684
5668
  return new ApiHttpError(403, {
@@ -4690,7 +5674,7 @@ export function sessionTenancyHttpError(error: unknown): Error {
4690
5674
  if (error instanceof SessionTenancyNotActivatedError) {
4691
5675
  return new ApiHttpError(409, {
4692
5676
  code: "conflict",
4693
- message: "Session tenancy is not activated for this organization.",
5677
+ message: "Only-me chats are not enabled for this organization.",
4694
5678
  retryable: false,
4695
5679
  details: { reason: "not_activated" },
4696
5680
  });
@@ -4711,6 +5695,25 @@ export function sessionTenancyHttpError(error: unknown): Error {
4711
5695
  },
4712
5696
  });
4713
5697
  }
5698
+ if (error instanceof SessionVisibilityWorkConflictError) {
5699
+ return new ApiHttpError(409, {
5700
+ code: "conflict",
5701
+ message:
5702
+ error.reason === "active_work"
5703
+ ? "The chat has work in progress. Send stopActiveWork to stop it and make the change."
5704
+ : "The chat's work is still stopping. Retry with the same idempotency key.",
5705
+ retryable: error.reason === "stopping_active_work",
5706
+ details: {
5707
+ reason: error.reason,
5708
+ runningTurnCount: error.work.runningTurnCount,
5709
+ queuedTurnCount: error.work.queuedTurnCount,
5710
+ activeGoal: error.work.activeGoal,
5711
+ liveVoice: error.work.liveVoice,
5712
+ otherPeoplePendingUpdateCount: error.work.otherPeoplePendingUpdateCount,
5713
+ otherPeopleAccountChoiceCount: error.work.otherPeopleAccountChoiceCount,
5714
+ },
5715
+ });
5716
+ }
4714
5717
  if (
4715
5718
  error instanceof SessionTenancyAccessError ||
4716
5719
  error instanceof SessionAuthorizationDeniedError
@@ -4722,6 +5725,14 @@ export function sessionTenancyHttpError(error: unknown): Error {
4722
5725
  });
4723
5726
  }
4724
5727
  if (error instanceof SessionTenancyInvalidRequestError) {
5728
+ if (error.forkPointRefusal) {
5729
+ return new ApiHttpError(422, {
5730
+ code: "validation_failed",
5731
+ message: SESSION_FORK_POINT_REFUSAL_MESSAGES[error.forkPointRefusal],
5732
+ retryable: false,
5733
+ details: { reason: `fork_point_${error.forkPointRefusal}` },
5734
+ });
5735
+ }
4725
5736
  return new ApiHttpError(422, {
4726
5737
  code: "validation_failed",
4727
5738
  message: "Invalid session tenancy request.",
@@ -4795,7 +5806,7 @@ function eventEnumList<T extends string>(
4795
5806
  });
4796
5807
  }
4797
5808
 
4798
- function sessionListQuery(
5809
+ export function sessionListQuery(
4799
5810
  query: Record<string, string>,
4800
5811
  allowCursor = true,
4801
5812
  ): {
@@ -4805,7 +5816,13 @@ function sessionListQuery(
4805
5816
  cursor: ReturnType<typeof decodeSessionListCursor> | undefined;
4806
5817
  search: string | undefined;
4807
5818
  pinsOnly: boolean;
5819
+ includePinned: boolean;
5820
+ includeTotals: boolean;
5821
+ needsYouOnly: boolean;
5822
+ contentArchivedOnly: boolean;
4808
5823
  archivedOnly: boolean;
5824
+ sortBy: "updatedAt" | "createdAt" | "name" | undefined;
5825
+ archiveStatus: "active" | "archived" | "all" | undefined;
4809
5826
  channelId: string | null | undefined;
4810
5827
  createdBy: { kind: "subject" | "service"; subjectId: string } | undefined;
4811
5828
  updatedFrom: Date | undefined;
@@ -4848,10 +5865,42 @@ function sessionListQuery(
4848
5865
  });
4849
5866
  }
4850
5867
  const pinsOnly = query.pinsOnly === "true";
5868
+ if (query.includePinned !== undefined && !["true", "false"].includes(query.includePinned)) {
5869
+ throw new HTTPException(400, { message: 'includePinned must be "true" or "false"' });
5870
+ }
5871
+ const includePinned = query.includePinned !== "false";
5872
+ for (const key of ["includeTotals", "needsYouOnly", "contentArchivedOnly"]) {
5873
+ if (query[key] !== undefined && !["true", "false"].includes(query[key]!))
5874
+ throw new HTTPException(400, { message: `${key} must be "true" or "false"` });
5875
+ }
5876
+ const includeTotals = query.includeTotals === "true";
5877
+ const needsYouOnly = query.needsYouOnly === "true";
5878
+ const contentArchivedOnly = query.contentArchivedOnly === "true";
5879
+ if (includeTotals && (!allowCursor || (parentSessionId !== "null" && !pinsOnly)))
5880
+ throw new HTTPException(400, { message: "includeTotals requires a root page" });
5881
+ if (needsYouOnly && !allowCursor)
5882
+ throw new HTTPException(400, { message: 'needsYouOnly requires view="page"' });
5883
+ if (pinsOnly && !includePinned) {
5884
+ throw new HTTPException(400, { message: "pinsOnly requires includePinned" });
5885
+ }
4851
5886
  if (query.archivedOnly !== undefined && query.archivedOnly !== "true") {
4852
5887
  throw new HTTPException(400, { message: 'archivedOnly must be the literal "true"' });
4853
5888
  }
4854
5889
  const archivedOnly = query.archivedOnly === "true";
5890
+ const sortBy = z.enum(["updatedAt", "createdAt", "name"]).optional().safeParse(query.sortBy);
5891
+ const archiveStatus = z
5892
+ .enum(["active", "archived", "all"])
5893
+ .optional()
5894
+ .safeParse(query.archiveStatus);
5895
+ if (!sortBy.success || !archiveStatus.success) {
5896
+ throw new HTTPException(400, { message: "Invalid session sortBy or archiveStatus" });
5897
+ }
5898
+ if (archivedOnly && archiveStatus.data !== undefined && archiveStatus.data !== "archived") {
5899
+ throw new HTTPException(400, { message: "archivedOnly conflicts with archiveStatus" });
5900
+ }
5901
+ if (pinsOnly && archiveStatus.data === "archived") {
5902
+ throw new HTTPException(400, { message: "pinsOnly cannot be combined with archived status" });
5903
+ }
4855
5904
  const channelId = query.channelId;
4856
5905
  if (
4857
5906
  channelId !== undefined &&
@@ -4918,12 +5967,14 @@ function sessionListQuery(
4918
5967
  const parsedEndUser = SessionScopeSubjectId.safeParse(query.scopeSubjectId);
4919
5968
  if (!parsedEndUser.success) {
4920
5969
  throw new HTTPException(400, {
4921
- message: "scopeSubjectId must be a canonical OpenGeni user subject",
5970
+ message: "scopeSubjectId must be a canonical Opengeni user subject",
4922
5971
  });
4923
5972
  }
4924
5973
  scopeSubjectId = parsedEndUser.data;
4925
5974
  }
4926
5975
  const hasPageFilters =
5976
+ needsYouOnly ||
5977
+ contentArchivedOnly ||
4927
5978
  originSiteId !== undefined ||
4928
5979
  channelId !== undefined ||
4929
5980
  createdByKind !== undefined ||
@@ -4955,7 +6006,13 @@ function sessionListQuery(
4955
6006
  cursor,
4956
6007
  search: search || undefined,
4957
6008
  pinsOnly,
6009
+ includePinned,
6010
+ includeTotals,
6011
+ needsYouOnly,
6012
+ contentArchivedOnly,
4958
6013
  archivedOnly,
6014
+ sortBy: sortBy.data,
6015
+ archiveStatus: archiveStatus.data,
4959
6016
  channelId: channelId === undefined ? undefined : channelId === "null" ? null : channelId,
4960
6017
  createdBy:
4961
6018
  createdByKind && createdBySubjectId
@@ -5310,8 +6367,182 @@ function optionalEventSequence(raw: string | undefined): number | undefined {
5310
6367
  return Math.floor(sequence);
5311
6368
  }
5312
6369
 
6370
+ /**
6371
+ * The minimal Site profile of a session a Site page starts: only the allowed
6372
+ * create fields, metadata without reserved `_opengeni` keys, no vault access,
6373
+ * and the workspace's default agent capabilities without workspace
6374
+ * administration or browser and computer control, so the page cannot reach
6375
+ * the viewer's Connected Machines, sandboxes or browsers. Workers it spawns
6376
+ * only narrow that. It runs without a sandbox; its first-party tools are the
6377
+ * ones the Site declares plus the Site's own data tools, and its MCP
6378
+ * connections only the ones the Site declares. Everything else (resources,
6379
+ * Skills, learning, goals) is the workspace default for a plain chat. Agents
6380
+ * started from a Site stay clamped
6381
+ * (`assertNotSiteClampedAgent`).
6382
+ */
6383
+ async function siteFrameSessionProfile(
6384
+ deps: ApiRouteDeps,
6385
+ workspaceId: string,
6386
+ payload: unknown,
6387
+ frame: { accountId: string; siteId: string | null; versionId: string | null },
6388
+ ): Promise<Record<string, unknown>> {
6389
+ const body = (payload ?? {}) as Record<string, unknown>;
6390
+ const profile: Record<string, unknown> = {};
6391
+ for (const field of SITE_FRAME_SESSION_CREATE_FIELDS)
6392
+ if (body[field] !== undefined && body[field] !== null) profile[field] = body[field];
6393
+ const metadata = body.metadata;
6394
+ profile.metadata =
6395
+ metadata && typeof metadata === "object" && !Array.isArray(metadata)
6396
+ ? Object.fromEntries(Object.entries(metadata).filter(([key]) => !key.startsWith("_opengeni")))
6397
+ : {};
6398
+ profile.vaultPolicy = { mode: "selected", vaultIds: [] };
6399
+ // Tools are the overlap of what the Site declares and what the person may
6400
+ // use: only the workspace MCP connections the Site version requests (the
6401
+ // person's own access to each still applies), never the workspace
6402
+ // default set. A frame with no published Site declares none.
6403
+ profile.tools = await siteFrameDeclaredTools(deps, workspaceId, frame);
6404
+ const unavailable = agentConfigDeploymentPolicy(deps.settings).unavailable;
6405
+ const capabilities = siteFrameCapabilities(
6406
+ resolveWorkspaceAgentDefaults((await requireWorkspace(deps.db, workspaceId)).settings)
6407
+ ?.capabilities ?? "all",
6408
+ unavailable,
6409
+ );
6410
+ profile.agent = { capabilities };
6411
+ // First-party tools are the ones the Site declares plus the Site's own data
6412
+ // tools, never the workspace default set; a Site page's agent runs without
6413
+ // a sandbox.
6414
+ profile.firstPartyMcpTools = (await siteFrameFirstPartyTools(deps, workspaceId, frame)).filter(
6415
+ (tool) => {
6416
+ const owner = FIRST_PARTY_MCP_TOOL_CAPABILITIES[tool];
6417
+ if (owner === "runtime") return true;
6418
+ if (owner === "sandbox" || unavailable[owner] !== undefined) return false;
6419
+ const value = (capabilities as Record<string, unknown>)[owner];
6420
+ return value === undefined ? capabilities.from === "all" : value !== false;
6421
+ },
6422
+ );
6423
+ profile.sandboxBackend = "none";
6424
+ return profile;
6425
+ }
6426
+
6427
+ /**
6428
+ * The first-party tools of a Site page's session: the session runtime's own,
6429
+ * the first-party tools the Site version declares, and, for a published Site,
6430
+ * its own data tools (`site_data_query`, `site_data_write`, and the Site SQL
6431
+ * tools when Site SQL is on), all within the deployment's tool allowlist.
6432
+ */
6433
+ async function siteFrameFirstPartyTools(
6434
+ deps: ApiRouteDeps,
6435
+ workspaceId: string,
6436
+ frame: { accountId: string; siteId: string | null; versionId: string | null },
6437
+ ): Promise<FirstPartyMcpToolName[]> {
6438
+ const allowed = new Set<string>(resolveFirstPartyMcpToolPolicy(deps.settings).allowed);
6439
+ const tools = new Set<FirstPartyMcpToolName>(["set_session_title", "wait_for_input"]);
6440
+ if (frame.siteId && frame.versionId) {
6441
+ const scope = { accountId: frame.accountId, workspaceId };
6442
+ const version = await getPublishedSiteVersion(
6443
+ deps.db,
6444
+ scope,
6445
+ frame.siteId,
6446
+ frame.versionId,
6447
+ ).catch(() => null);
6448
+ for (const tool of version?.requestedTools ?? []) {
6449
+ const parsed = FirstPartyMcpToolName.safeParse(tool.toolName);
6450
+ if (tool.serverId === "opengeni" && parsed.success) tools.add(parsed.data);
6451
+ }
6452
+ if (version)
6453
+ for (const tool of siteAgentDataToolNames({
6454
+ viewerKind: "member",
6455
+ siteSql: await siteAgentSiteSqlOn(deps, scope, frame.siteId),
6456
+ allowed: [...allowed],
6457
+ }))
6458
+ tools.add(tool);
6459
+ }
6460
+ return [...tools].filter((tool) => allowed.has(tool)).sort();
6461
+ }
6462
+
6463
+ /** The workspace MCP connections (not the first-party server) a Site version declares. */
6464
+ async function siteFrameDeclaredTools(
6465
+ deps: ApiRouteDeps,
6466
+ workspaceId: string,
6467
+ frame: { accountId: string; siteId: string | null; versionId: string | null },
6468
+ ): Promise<ToolRef[]> {
6469
+ if (!frame.siteId || !frame.versionId) return [];
6470
+ const version = await getPublishedSiteVersion(
6471
+ deps.db,
6472
+ { accountId: frame.accountId, workspaceId },
6473
+ frame.siteId,
6474
+ frame.versionId,
6475
+ ).catch(() => null);
6476
+ const declared = new Map<string, Set<string>>();
6477
+ for (const { serverId, toolName } of version?.requestedTools ?? []) {
6478
+ if (serverId === "opengeni" || !/^[A-Za-z0-9_-]{1,200}$/u.test(serverId)) continue;
6479
+ const names = declared.get(serverId) ?? new Set<string>();
6480
+ names.add(toolName);
6481
+ declared.set(serverId, names);
6482
+ }
6483
+ return [...declared].map(([id, names]) => ({
6484
+ kind: "mcp",
6485
+ id,
6486
+ optional: true,
6487
+ allowedTools: [...names].sort(),
6488
+ }));
6489
+ }
6490
+
6491
+ /**
6492
+ * A Site page's session capabilities: the workspace default with workspace
6493
+ * administration (sandboxes, Connected Machines, `run_on`) and browser and
6494
+ * computer control off. Capabilities this server does not offer are left to
6495
+ * the default, so the create never fails on them.
6496
+ */
6497
+ export function siteFrameCapabilities(
6498
+ workspaceDefault: AgentCapabilities,
6499
+ unavailable: Readonly<Partial<Record<string, string>>>,
6500
+ ): Exclude<AgentCapabilities, string> {
6501
+ if (typeof workspaceDefault === "string")
6502
+ return { from: workspaceDefault, workspaceAdmin: false, browser: false };
6503
+ const capabilities: Record<string, unknown> = {};
6504
+ for (const [name, value] of Object.entries(workspaceDefault))
6505
+ if (name === "from" || value === false || unavailable[name] === undefined)
6506
+ capabilities[name] = value;
6507
+ return {
6508
+ ...(capabilities as Exclude<AgentCapabilities, string>),
6509
+ workspaceAdmin: false,
6510
+ browser: false,
6511
+ };
6512
+ }
6513
+
5313
6514
  /** Stable, value-free JSON errors for only the create-session boundary. */
5314
6515
  export function sessionCreateErrorResponse(c: Context, error: unknown): Response {
6516
+ if (error instanceof SessionUsageLimitHttpError) {
6517
+ // a create-time usage limit the caller may not set (agents and
6518
+ // children) or that this server does not accept yet (producer flag off).
6519
+ return c.json(
6520
+ {
6521
+ code: error.status === 403 ? "SESSION_CREATE_FORBIDDEN" : "SESSION_CREATE_REJECTED",
6522
+ message: error.message,
6523
+ details: { code: error.code },
6524
+ },
6525
+ error.status as 403 | 404 | 409 | 422 | 503,
6526
+ );
6527
+ }
6528
+ if (error instanceof UnsupportedLatencyModeError) {
6529
+ return c.json(
6530
+ {
6531
+ code: "SESSION_CREATE_REJECTED",
6532
+ message: error.message,
6533
+ details: {
6534
+ code: error.code,
6535
+ modelId: error.modelId,
6536
+ latencyMode: error.latencyMode,
6537
+ allowedLatencyModes: [...error.allowedLatencyModes],
6538
+ },
6539
+ },
6540
+ 422,
6541
+ );
6542
+ }
6543
+ if (error instanceof SessionCreateConnectionSelectionUnavailableError) {
6544
+ return c.json({ code: error.code, message: error.message, retryable: false }, 409);
6545
+ }
5315
6546
  if (error instanceof SessionTenancyManagedHumanRequiredError) {
5316
6547
  return c.json(
5317
6548
  {
@@ -5374,6 +6605,24 @@ export function sessionCreateErrorResponse(c: Context, error: unknown): Response
5374
6605
  409,
5375
6606
  );
5376
6607
  }
6608
+ if (
6609
+ error instanceof HTTPException &&
6610
+ error.status === 422 &&
6611
+ error.cause instanceof AgentConfigError
6612
+ ) {
6613
+ // Typed agent-configuration failure: the specific code is details.code.
6614
+ return c.json(
6615
+ {
6616
+ code: "SESSION_CREATE_REJECTED",
6617
+ message: error.message,
6618
+ details: {
6619
+ code: error.cause.code,
6620
+ ...(error.cause.capability ? { capability: error.cause.capability } : {}),
6621
+ },
6622
+ },
6623
+ 422,
6624
+ );
6625
+ }
5377
6626
  if (error instanceof HTTPException && error.status === 422) {
5378
6627
  return c.json(
5379
6628
  {
@@ -5425,6 +6674,52 @@ function zodErrorFields(error: ZodError): string {
5425
6674
  return `${shown.join(", ")}${remainder > 0 ? `, and ${remainder} more` : ""}`;
5426
6675
  }
5427
6676
 
6677
+ /** Statuses in which a chat still has work that would keep running after it is archived. */
6678
+ const ARCHIVE_LIVE_WORK_STATUSES: ReadonlySet<string> = new Set([
6679
+ "queued",
6680
+ "running",
6681
+ "requires_action",
6682
+ "recovering",
6683
+ "waiting_capacity",
6684
+ ]);
6685
+
6686
+ /**
6687
+ * Archiving a chat you started means you are done with it, so work it still
6688
+ * has (a running or queued turn, a pending wait, an active goal) must not keep
6689
+ * running out of sight. It is paused exactly like the Pause control: reversible
6690
+ * with Resume, and a paused chat dispatches no goal continuations either. A
6691
+ * chat someone else started is never paused: archiving only hides it for you.
6692
+ * Settled chats are left alone so a later message to them still just runs.
6693
+ */
6694
+ async function pauseOwnArchivedWork(
6695
+ deps: ApiRouteDeps,
6696
+ grant: AccessGrant,
6697
+ workspaceId: string,
6698
+ session: Session,
6699
+ ): Promise<Session> {
6700
+ if (session.createdBy.kind !== "subject" || session.createdBy.subjectId !== grant.subjectId)
6701
+ return session;
6702
+ if (!hasPermission(grant.permissions, "sessions:control")) return session;
6703
+ if (session.effectiveControl.directState === "paused") return session;
6704
+ const liveWork =
6705
+ ARCHIVE_LIVE_WORK_STATUSES.has(session.status) ||
6706
+ session.activeTurnId !== null ||
6707
+ Boolean(session.inputWait) ||
6708
+ (await getSessionGoal(deps.db, workspaceId, session.id))?.status === "active";
6709
+ if (!liveWork) return session;
6710
+ const paused = await controlHumanSessionWorkstream(
6711
+ deps,
6712
+ { accountId: grant.accountId, workspaceId, sessionId: session.id, subjectId: grant.subjectId },
6713
+ {
6714
+ action: "pause",
6715
+ reason: "Archived",
6716
+ // One pause per archive: a retried archive replays it instead of pausing twice.
6717
+ clientEventId: `archive-pause:${session.id}:${session.archiveVersion}`,
6718
+ },
6719
+ );
6720
+ return { ...session, effectiveControl: paused.effectiveControl };
6721
+ }
6722
+
5428
6723
  function commandConflictResponse(c: Context, error: unknown): Response {
5429
6724
  if (error instanceof QueueCommandConflictError) {
5430
6725
  return c.json({ code: error.code, message: error.message, current: error.current }, 409);
@@ -5435,24 +6730,56 @@ function commandConflictResponse(c: Context, error: unknown): Response {
5435
6730
  if (error instanceof SessionCommandIdempotencyError) {
5436
6731
  return c.json({ code: error.code, message: error.message }, 409);
5437
6732
  }
6733
+ if (error instanceof PersonalSessionInitiatorRequiredError) {
6734
+ return c.json({ code: error.code, message: error.message }, 403);
6735
+ }
5438
6736
  throw error;
5439
6737
  }
5440
6738
 
5441
6739
  type EffectivePolicyContext = {
5442
6740
  workspaceServerIds: string[];
5443
6741
  workspaceDefaultServerIds: string[];
6742
+ effectiveToolsContext: Awaited<ReturnType<typeof workspaceSessionEffectiveToolsContext>>;
6743
+ /** The Codex session pointers by cutover disposition (see codex-session-pointers). */
6744
+ codexPointers: CodexSessionPointerProjection;
5444
6745
  };
5445
6746
 
6747
+ /**
6748
+ * Everything a Session response projection needs, loaded once for all the
6749
+ * sessions it returns. Every route that returns a Session projects it through
6750
+ * `sessionResponse` with this context.
6751
+ */
5446
6752
  async function loadEffectivePolicyContext(
5447
6753
  deps: ApiRouteDeps,
5448
6754
  workspaceId: string,
5449
6755
  subjectId: string,
6756
+ sessions: readonly Session[],
5450
6757
  ): Promise<EffectivePolicyContext> {
5451
- const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
5452
- workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings, subjectId),
5453
- workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings, subjectId),
6758
+ // Both projections read the same workspace row; hydrate it once.
6759
+ const workspaceRead = requireWorkspace(deps.db, workspaceId);
6760
+ void workspaceRead.catch(() => undefined);
6761
+ const [policy, effectiveToolsContext, codexPointers] = await Promise.all([
6762
+ workspaceSessionToolPolicyContext(
6763
+ deps.db,
6764
+ workspaceId,
6765
+ deps.settings,
6766
+ subjectId,
6767
+ workspaceRead,
6768
+ ),
6769
+ workspaceSessionEffectiveToolsContext(deps, workspaceId, subjectId, sessions, workspaceRead),
6770
+ loadCodexSessionPointerProjection(deps.db, workspaceId, sessions),
5454
6771
  ]);
5455
- return { workspaceServerIds, workspaceDefaultServerIds };
6772
+ return { ...policy, effectiveToolsContext, codexPointers };
6773
+ }
6774
+
6775
+ /** The response projection of one Session: Codex pointers and effective tool policy. */
6776
+ function sessionResponse(session: Session, policy: EffectivePolicyContext): Session {
6777
+ return sessionWithEffectiveToolPolicy(
6778
+ policy.codexPointers(session),
6779
+ policy.workspaceServerIds,
6780
+ policy.workspaceDefaultServerIds,
6781
+ policy.effectiveToolsContext,
6782
+ );
5456
6783
  }
5457
6784
 
5458
6785
  async function withEffectivePolicy(
@@ -5461,22 +6788,14 @@ async function withEffectivePolicy(
5461
6788
  subjectId: string,
5462
6789
  session: Session,
5463
6790
  ): Promise<Session> {
5464
- const policy = await loadEffectivePolicyContext(deps, workspaceId, subjectId);
5465
- return sessionWithEffectiveToolPolicy(
5466
- session,
5467
- policy.workspaceServerIds,
5468
- policy.workspaceDefaultServerIds,
5469
- );
6791
+ const policy = await loadEffectivePolicyContext(deps, workspaceId, subjectId, [session]);
6792
+ return sessionResponse(session, policy);
5470
6793
  }
5471
6794
 
5472
6795
  function mapLineageNodes(nodes: LineageNode[], policy: EffectivePolicyContext): LineageNode[] {
5473
6796
  return nodes.map((node) => ({
5474
6797
  ...node,
5475
- session: sessionWithEffectiveToolPolicy(
5476
- node.session as Session,
5477
- policy.workspaceServerIds,
5478
- policy.workspaceDefaultServerIds,
5479
- ),
6798
+ session: sessionResponse(node.session as Session, policy),
5480
6799
  children: mapLineageNodes(node.children, policy),
5481
6800
  }));
5482
6801
  }