@opengeni/api-router 0.30.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (436) hide show
  1. package/dist/access-grant-rls.d.ts +3 -0
  2. package/dist/admission-trace.d.ts +8 -0
  3. package/dist/api-websocket.d.ts +18 -0
  4. package/dist/app.d.ts +50 -2
  5. package/dist/app.js +12 -1
  6. package/dist/artifact-catalog-cursor.d.ts +27 -0
  7. package/dist/auth/managed-auth-attempt-context.d.ts +10 -0
  8. package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
  9. package/dist/auth/managed-auth-session-adapter.d.ts +6 -0
  10. package/dist/auth/managed-auth.d.ts +102 -3
  11. package/dist/auth/managed-email.d.ts +29 -0
  12. package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
  13. package/dist/auth/new-signups-gate.d.ts +23 -0
  14. package/dist/auth/organization-user-setup.d.ts +80 -0
  15. package/dist/auth/signup-funnel-metrics.d.ts +44 -0
  16. package/dist/browser-controller-authority.d.ts +1 -61
  17. package/dist/browser-ephemeral.d.ts +5 -0
  18. package/dist/browser-state-authority.d.ts +1 -27
  19. package/dist/chunk-6TZ5LG3O.js +123948 -0
  20. package/dist/chunk-6TZ5LG3O.js.map +1 -0
  21. package/dist/chunk-OGQDXVXY.js +18 -0
  22. package/dist/chunk-OGQDXVXY.js.map +1 -0
  23. package/dist/claude-subscription-account-conflicts.d.ts +1 -0
  24. package/dist/claude-subscription-account-usage.d.ts +39 -0
  25. package/dist/claude-subscription-oauth.d.ts +25 -0
  26. package/dist/claude-subscription-profile.d.ts +9 -0
  27. package/dist/claude-subscription-usage.d.ts +44 -0
  28. package/dist/claude-workspace-connection.d.ts +15 -0
  29. package/dist/codemode.d.ts +32 -1
  30. package/dist/company-brain-okf.d.ts +24 -0
  31. package/dist/connected-machine-computer-access.d.ts +11 -0
  32. package/dist/connection-ownership.d.ts +54 -0
  33. package/dist/controller-data-plane.d.ts +32 -0
  34. package/dist/editable-artifact-office-import.d.ts +7 -4
  35. package/dist/editable-artifact-source-session.d.ts +3 -0
  36. package/dist/editable-artifact-websocket.d.ts +10 -18
  37. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  38. package/dist/fatal-process-boundary.d.ts +25 -0
  39. package/dist/github-access.d.ts +31 -2
  40. package/dist/http/acting-person.d.ts +24 -0
  41. package/dist/http/api-contract-compat.d.ts +19 -0
  42. package/dist/http/api-error.d.ts +27 -1
  43. package/dist/http/cors.d.ts +17 -0
  44. package/dist/http/deprecation.d.ts +42 -0
  45. package/dist/http/interaction-control-error.d.ts +13 -0
  46. package/dist/http/keyed-admission.d.ts +12 -0
  47. package/dist/http/local-browser-boundary.d.ts +72 -0
  48. package/dist/http/metrics-listener.d.ts +22 -0
  49. package/dist/http/path-identifier.d.ts +17 -0
  50. package/dist/http/registered-route-label.d.ts +16 -0
  51. package/dist/http/request-body.d.ts +27 -0
  52. package/dist/http/request-source.d.ts +52 -0
  53. package/dist/http/sse.d.ts +17 -6
  54. package/dist/http/unmatched-route.d.ts +18 -0
  55. package/dist/http/user-content.d.ts +36 -0
  56. package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
  57. package/dist/index.d.ts +10 -3
  58. package/dist/index.js +1129 -166
  59. package/dist/index.js.map +1 -1
  60. package/dist/integration-endpoint-test.d.ts +20 -0
  61. package/dist/integrations/api-integrations.d.ts +2 -2
  62. package/dist/integrations/atlassian.d.ts +29 -3
  63. package/dist/integrations/connect-authority.d.ts +1 -0
  64. package/dist/integrations/connect-callback-return.d.ts +6 -0
  65. package/dist/integrations/fiken.d.ts +16 -2
  66. package/dist/integrations/github-app-connect.d.ts +47 -0
  67. package/dist/integrations/github-installation-proof.d.ts +3 -0
  68. package/dist/integrations/github-lens-connect.d.ts +116 -0
  69. package/dist/integrations/github-skill-source.d.ts +1 -5
  70. package/dist/integrations/google-drive.d.ts +25 -0
  71. package/dist/integrations/integration-commit-authority.d.ts +13 -0
  72. package/dist/integrations/marketplace-plugin.d.ts +2691 -0
  73. package/dist/integrations/oauth-client.d.ts +178 -18
  74. package/dist/integrations/oauth-profiles.d.ts +222 -0
  75. package/dist/integrations/oauth-return-path.d.ts +1 -0
  76. package/dist/integrations/personal-github-repositories.d.ts +84 -0
  77. package/dist/integrations/personal-github.d.ts +42 -0
  78. package/dist/integrations/pr-review-provider.d.ts +22 -0
  79. package/dist/integrations/provider-oauth.d.ts +19 -1
  80. package/dist/integrations/slack-app-home.d.ts +24 -0
  81. package/dist/integrations/slack-bot.d.ts +367 -12
  82. package/dist/integrations/slack-file-upload-flow.d.ts +62 -0
  83. package/dist/integrations/slack-install.d.ts +20 -0
  84. package/dist/integrations/slack-interactions.d.ts +113 -10
  85. package/dist/integrations/slack-mrkdwn.d.ts +41 -0
  86. package/dist/integrations/slack-routing.d.ts +120 -0
  87. package/dist/integrations/slack-session-defaults.d.ts +58 -0
  88. package/dist/integrations/slack-task-file-upload.d.ts +36 -0
  89. package/dist/integrations/social-oauth.d.ts +7 -0
  90. package/dist/interaction-agent-access.d.ts +15 -0
  91. package/dist/interaction-frame-proxy.d.ts +68 -0
  92. package/dist/interaction-metrics.d.ts +2 -0
  93. package/dist/mcp/action-catalog-types.d.ts +9 -0
  94. package/dist/mcp/action-catalog.gen.d.ts +2 -0
  95. package/dist/mcp/capability-account-readiness.d.ts +7 -0
  96. package/dist/mcp/child-read-evidence.d.ts +9 -0
  97. package/dist/mcp/command-read.d.ts +24 -0
  98. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  99. package/dist/mcp/contract-input.d.ts +11 -0
  100. package/dist/mcp/documents.d.ts +7 -5
  101. package/dist/mcp/files.d.ts +2 -2
  102. package/dist/mcp/first-party-tool-permissions.d.ts +650 -0
  103. package/dist/mcp/knowledge-entries.d.ts +5 -0
  104. package/dist/mcp/orchestration-failure-diagnostic.d.ts +43 -0
  105. package/dist/mcp/receipts.d.ts +9 -0
  106. package/dist/mcp/request-abort.d.ts +19 -0
  107. package/dist/mcp/scheduled-task-input.d.ts +1189 -0
  108. package/dist/mcp/scheduled-task-view.d.ts +22 -4
  109. package/dist/mcp/server.d.ts +255 -3
  110. package/dist/mcp/session-event-view.d.ts +94 -0
  111. package/dist/mcp/session-view.d.ts +66 -2
  112. package/dist/mcp/session-wait.d.ts +174 -0
  113. package/dist/mcp-oauth.d.ts +52 -0
  114. package/dist/model-catalog.d.ts +11 -31
  115. package/dist/observability.d.ts +3 -1
  116. package/dist/organization-mcp.d.ts +52 -0
  117. package/dist/organization-recovery-notifications.d.ts +41 -0
  118. package/dist/routes/analytics-consent.d.ts +26 -0
  119. package/dist/routes/api-integrations.d.ts +25 -0
  120. package/dist/routes/api-keys.d.ts +17 -0
  121. package/dist/routes/artifact-catalog.d.ts +6 -0
  122. package/dist/routes/automations.d.ts +19 -0
  123. package/dist/routes/billing.d.ts +49 -0
  124. package/dist/routes/browser-sessions.d.ts +71 -3
  125. package/dist/routes/claude-subscription-accounts.d.ts +3 -0
  126. package/dist/routes/claude-subscription-oauth.d.ts +3 -0
  127. package/dist/routes/client-errors.d.ts +146 -0
  128. package/dist/routes/codex.d.ts +76 -2
  129. package/dist/routes/company-brain.d.ts +3 -0
  130. package/dist/routes/company-profile.d.ts +2 -1
  131. package/dist/routes/computer-sessions.d.ts +14 -0
  132. package/dist/routes/connect.d.ts +5 -0
  133. package/dist/routes/editable-artifacts.d.ts +2 -0
  134. package/dist/routes/external-identity-links.d.ts +6 -0
  135. package/dist/routes/feedback.d.ts +3 -0
  136. package/dist/routes/files.d.ts +4 -0
  137. package/dist/routes/github-browser-pages.d.ts +12 -0
  138. package/dist/routes/github.d.ts +2 -0
  139. package/dist/routes/insights-response-cache.d.ts +10 -0
  140. package/dist/routes/insights-usage.d.ts +12 -0
  141. package/dist/routes/insights.d.ts +30 -1
  142. package/dist/routes/knowledge.d.ts +3 -0
  143. package/dist/routes/managed-auth-session-sets.d.ts +26 -0
  144. package/dist/routes/managed-onboarding.d.ts +29 -0
  145. package/dist/routes/managed-sign-in-methods.d.ts +6 -0
  146. package/dist/routes/model-connection-access.d.ts +3 -0
  147. package/dist/routes/organization-integration-policy.d.ts +3 -0
  148. package/dist/routes/organization-integrations.d.ts +6 -0
  149. package/dist/routes/organization-mcp-connections.d.ts +3 -0
  150. package/dist/routes/organization-model-providers.d.ts +3 -0
  151. package/dist/routes/organization-recovery.d.ts +19 -0
  152. package/dist/routes/organization-service-accounts.d.ts +3 -0
  153. package/dist/routes/organization-sessions.d.ts +26 -0
  154. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  155. package/dist/routes/personal-github.d.ts +3 -0
  156. package/dist/routes/pr-review-github.d.ts +3 -0
  157. package/dist/routes/pr-review.d.ts +3 -0
  158. package/dist/routes/session-artifact-associations.d.ts +4 -0
  159. package/dist/routes/session-history-imports.d.ts +9 -0
  160. package/dist/routes/sessions.d.ts +44 -6
  161. package/dist/routes/skill-content.d.ts +4 -0
  162. package/dist/routes/skill-install-authority.d.ts +6 -0
  163. package/dist/routes/subscription-account-pools.d.ts +34 -0
  164. package/dist/routes/subscription-pool-access.d.ts +16 -0
  165. package/dist/routes/supergrok.d.ts +6 -1
  166. package/dist/routes/usage-allowances.d.ts +12 -0
  167. package/dist/routes/user-resource-authorities.d.ts +3 -0
  168. package/dist/routes/workspace-artifacts.d.ts +2 -1
  169. package/dist/routes/workspace-capture.d.ts +10 -2
  170. package/dist/routes/workspace-integrations.d.ts +35 -0
  171. package/dist/routes/workspace-learning.d.ts +3 -0
  172. package/dist/routes/workspace-model-providers.d.ts +4 -0
  173. package/dist/routes/workspaces.d.ts +29 -0
  174. package/dist/sandbox/auth-callout.d.ts +8 -0
  175. package/dist/sandbox/channel-a.d.ts +19 -1
  176. package/dist/sandbox/connection-authority.d.ts +3 -0
  177. package/dist/sandbox/enrollment.d.ts +9 -1
  178. package/dist/sandbox/machines.d.ts +5 -2
  179. package/dist/sandbox/metrics-ingestion.d.ts +28 -20
  180. package/dist/sandbox/rematerialize.d.ts +2 -0
  181. package/dist/sandbox/session-attach-variable-sets.d.ts +30 -0
  182. package/dist/sandbox/viewer.d.ts +26 -1
  183. package/dist/sandbox-file-artifacts.d.ts +50 -0
  184. package/dist/scheduled-task-deletion.d.ts +13 -0
  185. package/dist/session-create-observability.d.ts +5 -0
  186. package/dist/session-event-view-MPS2OZD3.js +292 -0
  187. package/dist/session-event-view-MPS2OZD3.js.map +1 -0
  188. package/dist/site-session-origin.d.ts +13 -0
  189. package/dist/site-uploads.d.ts +41 -0
  190. package/dist/slack-reaction-files.d.ts +1 -1
  191. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  192. package/dist/temporal-schedule-sync.d.ts +26 -0
  193. package/dist/transcription/providers/xai-subscription.d.ts +1 -0
  194. package/dist/transcription/service.d.ts +2 -1
  195. package/dist/work-discovery-observability.d.ts +33 -0
  196. package/dist/workspace-artifact-content.d.ts +28 -0
  197. package/dist/workspace-artifact-provenance.d.ts +7 -0
  198. package/dist/workspace-delete-observability.d.ts +10 -0
  199. package/dist/workspace-deletion.d.ts +6 -0
  200. package/dist/workspace-state-projection.d.ts +3 -5
  201. package/dist/workspace-tool-gateway-observability.d.ts +17 -0
  202. package/dist/workspace-tool-gateway.d.ts +123 -0
  203. package/dist/workspace-webhook-dispatch.d.ts +26 -0
  204. package/package.json +23 -19
  205. package/src/access-grant-rls.ts +75 -0
  206. package/src/admission-trace.ts +14 -0
  207. package/src/api-websocket.ts +23 -0
  208. package/src/app.ts +1974 -256
  209. package/src/artifact-catalog-cursor.ts +60 -0
  210. package/src/auth/managed-auth-attempt-context.ts +70 -0
  211. package/src/auth/managed-auth-rate-limits.ts +252 -0
  212. package/src/auth/managed-auth-session-adapter.ts +222 -0
  213. package/src/auth/managed-auth.ts +536 -60
  214. package/src/auth/managed-email.ts +174 -0
  215. package/src/auth/managed-sign-in-notifications.ts +81 -0
  216. package/src/auth/new-signups-gate.ts +49 -0
  217. package/src/auth/organization-user-setup.ts +307 -0
  218. package/src/auth/signup-funnel-metrics.ts +164 -0
  219. package/src/browser-controller-authority.ts +1 -186
  220. package/src/browser-ephemeral.ts +39 -0
  221. package/src/browser-state-authority.ts +1 -236
  222. package/src/claude-subscription-account-conflicts.ts +12 -0
  223. package/src/claude-subscription-account-usage.ts +48 -0
  224. package/src/claude-subscription-oauth.ts +362 -0
  225. package/src/claude-subscription-profile.ts +73 -0
  226. package/src/claude-subscription-usage.ts +97 -0
  227. package/src/claude-workspace-connection.ts +82 -0
  228. package/src/codemode.ts +194 -30
  229. package/src/codex-realtime.ts +8 -2
  230. package/src/company-brain-okf.ts +340 -0
  231. package/src/connected-machine-computer-access.ts +33 -0
  232. package/src/connection-ownership.ts +199 -0
  233. package/src/controller-data-plane.ts +81 -0
  234. package/src/editable-artifact-native-kernel.ts +26 -15
  235. package/src/editable-artifact-office-import.ts +77 -9
  236. package/src/editable-artifact-production.ts +23 -7
  237. package/src/editable-artifact-source-session.ts +123 -0
  238. package/src/editable-artifact-websocket.ts +27 -21
  239. package/src/editable-artifact-workspace-files.ts +31 -6
  240. package/src/fatal-process-boundary.ts +246 -0
  241. package/src/gateway-realtime.ts +1 -1
  242. package/src/github-access.ts +147 -5
  243. package/src/http/acting-person.ts +71 -0
  244. package/src/http/api-contract-compat.ts +49 -0
  245. package/src/http/api-error.ts +115 -1
  246. package/src/http/auth.ts +48 -0
  247. package/src/http/cors.ts +67 -0
  248. package/src/http/deprecation.ts +118 -0
  249. package/src/http/interaction-control-error.ts +164 -0
  250. package/src/http/keyed-admission.ts +29 -0
  251. package/src/http/local-browser-boundary.ts +343 -0
  252. package/src/http/metrics-listener.ts +57 -0
  253. package/src/http/path-identifier.ts +54 -0
  254. package/src/http/registered-route-label.ts +34 -0
  255. package/src/http/request-body.ts +127 -0
  256. package/src/http/request-source.ts +178 -0
  257. package/src/http/sse.ts +486 -132
  258. package/src/http/unmatched-route.ts +60 -0
  259. package/src/http/user-content.ts +158 -0
  260. package/src/http/websocket-upgrade-dispatch.ts +29 -0
  261. package/src/index.ts +157 -52
  262. package/src/integration-endpoint-test.ts +291 -0
  263. package/src/integrations/api-integrations.ts +98 -11
  264. package/src/integrations/atlassian.ts +474 -136
  265. package/src/integrations/connect-authority.ts +2 -0
  266. package/src/integrations/connect-callback-return.ts +86 -0
  267. package/src/integrations/fiken.ts +354 -64
  268. package/src/integrations/github-app-connect.ts +435 -0
  269. package/src/integrations/github-installation-proof.ts +60 -0
  270. package/src/integrations/github-lens-connect.ts +76 -0
  271. package/src/integrations/github-skill-source.ts +2 -142
  272. package/src/integrations/google-drive.ts +820 -222
  273. package/src/integrations/integration-commit-authority.ts +69 -0
  274. package/src/integrations/marketplace-plugin.ts +48 -0
  275. package/src/integrations/oauth-client.ts +1356 -459
  276. package/src/integrations/oauth-profiles.ts +537 -0
  277. package/src/integrations/oauth-return-path.ts +18 -0
  278. package/src/integrations/personal-github-repositories.ts +674 -0
  279. package/src/integrations/personal-github.ts +825 -0
  280. package/src/integrations/pr-review-provider.ts +246 -0
  281. package/src/integrations/provider-oauth.ts +250 -22
  282. package/src/integrations/slack-app-home.ts +300 -0
  283. package/src/integrations/slack-bot.ts +963 -239
  284. package/src/integrations/slack-file-upload-flow.ts +147 -0
  285. package/src/integrations/slack-install.ts +83 -0
  286. package/src/integrations/slack-interactions.ts +3170 -474
  287. package/src/integrations/slack-mrkdwn.ts +193 -0
  288. package/src/integrations/slack-routing.ts +337 -0
  289. package/src/integrations/slack-session-defaults.ts +224 -0
  290. package/src/integrations/slack-task-file-upload.ts +323 -0
  291. package/src/integrations/social-oauth.ts +213 -15
  292. package/src/interaction-agent-access.ts +48 -0
  293. package/src/interaction-frame-proxy.ts +409 -0
  294. package/src/interaction-metrics.ts +30 -0
  295. package/src/mcp/action-catalog-types.ts +9 -0
  296. package/src/mcp/action-catalog.gen.ts +6696 -0
  297. package/src/mcp/capability-account-readiness.ts +17 -0
  298. package/src/mcp/child-read-evidence.ts +19 -0
  299. package/src/mcp/command-read.ts +109 -0
  300. package/src/mcp/company-profile-agent-admin.ts +198 -0
  301. package/src/mcp/contract-input.ts +141 -0
  302. package/src/mcp/documents.ts +65 -377
  303. package/src/mcp/editable-artifacts.ts +12 -6
  304. package/src/mcp/files.ts +80 -38
  305. package/src/mcp/first-party-tool-permissions.ts +305 -0
  306. package/src/mcp/knowledge-entries.ts +322 -0
  307. package/src/mcp/orchestration-failure-diagnostic.ts +43 -0
  308. package/src/mcp/receipts.ts +34 -0
  309. package/src/mcp/request-abort.ts +44 -0
  310. package/src/mcp/scheduled-task-input.ts +76 -0
  311. package/src/mcp/scheduled-task-view.ts +44 -3
  312. package/src/mcp/server.ts +3030 -1267
  313. package/src/mcp/session-event-view.ts +383 -0
  314. package/src/mcp/session-view.ts +84 -3
  315. package/src/mcp/session-wait.ts +665 -0
  316. package/src/mcp-oauth.ts +1123 -0
  317. package/src/model-catalog.ts +91 -333
  318. package/src/observability.ts +15 -1
  319. package/src/organization-mcp.ts +397 -0
  320. package/src/organization-recovery-notifications.ts +103 -0
  321. package/src/routes/analytics-consent.ts +123 -0
  322. package/src/routes/api-integrations.ts +364 -74
  323. package/src/routes/api-keys.ts +336 -10
  324. package/src/routes/artifact-catalog.ts +279 -0
  325. package/src/routes/automations.ts +768 -0
  326. package/src/routes/billing.ts +461 -46
  327. package/src/routes/browser-sessions.ts +839 -131
  328. package/src/routes/canonical-human-identities.ts +90 -14
  329. package/src/routes/capabilities.ts +107 -6
  330. package/src/routes/channels.ts +20 -3
  331. package/src/routes/claude-subscription-accounts.ts +334 -0
  332. package/src/routes/claude-subscription-oauth.ts +142 -0
  333. package/src/routes/client-errors.ts +362 -0
  334. package/src/routes/codex.ts +833 -116
  335. package/src/routes/company-brain.ts +322 -0
  336. package/src/routes/company-profile.ts +65 -1
  337. package/src/routes/computer-sessions.ts +557 -106
  338. package/src/routes/connect.ts +1938 -0
  339. package/src/routes/connections.ts +1040 -349
  340. package/src/routes/documents.ts +488 -287
  341. package/src/routes/editable-artifacts.ts +79 -9
  342. package/src/routes/enrollments.ts +135 -39
  343. package/src/routes/environments.ts +154 -58
  344. package/src/routes/external-identity-links.ts +201 -0
  345. package/src/routes/feedback.ts +113 -0
  346. package/src/routes/files.ts +337 -39
  347. package/src/routes/github-browser-pages.ts +162 -0
  348. package/src/routes/github.ts +446 -144
  349. package/src/routes/insights-response-cache.ts +83 -0
  350. package/src/routes/insights-usage.ts +345 -0
  351. package/src/routes/insights.ts +153 -19
  352. package/src/routes/install.ts +133 -38
  353. package/src/routes/integration-facets.ts +107 -33
  354. package/src/routes/interaction-resources.ts +30 -3
  355. package/src/routes/knowledge.ts +488 -0
  356. package/src/routes/machines.ts +313 -15
  357. package/src/routes/managed-auth-session-sets.ts +1218 -0
  358. package/src/routes/managed-onboarding.ts +337 -0
  359. package/src/routes/managed-sign-in-methods.ts +433 -0
  360. package/src/routes/memory-slack-publications.ts +3 -2
  361. package/src/routes/model-connection-access.ts +208 -0
  362. package/src/routes/organization-integration-policy.ts +116 -0
  363. package/src/routes/organization-integrations.ts +326 -0
  364. package/src/routes/organization-mcp-connections.ts +178 -0
  365. package/src/routes/organization-memberships.ts +1019 -27
  366. package/src/routes/organization-model-providers.ts +293 -0
  367. package/src/routes/organization-recovery.ts +325 -0
  368. package/src/routes/organization-service-accounts.ts +128 -0
  369. package/src/routes/organization-sessions.ts +350 -0
  370. package/src/routes/personal-github-git-broker.ts +785 -0
  371. package/src/routes/personal-github.ts +370 -0
  372. package/src/routes/plugins.ts +251 -22
  373. package/src/routes/pr-review-github.ts +881 -0
  374. package/src/routes/pr-review.ts +622 -0
  375. package/src/routes/preference-registry.ts +12 -93
  376. package/src/routes/rigs.ts +167 -50
  377. package/src/routes/scheduled-tasks.ts +346 -41
  378. package/src/routes/session-artifact-associations.ts +87 -0
  379. package/src/routes/session-history-imports.ts +280 -0
  380. package/src/routes/sessions.ts +2306 -264
  381. package/src/routes/skill-content.ts +297 -0
  382. package/src/routes/skill-install-authority.ts +64 -0
  383. package/src/routes/skills.ts +62 -9
  384. package/src/routes/social.ts +46 -18
  385. package/src/routes/subscription-account-pools.ts +436 -0
  386. package/src/routes/subscription-pool-access.ts +126 -0
  387. package/src/routes/supergrok.ts +219 -303
  388. package/src/routes/transcription-recordings.ts +38 -3
  389. package/src/routes/transcriptions.ts +4 -0
  390. package/src/routes/usage-allowances.ts +378 -0
  391. package/src/routes/user-resource-authorities.ts +138 -0
  392. package/src/routes/video-generation.ts +14 -10
  393. package/src/routes/workspace-artifacts.ts +275 -69
  394. package/src/routes/workspace-capture.ts +21 -0
  395. package/src/routes/workspace-integrations.ts +576 -0
  396. package/src/routes/workspace-learning.ts +159 -0
  397. package/src/routes/workspace-model-providers.ts +148 -0
  398. package/src/routes/workspace-state.ts +38 -25
  399. package/src/routes/workspaces.ts +1004 -127
  400. package/src/sandbox/auth-callout.ts +96 -19
  401. package/src/sandbox/channel-a.ts +323 -24
  402. package/src/sandbox/connection-authority.ts +3 -0
  403. package/src/sandbox/enrollment.ts +81 -6
  404. package/src/sandbox/machines.ts +218 -76
  405. package/src/sandbox/metrics-ingestion.ts +411 -62
  406. package/src/sandbox/rematerialize.ts +121 -19
  407. package/src/sandbox/session-attach-variable-sets.ts +131 -0
  408. package/src/sandbox/viewer.ts +280 -25
  409. package/src/sandbox-file-artifacts.ts +413 -0
  410. package/src/scheduled-task-deletion.ts +138 -0
  411. package/src/session-create-observability.ts +46 -0
  412. package/src/session-realtime-context.ts +6 -3
  413. package/src/site-session-origin.ts +48 -0
  414. package/src/site-uploads.ts +224 -0
  415. package/src/slack-reaction-files.ts +16 -5
  416. package/src/temporal-schedule-cleanup.ts +13 -0
  417. package/src/temporal-schedule-sync.ts +74 -0
  418. package/src/transcription/providers/codex-subscription.ts +2 -0
  419. package/src/transcription/providers/openai.ts +1 -0
  420. package/src/transcription/providers/xai-subscription.ts +49 -3
  421. package/src/transcription/segmenter.ts +8 -2
  422. package/src/transcription/service.ts +43 -6
  423. package/src/work-discovery-observability.ts +121 -0
  424. package/src/workspace-artifact-content.ts +144 -0
  425. package/src/workspace-artifact-provenance.ts +104 -0
  426. package/src/workspace-delete-observability.ts +75 -0
  427. package/src/workspace-deletion.ts +73 -0
  428. package/src/workspace-state-projection.ts +18 -148
  429. package/src/workspace-tool-gateway-observability.ts +100 -0
  430. package/src/workspace-tool-gateway.ts +870 -0
  431. package/src/workspace-webhook-dispatch.ts +190 -0
  432. package/src/xai-subscription-auth.ts +9 -2
  433. package/dist/chunk-2PQMSRCB.js +0 -49759
  434. package/dist/chunk-2PQMSRCB.js.map +0 -1
  435. package/dist/routes/packs.d.ts +0 -3
  436. package/src/routes/packs.ts +0 -729
@@ -1,6 +1,25 @@
1
+ import { getRetainedProviderCommand } from "@opengeni/db/retained-provider-commands";
2
+ import { UnsupportedLatencyModeError } from "@opengeni/config";
3
+ import { searchSessionMessagesForSubject, SessionMessageSearchCursorError } from "@opengeni/db";
4
+ import { listSessionEventSlices } from "@opengeni/db/session-event-slices";
5
+ import * as sessionPreviewSchema from "@opengeni/db/schema";
6
+ import { and, eq, sql } from "drizzle-orm";
7
+ import { SessionMessageSearchRequest } from "@opengeni/contracts";
8
+ import { scheduledSessionIds } from "@opengeni/db";
9
+ import { isVerifiedDelegatedHumanAuthorization, withSiteSessionOrigin } from "@opengeni/core";
10
+ import { freezeSessionRealtimeConnectionAccounts } from "@opengeni/core";
11
+ import { resolveSiteSessionOrigin, withOptionalSiteCommandOrigin } from "../site-session-origin";
12
+ import { SandboxRecoveryRequest } from "@opengeni/contracts";
13
+ import { getManagedHumanSandboxRecovery, consentManagedHumanSandboxRecovery } from "@opengeni/core";
14
+ import { SandboxRecoveryConflictError } from "@opengeni/db";
15
+ import { codexAccountJson } from "./codex";
16
+ import { getSessionCodexAccounts } from "@opengeni/db";
1
17
  import {
2
18
  AcknowledgeStreamRequest,
3
19
  ApplySessionGoalRevisionRequest,
20
+ ListSessionGoalRevisionsQuery,
21
+ RejectSessionGoalRevisionRequest,
22
+ RollbackSessionGoalRevisionRequest,
4
23
  ActivateCodexRealtimeConnectionRequest,
5
24
  AttachViewerRequest,
6
25
  BeginSessionRealtimeRequest,
@@ -9,6 +28,7 @@ import {
9
28
  GatewayRealtimeConnectRequest,
10
29
  ClientSessionEvent,
11
30
  CompactSessionContextRequest,
31
+ CreateSessionRequest,
12
32
  DeleteSessionQueueItemRequest,
13
33
  EditSessionQueueItemRequest,
14
34
  EndSessionRealtimeRequest,
@@ -19,6 +39,7 @@ import {
19
39
  FsMoveRequest,
20
40
  FsReadRequest,
21
41
  FsWriteRequest,
42
+ ForkSessionRequest,
22
43
  HumanInputRequestStatus,
23
44
  GitDiffRequest,
24
45
  GitReadBatchRequest,
@@ -30,38 +51,60 @@ import {
30
51
  PtyOpenRequest,
31
52
  PtyResizeRequest,
32
53
  PtyWriteRequest,
54
+ PublishSandboxFileArtifactRequest,
33
55
  RenewSessionRealtimeRequest,
34
56
  SyncSessionRealtimeLedgerRequest,
35
57
  SessionControlRequest,
58
+ SessionRetryRequest,
36
59
  SESSION_EVENT_RAW_DELTA_TYPES,
37
60
  SessionEventPayloadMode,
38
61
  SessionEventReadDirection,
39
62
  SessionEventReadMode,
40
63
  SessionEventLatestClass,
41
64
  SessionEventResultMode,
65
+ SessionScopeSubjectId,
42
66
  SessionEventSemanticClass,
43
67
  SessionEventType,
44
68
  SessionMcpServerId,
69
+ SaveNewSessionDraftRequest,
45
70
  compactSessionEventResult,
46
71
  sessionEventLatestClassToSemanticClass,
47
72
  SaveComposerDraftRequest,
73
+ SubmitComposerDraftRequest,
48
74
  SteerSessionQueueItemRequest,
49
75
  SteerSessionMessageRequest,
50
76
  TerminalExecRequest,
51
77
  UpdateSessionChannelRequest,
78
+ UpdateSessionAttentionRequest,
79
+ UpdateSessionArchiveRequest,
52
80
  UpdateSessionPinRequest,
53
81
  UpdateSessionGoalRequest,
54
82
  UpdateSessionMcpApprovalPolicyRequest,
55
83
  UpdateSessionRequest,
84
+ UpdateSessionVariableSetsRequest,
85
+ UpdateSessionVisibilityRequest,
56
86
  UpdateSessionToolPolicyRequest,
87
+ UpdateSessionAgentRequest,
88
+ AgentConfigError,
57
89
  ViewerHeartbeatRequest,
58
90
  WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
91
+ WORK_CLAIM_CANONICAL_KEY_MAX_BYTES,
92
+ WORK_CLAIM_DISCOVERY_LIMIT,
93
+ WORK_CLAIM_NAMESPACE_MAX_BYTES,
94
+ WORK_DISCOVERY_QUERY_MAX_CHARS,
95
+ WORK_DISCOVERY_RECENT_HOURS_MAX,
96
+ WorkClaimSubjectFilter as WorkClaimSubjectFilterSchema,
97
+ WorkClaimSubjectType,
98
+ normalizeWorkClaimCanonicalKey,
99
+ normalizeWorkClaimNamespace,
59
100
  workspaceControlUtf8Bytes,
60
101
  type AccessGrant,
61
102
  type AttachViewerResponse,
62
103
  type SandboxBackend,
63
104
  type LineageNode,
64
105
  type Session,
106
+ type SessionStatus,
107
+ type WorkClaimSubjectFilter,
65
108
  type SessionGoalRevision,
66
109
  type AgentTopologyPageResponse,
67
110
  type ErrorCode,
@@ -70,21 +113,27 @@ import {
70
113
  type TerminalPtyExitedPayload,
71
114
  type TerminalPtyOutputDeltaPayload,
72
115
  type TerminalPtyStartedPayload,
116
+ type VariableSet,
73
117
  } from "@opengeni/contracts";
74
118
  import { streamTokenDegraded } from "@opengeni/config";
75
119
  import {
120
+ recordAuditEvent,
76
121
  acceptSessionApprovalDecision,
77
122
  acceptSessionHumanInputResponse,
78
123
  clearSessionGoal,
79
124
  clearSessionContext,
80
125
  getOpenPtySession,
81
126
  getEnrollment,
127
+ getLiveEnrollmentConnection,
82
128
  getRetainedProcess,
83
129
  getSandbox,
84
130
  getSession,
131
+ readActiveSandbox,
132
+ deleteSessionTreeIfQuiescent,
85
133
  getSessionEvent,
86
134
  getSessionForSubject,
87
135
  getSessionGoal,
136
+ getLatestSessionModelContext,
88
137
  getSessionHumanInputRequest,
89
138
  getSessionGoalWithContinuation,
90
139
  getSessionGoalRevision,
@@ -93,28 +142,34 @@ import {
93
142
  insertPtySession,
94
143
  listSessionEventPage,
95
144
  listSessionHumanInputRequests,
145
+ listSessionGoalRevisionPage,
96
146
  listSessionGoalRevisions,
147
+ rejectSessionGoalRevisionWithEvent,
97
148
  listSessionIdsInGroup,
98
149
  listSessionDiscoverySummaries,
99
150
  listSessionDiscoveryAncestorPaths,
100
151
  listSessionsForSubject,
152
+ listSessionEntriesForSubject,
101
153
  getLatestStartedSessionTurn,
102
154
  listSessionTurns,
103
155
  projectEffectiveControlForRelatedAccess,
104
156
  projectSessionForRelatedAccess,
105
157
  recordStreamAcknowledgment,
106
158
  requestSessionCompaction,
107
- setSessionCodexPinInTransaction,
108
- withSessionCodexCapacityMutation,
159
+ switchSessionCodexAccount,
109
160
  setSessionChannel,
161
+ updateSessionVariableSets,
110
162
  ChannelNotFoundError,
163
+ setSessionAttention,
164
+ setSessionArchive,
111
165
  setSessionPin,
166
+ SessionAttentionVersionConflictError,
167
+ SessionArchiveVersionConflictError,
112
168
  SessionPinVersionConflictError,
113
169
  SessionPinAccessError,
114
170
  SessionListAccessError,
115
171
  SessionListCursorError,
116
172
  SessionListCursorExpiredError,
117
- SessionListSnapshotLimitError,
118
173
  decodeSessionListCursor,
119
174
  revokeViewer,
120
175
  setSessionGoalStatusWithEvent,
@@ -133,7 +188,15 @@ import {
133
188
  SessionRealtimeConflictError,
134
189
  SessionToolPolicyVersionConflictError,
135
190
  SessionContextBusyError,
191
+ SessionVariableSetSelectionUnavailableError,
192
+ SessionCreateConnectionSelectionUnavailableError,
193
+ workspaceControlRequestLockTimeoutMs,
194
+ SessionTenancyAccessError,
195
+ SessionTenancyConflictError,
196
+ SessionTenancyInvalidRequestError,
197
+ SessionTenancyNotActivatedError,
136
198
  HumanInputResponseValidationError,
199
+ SkillHumanResponseError,
137
200
  latestWorkspaceCapture,
138
201
  sessionLatestWorkspaceCapture,
139
202
  renewSessionRealtimeInTransaction,
@@ -147,12 +210,18 @@ import {
147
210
  type SandboxRetainedProcess,
148
211
  type Database,
149
212
  type SessionDiscoveryCursor,
213
+ type SessionDiscoveryOrderBy,
150
214
  type SessionDiscoveryAncestor,
151
215
  } from "@opengeni/db";
216
+ import {
217
+ backgroundCommandActivityForSessions,
218
+ listSessionBackgroundCommands,
219
+ requestSessionBackgroundCommandCancellation,
220
+ } from "@opengeni/db/session-background-commands";
152
221
  import {
153
222
  appendAndPublishEvents,
154
223
  boundSessionEventHttpPage,
155
- coalesceSessionEventDeltas,
224
+ coalesceSessionEventDeltasWithCoverage,
156
225
  publishDurableSessionEvents,
157
226
  } from "@opengeni/events";
158
227
  import {
@@ -160,6 +229,10 @@ import {
160
229
  GatewayRealtimeBrokerError,
161
230
  } from "../gateway-realtime";
162
231
  import { createXaiRealtimeConnectionSecret, XaiRealtimeBrokerError } from "../xai-realtime";
232
+ import {
233
+ prepareExternalLinkTurnAdmission,
234
+ externalContinuationCommitAuthorizer,
235
+ } from "@opengeni/core";
163
236
  import { z, ZodError } from "zod";
164
237
  import {
165
238
  runConcurrentChannelAReads,
@@ -173,24 +246,35 @@ import {
173
246
  NatsControlRpc,
174
247
  negotiateCapabilities,
175
248
  negotiateSelfhostedCapabilities,
249
+ resolveConnectedMachineWorkspaceRoot,
250
+ selectBackend,
176
251
  SelfhostedSession,
177
252
  } from "@opengeni/runtime/sandbox";
178
253
  import type { Context, Hono, MiddlewareHandler } from "hono";
179
254
  import { HTTPException } from "hono/http-exception";
180
255
  import type { ContentfulStatusCode } from "hono/utils/http-status";
181
256
  import {
257
+ getManagedAuthRequestActorEpoch,
182
258
  hasPermission,
183
259
  requireAccessGrant,
260
+ requireAccessGrantAuthorization,
261
+ requireFreshAccessGrant,
262
+ rotateSessionMcpCredentialsForRequest,
263
+ hasVerifiedOwningUserAuthorization,
184
264
  requirePermission,
185
265
  requireSessionAuthorization,
186
266
  requireSessionAuthorizationListScope,
267
+ resolveWorkspaceCatalogSettings,
187
268
  withResolvedSessionAuthorization,
188
269
  SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
189
270
  SessionAuthorizationDeniedError,
190
271
  SessionAuthorizationUnavailableError,
272
+ SessionTenancyManagedHumanRequiredError,
273
+ SessionTenancyPersistenceOutcomeUnknownError,
191
274
  type ResolvedSessionAuthorization,
192
275
  } from "@opengeni/core";
193
276
  import type { ApiRouteDeps } from "@opengeni/core";
277
+ import { SessionRetryConflictError } from "@opengeni/db";
194
278
  import {
195
279
  attachViewer,
196
280
  detachViewer,
@@ -207,12 +291,16 @@ import {
207
291
  import { buildSessionCodexRealtimeBroker, CodexRealtimeBrokerError } from "../codex-realtime";
208
292
  import {
209
293
  acceptSessionUserMessage,
294
+ validateSessionMcpCredentialUpdates,
210
295
  controlHumanSessionWorkstream,
296
+ retryFailedSession,
211
297
  createSessionForRequest,
212
298
  deleteHumanQueuePrompt,
213
299
  editHumanQueuePrompt,
214
300
  getActorNewSessionDraft,
301
+ getManagedHumanSessionCreateCapabilities,
215
302
  getHumanComposerDraft,
303
+ forkManagedHumanSession,
216
304
  moveHumanQueuePrompt,
217
305
  readSessionLineage,
218
306
  saveHumanComposerDraft,
@@ -220,27 +308,51 @@ import {
220
308
  SessionSpawnDeniedError,
221
309
  sessionSpawnDenialEnvelope,
222
310
  steerHumanQueuePrompt,
311
+ submitComposerDraftForRequest,
223
312
  updateSessionMcpApprovalPolicy,
313
+ updateManagedHumanSessionVisibility,
224
314
  updateSessionToolPolicy,
315
+ updateSessionAgent,
225
316
  updateSessionTitle,
226
317
  workflowIdForSession,
227
318
  sessionWithEffectiveToolPolicy,
228
- workspaceSessionToolPolicyDefaultServerIds,
229
- workspaceSessionToolPolicyServerIds,
319
+ workspaceSessionEffectiveToolsContext,
320
+ workspaceSessionToolPolicyContext,
230
321
  relayConfigFromSettings,
322
+ validateVariableSetAttachment,
231
323
  } from "@opengeni/core";
232
324
  import { assertSessionExists, boundedLimit } from "../http/common";
233
- import { sseSessionStream } from "../http/sse";
325
+ import { browserSseDeliveryOptions, sseSessionStream } from "../http/sse";
234
326
  import {
235
327
  serveWorkspaceCapture,
236
328
  serveWorkspaceCaptureFile,
237
329
  WorkspaceCaptureManifestCache,
238
330
  } from "./workspace-capture";
331
+ import { publishSandboxFileArtifact } from "../sandbox-file-artifacts";
332
+ import { ApiHttpError } from "../http/api-error";
333
+ import { observeWorkDiscovery, summarizeWorkDiscoveryRows } from "../work-discovery-observability";
334
+ import { recordAcceptedApiAdmission } from "../admission-trace";
335
+ import { parseRequestBody, parseRequestJson } from "../http/request-body";
336
+ import { measureSessionCreatePhase } from "../session-create-observability";
239
337
 
240
338
  type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
241
339
 
242
340
  const VIEWER_LIFECYCLE_PERMISSIONS = ["stream:view", "terminal:attach", "files:write"] as const;
243
341
 
342
+ /**
343
+ * Bounded responder kind recorded on a child's requires-action resolution
344
+ * notice for its parent: a key principal is `api`, a signed agent attempt is
345
+ * `agent_attempt`, and every other HTTP caller is a human. Never a subject id.
346
+ */
347
+ function childRequiresActionRespondedByKindForGrant(
348
+ grant: Pick<AccessGrant, "principalKind">,
349
+ ): "human" | "api" | "agent_attempt" | "system" {
350
+ if (grant.principalKind === "api_key" || grant.principalKind === "configured_key") return "api";
351
+ if (grant.principalKind === "agent_attempt") return "agent_attempt";
352
+ if (grant.principalKind === "service") return "system";
353
+ return "human";
354
+ }
355
+
244
356
  function requireViewerLifecyclePermission(grant: AccessGrant): void {
245
357
  if (
246
358
  VIEWER_LIFECYCLE_PERMISSIONS.some((permission) => hasPermission(grant.permissions, permission))
@@ -258,9 +370,18 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
258
370
  db,
259
371
  settings,
260
372
  bus,
373
+ objectStorage,
261
374
  observability: deps.observability,
262
375
  };
263
376
  const workspaceCaptureManifestCache = new WorkspaceCaptureManifestCache();
377
+ const withSiteCommandOrigin = <T>(c: Context, workspaceId: string, run: () => Promise<T>) =>
378
+ withOptionalSiteCommandOrigin(
379
+ db,
380
+ workspaceId,
381
+ c.req.header("x-opengeni-site-id"),
382
+ c.req.header("x-opengeni-site-version"),
383
+ run,
384
+ );
264
385
  const ptyIdentity = (pty: SandboxOpenPtySessionRow): SandboxPtyProcessIdentity => ({
265
386
  leaseId: pty.leaseId,
266
387
  sandboxGroupId: pty.sandboxGroupId,
@@ -317,8 +438,18 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
317
438
  message: "pty retained-process identity is stale; reopen the terminal",
318
439
  });
319
440
  }
441
+ const providerCommand = await getRetainedProviderCommand(db, {
442
+ accountId: ctx.accountId,
443
+ workspaceId: ctx.workspaceId,
444
+ sessionId: ctx.session.id,
445
+ processId: process.id,
446
+ });
320
447
  handle.routingSession.adoptRetainedProcess({
321
- process: { id: process.id, providerSessionId: process.providerSessionId },
448
+ process: {
449
+ id: process.id,
450
+ providerSessionId: process.providerSessionId,
451
+ ...(providerCommand ? { providerCommand } : {}),
452
+ },
322
453
  backend: {
323
454
  sandboxId: null,
324
455
  leaseEpoch: process.leaseEpoch,
@@ -406,6 +537,16 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
406
537
  if (!operation) {
407
538
  throw sessionAuthorizationHttpError(new SessionAuthorizationUnavailableError());
408
539
  }
540
+ if (operation === "session.visibility.write" || operation === "session.fork.create") {
541
+ // These product mutations perform their target-free managed-cookie,
542
+ // permission, and activation gates inside core before resolving the
543
+ // target exactly once. Visibility changes remain owner-only; a shared
544
+ // source may be forked by any currently authorized workspace member.
545
+ // Running the generic middleware here would both create a pre-gate
546
+ // existence oracle and double-call the host.
547
+ await next();
548
+ return;
549
+ }
409
550
  const grant = await requireAccessGrant(c, deps, workspaceId);
410
551
  try {
411
552
  const authorization = await requireSessionAuthorization(deps, grant, {
@@ -429,17 +570,34 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
429
570
  db,
430
571
  settings,
431
572
  bus,
573
+ objectStorage,
432
574
  ...(deps.establishSandboxSession
433
575
  ? { establishSandboxSession: deps.establishSandboxSession }
434
576
  : {}),
435
577
  };
436
578
 
579
+ app.get("/v1/workspaces/:workspaceId/session-tenancy/capabilities", async (c) => {
580
+ const workspaceId = c.req.param("workspaceId");
581
+ const authorization = await requireAccessGrantAuthorization(
582
+ c,
583
+ deps,
584
+ workspaceId,
585
+ "sessions:create",
586
+ );
587
+ return c.json(await getManagedHumanSessionCreateCapabilities(deps, authorization, workspaceId));
588
+ });
589
+
437
590
  app.post("/v1/workspaces/:workspaceId/sessions", async (c) => {
438
591
  const workspaceId = c.req.param("workspaceId");
439
- const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
592
+ const authorization = await measureSessionCreatePhase(deps.observability, "authorization", () =>
593
+ requireAccessGrantAuthorization(c, deps, workspaceId, "sessions:create"),
594
+ );
595
+ const grant = authorization.grant;
440
596
  let payload: unknown;
441
597
  try {
442
- payload = await c.req.json();
598
+ payload = await measureSessionCreatePhase(deps.observability, "body_read", () =>
599
+ c.req.json(),
600
+ );
443
601
  } catch {
444
602
  return c.json(
445
603
  {
@@ -451,25 +609,66 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
451
609
  }
452
610
  let session: Session;
453
611
  try {
454
- session = await createSessionForRequest(deps, grant, workspaceId, payload);
612
+ CreateSessionRequest.parse(payload);
613
+ const origin = await measureSessionCreatePhase(deps.observability, "site_origin", () =>
614
+ resolveSiteSessionOrigin(
615
+ db,
616
+ workspaceId,
617
+ c.req.header("x-opengeni-site-id"),
618
+ c.req.header("x-opengeni-site-version"),
619
+ ),
620
+ );
621
+ const create = () =>
622
+ createSessionForRequest(deps, grant, workspaceId, payload, authorization);
623
+ session = await measureSessionCreatePhase(deps.observability, "core_create", () =>
624
+ origin ? withSiteSessionOrigin(origin, create) : create(),
625
+ );
455
626
  } catch (error) {
456
627
  return sessionCreateErrorResponse(c, error);
457
628
  }
458
629
  // Creation has committed by this point. Keep response projection outside
459
630
  // the create-rejection boundary so a post-commit policy read cannot be
460
631
  // misreported as though the session itself was rejected.
461
- return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session), 202);
632
+ return c.json(
633
+ await measureSessionCreatePhase(deps.observability, "response_projection", () =>
634
+ withEffectivePolicy(deps, workspaceId, grant.subjectId, session),
635
+ ),
636
+ 202,
637
+ );
462
638
  });
463
639
 
464
640
  app.get("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
465
641
  const workspaceId = c.req.param("workspaceId");
466
- const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
467
- return c.json(await getActorNewSessionDraft({ settings, db }, grant, workspaceId));
642
+ const authorization = await requireAccessGrantAuthorization(
643
+ c,
644
+ deps,
645
+ workspaceId,
646
+ "sessions:read",
647
+ );
648
+ const grant = authorization.grant;
649
+ const catalog = await resolveWorkspaceCatalogSettings(db, settings, {
650
+ accountId: grant.accountId,
651
+ workspaceId,
652
+ });
653
+ return c.json(
654
+ await getActorNewSessionDraft(
655
+ { settings: catalog.settings, db },
656
+ grant,
657
+ workspaceId,
658
+ authorization,
659
+ ),
660
+ );
468
661
  });
469
662
 
470
663
  app.put("/v1/workspaces/:workspaceId/new-session-draft", async (c) => {
471
664
  const workspaceId = c.req.param("workspaceId");
472
- const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
665
+ const authorization = await requireAccessGrantAuthorization(
666
+ c,
667
+ deps,
668
+ workspaceId,
669
+ "sessions:create",
670
+ );
671
+ const grant = authorization.grant;
473
672
  let payload: unknown;
474
673
  try {
475
674
  payload = await c.req.json();
@@ -483,12 +682,19 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
483
682
  );
484
683
  }
485
684
  try {
685
+ SaveNewSessionDraftRequest.parse(payload);
686
+ const catalog = await resolveWorkspaceCatalogSettings(db, settings, {
687
+ accountId: grant.accountId,
688
+ workspaceId,
689
+ });
486
690
  return c.json(
487
691
  await saveActorNewSessionDraft(
488
- { settings, db, objectStorage },
692
+ { settings: catalog.settings, db, objectStorage },
489
693
  grant,
490
694
  workspaceId,
491
695
  payload,
696
+ authorization.canonicalManagedHumanSession,
697
+ authorization,
492
698
  ),
493
699
  );
494
700
  } catch (error) {
@@ -515,9 +721,68 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
515
721
  }
516
722
  });
517
723
 
724
+ app.get("/v1/workspaces/:workspaceId/session-message-search", async (c) => {
725
+ const workspaceId = c.req.param("workspaceId");
726
+ const authorization = await requireAccessGrantAuthorization(
727
+ c,
728
+ deps,
729
+ workspaceId,
730
+ "sessions:read",
731
+ );
732
+ const grant = authorization.grant;
733
+ // A single-session filter is not authority: both searches use the complete
734
+ // live host/agent list scope, plus ordinary private/archive/RLS boundaries.
735
+ let authorizationScope;
736
+ try {
737
+ authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "http");
738
+ } catch (error) {
739
+ throw sessionAuthorizationHttpError(error);
740
+ }
741
+ const raw = c.req.query();
742
+ const parsed = SessionMessageSearchRequest.safeParse({
743
+ ...raw,
744
+ ...(raw.limit !== undefined ? { limit: Number(raw.limit) } : {}),
745
+ });
746
+ if (!parsed.success)
747
+ throw new HTTPException(400, { message: "Invalid session message search request" });
748
+ try {
749
+ return c.json(
750
+ await searchSessionMessagesForSubject(
751
+ db,
752
+ workspaceId,
753
+ parsed.data,
754
+ {
755
+ subjectId: grant.subjectId,
756
+ ...(authorizationScope ? { authorizationScope } : {}),
757
+ personalWorkspaceOwnerException: hasVerifiedOwningUserAuthorization(authorization),
758
+ },
759
+ { signal: c.req.raw.signal },
760
+ ),
761
+ );
762
+ } catch (error) {
763
+ if (error instanceof SessionListAccessError)
764
+ throw new HTTPException(403, { message: error.message });
765
+ if (error instanceof SessionMessageSearchCursorError)
766
+ throw new HTTPException(400, { message: error.message });
767
+ // Clients are told to cancel superseded searches, so an abort here is a
768
+ // routine client disconnect, never a server failure. Match the existing
769
+ // client-gone convention (mcp/request-abort.ts): 499 with an empty body,
770
+ // which a departed client never reads and monitors never count as 5xx.
771
+ if (c.req.raw.signal.aborted || (error instanceof Error && error.name === "AbortError"))
772
+ return new Response(null, { status: 499 });
773
+ throw error;
774
+ }
775
+ });
776
+
518
777
  app.get("/v1/workspaces/:workspaceId/sessions", async (c) => {
519
778
  const workspaceId = c.req.param("workspaceId");
520
- const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
779
+ const authorization = await requireAccessGrantAuthorization(
780
+ c,
781
+ deps,
782
+ workspaceId,
783
+ "sessions:read",
784
+ );
785
+ const grant = authorization.grant;
521
786
  let authorizationScope;
522
787
  try {
523
788
  authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "http");
@@ -526,18 +791,50 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
526
791
  }
527
792
  const pageView = c.req.query("view") === "page";
528
793
  const query = sessionListQuery(c.req.query(), pageView);
529
- let page: Awaited<ReturnType<typeof listSessionsForSubject>>;
794
+ const projection = c.req.query("projection");
795
+ if (projection !== undefined && projection !== "summary")
796
+ throw new HTTPException(400, { message: 'projection must be "summary"' });
797
+ if (projection && !pageView)
798
+ throw new HTTPException(400, { message: 'summary projection requires view="page"' });
799
+ const summary = projection === "summary";
800
+ let page: Awaited<
801
+ ReturnType<typeof listSessionsForSubject | typeof listSessionEntriesForSubject>
802
+ >;
530
803
  try {
531
- page = await listSessionsForSubject(db, workspaceId, {
532
- subjectId: grant.subjectId,
533
- limit: boundedLimit(query.limit),
534
- materializeSnapshot: pageView,
535
- ...(query.cursor ? { cursor: query.cursor } : {}),
536
- ...(query.search ? { search: query.search } : {}),
537
- ...(query.pinsOnly ? { pinsOnly: true } : {}),
538
- ...(query.parentSessionId !== undefined ? { parentSessionId: query.parentSessionId } : {}),
539
- ...(authorizationScope ? { authorizationScope } : {}),
540
- });
804
+ page = await (summary ? listSessionEntriesForSubject : listSessionsForSubject)(
805
+ db,
806
+ workspaceId,
807
+ {
808
+ subjectId: grant.subjectId,
809
+ limit: boundedLimit(query.limit),
810
+ materializeSnapshot: pageView,
811
+ ...(query.cursor ? { cursor: query.cursor } : {}),
812
+ ...(query.search ? { search: query.search } : {}),
813
+ ...(query.pinsOnly ? { pinsOnly: true } : {}),
814
+ ...(query.includeTotals ? { includeTotals: true } : {}),
815
+ ...(query.needsYouOnly ? { needsYouOnly: true } : {}),
816
+ ...(query.includePinned === false ? { includePinned: false } : {}),
817
+ ...(query.archivedOnly ? { archivedOnly: true } : {}),
818
+ ...(query.sortBy ? { sortBy: query.sortBy } : {}),
819
+ ...(query.archiveStatus ? { archiveStatus: query.archiveStatus } : {}),
820
+ ...(query.parentSessionId !== undefined
821
+ ? { parentSessionId: query.parentSessionId }
822
+ : {}),
823
+ ...(query.channelId !== undefined ? { channelId: query.channelId } : {}),
824
+ ...(query.originSiteId ? { originSiteId: query.originSiteId } : {}),
825
+ ...(query.createdBy ? { createdBy: query.createdBy } : {}),
826
+ ...(query.updatedFrom ? { updatedFrom: query.updatedFrom } : {}),
827
+ ...(query.updatedBefore ? { updatedBefore: query.updatedBefore } : {}),
828
+ ...(query.createdFrom ? { createdFrom: query.createdFrom } : {}),
829
+ ...(query.createdBefore ? { createdBefore: query.createdBefore } : {}),
830
+ ...(query.scopeSubjectId ? { scopeSubjectId: query.scopeSubjectId } : {}),
831
+ ...(authorizationScope ? { authorizationScope } : {}),
832
+ // A managed human's own personal workspace has no membership row, so
833
+ // the list's removal fence must fall back to the organization-membership
834
+ // pointer, only with verified native or external owning-user provenance.
835
+ personalWorkspaceOwnerException: hasVerifiedOwningUserAuthorization(authorization),
836
+ },
837
+ );
541
838
  } catch (error) {
542
839
  if (error instanceof SessionListAccessError) {
543
840
  throw new HTTPException(403, { message: error.message });
@@ -552,26 +849,65 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
552
849
  if (error instanceof SessionListCursorError) {
553
850
  throw new HTTPException(400, { message: error.message });
554
851
  }
555
- if (error instanceof SessionListSnapshotLimitError) {
556
- c.header("Retry-After", "5");
557
- throw new HTTPException(429, { message: error.message });
558
- }
559
852
  throw error;
560
853
  }
561
854
  // The page body carries this fact directly. Preserve the historical array
562
855
  // body for older clients while still making its older-pin omission visible
563
856
  // to raw HTTP consumers without changing that response shape.
564
857
  c.header("x-opengeni-pinned-truncated", page.pinnedTruncated === true ? "true" : "false");
565
- const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
566
- const decorate = (session: Session): Session =>
567
- sessionWithEffectiveToolPolicy(
568
- session,
858
+ const commandActivity = await backgroundCommandActivityForSessions(db, {
859
+ accountId: grant.accountId,
860
+ workspaceId,
861
+ sessionIds: [...page.pinned, ...page.sessions].map((session) => session.id),
862
+ });
863
+ const scheduleTargets =
864
+ hasPermission(grant.permissions, "scheduled_tasks:run") &&
865
+ hasPermission(grant.permissions, "sessions:control")
866
+ ? await scheduledSessionIds(
867
+ db,
868
+ workspaceId,
869
+ [...page.pinned, ...page.sessions].map((session) => session.id),
870
+ )
871
+ : new Set<string>();
872
+ if ("projection" in page) {
873
+ const decorateEntry = (session: (typeof page.sessions)[number]) => ({
874
+ ...session,
875
+ hasSchedules: scheduleTargets.has(session.id),
876
+ ...(commandActivity.has(session.id)
877
+ ? { backgroundCommandActivity: commandActivity.get(session.id)! }
878
+ : {}),
879
+ });
880
+ return c.json({
881
+ ...page,
882
+ ...(query.hasPageFilters ? { filtersApplied: true as const } : {}),
883
+ ...(query.originSiteId ? { originSiteId: query.originSiteId } : {}),
884
+ pinned: page.pinned.map(decorateEntry),
885
+ sessions: page.sessions.map(decorateEntry),
886
+ });
887
+ }
888
+ const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId, [
889
+ ...page.pinned,
890
+ ...page.sessions,
891
+ ]);
892
+ const decorate = (session: Session): Session => {
893
+ const activity = commandActivity.get(session.id);
894
+ const decorated = {
895
+ ...session,
896
+ hasSchedules: scheduleTargets.has(session.id),
897
+ ...(activity ? { backgroundCommandActivity: activity } : {}),
898
+ };
899
+ return sessionWithEffectiveToolPolicy(
900
+ decorated,
569
901
  policy.workspaceServerIds,
570
902
  policy.workspaceDefaultServerIds,
903
+ policy.effectiveToolsContext,
571
904
  );
905
+ };
572
906
  if (pageView) {
573
907
  return c.json({
574
908
  ...page,
909
+ ...(query.hasPageFilters ? { filtersApplied: true as const } : {}),
910
+ ...(query.originSiteId ? { originSiteId: query.originSiteId } : {}),
575
911
  pinned: page.pinned.map(decorate),
576
912
  sessions: page.sessions.map(decorate),
577
913
  });
@@ -594,71 +930,144 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
594
930
  throw sessionAuthorizationHttpError(error);
595
931
  }
596
932
  const query = agentTopologyQuery(c.req.query());
597
- const page = await listSessionDiscoverySummaries(db, workspaceId, {
598
- limit: query.limit,
599
- orderBy: "updatedAt",
600
- subjectId: grant.subjectId,
601
- ...(query.cursor ? { cursor: query.cursor } : {}),
602
- ...(query.search ? { search: query.search } : { parentSessionId: query.parentSessionId }),
603
- ...(authorizationScope ? { authorizationScope } : {}),
604
- });
605
- const ancestorPaths = query.search
606
- ? await listSessionDiscoveryAncestorPaths(
607
- db,
608
- workspaceId,
609
- page.sessions.map((session) => session.id),
610
- authorizationScope ?? undefined,
611
- )
612
- : new Map<string, SessionDiscoveryAncestor[]>();
613
- const sessions: AgentTopologyPageResponse["sessions"] = page.sessions.map((session) => {
614
- const blocker = session.effectiveControl.primaryBlocker;
615
- return {
616
- id: session.id,
617
- title: session.title,
618
- titleTruncated:
619
- session.titleOriginalChars !== null &&
620
- session.titleOriginalChars > Array.from(session.title ?? "").length,
621
- parentSessionId: session.parentSessionId,
622
- rootSessionId: session.rootSessionId,
623
- nestedAgentDepth: session.nestedAgentDepth,
624
- ancestorPath: (ancestorPaths.get(session.id) ?? []).map((ancestor) => ({
625
- id: ancestor.id,
626
- title: ancestor.title,
933
+ const startedAtMs = performance.now();
934
+ const mode = query.subject ? "subject" : query.query ? "query" : "browse";
935
+ const metricAuthorizationScope = authorizationScope?.kind === "scoped" ? "scoped" : "workspace";
936
+ if ((query.query || query.subject) && !settings.workDiscoveryEnabled) {
937
+ observeWorkDiscovery(deps.observability, {
938
+ surface: "agent_topology",
939
+ mode,
940
+ outcome: "disabled",
941
+ authorizationScope: metricAuthorizationScope,
942
+ durationMs: performance.now() - startedAtMs,
943
+ responseBytes: 0,
944
+ resultCount: 0,
945
+ overlapCount: 0,
946
+ matchCounts: {},
947
+ });
948
+ throw new HTTPException(503, {
949
+ message: "Agent work discovery is disabled by the operator.",
950
+ });
951
+ }
952
+ try {
953
+ const orderBy: SessionDiscoveryOrderBy =
954
+ query.query || query.subject ? "relevance" : "updatedAt";
955
+ const page = await listSessionDiscoverySummaries(db, workspaceId, {
956
+ limit: query.limit,
957
+ orderBy,
958
+ subjectId: grant.subjectId,
959
+ ...(query.cursor ? { cursor: query.cursor } : {}),
960
+ ...(query.parentSessionId !== undefined ? { parentSessionId: query.parentSessionId } : {}),
961
+ ...(query.rootSessionId ? { rootSessionId: query.rootSessionId } : {}),
962
+ ...(query.query ? { query: query.query } : {}),
963
+ ...(query.statuses ? { statuses: query.statuses } : {}),
964
+ activeOnly: query.activeOnly,
965
+ ...(query.recentHours !== undefined ? { recentHours: query.recentHours } : {}),
966
+ ...(query.subject ? { subject: query.subject } : {}),
967
+ ...(query.claimLimit !== undefined ? { claimLimit: query.claimLimit } : {}),
968
+ includeWorkDiscovery: settings.workDiscoveryEnabled,
969
+ ...(authorizationScope ? { authorizationScope } : {}),
970
+ });
971
+ const ancestorPaths =
972
+ query.query || query.subject
973
+ ? await listSessionDiscoveryAncestorPaths(
974
+ db,
975
+ workspaceId,
976
+ page.sessions.map((session) => session.id),
977
+ authorizationScope ?? undefined,
978
+ grant.subjectId,
979
+ )
980
+ : new Map<string, SessionDiscoveryAncestor[]>();
981
+ const sessions: AgentTopologyPageResponse["sessions"] = page.sessions.map((session) => {
982
+ const blocker = session.effectiveControl.primaryBlocker;
983
+ return {
984
+ id: session.id,
985
+ title: session.title,
627
986
  titleTruncated:
628
- ancestor.titleOriginalChars !== null &&
629
- ancestor.titleOriginalChars > Array.from(ancestor.title ?? "").length,
630
- })),
631
- status: session.status,
632
- pause: {
633
- state: session.effectiveControl.state,
634
- additionalBlockerCount: session.effectiveControl.additionalBlockerCount,
635
- source: blocker
987
+ session.titleOriginalChars !== null &&
988
+ session.titleOriginalChars > Array.from(session.title ?? "").length,
989
+ parentSessionId: session.parentSessionId,
990
+ rootSessionId: session.rootSessionId,
991
+ nestedAgentDepth: session.nestedAgentDepth,
992
+ ancestorPath: (ancestorPaths.get(session.id) ?? []).map((ancestor) => ({
993
+ id: ancestor.id,
994
+ title: ancestor.title,
995
+ titleTruncated:
996
+ ancestor.titleOriginalChars !== null &&
997
+ ancestor.titleOriginalChars > Array.from(ancestor.title ?? "").length,
998
+ })),
999
+ status: session.status,
1000
+ goal: session.goal
636
1001
  ? {
637
- kind: blocker.kind,
638
- ...(blocker.sessionId ? { sessionId: blocker.sessionId } : {}),
639
- displayName: blocker.displayName,
640
- displayNameTruncated:
641
- blocker.displayNameOriginalChars > Array.from(blocker.displayName).length,
1002
+ status: session.goal.status,
1003
+ summary: session.goal.text,
1004
+ summaryTruncated:
1005
+ session.goal.textOriginalChars > Array.from(session.goal.text).length,
642
1006
  }
643
1007
  : null,
644
- },
645
- children: session.treeStats,
646
- createdAt: session.createdAt,
647
- updatedAt: session.updatedAt,
648
- };
649
- });
650
- return c.json({
651
- sessions,
652
- total: page.total,
653
- hasMore: page.hasMore,
654
- nextCursor: page.nextCursor
655
- ? encodeAgentTopologyCursor({
656
- cursor: page.nextCursor,
657
- parentSessionId: query.parentSessionId,
658
- search: query.search ?? null,
659
- })
660
- : null,
661
- } satisfies AgentTopologyPageResponse);
1008
+ pause: {
1009
+ state: session.effectiveControl.state,
1010
+ additionalBlockerCount: session.effectiveControl.additionalBlockerCount,
1011
+ source: blocker
1012
+ ? {
1013
+ kind: blocker.kind,
1014
+ ...(blocker.sessionId ? { sessionId: blocker.sessionId } : {}),
1015
+ displayName: blocker.displayName,
1016
+ displayNameTruncated:
1017
+ blocker.displayNameOriginalChars > Array.from(blocker.displayName).length,
1018
+ }
1019
+ : null,
1020
+ },
1021
+ children: session.treeStats,
1022
+ relatedWork: session.workDiscovery,
1023
+ createdAt: session.createdAt,
1024
+ updatedAt: session.updatedAt,
1025
+ };
1026
+ });
1027
+ const response = {
1028
+ sessions,
1029
+ total: page.total,
1030
+ hasMore: page.hasMore,
1031
+ humanAdvisoriesEnabled:
1032
+ settings.workDiscoveryEnabled && settings.workDiscoveryHumanAdvisoriesEnabled,
1033
+ nextCursor: page.nextCursor
1034
+ ? encodeAgentTopologyCursor({
1035
+ cursor: page.nextCursor,
1036
+ parentSessionId: query.parentSessionId === undefined ? "all" : query.parentSessionId,
1037
+ rootSessionId: query.rootSessionId ?? null,
1038
+ query: query.query ?? null,
1039
+ statuses: query.statuses ?? [],
1040
+ activeOnly: query.activeOnly,
1041
+ recentHours: query.recentHours ?? null,
1042
+ subject: query.subject ?? null,
1043
+ claimLimit: query.claimLimit ?? null,
1044
+ })
1045
+ : null,
1046
+ } satisfies AgentTopologyPageResponse;
1047
+ observeWorkDiscovery(deps.observability, {
1048
+ surface: "agent_topology",
1049
+ mode,
1050
+ outcome: sessions.length === 0 ? "empty" : "ok",
1051
+ authorizationScope: metricAuthorizationScope,
1052
+ durationMs: performance.now() - startedAtMs,
1053
+ responseBytes: Buffer.byteLength(JSON.stringify(response), "utf8"),
1054
+ ...summarizeWorkDiscoveryRows(sessions),
1055
+ });
1056
+ return c.json(response);
1057
+ } catch (error) {
1058
+ observeWorkDiscovery(deps.observability, {
1059
+ surface: "agent_topology",
1060
+ mode,
1061
+ outcome: "error",
1062
+ authorizationScope: metricAuthorizationScope,
1063
+ durationMs: performance.now() - startedAtMs,
1064
+ responseBytes: 0,
1065
+ resultCount: 0,
1066
+ overlapCount: 0,
1067
+ matchCounts: {},
1068
+ });
1069
+ throw error;
1070
+ }
662
1071
  });
663
1072
 
664
1073
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
@@ -678,7 +1087,143 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
678
1087
  if (!session) {
679
1088
  throw new HTTPException(404, { message: "session not found" });
680
1089
  }
681
- return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
1090
+ const activity = await backgroundCommandActivityForSessions(db, {
1091
+ accountId: grant.accountId,
1092
+ workspaceId,
1093
+ sessionIds: [sessionId],
1094
+ });
1095
+ const scheduleTargets =
1096
+ hasPermission(grant.permissions, "scheduled_tasks:run") &&
1097
+ hasPermission(grant.permissions, "sessions:control")
1098
+ ? await scheduledSessionIds(db, workspaceId, [sessionId])
1099
+ : new Set<string>();
1100
+ return c.json(
1101
+ await withEffectivePolicy(deps, workspaceId, grant.subjectId, {
1102
+ ...session,
1103
+ hasSchedules: scheduleTargets.has(sessionId),
1104
+ ...(activity.get(sessionId) ? { backgroundCommandActivity: activity.get(sessionId) } : {}),
1105
+ }),
1106
+ );
1107
+ });
1108
+
1109
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/model-context", async (c) => {
1110
+ const workspaceId = c.req.param("workspaceId");
1111
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1112
+ const sessionId = c.req.param("sessionId");
1113
+ if (!z.string().uuid().safeParse(sessionId).success) {
1114
+ throw new HTTPException(404, { message: "session not found" });
1115
+ }
1116
+ const session = await getSessionForSubject(
1117
+ db,
1118
+ workspaceId,
1119
+ sessionId,
1120
+ grant.subjectId,
1121
+ relatedSessionAccessFor(c),
1122
+ );
1123
+ if (!session) {
1124
+ throw new HTTPException(404, { message: "session not found" });
1125
+ }
1126
+ return c.json(
1127
+ await getLatestSessionModelContext(db, {
1128
+ accountId: grant.accountId,
1129
+ workspaceId,
1130
+ sessionId,
1131
+ }),
1132
+ );
1133
+ });
1134
+
1135
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/background-commands", async (c) => {
1136
+ const workspaceId = c.req.param("workspaceId");
1137
+ const sessionId = c.req.param("sessionId");
1138
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1139
+ const commands = await listSessionBackgroundCommands(db, {
1140
+ accountId: grant.accountId,
1141
+ workspaceId,
1142
+ sessionId,
1143
+ activeOnly: true,
1144
+ });
1145
+ return c.json({ commands });
1146
+ });
1147
+
1148
+ app.delete(
1149
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/background-commands/:commandId",
1150
+ async (c) => {
1151
+ const workspaceId = c.req.param("workspaceId");
1152
+ const sessionId = c.req.param("sessionId");
1153
+ const commandId = c.req.param("commandId");
1154
+ if (!z.string().uuid().safeParse(commandId).success) {
1155
+ throw new HTTPException(404, { message: "background command not found" });
1156
+ }
1157
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1158
+ const result = await requestSessionBackgroundCommandCancellation(db, {
1159
+ accountId: grant.accountId,
1160
+ workspaceId,
1161
+ sessionId,
1162
+ commandId,
1163
+ subjectId: grant.subjectId,
1164
+ });
1165
+ if (!result.command) {
1166
+ throw new HTTPException(404, { message: "background command not found" });
1167
+ }
1168
+ return c.json(result);
1169
+ },
1170
+ );
1171
+
1172
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/visibility", async (c) => {
1173
+ const workspaceId = c.req.param("workspaceId");
1174
+ const sessionId = c.req.param("sessionId");
1175
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
1176
+ const parsed = UpdateSessionVisibilityRequest.safeParse(await c.req.json().catch(() => null));
1177
+ if (!parsed.success) {
1178
+ throw new ApiHttpError(422, {
1179
+ code: "validation_failed",
1180
+ message: "Invalid session visibility request.",
1181
+ retryable: false,
1182
+ details: { fields: zodErrorFields(parsed.error) },
1183
+ });
1184
+ }
1185
+ try {
1186
+ return c.json(
1187
+ await updateManagedHumanSessionVisibility(
1188
+ deps,
1189
+ authorization,
1190
+ workspaceId,
1191
+ sessionId,
1192
+ parsed.data,
1193
+ "http",
1194
+ ),
1195
+ );
1196
+ } catch (error) {
1197
+ throw sessionTenancyHttpError(error);
1198
+ }
1199
+ });
1200
+
1201
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/forks", async (c) => {
1202
+ const workspaceId = c.req.param("workspaceId");
1203
+ const sessionId = c.req.param("sessionId");
1204
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
1205
+ const parsed = ForkSessionRequest.safeParse(await c.req.json().catch(() => null));
1206
+ if (!parsed.success) {
1207
+ throw new ApiHttpError(422, {
1208
+ code: "validation_failed",
1209
+ message: "Invalid session fork request.",
1210
+ retryable: false,
1211
+ details: { fields: zodErrorFields(parsed.error) },
1212
+ });
1213
+ }
1214
+ try {
1215
+ const response = await forkManagedHumanSession(
1216
+ deps,
1217
+ authorization,
1218
+ workspaceId,
1219
+ sessionId,
1220
+ parsed.data,
1221
+ "http",
1222
+ );
1223
+ return c.json(response, response.replay ? 200 : 201);
1224
+ } catch (error) {
1225
+ throw sessionTenancyHttpError(error);
1226
+ }
682
1227
  });
683
1228
 
684
1229
  const publishRealtimeMutation = async (
@@ -719,15 +1264,23 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
719
1264
  });
720
1265
  }
721
1266
  try {
722
- const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
723
- beginSessionRealtimeInTransaction(scopedDb, {
1267
+ const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) => {
1268
+ const accounts = await freezeSessionRealtimeConnectionAccounts({
1269
+ db: scopedDb as unknown as Database,
1270
+ settings,
1271
+ grant,
1272
+ workspaceId,
1273
+ sessionId,
1274
+ });
1275
+ return beginSessionRealtimeInTransaction(scopedDb, {
724
1276
  accountId: grant.accountId,
725
1277
  workspaceId,
726
1278
  sessionId,
727
1279
  ownerSubjectId: grant.subjectId,
728
1280
  ...parsed.data,
729
- }),
730
- );
1281
+ ...accounts,
1282
+ });
1283
+ });
731
1284
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
732
1285
  c.header("cache-control", "private, no-store");
733
1286
  return c.json({ mode: result.mode, replay: result.replay }, result.replay ? 200 : 201);
@@ -1276,7 +1829,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1276
1829
  const workspaceId = c.req.param("workspaceId");
1277
1830
  const sessionId = c.req.param("sessionId");
1278
1831
  const realtimeId = c.req.param("realtimeId");
1279
- const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1832
+ const authorization = await requireAccessGrantAuthorization(
1833
+ c,
1834
+ deps,
1835
+ workspaceId,
1836
+ "sessions:control",
1837
+ );
1838
+ const grant = authorization.grant;
1839
+ const beforeCommit = externalContinuationCommitAuthorizer(authorization);
1840
+ const captureLinked = prepareExternalLinkTurnAdmission(authorization);
1280
1841
  if (
1281
1842
  !z.string().uuid().safeParse(sessionId).success ||
1282
1843
  !z.string().uuid().safeParse(realtimeId).success
@@ -1292,15 +1853,29 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1292
1853
  });
1293
1854
  }
1294
1855
  try {
1295
- const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
1296
- syncSessionRealtimeLedgerInTransaction(scopedDb, {
1297
- workspaceId,
1298
- sessionId,
1299
- realtimeId,
1300
- ownerSubjectId: grant.subjectId,
1301
- ...parsed.data,
1302
- }),
1303
- );
1856
+ const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) => {
1857
+ // Acquire origin authority before inference/session locks. A deferred
1858
+ // realtime session has no initial worker turn; capture its exact
1859
+ // linked actor when the ledger actually admits ordinary agent work.
1860
+ await beforeCommit?.(scopedDb as unknown as Database);
1861
+ return syncSessionRealtimeLedgerInTransaction(
1862
+ scopedDb,
1863
+ {
1864
+ workspaceId,
1865
+ sessionId,
1866
+ realtimeId,
1867
+ ownerSubjectId: grant.subjectId,
1868
+ ...parsed.data,
1869
+ controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
1870
+ },
1871
+ captureLinked
1872
+ ? {
1873
+ afterDelegationAdmission: async ({ turnId }) =>
1874
+ captureLinked(scopedDb as unknown as Database, sessionId, turnId),
1875
+ }
1876
+ : {},
1877
+ );
1878
+ });
1304
1879
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1305
1880
  c.header("cache-control", "private, no-store");
1306
1881
  return c.json({ accepted: result.accepted, outbound: result.outbound });
@@ -1315,7 +1890,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1315
1890
  // (not session control) and returns 404 for a foreign/inaccessible session.
1316
1891
  app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/pin", async (c) => {
1317
1892
  const workspaceId = c.req.param("workspaceId");
1318
- const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1893
+ const authorization = await requireAccessGrantAuthorization(
1894
+ c,
1895
+ deps,
1896
+ workspaceId,
1897
+ "sessions:read",
1898
+ );
1899
+ const grant = authorization.grant;
1319
1900
  const sessionId = c.req.param("sessionId");
1320
1901
  if (!z.string().uuid().safeParse(sessionId).success) {
1321
1902
  throw new HTTPException(404, { message: "session not found" });
@@ -1329,6 +1910,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1329
1910
  workspaceId,
1330
1911
  subjectId: grant.subjectId,
1331
1912
  sessionId,
1913
+ // Same owner-only personal-workspace fallback as the list above.
1914
+ personalWorkspaceOwnerException: hasVerifiedOwningUserAuthorization(authorization),
1332
1915
  ...parsed.data,
1333
1916
  });
1334
1917
  if (!session) {
@@ -1359,28 +1942,186 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1359
1942
  }
1360
1943
  });
1361
1944
 
1362
- app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/lineage", async (c) => {
1945
+ // Personal follow-up state. Session reads stay side-effect free; clients use
1946
+ // this explicit mutation after a deliberate read/unread action. The managed
1947
+ // web console treats an exact foreground chat event frontier as that action,
1948
+ // while the actively-working label remains independent of acknowledgment.
1949
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/attention", async (c) => {
1363
1950
  const workspaceId = c.req.param("workspaceId");
1364
- const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1365
- const lineage = await readSessionLineage(deps, grant, c.req.param("sessionId"));
1366
- const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
1367
- return c.json({
1951
+ const authorization = await requireAccessGrantAuthorization(
1952
+ c,
1953
+ deps,
1954
+ workspaceId,
1955
+ "sessions:read",
1956
+ );
1957
+ const grant = authorization.grant;
1958
+ const sessionId = c.req.param("sessionId");
1959
+ if (!z.string().uuid().safeParse(sessionId).success) {
1960
+ throw new HTTPException(404, { message: "session not found" });
1961
+ }
1962
+ const parsed = UpdateSessionAttentionRequest.safeParse(await c.req.json().catch(() => null));
1963
+ if (!parsed.success) {
1964
+ throw new HTTPException(400, { message: "invalid session attention request" });
1965
+ }
1966
+ try {
1967
+ const session = await setSessionAttention(db, {
1968
+ workspaceId,
1969
+ subjectId: grant.subjectId,
1970
+ sessionId,
1971
+ personalWorkspaceOwnerException: hasVerifiedOwningUserAuthorization(authorization),
1972
+ ...parsed.data,
1973
+ });
1974
+ if (!session) throw new HTTPException(404, { message: "session not found" });
1975
+ return c.json(
1976
+ await withEffectivePolicy(
1977
+ deps,
1978
+ workspaceId,
1979
+ grant.subjectId,
1980
+ projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)),
1981
+ ),
1982
+ );
1983
+ } catch (error) {
1984
+ if (error instanceof SessionPinAccessError) {
1985
+ throw new HTTPException(403, { message: error.message });
1986
+ }
1987
+ if (error instanceof SessionAttentionVersionConflictError) {
1988
+ return c.json(
1989
+ {
1990
+ message: "session attention changed in another client",
1991
+ current: error.current,
1992
+ },
1993
+ 409,
1994
+ );
1995
+ }
1996
+ throw error;
1997
+ }
1998
+ });
1999
+
2000
+ // Personal chat organization: archived roots leave the ordinary rail only
2001
+ // for this member and remain recoverable through the archived list view.
2002
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/archive", async (c) => {
2003
+ const workspaceId = c.req.param("workspaceId");
2004
+ const authorization = await requireAccessGrantAuthorization(
2005
+ c,
2006
+ deps,
2007
+ workspaceId,
2008
+ "sessions:read",
2009
+ );
2010
+ const grant = authorization.grant;
2011
+ const sessionId = c.req.param("sessionId");
2012
+ const parsed = UpdateSessionArchiveRequest.safeParse(await c.req.json().catch(() => null));
2013
+ if (!parsed.success) {
2014
+ throw new HTTPException(400, { message: "invalid session archive request" });
2015
+ }
2016
+ try {
2017
+ const session = await setSessionArchive(db, {
2018
+ workspaceId,
2019
+ subjectId: grant.subjectId,
2020
+ sessionId,
2021
+ personalWorkspaceOwnerException: hasVerifiedOwningUserAuthorization(authorization),
2022
+ ...parsed.data,
2023
+ });
2024
+ if (!session) throw new HTTPException(404, { message: "session not found" });
2025
+ return c.json(
2026
+ await withEffectivePolicy(
2027
+ deps,
2028
+ workspaceId,
2029
+ grant.subjectId,
2030
+ projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)),
2031
+ ),
2032
+ );
2033
+ } catch (error) {
2034
+ if (error instanceof SessionPinAccessError) {
2035
+ throw new HTTPException(403, { message: error.message });
2036
+ }
2037
+ if (error instanceof SessionArchiveVersionConflictError) {
2038
+ return c.json(
2039
+ { message: "session archive changed in another client", current: error.current },
2040
+ 409,
2041
+ );
2042
+ }
2043
+ throw error;
2044
+ }
2045
+ });
2046
+
2047
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/lineage", async (c) => {
2048
+ const workspaceId = c.req.param("workspaceId");
2049
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
2050
+ const lineage = await readSessionLineage(deps, grant, c.req.param("sessionId"));
2051
+ const sessionIds = [
2052
+ c.req.param("sessionId"),
2053
+ ...lineage.ancestors.map((session) => session.id),
2054
+ ];
2055
+ const lineageSessions = [...lineage.ancestors];
2056
+ const collect = (nodes: LineageNode[]) => {
2057
+ for (const node of nodes) {
2058
+ sessionIds.push(node.session.id);
2059
+ lineageSessions.push(node.session as Session);
2060
+ collect(node.children);
2061
+ }
2062
+ };
2063
+ collect(lineage.children);
2064
+ const targets =
2065
+ hasPermission(grant.permissions, "scheduled_tasks:run") &&
2066
+ hasPermission(grant.permissions, "sessions:control")
2067
+ ? await scheduledSessionIds(db, workspaceId, sessionIds)
2068
+ : new Set<string>();
2069
+ const decorateNodes = (nodes: LineageNode[]): LineageNode[] =>
2070
+ nodes.map((node) => ({
2071
+ ...node,
2072
+ session: { ...node.session, hasSchedules: targets.has(node.session.id) },
2073
+ children: decorateNodes(node.children),
2074
+ }));
2075
+ const policy = await loadEffectivePolicyContext(
2076
+ deps,
2077
+ workspaceId,
2078
+ grant.subjectId,
2079
+ lineageSessions,
2080
+ );
2081
+ return c.json({
1368
2082
  ...lineage,
2083
+ sessionHasSchedules: targets.has(c.req.param("sessionId")),
1369
2084
  ancestors: lineage.ancestors.map((session) =>
1370
2085
  sessionWithEffectiveToolPolicy(
1371
- session,
2086
+ { ...session, hasSchedules: targets.has(session.id) },
1372
2087
  policy.workspaceServerIds,
1373
2088
  policy.workspaceDefaultServerIds,
2089
+ policy.effectiveToolsContext,
1374
2090
  ),
1375
2091
  ),
1376
- children: mapLineageNodes(lineage.children, policy),
2092
+ children: decorateNodes(mapLineageNodes(lineage.children, policy)),
2093
+ });
2094
+ });
2095
+
2096
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/codex-accounts", async (c) => {
2097
+ const workspaceId = c.req.param("workspaceId");
2098
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
2099
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
2100
+ // authorizeSessionHttp has already enforced private-session and agent scope.
2101
+ const projection = await getSessionCodexAccounts(db, workspaceId, c.req.param("sessionId"));
2102
+ if (!projection) throw new HTTPException(404, { message: "session not found" });
2103
+ const activeAccountId = projection.rotation?.activeCredentialId ?? null;
2104
+ return c.json({
2105
+ accounts: projection.accounts.map((account) => codexAccountJson(account)),
2106
+ activeAccountId,
2107
+ settings: {
2108
+ rotationEnabled: projection.rotation?.rotationEnabled ?? false,
2109
+ rotationStrategy: "sharded",
2110
+ activeCredentialId: activeAccountId,
2111
+ },
2112
+ currentSelection: projection.currentSelection,
2113
+ currentAccount: projection.currentAccount
2114
+ ? codexAccountJson(projection.currentAccount)
2115
+ : null,
2116
+ pinnedAccountId: projection.pinnedAccountId,
2117
+ lastAccountId: projection.lastAccountId,
1377
2118
  });
1378
2119
  });
1379
2120
 
1380
2121
  // Pin (or unpin) the session's Codex account. body { target: "auto" | "<id>" }:
1381
2122
  // "auto" clears the pin (the session follows the workspace active pointer); a
1382
- // uuid pins the session to that specific account. The pin applies to the NEXT
1383
- // turn (the worker reads it at turn start). 404 when the session or the target
2123
+ // uuid pins the session to that specific account. Overrides a capacity-blocked
2124
+ // turn; a running attempt keeps its account. 404 when the session or the target
1384
2125
  // account id isn't in the workspace.
1385
2126
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/codex-account", async (c) => {
1386
2127
  const workspaceId = c.req.param("workspaceId");
@@ -1405,15 +2146,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1405
2146
  }
1406
2147
  }
1407
2148
  const pinned = target === "auto" ? null : target;
1408
- const mutation = await withSessionCodexCapacityMutation(
1409
- db,
1410
- { workspaceId, reason: "codex_manual_session_pin_changed" },
1411
- async (tx) => {
1412
- const changed = await setSessionCodexPinInTransaction(tx, workspaceId, sessionId, pinned);
1413
- return { result: changed, changed };
1414
- },
1415
- );
1416
- const ok = mutation.result;
2149
+ const mutation = await switchSessionCodexAccount(db, {
2150
+ workspaceId,
2151
+ sessionId,
2152
+ credentialId: pinned,
2153
+ subjectId: grant.subjectId,
2154
+ });
2155
+ const ok = mutation.result.changed;
1417
2156
  if (!ok) {
1418
2157
  throw new HTTPException(404, {
1419
2158
  message: "session or codex account not found",
@@ -1439,7 +2178,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1439
2178
  }),
1440
2179
  ),
1441
2180
  );
1442
- return c.json({ pinned: target === "auto" ? "auto" : target });
2181
+ await publishDurableSessionEvents(bus, workspaceId, sessionId, mutation.result.events);
2182
+ return c.json({
2183
+ pinned: target === "auto" ? "auto" : target,
2184
+ appliedTo: mutation.result.appliedTo,
2185
+ });
1443
2186
  });
1444
2187
 
1445
2188
  // Re-file the session into a workspace channel (rail organization only;
@@ -1450,7 +2193,12 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1450
2193
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1451
2194
  const sessionId = c.req.param("sessionId");
1452
2195
  await assertSessionExists(db, workspaceId, sessionId);
1453
- const payload = UpdateSessionChannelRequest.parse(await c.req.json());
2196
+ await requireSessionAuthorization(deps, grant, {
2197
+ sessionId,
2198
+ operation: "session.control",
2199
+ surface: "http",
2200
+ });
2201
+ const payload = await parseRequestJson(c, UpdateSessionChannelRequest);
1454
2202
  try {
1455
2203
  const updated = await setSessionChannel(db, {
1456
2204
  workspaceId,
@@ -1479,6 +2227,91 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1479
2227
  return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
1480
2228
  });
1481
2229
 
2230
+ // Replace the complete ordered Variable Set selection. The DB mutation
2231
+ // serializes with turn claim, rejects live/shared sandbox use, and requests a
2232
+ // cold rotation before the new environment can be materialized.
2233
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/variable-sets", async (c) => {
2234
+ const workspaceId = c.req.param("workspaceId");
2235
+ const sessionId = c.req.param("sessionId");
2236
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2237
+ try {
2238
+ await requireSessionAuthorization(deps, grant, {
2239
+ sessionId,
2240
+ operation: "session.variable_sets.write",
2241
+ surface: "http",
2242
+ });
2243
+ } catch (error) {
2244
+ throw sessionAuthorizationHttpError(error);
2245
+ }
2246
+ // Detach still requires attach authority: it changes which protected
2247
+ // resources the session may materialize. Non-empty selections additionally
2248
+ // require use authority through validateVariableSetAttachment.
2249
+ requirePermission(grant, "variable-sets:attach");
2250
+ const parsed = UpdateSessionVariableSetsRequest.safeParse(await c.req.json().catch(() => null));
2251
+ if (!parsed.success) {
2252
+ throw new ApiHttpError(422, {
2253
+ code: "validation_failed",
2254
+ message: "Invalid session Variable Set request.",
2255
+ retryable: false,
2256
+ details: { fields: zodErrorFields(parsed.error) },
2257
+ });
2258
+ }
2259
+ const variableSets: VariableSet[] = [];
2260
+ for (const variableSetId of parsed.data.variableSetIds) {
2261
+ variableSets.push(
2262
+ await validateVariableSetAttachment({ settings, db }, grant, workspaceId, variableSetId),
2263
+ );
2264
+ }
2265
+ const result = await updateSessionVariableSets(db, {
2266
+ accountId: grant.accountId,
2267
+ workspaceId,
2268
+ sessionId,
2269
+ subjectId: grant.subjectId,
2270
+ variableSets: variableSets.map((variableSet) => ({
2271
+ id: variableSet.id,
2272
+ name: variableSet.name,
2273
+ scope: variableSet.scope,
2274
+ })),
2275
+ });
2276
+ if (result.status === "not_found") {
2277
+ throw new HTTPException(404, { message: "session not found" });
2278
+ }
2279
+ if (result.status === "invalid_variable_sets") {
2280
+ throw new ApiHttpError(422, {
2281
+ code: "validation_failed",
2282
+ message: "One or more selected Variable Sets are no longer available.",
2283
+ retryable: false,
2284
+ outcomeUnknown: false,
2285
+ details: { variableSetIds: result.variableSetIds },
2286
+ });
2287
+ }
2288
+ if (result.status === "blocked") {
2289
+ const messages = {
2290
+ turn_in_flight:
2291
+ "Variable Sets can be changed only when the session has no accepted, queued, claimed, or pending work.",
2292
+ shared_sandbox_group:
2293
+ "Variable Sets cannot be changed while this session shares a sandbox; fork it into a separate session first.",
2294
+ live_sandbox_holders:
2295
+ "Close active terminal, desktop, and sandbox operations before changing Variable Sets.",
2296
+ } as const;
2297
+ throw new HTTPException(409, { message: messages[result.reason] });
2298
+ }
2299
+ if (result.status === "updated") {
2300
+ await publishDurableSessionEvents(bus, workspaceId, sessionId, [result.event]);
2301
+ }
2302
+ const session = await getSessionForSubject(
2303
+ db,
2304
+ workspaceId,
2305
+ sessionId,
2306
+ grant.subjectId,
2307
+ relatedSessionAccessFor(c),
2308
+ );
2309
+ if (!session) {
2310
+ throw new HTTPException(404, { message: "session not found" });
2311
+ }
2312
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
2313
+ });
2314
+
1482
2315
  // Manual rename. A user-set title is permanent: the db write is
1483
2316
  // unconditional (source='user'), so it always pins the session over later
1484
2317
  // agent writes. Returns the refreshed session, mirroring GET detail.
@@ -1487,7 +2320,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1487
2320
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1488
2321
  const sessionId = c.req.param("sessionId");
1489
2322
  await assertSessionExists(db, workspaceId, sessionId);
1490
- const payload = UpdateSessionRequest.parse(await c.req.json());
2323
+ const payload = await parseRequestJson(c, UpdateSessionRequest);
1491
2324
  const titleUpdate = await updateSessionTitle(deps, grant, sessionId, payload.title, "user");
1492
2325
  // A session-returning member route must preserve the caller's private pin
1493
2326
  // projection. Returning the generic mapSession() default here would reset a
@@ -1505,6 +2338,49 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1505
2338
  return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
1506
2339
  });
1507
2340
 
2341
+ app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
2342
+ const workspaceId = c.req.param("workspaceId");
2343
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2344
+ const deleted = await deleteSessionTreeIfQuiescent(db, {
2345
+ workspaceId,
2346
+ subjectId: grant.subjectId,
2347
+ sessionId: c.req.param("sessionId"),
2348
+ controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
2349
+ });
2350
+ switch (deleted.status) {
2351
+ case "deleted":
2352
+ return c.json({ deletedSessionCount: deleted.deletedSessionCount });
2353
+ case "not_found":
2354
+ throw new HTTPException(404, { message: "session not found" });
2355
+ case "not_root":
2356
+ throw new HTTPException(409, {
2357
+ message: "delete the root session to remove the complete workstream",
2358
+ });
2359
+ case "active_sessions":
2360
+ throw new HTTPException(409, {
2361
+ message: "cancel the workstream and wait for active turns to finish before deleting it",
2362
+ });
2363
+ case "active_video_generations":
2364
+ throw new HTTPException(409, {
2365
+ message: "wait for active video generations to finish before deleting this workstream",
2366
+ });
2367
+ case "active_background_commands":
2368
+ throw new HTTPException(409, {
2369
+ message: "pause or cancel this workstream's background commands before deleting it",
2370
+ });
2371
+ case "live_sandboxes":
2372
+ throw new HTTPException(409, {
2373
+ message:
2374
+ "wait for the workstream's sandbox activity to finish draining before deleting it",
2375
+ });
2376
+ case "externally_referenced":
2377
+ throw new HTTPException(409, {
2378
+ message:
2379
+ "this workstream has durable workspace outputs or independent forks; archive it instead",
2380
+ });
2381
+ }
2382
+ });
2383
+
1508
2384
  app.patch(
1509
2385
  "/v1/workspaces/:workspaceId/sessions/:sessionId/mcp-servers/:serverId/approval-policy",
1510
2386
  async (c) => {
@@ -1537,7 +2413,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1537
2413
  const workspaceId = c.req.param("workspaceId");
1538
2414
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1539
2415
  const sessionId = c.req.param("sessionId");
1540
- const payload = UpdateSessionToolPolicyRequest.parse(await c.req.json().catch(() => null));
2416
+ const payload = parseRequestBody(
2417
+ UpdateSessionToolPolicyRequest,
2418
+ await c.req.json().catch(() => null),
2419
+ );
1541
2420
  try {
1542
2421
  const session = await updateSessionToolPolicy(deps, grant, sessionId, payload);
1543
2422
  return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
@@ -1556,6 +2435,35 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1556
2435
  }
1557
2436
  });
1558
2437
 
2438
+ // Replace the session's agent configuration (capabilities, identity,
2439
+ // instructions alias, renderer). Shares the tool-policy version CAS and
2440
+ // applies from the next attempt.
2441
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/agent", async (c) => {
2442
+ const workspaceId = c.req.param("workspaceId");
2443
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2444
+ const sessionId = c.req.param("sessionId");
2445
+ const payload = parseRequestBody(
2446
+ UpdateSessionAgentRequest,
2447
+ await c.req.json().catch(() => null),
2448
+ );
2449
+ try {
2450
+ const session = await updateSessionAgent(deps, grant, sessionId, payload);
2451
+ return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
2452
+ } catch (error) {
2453
+ if (error instanceof SessionToolPolicyVersionConflictError) {
2454
+ return c.json(
2455
+ {
2456
+ code: error.code,
2457
+ message: error.message,
2458
+ currentVersion: error.currentVersion,
2459
+ },
2460
+ 409,
2461
+ );
2462
+ }
2463
+ throw error;
2464
+ }
2465
+ });
2466
+
1559
2467
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
1560
2468
  const workspaceId = c.req.param("workspaceId");
1561
2469
  await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -1576,6 +2484,34 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1576
2484
  return c.json(await listSessionGoalRevisions(db, workspaceId, sessionId));
1577
2485
  });
1578
2486
 
2487
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal/revisions/page", async (c) => {
2488
+ const workspaceId = c.req.param("workspaceId");
2489
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
2490
+ const sessionId = c.req.param("sessionId");
2491
+ await assertSessionExists(db, workspaceId, sessionId);
2492
+ const parsedQuery = ListSessionGoalRevisionsQuery.safeParse({
2493
+ limit: c.req.query("limit"),
2494
+ before: c.req.query("before"),
2495
+ });
2496
+ if (!parsedQuery.success) {
2497
+ throw new HTTPException(400, { message: "invalid goal revision page query" });
2498
+ }
2499
+ const query = parsedQuery.data;
2500
+ try {
2501
+ return c.json(
2502
+ await listSessionGoalRevisionPage(db, workspaceId, sessionId, {
2503
+ limit: query.limit,
2504
+ ...(query.before ? { before: query.before } : {}),
2505
+ }),
2506
+ );
2507
+ } catch (error) {
2508
+ if (error instanceof SessionControlConflictError) {
2509
+ throw new HTTPException(409, { message: error.message, cause: error });
2510
+ }
2511
+ throw error;
2512
+ }
2513
+ });
2514
+
1579
2515
  app.post(
1580
2516
  "/v1/workspaces/:workspaceId/sessions/:sessionId/goal/revisions/:revisionId/apply",
1581
2517
  async (c) => {
@@ -1583,7 +2519,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1583
2519
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1584
2520
  const sessionId = c.req.param("sessionId");
1585
2521
  await assertSessionExists(db, workspaceId, sessionId);
1586
- const payload = ApplySessionGoalRevisionRequest.parse(await c.req.json());
2522
+ const payload = await parseRequestJson(c, ApplySessionGoalRevisionRequest);
1587
2523
  const revision = await getSessionGoalRevision(
1588
2524
  db,
1589
2525
  workspaceId,
@@ -1607,6 +2543,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1607
2543
  sessionId,
1608
2544
  text: revision.text,
1609
2545
  successCriteria: revision.successCriteria,
2546
+ rootConstraints: revision.rootConstraints,
1610
2547
  mutationPolicy: revision.mutationPolicy,
1611
2548
  expectedObjectiveRevision: payload.expectedObjectiveRevision,
1612
2549
  expectedGoalId: revision.goalId,
@@ -1639,12 +2576,94 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1639
2576
  },
1640
2577
  );
1641
2578
 
2579
+ app.post(
2580
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/goal/revisions/:revisionId/reject",
2581
+ async (c) => {
2582
+ const workspaceId = c.req.param("workspaceId");
2583
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2584
+ const sessionId = c.req.param("sessionId");
2585
+ await assertSessionExists(db, workspaceId, sessionId);
2586
+ const payload = await parseRequestJson(c, RejectSessionGoalRevisionRequest);
2587
+ try {
2588
+ const result = await rejectSessionGoalRevisionWithEvent(db, {
2589
+ accountId: grant.accountId,
2590
+ workspaceId,
2591
+ sessionId,
2592
+ revisionId: c.req.param("revisionId"),
2593
+ expectedObjectiveRevision: payload.expectedObjectiveRevision,
2594
+ rationale: payload.rationale,
2595
+ });
2596
+ await publishDurableSessionEvents(bus, workspaceId, sessionId, result.events);
2597
+ return c.json({ revision: result.revision, replay: result.replay });
2598
+ } catch (error) {
2599
+ if (error instanceof SessionControlConflictError) {
2600
+ throw new HTTPException(409, { message: error.message, cause: error });
2601
+ }
2602
+ throw error;
2603
+ }
2604
+ },
2605
+ );
2606
+
2607
+ app.post(
2608
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/goal/revisions/:revisionId/rollback",
2609
+ async (c) => {
2610
+ const workspaceId = c.req.param("workspaceId");
2611
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2612
+ const sessionId = c.req.param("sessionId");
2613
+ await assertSessionExists(db, workspaceId, sessionId);
2614
+ const payload = await parseRequestJson(c, RollbackSessionGoalRevisionRequest);
2615
+ const revision = await getSessionGoalRevision(
2616
+ db,
2617
+ workspaceId,
2618
+ sessionId,
2619
+ c.req.param("revisionId"),
2620
+ );
2621
+ if (!revision || revision.disposition !== "applied") {
2622
+ throw new HTTPException(404, { message: "applied goal revision not found" });
2623
+ }
2624
+ try {
2625
+ const { goal, workflowWakeRevision, events } = await upsertSessionGoalWithEvent(db, {
2626
+ accountId: grant.accountId,
2627
+ workspaceId,
2628
+ sessionId,
2629
+ text: revision.text,
2630
+ successCriteria: revision.successCriteria,
2631
+ rootConstraints: revision.rootConstraints,
2632
+ mutationPolicy: revision.mutationPolicy,
2633
+ expectedObjectiveRevision: payload.expectedObjectiveRevision,
2634
+ expectedGoalId: revision.goalId,
2635
+ changeKind: "replacement",
2636
+ changeRationale: payload.rationale,
2637
+ rollbackOfRevisionId: revision.id,
2638
+ createdBy: "api",
2639
+ actor: "api",
2640
+ });
2641
+ await publishDurableSessionEvents(bus, workspaceId, sessionId, events);
2642
+ if (workflowWakeRevision !== null) {
2643
+ await workflowClient.wakeSessionWorkflow({
2644
+ accountId: grant.accountId,
2645
+ workspaceId,
2646
+ sessionId,
2647
+ workflowId: workflowIdForSession(sessionId),
2648
+ wakeRevision: workflowWakeRevision,
2649
+ });
2650
+ }
2651
+ return c.json((await getSessionGoalWithContinuation(db, workspaceId, sessionId)) ?? goal);
2652
+ } catch (error) {
2653
+ if (error instanceof SessionControlConflictError) {
2654
+ throw new HTTPException(409, { message: error.message, cause: error });
2655
+ }
2656
+ throw error;
2657
+ }
2658
+ },
2659
+ );
2660
+
1642
2661
  app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
1643
2662
  const workspaceId = c.req.param("workspaceId");
1644
2663
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1645
2664
  const sessionId = c.req.param("sessionId");
1646
2665
  await assertSessionExists(db, workspaceId, sessionId);
1647
- const payload = UpdateSessionGoalRequest.parse(await c.req.json());
2666
+ const payload = await parseRequestJson(c, UpdateSessionGoalRequest);
1648
2667
  const existing = await getSessionGoal(db, workspaceId, sessionId);
1649
2668
  if (!existing) {
1650
2669
  throw new HTTPException(404, { message: "session goal not found" });
@@ -1660,6 +2679,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1660
2679
  payload.successCriteria !== undefined
1661
2680
  ? payload.successCriteria
1662
2681
  : existing.successCriteria,
2682
+ rootConstraints:
2683
+ payload.rootConstraints !== undefined
2684
+ ? payload.rootConstraints
2685
+ : existing.rootConstraints,
1663
2686
  maxAutoContinuations: existing.maxAutoContinuations,
1664
2687
  mutationPolicy: payload.mutationPolicy ?? existing.mutationPolicy,
1665
2688
  expectedObjectiveRevision: payload.expectedObjectiveRevision,
@@ -1752,10 +2775,17 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1752
2775
 
1753
2776
  app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
1754
2777
  const workspaceId = c.req.param("workspaceId");
1755
- await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2778
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1756
2779
  const sessionId = c.req.param("sessionId");
1757
2780
  await assertSessionExists(db, workspaceId, sessionId);
1758
- const { event } = await clearSessionGoal(db, workspaceId, sessionId);
2781
+ const { event } = await clearSessionGoal(db, workspaceId, sessionId, {
2782
+ actor: grant.principalKind === "agent_attempt" ? "agent" : "api",
2783
+ }).catch((error: unknown) => {
2784
+ if (error instanceof SessionControlConflictError) {
2785
+ throw new HTTPException(409, { message: error.message, cause: error });
2786
+ }
2787
+ throw error;
2788
+ });
1759
2789
  if (event) {
1760
2790
  try {
1761
2791
  await bus.publish(workspaceId, sessionId, [event]);
@@ -1818,7 +2848,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1818
2848
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1819
2849
  const sessionId = c.req.param("sessionId");
1820
2850
  await assertSessionExists(db, workspaceId, sessionId);
1821
- CompactSessionContextRequest.parse((await c.req.json().catch(() => ({}))) ?? {});
2851
+ parseRequestBody(CompactSessionContextRequest, (await c.req.json().catch(() => ({}))) ?? {});
1822
2852
  // /compact sets one durable request. The worker clears it only in the same
1823
2853
  // fenced transaction that installs replacement history, so failed or stale
1824
2854
  // attempts cannot lose the request.
@@ -1967,22 +2997,35 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1967
2997
  c.header("X-OpenGeni-Covered-Last", String(result.coveredSequence.last));
1968
2998
  return c.json(result);
1969
2999
  }
1970
- const projected = compact ? coalesceSessionEventDeltas(events) : events;
3000
+ const compactProjection = compact ? coalesceSessionEventDeltasWithCoverage(events) : null;
3001
+ const projected = compactProjection?.events ?? events;
3002
+ const forensicExact =
3003
+ !compact && mode === "forensic" && payloadMode === "full" && dbPage.fullPayloadsExact;
1971
3004
  const page = boundSessionEventHttpPage(projected, {
1972
3005
  direction,
1973
- eventProjection: mode === "forensic" && payloadMode === "full" ? "exact" : "bounded",
3006
+ eventProjection: payloadMode === "full" ? "exact" : "bounded",
3007
+ ...(compactProjection
3008
+ ? { coveredThroughBySequence: compactProjection.coveredThroughBySequence }
3009
+ : {}),
1974
3010
  });
1975
3011
  const hasMore = dbPage.hasMore || page.truncated;
1976
3012
  c.header("X-OpenGeni-Page-Bytes", String(page.bytes));
1977
- c.header("X-OpenGeni-Page-Max-Bytes", String(1024 * 1024));
3013
+ // One oversized exact event is admitted alone; never advertise a maximum
3014
+ // smaller than the response we actually deliver.
3015
+ c.header("X-OpenGeni-Page-Max-Bytes", String(Math.max(1024 * 1024, page.bytes)));
1978
3016
  c.header("X-OpenGeni-Page-Truncated", String(hasMore));
1979
3017
  c.header("X-OpenGeni-Has-More", String(hasMore));
1980
3018
  c.header("X-OpenGeni-Event-Mode", mode);
1981
3019
  c.header("X-OpenGeni-Event-Direction", direction);
1982
3020
  c.header("X-OpenGeni-Payload-Mode", payloadMode);
1983
- c.header("X-OpenGeni-Forensic-Exact", String(mode === "forensic" && payloadMode === "full"));
3021
+ c.header("X-OpenGeni-Forensic-Exact", String(forensicExact));
1984
3022
  const coveredFirst = page.events[0]?.sequence;
1985
- const coveredLast = page.events.at(-1)?.sequence;
3023
+ const coveredLastEvent = page.events.at(-1);
3024
+ const coveredLast =
3025
+ coveredLastEvent === undefined
3026
+ ? undefined
3027
+ : (compactProjection?.coveredThroughBySequence.get(coveredLastEvent.sequence) ??
3028
+ coveredLastEvent.sequence);
1986
3029
  if (coveredFirst !== undefined) c.header("X-OpenGeni-Covered-First", String(coveredFirst));
1987
3030
  if (coveredLast !== undefined) c.header("X-OpenGeni-Covered-Last", String(coveredLast));
1988
3031
  const truncatedBy = page.truncated ? "http_bytes" : dbPage.truncatedBy;
@@ -1996,6 +3039,90 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1996
3039
  return c.json(page.events);
1997
3040
  });
1998
3041
 
3042
+ // Selected search hit only: never use the audit event projection here, even
3043
+ // in summary mode (it can contain modelContext or truncate visible text).
3044
+ app.get(
3045
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/events/:eventId/message-preview",
3046
+ async (c) => {
3047
+ const workspaceId = c.req.param("workspaceId");
3048
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
3049
+ const sessionId = c.req.param("sessionId");
3050
+ await assertSessionExists(db, workspaceId, sessionId);
3051
+ const eventId = c.req.param("eventId");
3052
+ const rawSequence = c.req.query("sequence");
3053
+ const sequence = Number(rawSequence);
3054
+ if (
3055
+ !z.string().uuid().safeParse(eventId).success ||
3056
+ rawSequence === undefined ||
3057
+ !/^\d+$/.test(rawSequence) ||
3058
+ !Number.isSafeInteger(sequence) ||
3059
+ sequence < 1 ||
3060
+ sequence > 2_147_483_647
3061
+ )
3062
+ throw new HTTPException(400, { message: "Invalid message preview reference" });
3063
+
3064
+ const notFound = () => new HTTPException(404, { message: "message not found" });
3065
+ const maxUnits = 12_000;
3066
+ const eventTable = sessionPreviewSchema.sessionEvents;
3067
+ // The slice helper supports structured result views too. Check only the
3068
+ // JSON scalar *kind* before calling it; never fetch a structured text value.
3069
+ const candidates = await withWorkspaceRls(db, workspaceId, (tx) =>
3070
+ tx
3071
+ .select({ kind: sql<string | null>`jsonb_typeof(${eventTable.payload}->'text')` })
3072
+ .from(eventTable)
3073
+ .where(
3074
+ and(
3075
+ eq(eventTable.workspaceId, workspaceId),
3076
+ eq(eventTable.sessionId, sessionId),
3077
+ eq(eventTable.id, eventId),
3078
+ eq(eventTable.sequence, sequence),
3079
+ sql`${eventTable.type} in ('user.message', 'agent.message.completed')`,
3080
+ ),
3081
+ )
3082
+ .limit(1),
3083
+ );
3084
+ if (candidates[0]?.kind !== "string") throw notFound();
3085
+ const read = (offset: number) =>
3086
+ listSessionEventSlices(db, workspaceId, sessionId, {
3087
+ sourceSequence: sequence,
3088
+ sourceOffset: offset,
3089
+ after: sequence - 1,
3090
+ before: sequence + 1,
3091
+ includeTypes: ["user.message", "agent.message.completed"],
3092
+ view: "conversation",
3093
+ });
3094
+ const first = await read(0);
3095
+ const event = first.events[0];
3096
+ const slice = first.slices?.[sequence];
3097
+ // Includes the slice reader's duplicate, late and unclaimed-prompt gates.
3098
+ if (!event || event.id !== eventId || event.sequence !== sequence || !slice || slice.omitted)
3099
+ throw notFound();
3100
+ if (slice.total > maxUnits) return c.json({ status: "unavailable" as const });
3101
+
3102
+ let text = slice.text;
3103
+ let offset = slice.unit === "utf16" ? slice.text.length : Array.from(slice.text).length;
3104
+ if (text.length > maxUnits) return c.json({ status: "unavailable" as const });
3105
+ while (offset < slice.total) {
3106
+ if (offset === 0) throw notFound();
3107
+ const next = await read(offset);
3108
+ const part = next.slices?.[sequence];
3109
+ if (
3110
+ next.events[0]?.id !== eventId ||
3111
+ !part ||
3112
+ part.omitted ||
3113
+ part.unit !== slice.unit ||
3114
+ part.total !== slice.total ||
3115
+ part.offset !== offset
3116
+ )
3117
+ throw notFound();
3118
+ text += part.text;
3119
+ offset += slice.unit === "utf16" ? part.text.length : Array.from(part.text).length;
3120
+ if (text.length > maxUnits) return c.json({ status: "unavailable" as const });
3121
+ }
3122
+ return c.json({ status: "available" as const, text });
3123
+ },
3124
+ );
3125
+
1999
3126
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events/stream", async (c) => {
2000
3127
  const workspaceId = c.req.param("workspaceId");
2001
3128
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -2020,20 +3147,19 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2020
3147
  Number.isFinite(after) ? after : 0,
2021
3148
  c.req.raw.signal,
2022
3149
  {
3150
+ ...browserSseDeliveryOptions(c.req.query("transport")),
2023
3151
  observability: deps.observability,
2024
- ...(authorization
2025
- ? {
2026
- reauthorizeAfterMs:
2027
- authorization.reauthorizeAfterMs ?? SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
2028
- reauthorize: async () => {
2029
- await requireSessionAuthorization(deps, grant, {
2030
- sessionId,
2031
- operation: "session.stream.read",
2032
- surface: "stream",
2033
- });
2034
- },
2035
- }
2036
- : {}),
3152
+ actorEpoch: getManagedAuthRequestActorEpoch(c.req.raw) ?? undefined,
3153
+ reauthorizeAfterMs:
3154
+ authorization?.reauthorizeAfterMs ?? SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
3155
+ reauthorize: async () => {
3156
+ const freshGrant = await requireFreshAccessGrant(c, deps, workspaceId, "sessions:read");
3157
+ await requireSessionAuthorization(deps, freshGrant, {
3158
+ sessionId,
3159
+ operation: "session.stream.read",
3160
+ surface: "stream",
3161
+ });
3162
+ },
2037
3163
  },
2038
3164
  );
2039
3165
  });
@@ -2055,6 +3181,27 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2055
3181
  );
2056
3182
  });
2057
3183
 
3184
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/mcp-credentials/rotate", async (c) => {
3185
+ const workspaceId = c.req.param("workspaceId");
3186
+ const authorization = await requireAccessGrantAuthorization(
3187
+ c,
3188
+ deps,
3189
+ workspaceId,
3190
+ "sessions:control",
3191
+ );
3192
+ const payload = await c.req.json().catch(() => null);
3193
+ c.header("cache-control", "private, no-store");
3194
+ return c.json(
3195
+ await rotateSessionMcpCredentialsForRequest(
3196
+ deps,
3197
+ authorization,
3198
+ c.req.param("sessionId"),
3199
+ payload,
3200
+ (tx) => requireFreshAccessGrant(c, { ...deps, db: tx }, workspaceId, "sessions:control"),
3201
+ ),
3202
+ );
3203
+ });
3204
+
2058
3205
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/queue", async (c) => {
2059
3206
  const workspaceId = c.req.param("workspaceId");
2060
3207
  await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -2069,7 +3216,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2069
3216
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2070
3217
  const sessionId = c.req.param("sessionId");
2071
3218
  await assertSessionExists(db, workspaceId, sessionId);
2072
- const payload = MoveSessionQueueItemRequest.parse(await c.req.json());
3219
+ const payload = await parseRequestJson(c, MoveSessionQueueItemRequest);
2073
3220
  try {
2074
3221
  const response = await moveHumanQueuePrompt(
2075
3222
  deps,
@@ -2096,7 +3243,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2096
3243
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2097
3244
  const sessionId = c.req.param("sessionId");
2098
3245
  await assertSessionExists(db, workspaceId, sessionId);
2099
- const payload = EditSessionQueueItemRequest.parse(await c.req.json());
3246
+ const payload = await parseRequestJson(c, EditSessionQueueItemRequest);
2100
3247
  try {
2101
3248
  const response = await editHumanQueuePrompt(
2102
3249
  deps,
@@ -2123,7 +3270,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2123
3270
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2124
3271
  const sessionId = c.req.param("sessionId");
2125
3272
  await assertSessionExists(db, workspaceId, sessionId);
2126
- const payload = SteerSessionQueueItemRequest.parse(await c.req.json());
3273
+ const payload = await parseRequestJson(c, SteerSessionQueueItemRequest);
2127
3274
  try {
2128
3275
  const response = await steerHumanQueuePrompt(
2129
3276
  deps,
@@ -2150,7 +3297,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2150
3297
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2151
3298
  const sessionId = c.req.param("sessionId");
2152
3299
  await assertSessionExists(db, workspaceId, sessionId);
2153
- const payload = DeleteSessionQueueItemRequest.parse(await c.req.json());
3300
+ const payload = await parseRequestJson(c, DeleteSessionQueueItemRequest);
2154
3301
  try {
2155
3302
  const response = await deleteHumanQueuePrompt(
2156
3303
  deps,
@@ -2190,7 +3337,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2190
3337
  const workspaceId = c.req.param("workspaceId");
2191
3338
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2192
3339
  const sessionId = c.req.param("sessionId");
2193
- const payload = SaveComposerDraftRequest.parse(await c.req.json());
3340
+ const payload = await parseRequestJson(c, SaveComposerDraftRequest);
2194
3341
  try {
2195
3342
  return c.json(
2196
3343
  await saveHumanComposerDraft(
@@ -2209,6 +3356,112 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2209
3356
  }
2210
3357
  });
2211
3358
 
3359
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/sandbox-recovery", async (c) => {
3360
+ const workspaceId = c.req.param("workspaceId");
3361
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
3362
+ try {
3363
+ return c.json(
3364
+ await getManagedHumanSandboxRecovery(
3365
+ deps,
3366
+ authorization,
3367
+ workspaceId,
3368
+ c.req.param("sessionId"),
3369
+ ),
3370
+ );
3371
+ } catch (error) {
3372
+ throw sessionTenancyHttpError(error);
3373
+ }
3374
+ });
3375
+
3376
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/sandbox-recovery", async (c) => {
3377
+ const workspaceId = c.req.param("workspaceId");
3378
+ const sessionId = c.req.param("sessionId");
3379
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
3380
+ const parsed = SandboxRecoveryRequest.safeParse(await c.req.json().catch(() => null));
3381
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid checkpoint consent" });
3382
+ let consentCommitted = false;
3383
+ try {
3384
+ const receipt = await consentManagedHumanSandboxRecovery(
3385
+ deps,
3386
+ authorization,
3387
+ workspaceId,
3388
+ sessionId,
3389
+ parsed.data,
3390
+ );
3391
+ consentCommitted = true;
3392
+ const projection = await getManagedHumanSandboxRecovery(
3393
+ deps,
3394
+ authorization,
3395
+ workspaceId,
3396
+ sessionId,
3397
+ );
3398
+ if (
3399
+ projection.operationId === receipt.operationId &&
3400
+ projection.status === "consent_accepted"
3401
+ ) {
3402
+ // Establish/verify only. No failed-turn Retry, Send, tool callback or
3403
+ // command replay. The ordinary cold->warming election owns creation.
3404
+ const session = await getSession(db, workspaceId, sessionId);
3405
+ if (session) {
3406
+ try {
3407
+ await withChannelARead(
3408
+ channelAServices,
3409
+ {
3410
+ accountId: authorization.grant.accountId,
3411
+ workspaceId,
3412
+ session,
3413
+ subjectId: authorization.grant.subjectId,
3414
+ },
3415
+ async () => undefined,
3416
+ );
3417
+ } catch {
3418
+ // Consent committed. Never turn a provider failure into a false
3419
+ // rejection of consent or leak native provider bindings/messages.
3420
+ }
3421
+ }
3422
+ }
3423
+ return c.json({
3424
+ ...receipt,
3425
+ recovery: await getManagedHumanSandboxRecovery(deps, authorization, workspaceId, sessionId),
3426
+ });
3427
+ } catch (error) {
3428
+ if (consentCommitted) {
3429
+ // A later authorization/read failure cannot revoke the committed
3430
+ // receipt. Return no session state, checkpoint or provider details.
3431
+ return c.json(
3432
+ {
3433
+ code: "upstream_unavailable",
3434
+ message:
3435
+ "Recovery status could not be confirmed. Check status without resubmitting consent.",
3436
+ outcomeUnknown: true,
3437
+ retryable: false,
3438
+ },
3439
+ 503,
3440
+ );
3441
+ }
3442
+ if (error instanceof SandboxRecoveryConflictError)
3443
+ return c.json({ code: error.code, message: error.message }, 409);
3444
+ if (error instanceof SessionCommandIdempotencyError) return commandConflictResponse(c, error);
3445
+ throw sessionTenancyHttpError(error);
3446
+ }
3447
+ });
3448
+
3449
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/retry", async (c) => {
3450
+ const workspaceId = c.req.param("workspaceId");
3451
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
3452
+ const parsed = SessionRetryRequest.safeParse(await c.req.json().catch(() => null));
3453
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid session retry request" });
3454
+ try {
3455
+ return c.json(
3456
+ await retryFailedSession(deps, grant, workspaceId, c.req.param("sessionId"), parsed.data),
3457
+ );
3458
+ } catch (error) {
3459
+ if (error instanceof SessionRetryConflictError)
3460
+ return c.json({ code: error.code, message: error.message }, 409);
3461
+ return commandConflictResponse(c, error);
3462
+ }
3463
+ });
3464
+
2212
3465
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/control", async (c) => {
2213
3466
  const workspaceId = c.req.param("workspaceId");
2214
3467
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
@@ -2246,37 +3499,91 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2246
3499
  } catch (error) {
2247
3500
  return commandConflictResponse(c, error);
2248
3501
  }
2249
- });
2250
-
2251
- app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/steer", async (c) => {
2252
- const workspaceId = c.req.param("workspaceId");
2253
- const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
2254
- const sessionId = c.req.param("sessionId");
2255
- await assertSessionExists(db, workspaceId, sessionId);
2256
- const payload = parseSteerSessionAdmission(await c.req.json().catch(() => null));
2257
- const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
2258
- text: payload.text,
2259
- annotations: payload.annotations,
2260
- turnInstructions: payload.turnInstructions ?? null,
2261
- resources: payload.resources,
2262
- model: payload.model ?? null,
2263
- reasoningEffort: payload.reasoningEffort ?? null,
2264
- latencyMode: payload.latencyMode ?? null,
2265
- mcpCredentialUpdates: payload.mcpCredentialUpdates ?? [],
2266
- delivery: "steer",
2267
- origin: "human",
2268
- ...(payload.controlEtag !== undefined ? { controlEtag: payload.controlEtag } : {}),
2269
- ...(payload.expectedDraftRevision !== undefined
2270
- ? { expectedDraftRevision: payload.expectedDraftRevision }
2271
- : {}),
2272
- ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
2273
- });
3502
+ });
3503
+
3504
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/steer", async (c) => {
3505
+ const workspaceId = c.req.param("workspaceId");
3506
+ const authorization = await requireAccessGrantAuthorization(
3507
+ c,
3508
+ deps,
3509
+ workspaceId,
3510
+ "sessions:control",
3511
+ );
3512
+ const grant = authorization.grant;
3513
+ const sessionId = c.req.param("sessionId");
3514
+ await assertSessionExists(db, workspaceId, sessionId);
3515
+ const payload = parseSteerSessionAdmission(await c.req.json().catch(() => null));
3516
+ let result: Awaited<ReturnType<typeof acceptSessionUserMessage>>;
3517
+ try {
3518
+ result = await withSiteCommandOrigin(c, workspaceId, () =>
3519
+ acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
3520
+ text: payload.text,
3521
+ annotations: payload.annotations,
3522
+ modelContext: payload.modelContext ?? null,
3523
+ resources: payload.resources,
3524
+ model: payload.model ?? null,
3525
+ reasoningEffort: payload.reasoningEffort ?? null,
3526
+ latencyMode: payload.latencyMode ?? null,
3527
+ mcpCredentialUpdates: payload.mcpCredentialUpdates ?? [],
3528
+ connectionAccounts: payload.connectionAccounts,
3529
+ ...(payload.personalResourceAttachment
3530
+ ? { personalResourceAttachment: payload.personalResourceAttachment }
3531
+ : {}),
3532
+ authorization,
3533
+ delivery: "steer",
3534
+ origin: "human",
3535
+ ...(payload.controlEtag !== undefined ? { controlEtag: payload.controlEtag } : {}),
3536
+ ...(payload.expectedDraftRevision !== undefined
3537
+ ? { expectedDraftRevision: payload.expectedDraftRevision }
3538
+ : {}),
3539
+ ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
3540
+ }),
3541
+ );
3542
+ } catch (error) {
3543
+ return commandConflictResponse(c, error);
3544
+ }
3545
+ recordAcceptedApiAdmission(deps.observability, result);
3546
+ return c.json(result, 202);
3547
+ });
3548
+
3549
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/composer-draft/submit", async (c) => {
3550
+ const workspaceId = c.req.param("workspaceId");
3551
+ const authorization = await requireAccessGrantAuthorization(
3552
+ c,
3553
+ deps,
3554
+ workspaceId,
3555
+ "sessions:control",
3556
+ );
3557
+ const grant = authorization.grant;
3558
+ const sessionId = c.req.param("sessionId");
3559
+ await assertSessionExists(db, workspaceId, sessionId);
3560
+ const payload = parseRequestBody(
3561
+ SubmitComposerDraftRequest,
3562
+ await c.req.json().catch(() => null),
3563
+ );
3564
+ let result: Awaited<ReturnType<typeof submitComposerDraftForRequest>>;
3565
+ try {
3566
+ result = await withSiteCommandOrigin(c, workspaceId, () =>
3567
+ submitComposerDraftForRequest(deps, grant, workspaceId, sessionId, payload, {
3568
+ authorization,
3569
+ }),
3570
+ );
3571
+ } catch (error) {
3572
+ return commandConflictResponse(c, error);
3573
+ }
3574
+ recordAcceptedApiAdmission(deps.observability, result);
2274
3575
  return c.json(result, 202);
2275
3576
  });
2276
3577
 
2277
3578
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/events", async (c) => {
2278
3579
  const workspaceId = c.req.param("workspaceId");
2279
- const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
3580
+ const authorization = await requireAccessGrantAuthorization(
3581
+ c,
3582
+ deps,
3583
+ workspaceId,
3584
+ "sessions:control",
3585
+ );
3586
+ const grant = authorization.grant;
2280
3587
  const sessionId = c.req.param("sessionId");
2281
3588
  const event = parseSessionEventAdmission(await c.req.json().catch(() => null));
2282
3589
  const refinedOperation =
@@ -2297,35 +3604,75 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2297
3604
  }
2298
3605
  }
2299
3606
  if (event.type === "user.message") {
2300
- const { accepted } = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
2301
- text: event.payload.text,
2302
- annotations: event.payload.annotations,
2303
- turnInstructions: event.payload.turnInstructions ?? null,
2304
- resources: event.payload.resources ?? [],
2305
- model: event.payload.model ?? null,
2306
- reasoningEffort: event.payload.reasoningEffort ?? null,
2307
- latencyMode: event.payload.latencyMode ?? null,
2308
- mcpCredentialUpdates: event.payload.mcpCredentialUpdates ?? [],
2309
- ...(event.payload.controlEtag !== undefined
2310
- ? { controlEtag: event.payload.controlEtag }
2311
- : {}),
2312
- ...(event.payload.expectedDraftRevision !== undefined
2313
- ? { expectedDraftRevision: event.payload.expectedDraftRevision }
2314
- : {}),
2315
- ...(event.clientEventId ? { clientEventId: event.clientEventId } : {}),
3607
+ let result: Awaited<ReturnType<typeof acceptSessionUserMessage>>;
3608
+ try {
3609
+ result = await withSiteCommandOrigin(c, workspaceId, () =>
3610
+ acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
3611
+ text: event.payload.text,
3612
+ annotations: event.payload.annotations,
3613
+ modelContext: event.payload.modelContext ?? null,
3614
+ resources: event.payload.resources ?? [],
3615
+ model: event.payload.model ?? null,
3616
+ reasoningEffort: event.payload.reasoningEffort ?? null,
3617
+ latencyMode: event.payload.latencyMode ?? null,
3618
+ mcpCredentialUpdates: event.payload.mcpCredentialUpdates ?? [],
3619
+ connectionAccounts: event.payload.connectionAccounts,
3620
+ ...(event.payload.personalResourceAttachment
3621
+ ? { personalResourceAttachment: event.payload.personalResourceAttachment }
3622
+ : {}),
3623
+ authorization,
3624
+ ...(event.payload.controlEtag !== undefined
3625
+ ? { controlEtag: event.payload.controlEtag }
3626
+ : {}),
3627
+ ...(event.payload.expectedDraftRevision !== undefined
3628
+ ? { expectedDraftRevision: event.payload.expectedDraftRevision }
3629
+ : {}),
3630
+ ...(event.clientEventId ? { clientEventId: event.clientEventId } : {}),
3631
+ }),
3632
+ );
3633
+ } catch (error) {
3634
+ return commandConflictResponse(c, error);
3635
+ }
3636
+ recordAcceptedApiAdmission(deps.observability, result);
3637
+ return c.json(result.accepted, 202);
3638
+ }
3639
+
3640
+ // Responses use the same header-only validation, permission and encryption
3641
+ // boundary as Send/Steer. Persistence belongs to the response transaction,
3642
+ // never a separate rotation that could commit without accepting the reply.
3643
+ const updates = event.payload.mcpCredentialUpdates ?? [];
3644
+ let mcpCredentialUpdates = [] as ReturnType<typeof validateSessionMcpCredentialUpdates>;
3645
+ if (updates.length) {
3646
+ const session = await getSession(db, workspaceId, sessionId);
3647
+ if (!session) throw new HTTPException(404, { message: "session not found" });
3648
+ mcpCredentialUpdates = validateSessionMcpCredentialUpdates({
3649
+ settings,
3650
+ grant,
3651
+ session,
3652
+ updates,
2316
3653
  });
2317
- return c.json(accepted, 202);
2318
3654
  }
2319
3655
 
2320
3656
  if (event.type === "user.approvalDecision") {
2321
- const accepted = await acceptSessionApprovalDecision(db, {
2322
- accountId: grant.accountId,
2323
- workspaceId,
2324
- sessionId,
2325
- subjectId: grant.subjectId,
2326
- payload: event.payload,
2327
- clientEventId: event.clientEventId ?? null,
2328
- });
3657
+ const { mcpCredentialUpdates: _writeOnlyCredentials, ...payload } = event.payload;
3658
+ let accepted;
3659
+ try {
3660
+ accepted = await acceptSessionApprovalDecision(db, {
3661
+ accountId: grant.accountId,
3662
+ workspaceId,
3663
+ sessionId,
3664
+ subjectId: grant.subjectId,
3665
+ respondedByKind: childRequiresActionRespondedByKindForGrant(grant),
3666
+ payload,
3667
+ mcpCredentialUpdates,
3668
+ clientEventId: event.clientEventId ?? null,
3669
+ });
3670
+ } catch (error) {
3671
+ if (error instanceof Error && error.message.startsWith("Unknown session MCP server")) {
3672
+ throw new HTTPException(422, { message: error.message });
3673
+ }
3674
+ throw error;
3675
+ }
2329
3676
  if (accepted.action === "conflict") {
2330
3677
  throw new HTTPException(409, {
2331
3678
  message: `session is ${accepted.sessionStatus}; no unhandled approval is pending`,
@@ -2354,13 +3701,37 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2354
3701
  requestId: event.payload.requestId,
2355
3702
  response: event.payload.response,
2356
3703
  respondedBy: grant.subjectId,
3704
+ canonicalHumanSession:
3705
+ authorization.canonicalManagedHumanSession ||
3706
+ authorization.canonicalLocalHumanSession ||
3707
+ isVerifiedDelegatedHumanAuthorization(authorization),
3708
+ respondedByKind: childRequiresActionRespondedByKindForGrant(grant),
2357
3709
  clientEventId: event.clientEventId ?? null,
3710
+ mcpCredentialUpdates,
2358
3711
  });
2359
3712
  } catch (error) {
3713
+ if (error instanceof Error && error.message.startsWith("Unknown session MCP server")) {
3714
+ throw new HTTPException(422, { message: error.message });
3715
+ }
3716
+ if (error instanceof SkillHumanResponseError) {
3717
+ throw new HTTPException(
3718
+ error.code === "conflict" ? 409 : error.code === "forbidden" ? 403 : 422,
3719
+ {
3720
+ message: error.message,
3721
+ },
3722
+ );
3723
+ }
2360
3724
  if (error instanceof HumanInputResponseValidationError) {
2361
- throw new HTTPException(error.code === "SKIP_NOT_ALLOWED" ? 409 : 422, {
2362
- message: error.message,
2363
- });
3725
+ throw new HTTPException(
3726
+ error.code === "HUMAN_AUTH_REQUIRED"
3727
+ ? 403
3728
+ : error.code === "SKIP_NOT_ALLOWED"
3729
+ ? 409
3730
+ : 422,
3731
+ {
3732
+ message: error.message,
3733
+ },
3734
+ );
2364
3735
  }
2365
3736
  throw error;
2366
3737
  }
@@ -2382,10 +3753,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2382
3753
  }
2383
3754
  if (accepted.action === "conflict") {
2384
3755
  throw new HTTPException(409, {
2385
- message: `human-input request is ${accepted.request.status}`,
3756
+ message: "human-input request is not currently actionable",
2386
3757
  });
2387
3758
  }
2388
- return c.json(accepted.event, 202);
3759
+ return c.json(accepted.event, accepted.action === "completed" ? 200 : 202);
2389
3760
  }
2390
3761
  });
2391
3762
 
@@ -2481,7 +3852,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2481
3852
  // would mint its relay terminal/desktop cells. Resolve the active target once
2482
3853
  // and use the selfhosted liveness probe when it is a connected machine.
2483
3854
  const activeSandbox = session.activeSandboxId
2484
- ? await getSandbox(db, workspaceId, session.activeSandboxId)
3855
+ ? await getSandbox(
3856
+ db,
3857
+ { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
3858
+ session.activeSandboxId,
3859
+ )
2485
3860
  : null;
2486
3861
  const selfhostedActive = activeSandbox?.kind === "selfhosted";
2487
3862
  const lease = await readGroupLease(
@@ -2513,12 +3888,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2513
3888
  // Human take-control: when the desktop is available + this policy is on
2514
3889
  // (default), the cell is mode "interactive" — the noVNC viewer drives :0
2515
3890
  // (x11vnc runs without -viewonly). Off → mode "read-only" (client disables
2516
- // take-control). Independent of the agent's computerUseReadOnly.
3891
+ // take-control). Agent interaction uses managed ComputerSession tools.
2517
3892
  desktopInteractive: settings.sandboxDesktopInteractive,
2518
- // P4.3 computer-use: the agent drives the active display; availability
2519
- // tracks the desktop tier + a desktop-capable backend.
2520
- computerUseEnabled: settings.computerUseEnabled,
2521
- computerUseReadOnly: settings.computerUseReadOnly,
2522
3893
  // Graceful degrade when scoped stream credentials cannot be minted.
2523
3894
  streamTokenSecretAvailable: !streamTokenDegraded(settings),
2524
3895
  desktopAcknowledged: acknowledged,
@@ -2529,12 +3900,23 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2529
3900
 
2530
3901
  let capabilities;
2531
3902
  if (selfhostedActive && activeSandbox.enrollmentId) {
2532
- const enrollment = await getEnrollment(db, workspaceId, activeSandbox.enrollmentId);
3903
+ const [enrollment, liveConnection] = await Promise.all([
3904
+ getEnrollment(db, grant, activeSandbox.enrollmentId),
3905
+ getLiveEnrollmentConnection(db, grant, activeSandbox.enrollmentId),
3906
+ ]);
3907
+ const reportedWorkspaceRoot = liveConnection?.workspaceRoot ?? enrollment?.workspaceRoot;
3908
+ const effectiveWorkspaceRoot = reportedWorkspaceRoot
3909
+ ? resolveConnectedMachineWorkspaceRoot(reportedWorkspaceRoot, session.workingDir)
3910
+ : null;
2533
3911
  let probeResponded = false;
2534
- if (enrollment?.status === "active") {
3912
+ if (liveConnection?.connectionInstanceId) {
2535
3913
  const machine = new SelfhostedSession({
2536
- workspaceId,
2537
- agentId: enrollment.id,
3914
+ workspaceId: activeSandbox.workspaceId,
3915
+ agentId: liveConnection.id,
3916
+ connectionInstanceId: liveConnection.connectionInstanceId,
3917
+ // Capability negotiation only pings, so a pre-root agent may still
3918
+ // report upgrade guidance without exposing a false filesystem root.
3919
+ workspaceRoot: effectiveWorkspaceRoot ?? liveConnection.workspaceRoot ?? "/",
2538
3920
  controlRpc: new NatsControlRpc(async () => bus.getRequestConnection()),
2539
3921
  relay: relayConfigFromSettings(settings),
2540
3922
  epoch: session.activeEpoch,
@@ -2550,7 +3932,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2550
3932
  ...commonNegotiation,
2551
3933
  os: enrollment?.os ?? session.sandboxOs,
2552
3934
  leaseEpoch: session.activeEpoch,
2553
- enrollment,
3935
+ enrollment:
3936
+ enrollment && effectiveWorkspaceRoot
3937
+ ? { ...enrollment, workspaceRoot: effectiveWorkspaceRoot }
3938
+ : enrollment,
2554
3939
  probeResponded,
2555
3940
  });
2556
3941
  } else {
@@ -2597,6 +3982,20 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2597
3982
  // place (resolveActiveDesktopTransport), covering BOTH swap directions.
2598
3983
  let responseCapabilities = {
2599
3984
  ...capabilities,
3985
+ FileSystem: {
3986
+ ...capabilities.FileSystem,
3987
+ root: selfhostedActive
3988
+ ? capabilities.FileSystem.root
3989
+ : selectBackend(
3990
+ (activeSandbox?.kind === "modal"
3991
+ ? "modal"
3992
+ : session.sandboxBackend === "selfhosted" &&
3993
+ settings.sandboxBackend !== "selfhosted" &&
3994
+ settings.sandboxBackend !== "none"
3995
+ ? settings.sandboxBackend
3996
+ : session.sandboxBackend) as SandboxBackend,
3997
+ ).workspaceRoot,
3998
+ },
2600
3999
  Git: {
2601
4000
  ...capabilities.Git,
2602
4001
  repos: capabilities.Git.available ? repositoryRoots : [],
@@ -2612,6 +4011,21 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2612
4011
  DesktopStream: { ...capabilities.DesktopStream, ...wire },
2613
4012
  };
2614
4013
  }
4014
+ if (selfhostedActive) {
4015
+ const currentPointer = await readActiveSandbox(db, workspaceId, sessionId);
4016
+ if (
4017
+ !currentPointer ||
4018
+ currentPointer.activeSandboxId !== session.activeSandboxId ||
4019
+ currentPointer.activeEpoch !== session.activeEpoch ||
4020
+ currentPointer.workingDir !== session.workingDir
4021
+ ) {
4022
+ throw new ApiHttpError(409, {
4023
+ code: "conflict",
4024
+ message: "sandbox route changed while capabilities were being negotiated; retry",
4025
+ retryable: true,
4026
+ });
4027
+ }
4028
+ }
2615
4029
  return c.json(responseCapabilities);
2616
4030
  });
2617
4031
 
@@ -2723,7 +4137,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2723
4137
  // attachViewer (it warms the Modal group box — the wrong target). Synthesize a
2724
4138
  // result shaped like ViewerAttachResult and mint relay cells directly.
2725
4139
  const activeSandbox = session.activeSandboxId
2726
- ? await getSandbox(db, workspaceId, session.activeSandboxId)
4140
+ ? await getSandbox(
4141
+ db,
4142
+ { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
4143
+ session.activeSandboxId,
4144
+ )
2727
4145
  : null;
2728
4146
  const selfhostedActive = activeSandbox?.kind === "selfhosted";
2729
4147
 
@@ -2753,6 +4171,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2753
4171
  stream = await mintDesktopStream(viewerServices, {
2754
4172
  accountId: grant.accountId,
2755
4173
  workspaceId,
4174
+ resourceSubjectId: grant.subjectId,
4175
+ resourceSubjectDelegated: grant.metadata?.delegated === true,
2756
4176
  session,
2757
4177
  viewerId,
2758
4178
  // No Modal lease for selfhosted-active; the mint routes to the relay.
@@ -2762,6 +4182,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2762
4182
  terminal = await mintTerminalStream(viewerServices, {
2763
4183
  accountId: grant.accountId,
2764
4184
  workspaceId,
4185
+ resourceSubjectId: grant.subjectId,
4186
+ resourceSubjectDelegated: grant.metadata?.delegated === true,
2765
4187
  session,
2766
4188
  viewerId,
2767
4189
  // No Modal lease for selfhosted-active; the mint routes to the relay.
@@ -2773,6 +4195,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2773
4195
  accountId: grant.accountId,
2774
4196
  workspaceId,
2775
4197
  session,
4198
+ viewerSubjectId: grant.subjectId,
2776
4199
  waitSignal: c.req.raw.signal,
2777
4200
  ...(parsed.data.viewerId ? { viewerId: parsed.data.viewerId } : {}),
2778
4201
  });
@@ -2800,6 +4223,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2800
4223
  stream = await mintDesktopStream(viewerServices, {
2801
4224
  accountId: grant.accountId,
2802
4225
  workspaceId,
4226
+ resourceSubjectId: grant.subjectId,
4227
+ resourceSubjectDelegated: grant.metadata?.delegated === true,
2803
4228
  session,
2804
4229
  viewerId: result.viewerId,
2805
4230
  lease,
@@ -2809,6 +4234,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2809
4234
  terminal = await mintTerminalStream(viewerServices, {
2810
4235
  accountId: grant.accountId,
2811
4236
  workspaceId,
4237
+ resourceSubjectId: grant.subjectId,
4238
+ resourceSubjectDelegated: grant.metadata?.delegated === true,
2812
4239
  session,
2813
4240
  viewerId: result.viewerId,
2814
4241
  lease,
@@ -2951,6 +4378,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2951
4378
  // (rides files:read); Terminal exec + PTY ride terminal:attach.
2952
4379
 
2953
4380
  type ChannelARouteCtx = ChannelAContext & {
4381
+ grant: AccessGrant;
2954
4382
  waitSignal: AbortSignal;
2955
4383
  operation: ChannelAOperation;
2956
4384
  };
@@ -2971,6 +4399,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2971
4399
  throw new HTTPException(404, { message: "session not found" });
2972
4400
  }
2973
4401
  return {
4402
+ grant,
2974
4403
  accountId: grant.accountId,
2975
4404
  workspaceId,
2976
4405
  session,
@@ -3020,6 +4449,36 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3020
4449
  return c.json(out);
3021
4450
  });
3022
4451
 
4452
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/read-workspace", async (c) => {
4453
+ const ctx = await channelAPreamble(c, "files:read", "fs.read");
4454
+ const req = await parseChannelABody(c, FsReadRequest);
4455
+ const out = await withChannelARead(channelAServices, ctx, ({ service }) =>
4456
+ service.fsRead({ ...req, workspaceOnly: true }),
4457
+ );
4458
+ return c.json(out);
4459
+ });
4460
+
4461
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/artifacts/publish", async (c) => {
4462
+ const workspaceId = c.req.param("workspaceId");
4463
+ const authorization = await requireAccessGrantAuthorization(
4464
+ c,
4465
+ deps,
4466
+ workspaceId,
4467
+ "files:upload",
4468
+ );
4469
+ const ctx = await channelAPreamble(c, "files:read", "artifact.publish");
4470
+ const request = await parseChannelABody(c, PublishSandboxFileArtifactRequest);
4471
+ return c.json(
4472
+ await publishSandboxFileArtifact(deps, {
4473
+ grant: ctx.grant,
4474
+ authorization,
4475
+ session: ctx.session,
4476
+ path: request.path,
4477
+ signal: ctx.waitSignal,
4478
+ }),
4479
+ );
4480
+ });
4481
+
3023
4482
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/write", async (c) => {
3024
4483
  const ctx = await channelAPreamble(c, "files:write", "fs.write");
3025
4484
  const req = await parseChannelABody(c, FsWriteRequest);
@@ -3142,7 +4601,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3142
4601
  // the live/wake path — the feature degrades to today's behavior, never worse.
3143
4602
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/workspace/capture", async (c) => {
3144
4603
  const workspaceId = c.req.param("workspaceId") ?? "";
3145
- await requireAccessGrant(c, deps, workspaceId, "files:read");
4604
+ const grant = await requireAccessGrant(c, deps, workspaceId, "files:read");
3146
4605
  const sessionId = c.req.param("sessionId") ?? "";
3147
4606
  const lookup = await sessionLatestWorkspaceCapture(db, workspaceId, sessionId);
3148
4607
  if (!lookup.sessionExists) {
@@ -3153,13 +4612,27 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3153
4612
  return c.json({ available: false });
3154
4613
  }
3155
4614
  return c.json(
3156
- await serveWorkspaceCapture(lookup.capture, objectStorage, workspaceCaptureManifestCache),
4615
+ await serveWorkspaceCapture(
4616
+ lookup.capture,
4617
+ objectStorage,
4618
+ workspaceCaptureManifestCache,
4619
+ (fact) =>
4620
+ recordAuditEvent(db, {
4621
+ accountId: grant.accountId,
4622
+ workspaceId,
4623
+ subjectId: grant.subjectId,
4624
+ action: "file.signed_url.issued",
4625
+ targetType: "workspace_capture",
4626
+ targetId: sessionId,
4627
+ metadata: { sessionId, ...fact },
4628
+ }),
4629
+ ),
3157
4630
  );
3158
4631
  });
3159
4632
 
3160
4633
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/workspace/capture/file", async (c) => {
3161
4634
  const workspaceId = c.req.param("workspaceId") ?? "";
3162
- await requireAccessGrant(c, deps, workspaceId, "files:read");
4635
+ const grant = await requireAccessGrant(c, deps, workspaceId, "files:read");
3163
4636
  const sessionId = c.req.param("sessionId") ?? "";
3164
4637
  const path = c.req.query("path");
3165
4638
  if (!path) {
@@ -3188,7 +4661,21 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3188
4661
  } else {
3189
4662
  row = await latestWorkspaceCapture(db, workspaceId, sessionId);
3190
4663
  }
3191
- return c.json(await serveWorkspaceCaptureFile(row, path, objectStorage));
4664
+ return c.json(
4665
+ await serveWorkspaceCaptureFile(row, path, objectStorage, (fact) =>
4666
+ recordAuditEvent(db, {
4667
+ accountId: grant.accountId,
4668
+ workspaceId,
4669
+ subjectId: grant.subjectId,
4670
+ action: "file.signed_url.issued",
4671
+ targetType: "workspace_capture",
4672
+ targetId: sessionId,
4673
+ // The captured PATH is workspace-file identity (like a filename),
4674
+ // not content; the signed URL and object key are never recorded.
4675
+ metadata: { sessionId, path, ...fact },
4676
+ }),
4677
+ ),
4678
+ );
3192
4679
  });
3193
4680
 
3194
4681
  // ── Terminal: synchronous exec ────────────────────────────────────────────
@@ -3510,15 +4997,30 @@ export function sessionAuthorizationOperationForHttp(
3510
4997
  if (suffix === "") {
3511
4998
  if (verb === "GET") return "session.read";
3512
4999
  if (verb === "PATCH") return "session.title.write";
5000
+ if (verb === "DELETE") return "session.delete";
3513
5001
  return null;
3514
5002
  }
3515
5003
  if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
5004
+ if (suffix === "/attention" && verb === "PUT") return "session.attention.write";
5005
+ if (suffix === "/archive" && verb === "PUT") return "session.archive.write";
5006
+ if (suffix === "/visibility" && verb === "PUT") return "session.visibility.write";
5007
+ if (suffix === "/forks" && verb === "POST") return "session.fork.create";
3516
5008
  if (suffix === "/channel" && verb === "PUT") return "session.channel.write";
5009
+ if (suffix === "/variable-sets" && verb === "PUT") return "session.variable_sets.write";
3517
5010
  if (suffix === "/tool-policy" && verb === "PUT") return "session.tool_policy.write";
5011
+ if (suffix === "/agent" && verb === "PUT") return "session.tool_policy.write";
5012
+ if (suffix === "/mcp-credentials/rotate" && verb === "POST")
5013
+ return "session.mcp.credentials.rotate";
3518
5014
  if (/^\/mcp-servers\/[^/]+\/approval-policy$/.test(suffix) && verb === "PATCH") {
3519
5015
  return "session.mcp.approval_policy.write";
3520
5016
  }
3521
5017
  if (suffix === "/lineage" && verb === "GET") return "session.lineage.read";
5018
+ if (suffix === "/background-commands" && verb === "GET") return "session.read";
5019
+ if (suffix === "/model-context" && verb === "GET") return "session.read";
5020
+ if (suffix === "/codex-accounts" && verb === "GET") return "session.read";
5021
+ if (/^\/background-commands\/[^/]+$/.test(suffix) && verb === "DELETE") {
5022
+ return "session.control";
5023
+ }
3522
5024
  if (suffix === "/codex-account" && verb === "POST") {
3523
5025
  return "session.codex_account.write";
3524
5026
  }
@@ -3554,13 +5056,19 @@ export function sessionAuthorizationOperationForHttp(
3554
5056
  : null;
3555
5057
  }
3556
5058
  if (suffix === "/goal/revisions" && verb === "GET") return "session.goal.read";
5059
+ if (suffix === "/goal/revisions/page" && verb === "GET") return "session.goal.read";
3557
5060
  if (/^\/goal\/revisions\/[^/]+\/apply$/.test(suffix) && verb === "POST") {
3558
5061
  return "session.goal.write";
3559
5062
  }
5063
+ if (/^\/goal\/revisions\/[^/]+\/(reject|rollback)$/.test(suffix) && verb === "POST") {
5064
+ return "session.goal.write";
5065
+ }
3560
5066
  if (suffix === "/context/clear" || suffix === "/context/compact") {
3561
5067
  return verb === "POST" ? "session.context.write" : null;
3562
5068
  }
3563
5069
  if (suffix === "/events/stream" && verb === "GET") return "session.stream.read";
5070
+ if (/^\/events\/[^/]+\/message-preview$/.test(suffix) && verb === "GET")
5071
+ return "session.events.read";
3564
5072
  if (suffix === "/events") {
3565
5073
  if (verb === "GET") return "session.events.read";
3566
5074
  if (verb === "POST") return "session.append";
@@ -3574,7 +5082,12 @@ export function sessionAuthorizationOperationForHttp(
3574
5082
  if (verb === "PUT") return "session.composer.write";
3575
5083
  return null;
3576
5084
  }
5085
+ // Same HTTP gate as POST /events. Delivery `steer` is re-authorized inside
5086
+ // acceptSessionUserMessageWithOutcome after the body is parsed.
5087
+ if (suffix === "/composer-draft/submit" && verb === "POST") return "session.append";
3577
5088
  if (suffix === "/control" && verb === "POST") return "session.control";
5089
+ if (suffix === "/retry" && verb === "POST") return "session.control";
5090
+ if (suffix === "/sandbox-recovery" && ["GET", "POST"].includes(verb)) return "session.control";
3578
5091
  if (suffix === "/steer" && verb === "POST") return "session.steer";
3579
5092
  if (suffix === "/human-input-requests" && verb === "GET") {
3580
5093
  return "session.human_input.read";
@@ -3590,9 +5103,16 @@ export function sessionAuthorizationOperationForHttp(
3590
5103
  if (suffix.startsWith("/viewers/") && ["POST", "DELETE"].includes(verb)) {
3591
5104
  return "session.viewer.control";
3592
5105
  }
3593
- if (suffix === "/fs/list" || suffix === "/fs/list-batch" || suffix === "/fs/read") {
5106
+ if (
5107
+ suffix === "/fs/list" ||
5108
+ suffix === "/fs/list-batch" ||
5109
+ suffix === "/fs/read" ||
5110
+ suffix === "/fs/read-workspace"
5111
+ ) {
3594
5112
  return verb === "POST" ? "session.files.read" : null;
3595
5113
  }
5114
+ if (suffix === "/artifacts/publish" && verb === "POST") return "session.files.write";
5115
+ if (suffix.startsWith("/artifact-associations/") && verb === "GET") return "session.read";
3596
5116
  if (["/fs/write", "/fs/delete", "/fs/move", "/fs/mkdir"].includes(suffix)) {
3597
5117
  return verb === "POST" ? "session.files.write" : null;
3598
5118
  }
@@ -3621,6 +5141,70 @@ function sessionAuthorizationHttpError(error: unknown): HTTPException {
3621
5141
  throw error;
3622
5142
  }
3623
5143
 
5144
+ export function sessionTenancyHttpError(error: unknown): Error {
5145
+ if (error instanceof SessionTenancyManagedHumanRequiredError) {
5146
+ return new ApiHttpError(403, {
5147
+ code: "forbidden",
5148
+ message: "Session tenancy mutations require the owning managed-human session.",
5149
+ retryable: false,
5150
+ });
5151
+ }
5152
+ if (error instanceof SessionTenancyNotActivatedError) {
5153
+ return new ApiHttpError(409, {
5154
+ code: "conflict",
5155
+ message: "Session tenancy is not activated for this organization.",
5156
+ retryable: false,
5157
+ details: { reason: "not_activated" },
5158
+ });
5159
+ }
5160
+ if (error instanceof SessionTenancyConflictError) {
5161
+ return new ApiHttpError(409, {
5162
+ code: error.reason === "operation_reuse" ? "idempotency_conflict" : "conflict",
5163
+ message:
5164
+ error.reason === "not_quiescent"
5165
+ ? "The session must be fully quiescent before this change."
5166
+ : error.reason === "authority_epoch"
5167
+ ? "The session authority changed before this operation committed."
5168
+ : "The idempotency key was already used with different input.",
5169
+ retryable: false,
5170
+ details: {
5171
+ reason: error.reason,
5172
+ ...(error.blocker ? { blocker: error.blocker } : {}),
5173
+ },
5174
+ });
5175
+ }
5176
+ if (
5177
+ error instanceof SessionTenancyAccessError ||
5178
+ error instanceof SessionAuthorizationDeniedError
5179
+ ) {
5180
+ return new ApiHttpError(404, {
5181
+ code: "not_found",
5182
+ message: "Session not found.",
5183
+ retryable: false,
5184
+ });
5185
+ }
5186
+ if (error instanceof SessionTenancyInvalidRequestError) {
5187
+ return new ApiHttpError(422, {
5188
+ code: "validation_failed",
5189
+ message: "Invalid session tenancy request.",
5190
+ retryable: false,
5191
+ });
5192
+ }
5193
+ if (error instanceof SessionTenancyPersistenceOutcomeUnknownError) {
5194
+ return new ApiHttpError(503, {
5195
+ code: "upstream_unavailable",
5196
+ message:
5197
+ "The session tenancy mutation outcome is unknown. Retry with the same idempotency key.",
5198
+ retryable: true,
5199
+ outcomeUnknown: true,
5200
+ });
5201
+ }
5202
+ if (error instanceof SessionAuthorizationUnavailableError) {
5203
+ return sessionAuthorizationHttpError(error);
5204
+ }
5205
+ return error instanceof Error ? error : new Error("Unknown session tenancy error");
5206
+ }
5207
+
3624
5208
  function eventEnumValue<T extends string>(
3625
5209
  raw: string | undefined,
3626
5210
  schema: { safeParse(value: unknown): { success: boolean; data?: T } },
@@ -3673,17 +5257,35 @@ function eventEnumList<T extends string>(
3673
5257
  });
3674
5258
  }
3675
5259
 
3676
- function sessionListQuery(
5260
+ export function sessionListQuery(
3677
5261
  query: Record<string, string>,
3678
5262
  allowCursor = true,
3679
5263
  ): {
5264
+ originSiteId: string | undefined;
3680
5265
  limit: string | undefined;
3681
5266
  parentSessionId: string | null | undefined;
3682
5267
  cursor: ReturnType<typeof decodeSessionListCursor> | undefined;
3683
5268
  search: string | undefined;
3684
5269
  pinsOnly: boolean;
5270
+ includePinned: boolean;
5271
+ includeTotals: boolean;
5272
+ needsYouOnly: boolean;
5273
+ archivedOnly: boolean;
5274
+ sortBy: "updatedAt" | "createdAt" | "name" | undefined;
5275
+ archiveStatus: "active" | "archived" | "all" | undefined;
5276
+ channelId: string | null | undefined;
5277
+ createdBy: { kind: "subject" | "service"; subjectId: string } | undefined;
5278
+ updatedFrom: Date | undefined;
5279
+ updatedBefore: Date | undefined;
5280
+ createdFrom: Date | undefined;
5281
+ createdBefore: Date | undefined;
5282
+ scopeSubjectId: SessionScopeSubjectId | undefined;
5283
+ hasPageFilters: boolean;
3685
5284
  } {
3686
5285
  const parentSessionId = query.parentSessionId;
5286
+ const originSiteId = query.originSiteId;
5287
+ if (originSiteId !== undefined && !z.string().uuid().safeParse(originSiteId).success)
5288
+ throw new HTTPException(400, { message: "originSiteId must be a Site id" });
3687
5289
  // "null" = roots only; a uuid = children of that session; anything else is
3688
5290
  // a client error (an unvalidated value would surface as a Postgres uuid cast
3689
5291
  // failure -> 500 rather than an honest 400).
@@ -3713,16 +5315,136 @@ function sessionListQuery(
3713
5315
  });
3714
5316
  }
3715
5317
  const pinsOnly = query.pinsOnly === "true";
5318
+ if (query.includePinned !== undefined && !["true", "false"].includes(query.includePinned)) {
5319
+ throw new HTTPException(400, { message: 'includePinned must be "true" or "false"' });
5320
+ }
5321
+ const includePinned = query.includePinned !== "false";
5322
+ for (const key of ["includeTotals", "needsYouOnly"]) {
5323
+ if (query[key] !== undefined && !["true", "false"].includes(query[key]!))
5324
+ throw new HTTPException(400, { message: `${key} must be "true" or "false"` });
5325
+ }
5326
+ const includeTotals = query.includeTotals === "true";
5327
+ const needsYouOnly = query.needsYouOnly === "true";
5328
+ if (includeTotals && (!allowCursor || (parentSessionId !== "null" && !pinsOnly)))
5329
+ throw new HTTPException(400, { message: "includeTotals requires a root page" });
5330
+ if (needsYouOnly && !allowCursor)
5331
+ throw new HTTPException(400, { message: 'needsYouOnly requires view="page"' });
5332
+ if (pinsOnly && !includePinned) {
5333
+ throw new HTTPException(400, { message: "pinsOnly requires includePinned" });
5334
+ }
5335
+ if (query.archivedOnly !== undefined && query.archivedOnly !== "true") {
5336
+ throw new HTTPException(400, { message: 'archivedOnly must be the literal "true"' });
5337
+ }
5338
+ const archivedOnly = query.archivedOnly === "true";
5339
+ const sortBy = z.enum(["updatedAt", "createdAt", "name"]).optional().safeParse(query.sortBy);
5340
+ const archiveStatus = z
5341
+ .enum(["active", "archived", "all"])
5342
+ .optional()
5343
+ .safeParse(query.archiveStatus);
5344
+ if (!sortBy.success || !archiveStatus.success) {
5345
+ throw new HTTPException(400, { message: "Invalid session sortBy or archiveStatus" });
5346
+ }
5347
+ if (archivedOnly && archiveStatus.data !== undefined && archiveStatus.data !== "archived") {
5348
+ throw new HTTPException(400, { message: "archivedOnly conflicts with archiveStatus" });
5349
+ }
5350
+ if (pinsOnly && archiveStatus.data === "archived") {
5351
+ throw new HTTPException(400, { message: "pinsOnly cannot be combined with archived status" });
5352
+ }
5353
+ const channelId = query.channelId;
5354
+ if (
5355
+ channelId !== undefined &&
5356
+ channelId !== "null" &&
5357
+ !z.string().uuid().safeParse(channelId).success
5358
+ ) {
5359
+ throw new HTTPException(400, {
5360
+ message: 'channelId must be a channel id or the literal "null"',
5361
+ });
5362
+ }
5363
+ const createdByKind = query.createdByKind;
5364
+ const createdBySubjectId = query.createdBySubjectId;
5365
+ if ((createdByKind === undefined) !== (createdBySubjectId === undefined)) {
5366
+ throw new HTTPException(400, {
5367
+ message: "createdByKind and createdBySubjectId must be supplied together",
5368
+ });
5369
+ }
5370
+ if (createdByKind !== undefined && createdByKind !== "subject" && createdByKind !== "service") {
5371
+ throw new HTTPException(400, {
5372
+ message: 'createdByKind must be "subject" or "service"',
5373
+ });
5374
+ }
5375
+ if (
5376
+ createdBySubjectId !== undefined &&
5377
+ (createdBySubjectId.trim().length < 1 || createdBySubjectId.length > 1_024)
5378
+ ) {
5379
+ throw new HTTPException(400, {
5380
+ message: "createdBySubjectId must be between 1 and 1024 characters",
5381
+ });
5382
+ }
5383
+ const parseDateBound = (name: string): Date | undefined => {
5384
+ const raw = query[name];
5385
+ if (raw === undefined) return undefined;
5386
+ const parsed = z.string().datetime({ offset: true }).safeParse(raw);
5387
+ if (!parsed.success) {
5388
+ throw new HTTPException(400, { message: `${name} must be an ISO-8601 timestamp` });
5389
+ }
5390
+ // Date-backed filters must not silently round accepted microseconds to an
5391
+ // earlier boundary. Clients can use any ISO offset with at most 3 decimals.
5392
+ if (/\.\d{4,}/.test(parsed.data)) {
5393
+ throw new HTTPException(400, { message: `${name} supports at most millisecond precision` });
5394
+ }
5395
+ return new Date(parsed.data);
5396
+ };
5397
+ const updatedFrom = parseDateBound("updatedFrom");
5398
+ const updatedBefore = parseDateBound("updatedBefore");
5399
+ const createdFrom = parseDateBound("createdFrom");
5400
+ const createdBefore = parseDateBound("createdBefore");
5401
+ if (updatedFrom && updatedBefore && updatedFrom >= updatedBefore) {
5402
+ throw new HTTPException(400, { message: "updatedFrom must be earlier than updatedBefore" });
5403
+ }
5404
+ if (createdFrom && createdBefore && createdFrom >= createdBefore) {
5405
+ throw new HTTPException(400, { message: "createdFrom must be earlier than createdBefore" });
5406
+ }
5407
+ // The opaque end-user label filter is an exact pair: one half alone is a
5408
+ // client error rather than a silently unfiltered list.
5409
+ if (query.endUserSource !== undefined || query.endUserId !== undefined) {
5410
+ throw new HTTPException(400, {
5411
+ message: "Use canonical scopeSubjectId, not an external-user label",
5412
+ });
5413
+ }
5414
+ let scopeSubjectId: SessionScopeSubjectId | undefined;
5415
+ if (query.scopeSubjectId !== undefined) {
5416
+ const parsedEndUser = SessionScopeSubjectId.safeParse(query.scopeSubjectId);
5417
+ if (!parsedEndUser.success) {
5418
+ throw new HTTPException(400, {
5419
+ message: "scopeSubjectId must be a canonical Opengeni user subject",
5420
+ });
5421
+ }
5422
+ scopeSubjectId = parsedEndUser.data;
5423
+ }
5424
+ const hasPageFilters =
5425
+ needsYouOnly ||
5426
+ originSiteId !== undefined ||
5427
+ channelId !== undefined ||
5428
+ createdByKind !== undefined ||
5429
+ updatedFrom !== undefined ||
5430
+ updatedBefore !== undefined ||
5431
+ createdFrom !== undefined ||
5432
+ createdBefore !== undefined ||
5433
+ scopeSubjectId !== undefined;
3716
5434
  if (pinsOnly && !allowCursor) {
3717
5435
  throw new HTTPException(400, { message: 'pinsOnly requires view="page"' });
3718
5436
  }
3719
- if (pinsOnly && (rawCursor || parentSessionId !== undefined || search)) {
5437
+ if (pinsOnly && (rawCursor || parentSessionId !== undefined || search || hasPageFilters)) {
3720
5438
  throw new HTTPException(400, {
3721
- message: "pinsOnly cannot be combined with cursor, parentSessionId, or search",
5439
+ message: "pinsOnly cannot be combined with cursor, parentSessionId, search, or filters",
3722
5440
  });
3723
5441
  }
5442
+ if (pinsOnly && archivedOnly) {
5443
+ throw new HTTPException(400, { message: "pinsOnly cannot be combined with archivedOnly" });
5444
+ }
3724
5445
  return {
3725
5446
  limit: query.limit,
5447
+ originSiteId,
3726
5448
  parentSessionId:
3727
5449
  parentSessionId === undefined
3728
5450
  ? undefined
@@ -3732,17 +5454,40 @@ function sessionListQuery(
3732
5454
  cursor,
3733
5455
  search: search || undefined,
3734
5456
  pinsOnly,
5457
+ includePinned,
5458
+ includeTotals,
5459
+ needsYouOnly,
5460
+ archivedOnly,
5461
+ sortBy: sortBy.data,
5462
+ archiveStatus: archiveStatus.data,
5463
+ channelId: channelId === undefined ? undefined : channelId === "null" ? null : channelId,
5464
+ createdBy:
5465
+ createdByKind && createdBySubjectId
5466
+ ? { kind: createdByKind, subjectId: createdBySubjectId }
5467
+ : undefined,
5468
+ updatedFrom,
5469
+ updatedBefore,
5470
+ createdFrom,
5471
+ createdBefore,
5472
+ scopeSubjectId,
5473
+ hasPageFilters,
3735
5474
  };
3736
5475
  }
3737
5476
 
3738
5477
  export type AgentTopologyCursorEnvelope = {
3739
5478
  cursor: SessionDiscoveryCursor;
3740
- parentSessionId: string | null;
3741
- search: string | null;
5479
+ parentSessionId: string | null | "all";
5480
+ rootSessionId: string | null;
5481
+ query: string | null;
5482
+ statuses: SessionStatus[];
5483
+ activeOnly: boolean;
5484
+ recentHours: number | null;
5485
+ subject: WorkClaimSubjectFilter | null;
5486
+ claimLimit: number | null;
3742
5487
  };
3743
5488
 
3744
5489
  export function encodeAgentTopologyCursor(value: AgentTopologyCursorEnvelope): string {
3745
- return Buffer.from(JSON.stringify({ v: 1, ...value }), "utf8").toString("base64url");
5490
+ return Buffer.from(JSON.stringify({ v: 2, ...value }), "utf8").toString("base64url");
3746
5491
  }
3747
5492
 
3748
5493
  function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
@@ -3752,25 +5497,92 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
3752
5497
  });
3753
5498
  }
3754
5499
  try {
3755
- const parsed = z
5500
+ const decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as unknown;
5501
+ const legacy = z
3756
5502
  .object({
3757
5503
  v: z.literal(1),
3758
5504
  parentSessionId: z.string().uuid().nullable(),
3759
- search: z.string().max(200).nullable(),
5505
+ search: z.string().max(WORK_DISCOVERY_QUERY_MAX_CHARS).nullable(),
5506
+ cursor: z.object({
5507
+ orderBy: z.literal("updatedAt"),
5508
+ sortRevision: z.string().max(64),
5509
+ sortAt: z.string().max(64),
5510
+ id: z.string().uuid(),
5511
+ snapshotAt: z.string().max(64),
5512
+ snapshotRevision: z.string().max(64),
5513
+ updatedAfter: z.null(),
5514
+ }),
5515
+ })
5516
+ .safeParse(decoded);
5517
+ if (legacy.success) {
5518
+ if (legacy.data.search !== null) {
5519
+ throw new Error("legacy search cursor is not relevance-fenced");
5520
+ }
5521
+ const cursor = {
5522
+ ...legacy.data.cursor,
5523
+ sortRank: null,
5524
+ filterHash: null,
5525
+ } satisfies SessionDiscoveryCursor;
5526
+ return {
5527
+ cursor,
5528
+ parentSessionId: legacy.data.parentSessionId,
5529
+ rootSessionId: null,
5530
+ query: null,
5531
+ statuses: [],
5532
+ activeOnly: false,
5533
+ recentHours: null,
5534
+ subject: null,
5535
+ claimLimit: null,
5536
+ };
5537
+ }
5538
+ const parsed = z
5539
+ .object({
5540
+ v: z.literal(2),
5541
+ parentSessionId: z.union([z.string().uuid(), z.literal("all"), z.null()]),
5542
+ rootSessionId: z.string().uuid().nullable(),
5543
+ query: z.string().max(WORK_DISCOVERY_QUERY_MAX_CHARS).nullable(),
5544
+ statuses: z
5545
+ .array(
5546
+ z.enum([
5547
+ "queued",
5548
+ "running",
5549
+ "idle",
5550
+ "requires_action",
5551
+ "recovering",
5552
+ "waiting_capacity",
5553
+ "failed",
5554
+ "cancelled",
5555
+ ]),
5556
+ )
5557
+ .max(8),
5558
+ activeOnly: z.boolean(),
5559
+ recentHours: z.number().int().positive().max(WORK_DISCOVERY_RECENT_HOURS_MAX).nullable(),
5560
+ subject: z
5561
+ .object({
5562
+ namespace: z.string().min(1).max(WORK_CLAIM_NAMESPACE_MAX_BYTES),
5563
+ type: WorkClaimSubjectType,
5564
+ canonicalKey: z.string().min(1).max(WORK_CLAIM_CANONICAL_KEY_MAX_BYTES),
5565
+ })
5566
+ .strict()
5567
+ .nullable(),
5568
+ claimLimit: z.number().int().positive().max(WORK_CLAIM_DISCOVERY_LIMIT).nullable(),
3760
5569
  cursor: z.object({
3761
- orderBy: z.enum(["createdAt", "updatedAt"]),
5570
+ orderBy: z.enum(["updatedAt", "relevance"]),
5571
+ sortRank: z.number().int().nonnegative().nullable(),
3762
5572
  sortRevision: z.string().max(64),
3763
5573
  sortAt: z.string().max(64),
3764
5574
  id: z.string().uuid(),
3765
5575
  snapshotAt: z.string().max(64),
3766
5576
  snapshotRevision: z.string().max(64),
3767
- updatedAfter: z.string().max(64).nullable(),
5577
+ updatedAfter: z.null(),
5578
+ filterHash: z
5579
+ .string()
5580
+ .regex(/^[0-9a-f]{64}$/)
5581
+ .nullable(),
3768
5582
  }),
3769
5583
  })
3770
- .parse(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
5584
+ .parse(decoded);
3771
5585
  if (
3772
- parsed.cursor.orderBy !== "updatedAt" ||
3773
- parsed.cursor.updatedAfter !== null ||
3774
5586
  !/^(?:0|[1-9]\d*)$/.test(parsed.cursor.sortRevision) ||
3775
5587
  !/^(?:0|[1-9]\d*)$/.test(parsed.cursor.snapshotRevision) ||
3776
5588
  BigInt(parsed.cursor.sortRevision) > 9_223_372_036_854_775_807n ||
@@ -3780,6 +5592,14 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
3780
5592
  ) {
3781
5593
  throw new Error("invalid topology cursor fields");
3782
5594
  }
5595
+ if (
5596
+ (parsed.cursor.orderBy === "relevance" &&
5597
+ (parsed.cursor.sortRank === null || parsed.cursor.filterHash === null)) ||
5598
+ (parsed.cursor.orderBy === "updatedAt" &&
5599
+ (parsed.cursor.sortRank !== null || parsed.cursor.filterHash !== null))
5600
+ ) {
5601
+ throw new Error("invalid topology cursor relevance fields");
5602
+ }
3783
5603
  return parsed;
3784
5604
  } catch {
3785
5605
  throw new HTTPException(400, {
@@ -3790,8 +5610,14 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
3790
5610
 
3791
5611
  export function agentTopologyQuery(query: Record<string, string>): {
3792
5612
  limit: number;
3793
- parentSessionId: string | null;
3794
- search: string | undefined;
5613
+ parentSessionId: string | null | undefined;
5614
+ rootSessionId: string | undefined;
5615
+ query: string | undefined;
5616
+ statuses: SessionStatus[] | undefined;
5617
+ activeOnly: boolean;
5618
+ recentHours: number | undefined;
5619
+ subject: WorkClaimSubjectFilter | undefined;
5620
+ claimLimit: number | undefined;
3795
5621
  cursor: SessionDiscoveryCursor | undefined;
3796
5622
  } {
3797
5623
  const rawLimit = query.limit;
@@ -3811,22 +5637,141 @@ export function agentTopologyQuery(query: Record<string, string>): {
3811
5637
  message: 'parentSessionId must be a session id or the literal "null"',
3812
5638
  });
3813
5639
  }
3814
- const parentSessionId = rawParent === undefined || rawParent === "null" ? null : rawParent;
3815
- const search = query.search?.trim();
3816
- if (search && search.length > 200) {
5640
+ const rootSessionId = query.rootSessionId?.trim();
5641
+ if (rootSessionId && !z.string().uuid().safeParse(rootSessionId).success) {
5642
+ throw new HTTPException(400, { message: "rootSessionId must be a session id" });
5643
+ }
5644
+ const normalizeSearchQuery = (value: string | undefined): string | undefined => {
5645
+ if (value === undefined) return undefined;
5646
+ const canonical = value.normalize("NFKC");
5647
+ if (/[\u0000-\u001f\u007f-\u009f]/u.test(canonical)) {
5648
+ throw new HTTPException(400, { message: "query must not contain control characters" });
5649
+ }
5650
+ const normalized = canonical.trim().replace(/\s+/gu, " ").toLowerCase();
5651
+ if (!normalized) return undefined;
5652
+ if (Array.from(normalized).length > WORK_DISCOVERY_QUERY_MAX_CHARS) {
5653
+ throw new HTTPException(400, {
5654
+ message: `query must be at most ${WORK_DISCOVERY_QUERY_MAX_CHARS} characters`,
5655
+ });
5656
+ }
5657
+ return normalized;
5658
+ };
5659
+ const requestedQuery = normalizeSearchQuery(query.query);
5660
+ const legacySearch = normalizeSearchQuery(query.search);
5661
+ const searchQuery = requestedQuery ?? legacySearch;
5662
+ if (requestedQuery && legacySearch && requestedQuery !== legacySearch) {
5663
+ throw new HTTPException(400, { message: "query and legacy search must match" });
5664
+ }
5665
+ const statuses = query.statuses
5666
+ ? [
5667
+ ...new Set(
5668
+ query.statuses
5669
+ .split(",")
5670
+ .map((status) => status.trim())
5671
+ .filter(Boolean),
5672
+ ),
5673
+ ].sort()
5674
+ : [];
5675
+ const parsedStatuses = z
5676
+ .array(
5677
+ z.enum([
5678
+ "queued",
5679
+ "running",
5680
+ "idle",
5681
+ "requires_action",
5682
+ "recovering",
5683
+ "waiting_capacity",
5684
+ "failed",
5685
+ "cancelled",
5686
+ ]),
5687
+ )
5688
+ .max(8)
5689
+ .safeParse(statuses);
5690
+ if (!parsedStatuses.success) {
5691
+ throw new HTTPException(400, { message: "statuses contains an unsupported lifecycle state" });
5692
+ }
5693
+ const activeOnly = query.activeOnly === "true";
5694
+ if (
5695
+ query.activeOnly !== undefined &&
5696
+ query.activeOnly !== "true" &&
5697
+ query.activeOnly !== "false"
5698
+ ) {
5699
+ throw new HTTPException(400, { message: "activeOnly must be true or false" });
5700
+ }
5701
+ const recentHours = query.recentHours === undefined ? undefined : Number(query.recentHours);
5702
+ if (
5703
+ recentHours !== undefined &&
5704
+ (!Number.isSafeInteger(recentHours) ||
5705
+ recentHours < 1 ||
5706
+ recentHours > WORK_DISCOVERY_RECENT_HOURS_MAX)
5707
+ ) {
3817
5708
  throw new HTTPException(400, {
3818
- message: "search must be at most 200 characters",
5709
+ message: `recentHours must be an integer between 1 and ${WORK_DISCOVERY_RECENT_HOURS_MAX}`,
5710
+ });
5711
+ }
5712
+ const subjectFields = [query.subjectNamespace, query.subjectType, query.subjectKey];
5713
+ if (subjectFields.some((value) => value !== undefined) && subjectFields.some((value) => !value)) {
5714
+ throw new HTTPException(400, {
5715
+ message: "subjectNamespace, subjectType, and subjectKey must be supplied together",
3819
5716
  });
3820
5717
  }
3821
- if (search && rawParent !== undefined) {
5718
+ const parsedSubject = subjectFields.every((value) => value !== undefined)
5719
+ ? WorkClaimSubjectFilterSchema.safeParse({
5720
+ namespace: normalizeWorkClaimNamespace(query.subjectNamespace!),
5721
+ type: query.subjectType,
5722
+ canonicalKey: normalizeWorkClaimCanonicalKey(query.subjectKey!),
5723
+ })
5724
+ : null;
5725
+ if (parsedSubject && !parsedSubject.success) {
5726
+ throw new HTTPException(400, { message: "exact subject filter is invalid" });
5727
+ }
5728
+ const subject = parsedSubject?.success ? parsedSubject.data : undefined;
5729
+ if (searchQuery && subject) {
5730
+ throw new HTTPException(400, { message: "query cannot be combined with an exact subject" });
5731
+ }
5732
+ const relevanceRequested = Boolean(searchQuery || subject);
5733
+ // An explicit root scopes the whole workstream; only an explicit parent
5734
+ // narrows it to one level. Ordinary browse still defaults to root sessions.
5735
+ const parentSessionId =
5736
+ rawParent === undefined
5737
+ ? relevanceRequested || Boolean(rootSessionId)
5738
+ ? undefined
5739
+ : null
5740
+ : rawParent === "null"
5741
+ ? null
5742
+ : rawParent;
5743
+ const claimLimit = query.claimLimit === undefined ? undefined : Number(query.claimLimit);
5744
+ if (
5745
+ claimLimit !== undefined &&
5746
+ (!Number.isSafeInteger(claimLimit) || claimLimit < 1 || claimLimit > WORK_CLAIM_DISCOVERY_LIMIT)
5747
+ ) {
3822
5748
  throw new HTTPException(400, {
3823
- message: "search cannot be combined with parentSessionId",
5749
+ message: `claimLimit must be an integer between 1 and ${WORK_CLAIM_DISCOVERY_LIMIT}`,
3824
5750
  });
3825
5751
  }
3826
5752
  const envelope = query.cursor ? decodeAgentTopologyCursor(query.cursor) : undefined;
5753
+ const expectedEnvelope = {
5754
+ parentSessionId: parentSessionId === undefined ? "all" : parentSessionId,
5755
+ rootSessionId: rootSessionId ?? null,
5756
+ query: searchQuery || null,
5757
+ statuses: parsedStatuses.data,
5758
+ activeOnly,
5759
+ recentHours: recentHours ?? null,
5760
+ subject: subject ?? null,
5761
+ claimLimit: claimLimit ?? null,
5762
+ };
3827
5763
  if (
3828
5764
  envelope &&
3829
- (envelope.parentSessionId !== parentSessionId || envelope.search !== (search || null))
5765
+ JSON.stringify({
5766
+ parentSessionId: envelope.parentSessionId,
5767
+ rootSessionId: envelope.rootSessionId,
5768
+ query: envelope.query,
5769
+ statuses: envelope.statuses,
5770
+ activeOnly: envelope.activeOnly,
5771
+ recentHours: envelope.recentHours,
5772
+ subject: envelope.subject,
5773
+ claimLimit: envelope.claimLimit,
5774
+ }) !== JSON.stringify(expectedEnvelope)
3830
5775
  ) {
3831
5776
  throw new HTTPException(400, {
3832
5777
  message: "agent topology cursor does not match its filters",
@@ -3835,7 +5780,13 @@ export function agentTopologyQuery(query: Record<string, string>): {
3835
5780
  return {
3836
5781
  limit,
3837
5782
  parentSessionId,
3838
- search: search || undefined,
5783
+ rootSessionId: rootSessionId || undefined,
5784
+ query: searchQuery || undefined,
5785
+ statuses: parsedStatuses.data.length > 0 ? parsedStatuses.data : undefined,
5786
+ activeOnly,
5787
+ recentHours,
5788
+ subject,
5789
+ claimLimit,
3839
5790
  cursor: envelope?.cursor,
3840
5791
  };
3841
5792
  }
@@ -3865,11 +5816,57 @@ function optionalEventSequence(raw: string | undefined): number | undefined {
3865
5816
 
3866
5817
  /** Stable, value-free JSON errors for only the create-session boundary. */
3867
5818
  export function sessionCreateErrorResponse(c: Context, error: unknown): Response {
5819
+ if (error instanceof UnsupportedLatencyModeError) {
5820
+ return c.json(
5821
+ {
5822
+ code: "SESSION_CREATE_REJECTED",
5823
+ message: error.message,
5824
+ details: {
5825
+ code: error.code,
5826
+ modelId: error.modelId,
5827
+ latencyMode: error.latencyMode,
5828
+ allowedLatencyModes: [...error.allowedLatencyModes],
5829
+ },
5830
+ },
5831
+ 422,
5832
+ );
5833
+ }
5834
+ if (error instanceof SessionCreateConnectionSelectionUnavailableError) {
5835
+ return c.json({ code: error.code, message: error.message, retryable: false }, 409);
5836
+ }
5837
+ if (error instanceof SessionTenancyManagedHumanRequiredError) {
5838
+ return c.json(
5839
+ {
5840
+ code: "SESSION_CREATE_FORBIDDEN",
5841
+ message: "Only managed-account users can create an Only-me session.",
5842
+ },
5843
+ 403,
5844
+ );
5845
+ }
5846
+ if (error instanceof SessionTenancyNotActivatedError) {
5847
+ return c.json(
5848
+ {
5849
+ code: "SESSION_TENANCY_NOT_ACTIVATED",
5850
+ message: "Private sessions are not enabled for this organization.",
5851
+ },
5852
+ 409,
5853
+ );
5854
+ }
3868
5855
  if (error instanceof ChannelNotFoundError) {
3869
5856
  // Covers the create-vs-channel-delete race the pre-validation cannot: the
3870
5857
  // insert's FK rejection surfaces as the same 422 an unknown id gets.
3871
5858
  return c.json({ code: "SESSION_CREATE_REJECTED", message: error.message }, 422);
3872
5859
  }
5860
+ if (error instanceof SessionVariableSetSelectionUnavailableError) {
5861
+ return c.json(
5862
+ {
5863
+ code: "SESSION_CREATE_REJECTED",
5864
+ message: error.message,
5865
+ details: { variableSetIds: error.variableSetIds },
5866
+ },
5867
+ 422,
5868
+ );
5869
+ }
3873
5870
  if (error instanceof SessionSpawnDeniedError) {
3874
5871
  return c.json(
3875
5872
  sessionSpawnDenialEnvelope(error),
@@ -3885,6 +5882,38 @@ export function sessionCreateErrorResponse(c: Context, error: unknown): Response
3885
5882
  422,
3886
5883
  );
3887
5884
  }
5885
+ if (
5886
+ error instanceof HTTPException &&
5887
+ error.status === 409 &&
5888
+ error.cause instanceof NewSessionDraftConflictError
5889
+ ) {
5890
+ return c.json(
5891
+ {
5892
+ code: "NEW_SESSION_DRAFT_CONFLICT",
5893
+ message: error.cause.message,
5894
+ currentRevision: error.cause.currentRevision,
5895
+ },
5896
+ 409,
5897
+ );
5898
+ }
5899
+ if (
5900
+ error instanceof HTTPException &&
5901
+ error.status === 422 &&
5902
+ error.cause instanceof AgentConfigError
5903
+ ) {
5904
+ // Typed agent-configuration failure: the specific code is details.code.
5905
+ return c.json(
5906
+ {
5907
+ code: "SESSION_CREATE_REJECTED",
5908
+ message: error.message,
5909
+ details: {
5910
+ code: error.cause.code,
5911
+ ...(error.cause.capability ? { capability: error.cause.capability } : {}),
5912
+ },
5913
+ },
5914
+ 422,
5915
+ );
5916
+ }
3888
5917
  if (error instanceof HTTPException && error.status === 422) {
3889
5918
  return c.json(
3890
5919
  {
@@ -3894,6 +5923,15 @@ export function sessionCreateErrorResponse(c: Context, error: unknown): Response
3894
5923
  422,
3895
5924
  );
3896
5925
  }
5926
+ if (error instanceof HTTPException && error.status === 409) {
5927
+ return c.json(
5928
+ {
5929
+ code: "SESSION_CREATE_CONFLICT",
5930
+ message: error.message,
5931
+ },
5932
+ 409,
5933
+ );
5934
+ }
3897
5935
  throw error;
3898
5936
  }
3899
5937
 
@@ -3943,18 +5981,20 @@ function commandConflictResponse(c: Context, error: unknown): Response {
3943
5981
  type EffectivePolicyContext = {
3944
5982
  workspaceServerIds: string[];
3945
5983
  workspaceDefaultServerIds: string[];
5984
+ effectiveToolsContext: Awaited<ReturnType<typeof workspaceSessionEffectiveToolsContext>>;
3946
5985
  };
3947
5986
 
3948
5987
  async function loadEffectivePolicyContext(
3949
5988
  deps: ApiRouteDeps,
3950
5989
  workspaceId: string,
3951
5990
  subjectId: string,
5991
+ sessions: readonly Session[],
3952
5992
  ): Promise<EffectivePolicyContext> {
3953
- const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
3954
- workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings, subjectId),
3955
- workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings, subjectId),
5993
+ const [policy, effectiveToolsContext] = await Promise.all([
5994
+ workspaceSessionToolPolicyContext(deps.db, workspaceId, deps.settings, subjectId),
5995
+ workspaceSessionEffectiveToolsContext(deps, workspaceId, subjectId, sessions),
3956
5996
  ]);
3957
- return { workspaceServerIds, workspaceDefaultServerIds };
5997
+ return { ...policy, effectiveToolsContext };
3958
5998
  }
3959
5999
 
3960
6000
  async function withEffectivePolicy(
@@ -3963,11 +6003,12 @@ async function withEffectivePolicy(
3963
6003
  subjectId: string,
3964
6004
  session: Session,
3965
6005
  ): Promise<Session> {
3966
- const policy = await loadEffectivePolicyContext(deps, workspaceId, subjectId);
6006
+ const policy = await loadEffectivePolicyContext(deps, workspaceId, subjectId, [session]);
3967
6007
  return sessionWithEffectiveToolPolicy(
3968
6008
  session,
3969
6009
  policy.workspaceServerIds,
3970
6010
  policy.workspaceDefaultServerIds,
6011
+ policy.effectiveToolsContext,
3971
6012
  );
3972
6013
  }
3973
6014
 
@@ -3978,6 +6019,7 @@ function mapLineageNodes(nodes: LineageNode[], policy: EffectivePolicyContext):
3978
6019
  node.session as Session,
3979
6020
  policy.workspaceServerIds,
3980
6021
  policy.workspaceDefaultServerIds,
6022
+ policy.effectiveToolsContext,
3981
6023
  ),
3982
6024
  children: mapLineageNodes(node.children, policy),
3983
6025
  }));