@ours.network/fleet 1.2.0-nightly.9 → 2.0.1

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 (429) hide show
  1. package/README.md +280 -68
  2. package/dist/account-origin.d.ts +5 -0
  3. package/dist/account-origin.js +31 -0
  4. package/dist/agent-ours/bridge.js +26 -9
  5. package/dist/agent-ours/correspondence.d.ts +10 -0
  6. package/dist/agent-ours/correspondence.js +1 -0
  7. package/dist/agent-ours/file-rpc.d.ts +12 -0
  8. package/dist/agent-ours/file-rpc.js +18 -4
  9. package/dist/agent-ours/harness.js +9 -1
  10. package/dist/agent-ours/mcp-endpoint.d.ts +22 -0
  11. package/dist/agent-ours/mcp-endpoint.js +101 -25
  12. package/dist/agent-ours/runtime.d.ts +9 -1
  13. package/dist/agent-ours/runtime.js +50 -2
  14. package/dist/agent-ours/service.d.ts +27 -1
  15. package/dist/agent-ours/service.js +107 -13
  16. package/dist/application/codex-models.d.ts +15 -0
  17. package/dist/application/codex-models.js +56 -0
  18. package/dist/application/conversation-history.d.ts +31 -0
  19. package/dist/application/conversation-history.js +339 -0
  20. package/dist/application/conversation-role-observer.d.ts +62 -0
  21. package/dist/application/conversation-role-observer.js +206 -0
  22. package/dist/application/fleet-query-service.d.ts +1 -1
  23. package/dist/application/fleet-query-service.js +3 -3
  24. package/dist/application/log-service.js +8 -8
  25. package/dist/application/model-catalog.d.ts +1 -1
  26. package/dist/application/model-catalog.js +5 -2
  27. package/dist/application/preset-provenance.d.ts +19 -0
  28. package/dist/application/preset-provenance.js +59 -0
  29. package/dist/application/resource-observer.d.ts +43 -0
  30. package/dist/application/resource-observer.js +323 -0
  31. package/dist/application/role-creation-service.js +3 -2
  32. package/dist/application/role-removal-service.js +17 -3
  33. package/dist/application/role-repository.d.ts +3 -1
  34. package/dist/application/role-repository.js +89 -54
  35. package/dist/application/room-layout-definitions.d.ts +45 -0
  36. package/dist/application/room-layout-definitions.js +130 -0
  37. package/dist/application/session-control.d.ts +63 -17
  38. package/dist/application/session-control.js +221 -23
  39. package/dist/application/supervisor-ours-tools.js +5 -11
  40. package/dist/application/task-layouts.d.ts +91 -0
  41. package/dist/application/task-layouts.js +261 -0
  42. package/dist/application/task-room-service.d.ts +101 -3
  43. package/dist/application/task-room-service.js +286 -33
  44. package/dist/application/types.d.ts +2 -0
  45. package/dist/atomic-file.d.ts +34 -1
  46. package/dist/atomic-file.js +161 -18
  47. package/dist/briefing.d.ts +5 -1
  48. package/dist/briefing.js +138 -31
  49. package/dist/build-info.json +10 -5
  50. package/dist/capabilities.d.ts +9 -1
  51. package/dist/capabilities.js +13 -0
  52. package/dist/cli.js +391 -38
  53. package/dist/client-profile.d.ts +5 -17
  54. package/dist/client-profile.js +4 -127
  55. package/dist/config.d.ts +45 -0
  56. package/dist/config.js +57 -6
  57. package/dist/creation.d.ts +1 -1
  58. package/dist/creation.js +4 -4
  59. package/dist/daemon-recovery.d.ts +14 -9
  60. package/dist/daemon-recovery.js +124 -115
  61. package/dist/docs.d.ts +1 -1
  62. package/dist/docs.js +402 -20
  63. package/dist/doctor.d.ts +4 -1
  64. package/dist/doctor.js +75 -60
  65. package/dist/erased-resources.d.ts +9 -0
  66. package/dist/erased-resources.js +87 -0
  67. package/dist/file-delivery/reader.d.ts +15 -0
  68. package/dist/file-delivery/reader.js +63 -0
  69. package/dist/file-delivery/store.d.ts +15 -0
  70. package/dist/file-delivery/store.js +124 -0
  71. package/dist/file-delivery/types.d.ts +19 -0
  72. package/dist/file-delivery/types.js +1 -0
  73. package/dist/fleet-command-audit.d.ts +13 -0
  74. package/dist/fleet-command-audit.js +31 -9
  75. package/dist/harness/agent-session.d.ts +4 -0
  76. package/dist/harness/claude-code-session.js +1 -1
  77. package/dist/harness/claude-code.js +21 -11
  78. package/dist/harness/claude-runtime.d.ts +12 -0
  79. package/dist/harness/claude-runtime.js +43 -0
  80. package/dist/harness/codex-app-server-proxy.d.ts +4 -1
  81. package/dist/harness/codex-app-server-proxy.js +41 -5
  82. package/dist/harness/codex-reasoning.d.ts +5 -0
  83. package/dist/harness/codex-reasoning.js +12 -0
  84. package/dist/harness/codex-runtime.d.ts +11 -1
  85. package/dist/harness/codex-runtime.js +31 -4
  86. package/dist/harness/codex-session.js +2 -2
  87. package/dist/harness/codex.js +24 -12
  88. package/dist/init-wizard.d.ts +2 -0
  89. package/dist/init-wizard.js +38 -19
  90. package/dist/isolation/policy.js +6 -1
  91. package/dist/isolation/types.d.ts +5 -0
  92. package/dist/managed-cli.d.ts +253 -0
  93. package/dist/managed-cli.js +923 -0
  94. package/dist/managed-recovery.d.ts +13 -0
  95. package/dist/managed-recovery.js +31 -0
  96. package/dist/minimal-setup.d.ts +2 -0
  97. package/dist/minimal-setup.js +44 -0
  98. package/dist/monitor.d.ts +9 -34
  99. package/dist/monitor.js +90 -153
  100. package/dist/notifications/fleet-producer.d.ts +31 -0
  101. package/dist/notifications/fleet-producer.js +351 -0
  102. package/dist/notifications/outbox.d.ts +59 -0
  103. package/dist/notifications/outbox.js +199 -0
  104. package/dist/notifications/target-cleanup.d.ts +16 -0
  105. package/dist/notifications/target-cleanup.js +75 -0
  106. package/dist/notifications/task-producer.d.ts +20 -0
  107. package/dist/notifications/task-producer.js +100 -0
  108. package/dist/ops.d.ts +2 -0
  109. package/dist/ops.js +20 -3
  110. package/dist/owner-channel/channel.js +17 -6
  111. package/dist/owner-channel/commands.d.ts +4 -0
  112. package/dist/owner-channel/commands.js +8 -0
  113. package/dist/owner-channel/lifecycle-outbox.js +4 -3
  114. package/dist/owner-channel/ours-client.js +2 -17
  115. package/dist/paths.d.ts +5 -0
  116. package/dist/paths.js +5 -0
  117. package/dist/preset-migration.js +55 -0
  118. package/dist/process-incarnation.d.ts +23 -0
  119. package/dist/process-incarnation.js +42 -0
  120. package/dist/reminders/manager.d.ts +75 -0
  121. package/dist/reminders/manager.js +278 -0
  122. package/dist/reminders/runtime.d.ts +27 -0
  123. package/dist/reminders/runtime.js +32 -0
  124. package/dist/reminders/schedule.d.ts +37 -0
  125. package/dist/reminders/schedule.js +104 -0
  126. package/dist/reminders/store.d.ts +341 -0
  127. package/dist/reminders/store.js +762 -0
  128. package/dist/reminders/tools.d.ts +20 -0
  129. package/dist/reminders/tools.js +114 -0
  130. package/dist/resolved-plan.js +1 -0
  131. package/dist/rooms-tasks/add-member.d.ts +48 -0
  132. package/dist/rooms-tasks/add-member.js +84 -0
  133. package/dist/rooms-tasks/archived-absence.d.ts +4 -0
  134. package/dist/rooms-tasks/archived-absence.js +11 -0
  135. package/dist/rooms-tasks/cli.js +182 -5
  136. package/dist/rooms-tasks/close.d.ts +5 -2
  137. package/dist/rooms-tasks/close.js +224 -36
  138. package/dist/rooms-tasks/config.d.ts +1 -1
  139. package/dist/rooms-tasks/config.js +11 -4
  140. package/dist/rooms-tasks/cowork-adapter.d.ts +12 -5
  141. package/dist/rooms-tasks/cowork-adapter.js +15 -121
  142. package/dist/rooms-tasks/cowork-http.js +56 -6
  143. package/dist/rooms-tasks/deletion.d.ts +1 -1
  144. package/dist/rooms-tasks/deletion.js +66 -10
  145. package/dist/rooms-tasks/erasure.d.ts +6 -0
  146. package/dist/rooms-tasks/erasure.js +173 -0
  147. package/dist/rooms-tasks/external-worker.d.ts +1 -1
  148. package/dist/rooms-tasks/external-worker.js +3 -4
  149. package/dist/rooms-tasks/layout-binding-client.d.ts +11 -0
  150. package/dist/rooms-tasks/layout-binding-client.js +56 -0
  151. package/dist/rooms-tasks/layout-binding-grants.d.ts +21 -0
  152. package/dist/rooms-tasks/layout-binding-grants.js +128 -0
  153. package/dist/rooms-tasks/layout-cli.d.ts +4 -0
  154. package/dist/rooms-tasks/layout-cli.js +107 -0
  155. package/dist/rooms-tasks/layout-config.d.ts +11 -0
  156. package/dist/rooms-tasks/layout-config.js +89 -0
  157. package/dist/rooms-tasks/layout-control.d.ts +21 -0
  158. package/dist/rooms-tasks/layout-control.js +52 -0
  159. package/dist/rooms-tasks/layout-member-retirement.d.ts +10 -0
  160. package/dist/rooms-tasks/layout-member-retirement.js +90 -0
  161. package/dist/rooms-tasks/layout-service.d.ts +43 -0
  162. package/dist/rooms-tasks/layout-service.js +203 -0
  163. package/dist/rooms-tasks/layout.d.ts +121 -0
  164. package/dist/rooms-tasks/layout.js +286 -0
  165. package/dist/rooms-tasks/legacy-task-member.d.ts +19 -0
  166. package/dist/rooms-tasks/legacy-task-member.js +116 -0
  167. package/dist/rooms-tasks/live-readiness.d.ts +18 -0
  168. package/dist/rooms-tasks/live-readiness.js +85 -0
  169. package/dist/rooms-tasks/member-ownership.d.ts +5 -0
  170. package/dist/rooms-tasks/member-ownership.js +40 -0
  171. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  172. package/dist/rooms-tasks/member-startup.js +2 -0
  173. package/dist/rooms-tasks/provision.d.ts +3 -0
  174. package/dist/rooms-tasks/provision.js +101 -49
  175. package/dist/rooms-tasks/recovery.d.ts +19 -0
  176. package/dist/rooms-tasks/recovery.js +110 -0
  177. package/dist/rooms-tasks/room-state.d.ts +4 -2
  178. package/dist/rooms-tasks/room-state.js +47 -8
  179. package/dist/rooms-tasks/task-review.d.ts +113 -0
  180. package/dist/rooms-tasks/task-review.js +561 -0
  181. package/dist/rooms-tasks/task-service-retirement.d.ts +11 -0
  182. package/dist/rooms-tasks/task-service-retirement.js +21 -0
  183. package/dist/rooms-tasks/task-state.d.ts +51 -5
  184. package/dist/rooms-tasks/task-state.js +358 -50
  185. package/dist/rooms-tasks/task-tools.d.ts +35 -0
  186. package/dist/rooms-tasks/task-tools.js +134 -0
  187. package/dist/rooms-tasks/types.d.ts +78 -0
  188. package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
  189. package/dist/rooms-tasks/workspace-artifacts.js +93 -0
  190. package/dist/rooms-tasks/workspace.d.ts +35 -0
  191. package/dist/rooms-tasks/workspace.js +325 -0
  192. package/dist/runner.d.ts +23 -4
  193. package/dist/runner.js +409 -61
  194. package/dist/session/acp.d.ts +75 -2
  195. package/dist/session/acp.js +345 -82
  196. package/dist/session/arbiter.d.ts +98 -0
  197. package/dist/session/arbiter.js +272 -3
  198. package/dist/session/codex-app-server.d.ts +45 -1
  199. package/dist/session/codex-app-server.js +225 -32
  200. package/dist/session/control.d.ts +17 -3
  201. package/dist/session/control.js +219 -23
  202. package/dist/session/conversation-normalizer.d.ts +2 -1
  203. package/dist/session/conversation-normalizer.js +1 -1
  204. package/dist/session/conversation-observation.d.ts +25 -0
  205. package/dist/session/conversation-observation.js +100 -0
  206. package/dist/session/conversation-replay.d.ts +50 -0
  207. package/dist/session/conversation-replay.js +203 -0
  208. package/dist/session/conversation-store.d.ts +15 -1
  209. package/dist/session/conversation-store.js +213 -26
  210. package/dist/session/conversation-types.d.ts +57 -4
  211. package/dist/session/prompt-batch.d.ts +79 -0
  212. package/dist/session/prompt-batch.js +120 -0
  213. package/dist/session/task-notice.d.ts +14 -0
  214. package/dist/session/task-notice.js +13 -0
  215. package/dist/session/types.d.ts +42 -1
  216. package/dist/setup-tunnel-preflight.d.ts +34 -0
  217. package/dist/setup-tunnel-preflight.js +62 -0
  218. package/dist/socket-path.d.ts +2 -0
  219. package/dist/socket-path.js +22 -0
  220. package/dist/spawn.d.ts +5 -1
  221. package/dist/spawn.js +40 -4
  222. package/dist/startup-prompt.d.ts +15 -0
  223. package/dist/startup-prompt.js +71 -0
  224. package/dist/startup-readiness.d.ts +20 -1
  225. package/dist/startup-readiness.js +66 -3
  226. package/dist/startup-telemetry.d.ts +16 -0
  227. package/dist/startup-telemetry.js +46 -0
  228. package/dist/subscriptions/cli.d.ts +91 -0
  229. package/dist/subscriptions/cli.js +223 -0
  230. package/dist/subscriptions/launch.d.ts +37 -0
  231. package/dist/subscriptions/launch.js +93 -0
  232. package/dist/subscriptions/login.d.ts +77 -0
  233. package/dist/subscriptions/login.js +215 -0
  234. package/dist/subscriptions/observed.d.ts +7 -0
  235. package/dist/subscriptions/observed.js +44 -0
  236. package/dist/subscriptions/probe.d.ts +10 -0
  237. package/dist/subscriptions/probe.js +73 -0
  238. package/dist/subscriptions/service.d.ts +102 -0
  239. package/dist/subscriptions/service.js +364 -0
  240. package/dist/subscriptions/store.d.ts +71 -0
  241. package/dist/subscriptions/store.js +143 -0
  242. package/dist/supervisor/adoption.d.ts +8 -0
  243. package/dist/supervisor/adoption.js +78 -0
  244. package/dist/supervisor/catalog.d.ts +62 -0
  245. package/dist/supervisor/catalog.js +254 -0
  246. package/dist/supervisor/fleet.d.ts +8 -0
  247. package/dist/supervisor/fleet.js +89 -0
  248. package/dist/supervisor/index.js +3 -6
  249. package/dist/supervisor/launchd.d.ts +1 -1
  250. package/dist/supervisor/launchd.js +26 -13
  251. package/dist/supervisor/legacy.d.ts +35 -0
  252. package/dist/supervisor/legacy.js +524 -0
  253. package/dist/supervisor/manager.d.ts +15 -0
  254. package/dist/supervisor/manager.js +270 -0
  255. package/dist/supervisor/registration-refresh.d.ts +45 -0
  256. package/dist/supervisor/registration-refresh.js +266 -0
  257. package/dist/supervisor/scope.d.ts +4 -0
  258. package/dist/supervisor/scope.js +10 -0
  259. package/dist/supervisor/systemd.d.ts +2 -2
  260. package/dist/supervisor/systemd.js +18 -37
  261. package/dist/task-supervision.d.ts +9 -0
  262. package/dist/task-supervision.js +145 -0
  263. package/dist/temp-idle.d.ts +17 -0
  264. package/dist/temp-idle.js +42 -0
  265. package/dist/temp-lifecycle.d.ts +23 -8
  266. package/dist/temp-lifecycle.js +123 -66
  267. package/dist/temp-supervisor-recovery.d.ts +1 -1
  268. package/dist/temp-supervisor-recovery.js +2 -2
  269. package/dist/tunnel-setup-completion.d.ts +7 -0
  270. package/dist/tunnel-setup-completion.js +13 -0
  271. package/dist/tunnel-setup-input.d.ts +19 -0
  272. package/dist/tunnel-setup-input.js +130 -0
  273. package/dist/watchdog/query.d.ts +3 -1
  274. package/dist/watchdog/query.js +2 -1
  275. package/dist/web/agent-attachments.d.ts +29 -0
  276. package/dist/web/agent-attachments.js +162 -0
  277. package/dist/web/auth.d.ts +65 -2
  278. package/dist/web/auth.js +201 -11
  279. package/dist/web/available-port.d.ts +2 -0
  280. package/dist/web/available-port.js +23 -0
  281. package/dist/web/control.d.ts +3 -3
  282. package/dist/web/control.js +10 -8
  283. package/dist/web/conversation-stream-codec.d.ts +23 -0
  284. package/dist/web/conversation-stream-codec.js +110 -0
  285. package/dist/web/conversation-stream.d.ts +26 -0
  286. package/dist/web/conversation-stream.js +239 -0
  287. package/dist/web/events.d.ts +2 -0
  288. package/dist/web/events.js +9 -0
  289. package/dist/web/fleet-config-service.d.ts +7 -1
  290. package/dist/web/fleet-config-service.js +61 -16
  291. package/dist/web/prefix-gateway.d.ts +32 -0
  292. package/dist/web/prefix-gateway.js +248 -0
  293. package/dist/web/resource-views.d.ts +138 -0
  294. package/dist/web/resource-views.js +519 -0
  295. package/dist/web/runtime.d.ts +3 -1
  296. package/dist/web/runtime.js +132 -34
  297. package/dist/web/server.d.ts +19 -0
  298. package/dist/web/server.js +893 -71
  299. package/dist/web/service.d.ts +11 -2
  300. package/dist/web/service.js +30 -4
  301. package/dist/web/workspace-devices.d.ts +107 -0
  302. package/dist/web/workspace-devices.js +277 -0
  303. package/dist/web/workspace-tunnel.d.ts +1 -0
  304. package/dist/web/workspace-tunnel.js +34 -0
  305. package/dist/workspace-enrollment.d.ts +83 -0
  306. package/dist/workspace-enrollment.js +605 -0
  307. package/dist/workspace-replacement.d.ts +66 -0
  308. package/dist/workspace-replacement.js +238 -0
  309. package/docs/acp-files.md +146 -0
  310. package/docs/conversation-stream.md +216 -0
  311. package/docs/fleet-migration.md +54 -0
  312. package/docs/live-voice.md +13 -0
  313. package/docs/room-layouts.md +227 -0
  314. package/docs/self-reminders.md +374 -0
  315. package/docs/task-progress-review.md +143 -0
  316. package/docs/task-workspaces.md +225 -0
  317. package/docs/validation/managed-cli-permissions.md +421 -0
  318. package/examples/room-layouts/fresh.yaml +27 -0
  319. package/examples/room-layouts/scoped.yaml +21 -0
  320. package/examples/room-layouts/shared.yaml +19 -0
  321. package/package.json +25 -20
  322. package/presets/README.md +78 -0
  323. package/presets/brain-catalog.json +8 -2
  324. package/presets/fleet/agent_templates/Critic.yaml +1 -1
  325. package/presets/fleet/agent_templates/Developer.yaml +1 -1
  326. package/presets/fleet/agent_templates/Engineer.yaml +5 -0
  327. package/presets/fleet/agent_templates/LocalCoordinator.yaml +6 -3
  328. package/presets/fleet/agents/FleetCoordinator.yaml +1 -1
  329. package/presets/fleet/brains/claude-fable-5-1-high.yaml +5 -0
  330. package/presets/fleet/brains/claude-fable-5-1-low.yaml +5 -0
  331. package/presets/fleet/brains/claude-fable-5-1-max.yaml +5 -0
  332. package/presets/fleet/brains/claude-fable-5-1-medium.yaml +5 -0
  333. package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +5 -0
  334. package/presets/fleet/brains/claude-fable-5-high.yaml +1 -1
  335. package/presets/fleet/brains/claude-fable-5-low.yaml +1 -1
  336. package/presets/fleet/brains/claude-fable-5-max.yaml +1 -1
  337. package/presets/fleet/brains/claude-fable-5-medium.yaml +1 -1
  338. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +1 -1
  339. package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +4 -0
  340. package/presets/fleet/brains/claude-opus-5-5-high.yaml +5 -0
  341. package/presets/fleet/brains/claude-opus-5-5-low.yaml +5 -0
  342. package/presets/fleet/brains/claude-opus-5-5-max.yaml +5 -0
  343. package/presets/fleet/brains/claude-opus-5-5-medium.yaml +5 -0
  344. package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +5 -0
  345. package/presets/fleet/brains/claude-opus-5-high.yaml +1 -1
  346. package/presets/fleet/brains/claude-opus-5-low.yaml +1 -1
  347. package/presets/fleet/brains/claude-opus-5-max.yaml +1 -1
  348. package/presets/fleet/brains/claude-opus-5-medium.yaml +1 -1
  349. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +1 -1
  350. package/presets/fleet/brains/claude-sonnet-5-high.yaml +1 -1
  351. package/presets/fleet/brains/claude-sonnet-5-low.yaml +1 -1
  352. package/presets/fleet/brains/claude-sonnet-5-max.yaml +1 -1
  353. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +1 -1
  354. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +1 -1
  355. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +1 -1
  356. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +1 -1
  357. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +1 -1
  358. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +1 -1
  359. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +1 -1
  360. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +1 -1
  361. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +1 -1
  362. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +1 -1
  363. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +1 -1
  364. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +1 -1
  365. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +1 -1
  366. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +1 -1
  367. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +1 -1
  368. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +1 -1
  369. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +1 -1
  370. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +1 -1
  371. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +1 -1
  372. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +1 -1
  373. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +1 -1
  374. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +1 -1
  375. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +1 -1
  376. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +1 -1
  377. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +1 -1
  378. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +1 -1
  379. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +1 -1
  380. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +1 -1
  381. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +1 -1
  382. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +1 -1
  383. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +1 -1
  384. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +1 -1
  385. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +1 -1
  386. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +1 -1
  387. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +1 -1
  388. package/presets/fleet/brains/codex-gpt-6-1-sol-high.yaml +5 -0
  389. package/presets/fleet/brains/codex-gpt-6-1-sol-low.yaml +5 -0
  390. package/presets/fleet/brains/codex-gpt-6-1-sol-max.yaml +5 -0
  391. package/presets/fleet/brains/codex-gpt-6-1-sol-medium.yaml +5 -0
  392. package/presets/fleet/brains/codex-gpt-6-1-sol-ultra.yaml +5 -0
  393. package/presets/fleet/brains/codex-gpt-6-1-sol-xhigh.yaml +5 -0
  394. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +1 -1
  395. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +1 -1
  396. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +1 -1
  397. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +1 -1
  398. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +1 -1
  399. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +1 -1
  400. package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +5 -0
  401. package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +5 -0
  402. package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +5 -0
  403. package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +5 -0
  404. package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +5 -0
  405. package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +5 -0
  406. package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +5 -0
  407. package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +5 -0
  408. package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +5 -0
  409. package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +5 -0
  410. package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +5 -0
  411. package/presets/fleet/roles/Coordinator.yaml +21 -13
  412. package/presets/fleet/roles/Critic.yaml +24 -0
  413. package/presets/fleet/roles/Developer.yaml +32 -0
  414. package/presets/fleet/roles/Engineer.yaml +72 -0
  415. package/presets/fleet/roles/LocalCoordinator.yaml +17 -0
  416. package/presets/fleet/room_layouts/engineering.yaml +25 -0
  417. package/presets/fleet/room_layouts/pair.yaml +24 -0
  418. package/presets/fleet/room_layouts/single.yaml +17 -0
  419. package/presets/fleet/room_layouts/team.yaml +30 -0
  420. package/presets/fleet/room_templates/engineering.yaml +19 -0
  421. package/presets/manifest.json +2 -2
  422. package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
  423. package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
  424. package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
  425. package/dist/web-app/icons/ours-fleet.svg +0 -4
  426. package/dist/web-app/index.html +0 -17
  427. package/dist/web-app/manifest.webmanifest +0 -15
  428. package/dist/web-app/offline.html +0 -18
  429. package/dist/web-app/sw.js +0 -51
@@ -1,9 +1,21 @@
1
+ import { FleetResourceViews, ResourceBindingError, canonicalResourceDescriptor } from './resource-views.js';
2
+ import { publishFleetResourceCommit } from '../application/resource-observer.js';
3
+ import { stateRoot, defaultConfigPath } from '../paths.js';
4
+ import { conversationPreview } from '../application/conversation-history.js';
5
+ import { registerConversationStream } from './conversation-stream.js';
6
+ import { conversationHasAttachment } from '../application/conversation-history.js';
7
+ import { readDeliveredFile } from '../file-delivery/store.js';
8
+ import { WorkspaceDeviceAuthError } from './workspace-devices.js';
9
+ import { readFileSync } from 'node:fs';
10
+ import { packagedPresetRoot } from '../preset-bootstrap.js';
11
+ import { validateCatalog } from '../init-wizard.js';
12
+ import { getAdapter } from '../harness/registry.js';
13
+ import { storeAgentAttachment, attachmentPrompt, MAX_ATTACHMENT_BODY, readAgentAttachment, prepareAttachmentPresentation, presentAttachmentEvent } from './agent-attachments.js';
14
+ import { readChatIdle } from '../temp-idle.js';
15
+ import { agentDir } from '../paths.js';
1
16
  import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
2
- import { existsSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
17
+ import { join } from 'node:path';
5
18
  import Fastify from 'fastify';
6
- import fastifyStatic from '@fastify/static';
7
19
  import fastifyWebsocket from '@fastify/websocket';
8
20
  import { createHmac, randomBytes } from 'node:crypto';
9
21
  import { FleetError, normalizeError } from '../application/errors.js';
@@ -14,6 +26,9 @@ import { FleetEventBus } from './events.js';
14
26
  import { ROLE_NAME_RE } from '../config.js';
15
27
  import { TaskListError } from '../rooms-tasks/task-lists.js';
16
28
  import { TaskStateError } from '../rooms-tasks/task-state.js';
29
+ import { appendTaskProgress, getTaskReview, listTaskProgress, publicProgressEntry, publicReview, readTaskReviewArtifact, setTaskReviewMarkdown, taskContributionSummary, TaskReviewError, } from '../rooms-tasks/task-review.js';
30
+ import { isProvider } from '../subscriptions/store.js';
31
+ const ONBOARDING_WORK = ['coordination', 'development', 'review'];
17
32
  const statusFor = (code) => ({
18
33
  role_not_found: 404, resource_not_found: 404, unauthorized: 401, forbidden: 403, conflict: 409,
19
34
  idempotency_conflict: 409, stale_state: 409, rate_limited: 429,
@@ -34,25 +49,46 @@ export async function buildWebServer(services, boundary, options = {}) {
34
49
  app.addHook('onRequest', async (request, reply) => {
35
50
  reply.header('X-Content-Type-Options', 'nosniff');
36
51
  reply.header('Referrer-Policy', 'no-referrer');
37
- reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
38
- reply.header('X-Frame-Options', 'DENY');
39
- reply.header('Content-Security-Policy', `default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; ` +
40
- `connect-src 'self' ${auth.secureCookies ? 'wss' : 'ws'}://${auth.host}; object-src 'none'; base-uri 'none'; ` +
41
- `frame-ancestors 'none'; form-action 'self'; manifest-src 'self'; worker-src 'self'`);
52
+ reply.header('Permissions-Policy', 'camera=(), microphone=(self), geolocation=(), payment=()');
53
+ // Fleet serves an API only: nothing it returns may run, load or be framed as a page.
54
+ reply.header('Content-Security-Policy', "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
42
55
  if (request.url.startsWith('/api/'))
43
56
  reply.header('Cache-Control', 'no-store');
44
- try {
45
- auth.validateBoundary(request, false);
57
+ if (auth.appOrigin && request.headers.origin === auth.appOrigin && request.url.startsWith('/api/')) {
58
+ reply.header('Access-Control-Allow-Origin', auth.appOrigin).header('Vary', 'Origin');
59
+ reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
60
+ // The App binds resource reads by these headers; it must be able to send and read them cross-origin.
61
+ reply.header('Access-Control-Expose-Headers', 'X-Ours-Workspace-Auth, X-Ours-Request-Id, X-Resource-Producer, X-Resource-Revision, X-Resource-Source-Generation, X-Resource-Source-Position, X-Resource-Connection, X-Resource-View, X-Resource-Epoch, X-Resource-Baseline');
62
+ reply.header('Access-Control-Allow-Headers', 'Authorization,Content-Type,X-CSRF-Token,Idempotency-Key,X-Ours-Messenger-CSRF,X-Resource-Connection,X-Resource-View,X-Resource-Epoch,X-Resource-Baseline');
63
+ if (request.method === 'OPTIONS') {
64
+ auth.validateBoundary(request, false);
65
+ return reply.code(204).send();
66
+ }
46
67
  }
47
- catch (error) {
48
- if (request.url.startsWith('/api/'))
49
- throw error;
50
- const message = normalizeError(error).message;
51
- return reply.code(421).type('text/html').send(`<!doctype html><html><head><title>Fleet console address</title></head><body><main><h1>This fleet-console address is not configured</h1><p>${escapeHtml(message)}</p><p>For nginx or VPS access, configure an explicit public origin. The console does not guess proxy hosts.</p></main></body></html>`);
68
+ auth.validateBoundary(request, false);
69
+ });
70
+ app.addHook('onResponse', async (request, reply) => {
71
+ if (reply.statusCode < 400 && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method) && !request.url.includes('/resource-subscriptions')) {
72
+ publishFleetResourceCommit(services.configuration?.path ?? defaultConfigPath());
73
+ publishFleetResourceCommit(stateRoot());
52
74
  }
53
75
  });
76
+ app.post('/api/v1/layout-bindings/:id/control', async (request) => {
77
+ // Initial support is direct same-host Fleet-to-Fleet transport.
78
+ const forwarded = Object.keys(request.headers).some(name => name === 'forwarded' || name.startsWith('x-forwarded-') || name === 'x-real-ip' || name === 'via');
79
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.ip) || forwarded)
80
+ throw new FleetError('forbidden', 'layout bindings require direct loopback access');
81
+ // Machine credentials cannot authenticate browser or lifecycle routes.
82
+ if (request.headers.origin)
83
+ auth.validateBoundary(request, true);
84
+ if (!services.layoutBindings)
85
+ throw new FleetError('capability_unavailable', 'layout bindings unavailable');
86
+ return services.layoutBindings.control(request.params.id, request.headers.authorization, request.body);
87
+ });
54
88
  app.setErrorHandler(async (error, request, reply) => {
55
- const privateToolParseError = request.routeOptions.url === '/api/v1/roles/:id/ours/call'
89
+ if (error instanceof ResourceBindingError)
90
+ return reply.code(error.status).send({ error: { code: 'resource_binding', message: error.message } });
91
+ const privateToolParseError = ['/api/v1/roles/:id/ours/call', '/api/v1/layout-bindings/:id/control'].includes(request.routeOptions.url ?? '')
56
92
  && error instanceof Error && 'code' in error
57
93
  && typeof error.code === 'string' && error.code.startsWith('FST_ERR_CTP_');
58
94
  const fleetError = normalizeError(privateToolParseError
@@ -61,8 +97,13 @@ export async function buildWebServer(services, boundary, options = {}) {
61
97
  requestId: request.id, action: `${request.method} ${request.routeOptions.url ?? request.url}`,
62
98
  result: 'rejected', errorCode: fleetError.code,
63
99
  });
100
+ if (error instanceof WorkspaceDeviceAuthError)
101
+ reply.header('X-Ours-Workspace-Auth', 'rejected');
102
+ if (fleetError.code === 'unauthorized')
103
+ reply.header('X-Ours-Request-Id', request.id);
64
104
  reply.code(statusFor(fleetError.code)).send({ error: fleetError.toJSON() });
65
105
  });
106
+ registerConversationStream(app, services, auth);
66
107
  app.post('/api/v1/auth/exchange', async (request, reply) => {
67
108
  const { session, device } = auth.exchange(request);
68
109
  setAuthCookies(reply, session.id, device.token, auth.secureCookies);
@@ -101,16 +142,145 @@ export async function buildWebServer(services, boundary, options = {}) {
101
142
  const session = auth.authenticate(request);
102
143
  return { csrfToken: session.csrf, expiresAt: new Date(session.absoluteExpiresAt).toISOString() };
103
144
  });
145
+ app.get('/api/v1/workspace/identity', async () => auth.workspaceIdentity());
146
+ app.post('/api/v1/devices/enroll', async (request, reply) => {
147
+ reply.header('Cache-Control', 'no-store');
148
+ const input = request.body;
149
+ if (!input)
150
+ throw new FleetError('invalid_request', 'device enrollment body required');
151
+ return auth.enrollWorkspace(request, input);
152
+ });
153
+ // Refresh credentials travel between the account server and this host; these routes refuse browsers.
154
+ const refreshInput = (request, name) => {
155
+ const value = request.body?.[name];
156
+ if (typeof value !== 'string')
157
+ throw new FleetError('invalid_request', `${name} is required`);
158
+ return value;
159
+ };
160
+ app.post('/api/v1/devices/refresh', async (request, reply) => {
161
+ reply.header('Cache-Control', 'no-store');
162
+ return auth.refreshWorkspace(request, refreshInput(request, 'refreshToken'));
163
+ });
164
+ app.post('/api/v1/devices/exchange', async (request, reply) => {
165
+ reply.header('Cache-Control', 'no-store');
166
+ return auth.exchangeWorkspace(request);
167
+ });
168
+ app.post('/api/v1/devices/revoke', async (request) => ({ revoked: auth.revokeWorkspaceRefresh(request, refreshInput(request, 'refreshHash')) }));
169
+ app.post('/api/v1/devices/link', async (request, reply) => {
170
+ const proof = request.body?.refreshToken;
171
+ if (proof !== undefined) {
172
+ reply.header('Cache-Control', 'no-store');
173
+ const { enrollment, enrollmentExpiresAt, ...credentials } = auth.linkWorkspaceDevice(request, refreshInput(request, 'refreshToken'));
174
+ return { ...credentials, code: { version: 1, origin: auth.origin, enrollment, workspaceId: credentials.workspaceId, expiresAt: enrollmentExpiresAt } };
175
+ }
176
+ const session = auth.authenticate(request, true);
177
+ // A short-lived access credential must not be able to create a lasting one.
178
+ if (session.rotating)
179
+ throw new FleetError('forbidden', 'linking another device requires this link\'s refresh credential');
180
+ return { version: 1, origin: auth.origin, ...auth.workspaceEnrollment(session.id) };
181
+ });
182
+ app.get('/api/v1/devices/current', async (request) => {
183
+ const session = auth.authenticate(request);
184
+ if (!session.id.startsWith('workspace:'))
185
+ throw new FleetError('resource_not_found', 'no workspace device');
186
+ return { deviceId: session.id.slice(10), authorizedUntil: session.absoluteExpiresAt };
187
+ });
188
+ app.get('/api/v1/devices', async (request) => { auth.authenticate(request); return { devices: auth.listWorkspaceDevices() }; });
189
+ app.delete('/api/v1/devices/:id', async (request) => { auth.authenticate(request, true); auth.revokeWorkspaceDevice(request.params.id); return { revoked: true }; });
104
190
  app.get('/api/v1/meta', async (request) => {
105
191
  auth.authenticate(request);
106
192
  return {
107
- version: VERSION, api: { major: 1, minor: 0 },
193
+ version: VERSION, api: { major: 1, minor: 1 },
108
194
  websocketProtocols: [
109
195
  'ours-fleet-events.v1', 'ours-fleet-conversation.v1',
110
196
  ],
111
197
  auditDegraded: audit.degraded,
112
198
  };
113
199
  });
200
+ let onboardingObserved;
201
+ app.get('/api/v1/onboarding', async (request) => {
202
+ const browser = auth.authenticate(request);
203
+ const revision = services.configuration?.read().revision;
204
+ // Opening an existing workspace needs configuration state, not CLI/account discovery.
205
+ // Keep the full report for the setup screens and older App clients.
206
+ if (request.query.view === 'state')
207
+ return { configured: onboardingConfigured() };
208
+ const harnesses = await Promise.all(['codex', 'claude-code'].map(async (harness) => {
209
+ try {
210
+ return { harness, ...await getAdapter(harness).checkPrereqs() };
211
+ }
212
+ catch {
213
+ return { harness, ok: false, checks: [{ name: 'availability', ok: false, detail: 'Harness checks unavailable' }] };
214
+ }
215
+ }));
216
+ const catalog = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8')));
217
+ // Codex models are offered only when the runtime itself listed them for the active account.
218
+ // When it could not be asked, none is offered and the reason is reported.
219
+ const codexModels = services.codexModels ? await services.codexModels() : undefined;
220
+ const offered = (model) => model.harness !== 'codex' || !codexModels || (codexModels.state === 'known' && codexModels.models.includes(model.model));
221
+ const payload = { harnesses, catalog: catalog.models.filter(offered), ...(codexModels ? { codexModels: codexModels.state === 'known' ? { state: 'known' } : codexModels } : {}), providers: services.subscriptions ? await services.subscriptions.list() : [], coordinator: 'FleetCoordinator', configured: onboardingConfigured() };
222
+ if (revision === services.configuration?.read().revision) {
223
+ onboardingObserved = { owner: browser.id, revision, payload };
224
+ events.publish('onboarding.changed', {});
225
+ }
226
+ return payload;
227
+ });
228
+ // First-time setup is what creates the persistent coordinator. A configuration that loads and
229
+ // has it is set up, whatever its brains are called; its models are then changed by editing it.
230
+ const onboardingConfigured = () => {
231
+ if (!services.configuration)
232
+ return false;
233
+ try {
234
+ return Boolean(services.configuration.read(false).model.agents.FleetCoordinator);
235
+ }
236
+ catch {
237
+ return false;
238
+ }
239
+ };
240
+ let onboardingSetupQueue = Promise.resolve();
241
+ app.post('/api/v1/onboarding/setup', async (request) => {
242
+ auth.authenticate(request, true);
243
+ if (!services.configuration || !services.onboardingSetup)
244
+ throw new FleetError('capability_unavailable', 'first-time Fleet setup is unavailable');
245
+ const requested = request.body?.models;
246
+ const codexModels = services.codexModels ? await services.codexModels() : undefined;
247
+ const supported = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8'))).models
248
+ .filter(model => model.harness !== 'codex' || !codexModels || (codexModels.state === 'known' && codexModels.models.includes(model.model)));
249
+ const models = {};
250
+ for (const work of ONBOARDING_WORK) {
251
+ const matches = supported.filter(model => model.harness === requested?.[work]?.harness && model.model === requested?.[work]?.model);
252
+ if (matches.length !== 1)
253
+ throw new FleetError('invalid_request', `choose a supported model for ${work}`);
254
+ models[work] = matches[0];
255
+ }
256
+ const chosen = Object.values(models);
257
+ const subscriptions = [...new Set(chosen.map(model => model.harness === 'codex' ? 'codex' : 'claude'))];
258
+ const oneModel = new Set(chosen.map(model => `${model.harness}\0${model.model}`)).size === 1;
259
+ const configuration = services.configuration, setup = services.onboardingSetup;
260
+ // One setup at a time: the first-time check and the publication it guards must not interleave.
261
+ const run = onboardingSetupQueue.then(async () => {
262
+ if (onboardingConfigured())
263
+ throw new FleetError('conflict', 'Fleet is already set up; change its models in the configuration');
264
+ try {
265
+ await setup({ subscriptions, assignmentStrategy: oneModel ? 'one-model' : 'per-job', models, reasoning: 'balanced' });
266
+ }
267
+ catch (error) {
268
+ throw new FleetError('backend_failure', `Fleet setup did not complete: ${error.message}`);
269
+ }
270
+ // Setup preserves files it finds, so confirm the published models are the ones this request chose.
271
+ // The prepared agents reference packaged Brain presets, so resolve what each of them now points at:
272
+ // a preserved file of a packaged name may hold another model, session or reasoning than its name says.
273
+ const published = configuration.read(true).model, brains = published.brains ?? {};
274
+ const brainOf = (holder) => brains[String(holder?.brain?.ref ?? '')];
275
+ const assigned = { coordination: brainOf(published.agents.FleetCoordinator), development: brainOf(published.agent_templates?.Developer), review: brainOf(published.agent_templates?.Critic) };
276
+ for (const work of ONBOARDING_WORK)
277
+ if (assigned[work]?.harness !== models[work].harness || assigned[work]?.model !== models[work].model || assigned[work]?.session !== models[work].session || assigned[work]?.effort !== 'medium')
278
+ throw new FleetError('conflict', 'Fleet was set up with different models; review its configuration');
279
+ return { configured: true };
280
+ });
281
+ onboardingSetupQueue = run.catch(() => { });
282
+ return run;
283
+ });
114
284
  app.get('/api/v1/creation-capabilities', async (request) => {
115
285
  auth.authenticate(request);
116
286
  return services.creation.capabilities();
@@ -133,7 +303,11 @@ export async function buildWebServer(services, boundary, options = {}) {
133
303
  });
134
304
  app.get('/api/v1/roles', async (request) => {
135
305
  auth.authenticate(request);
136
- return { roles: await services.query.list() };
306
+ const query = request.query;
307
+ // Unbound REST retains the existing problem-bearing inventory. Bound reads
308
+ // are handled by the strict resource preHandler before this route.
309
+ services.invalidateResourceCaches?.();
310
+ return { roles: await services.query.list(query.includeTemporary === 'true') };
137
311
  });
138
312
  const taskApi = async (fn) => {
139
313
  try {
@@ -146,20 +320,194 @@ export async function buildWebServer(services, boundary, options = {}) {
146
320
  ? 'conflict' : 'invalid_request';
147
321
  throw new FleetError(code, error.message);
148
322
  }
323
+ if (error instanceof TaskStateError && 'accepted' in error && error.accepted === false)
324
+ return Promise.reject(Object.assign(new FleetError('invalid_request', error.message), { memberNotAccepted: true }));
149
325
  if (error instanceof TaskStateError) {
150
326
  throw new FleetError(error.message.startsWith('task not found:') ? 'resource_not_found' : 'conflict', error.message);
151
327
  }
152
328
  throw error;
153
329
  }
154
330
  };
331
+ const reviewApi = async (fn) => taskApi(async () => {
332
+ try {
333
+ return await fn();
334
+ }
335
+ catch (error) {
336
+ if (error instanceof TaskReviewError) {
337
+ const code = error.code === 'not_found' ? 'resource_not_found' : error.code === 'limit_exceeded' ? 'conflict' : error.code;
338
+ throw new FleetError(code, error.message);
339
+ }
340
+ if (error instanceof TaskStateError && error.message.startsWith('invalid task ID'))
341
+ throw new FleetError('resource_not_found', 'task not found');
342
+ throw error;
343
+ }
344
+ });
345
+ /** Browser task payloads carry Progress/To review counts; bodies have their own routes. */
346
+ const wireTask = (task) => {
347
+ const { progress: _progress, review: _review, ...rest } = task;
348
+ return { ...rest, ...taskContributionSummary(task) };
349
+ };
350
+ const wireTaskResult = (result) => {
351
+ const value = result;
352
+ if (value && typeof value === 'object' && value.task && typeof value.task === 'object')
353
+ return { ...value, task: wireTask(value.task) };
354
+ return result;
355
+ };
155
356
  const requireTaskRooms = () => {
156
357
  if (!services.taskRooms)
157
358
  throw new FleetError('capability_unavailable', 'task operations are unavailable');
158
359
  return services.taskRooms;
159
360
  };
361
+ const projectTasks = async (query, strict = true) => {
362
+ const states = ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'];
363
+ if (query.state && query.state !== 'all' && !states.includes(query.state))
364
+ throw new FleetError('invalid_request', 'invalid task state filter');
365
+ if (query.groupByList !== undefined && !['true', 'false'].includes(query.groupByList))
366
+ throw new FleetError('invalid_request', 'groupByList must be true or false');
367
+ if (query.includeDeleting !== undefined && !['true', 'false'].includes(query.includeDeleting))
368
+ throw new FleetError('invalid_request', 'includeDeleting must be true or false');
369
+ const state = query.state && query.state !== 'all' ? query.state : undefined;
370
+ const filter = { strict, ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
371
+ ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
372
+ const api = requireTaskRooms();
373
+ return taskApi(() => query.groupByList === 'true'
374
+ ? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => wireTask(api.withLayoutRooms(task))) })) }
375
+ : { tasks: api.listTasks(filter).map(task => wireTask(api.withLayoutRooms(task))) });
376
+ };
377
+ const project = async (descriptor, signal, owner = '') => {
378
+ signal.throwIfAborted();
379
+ services.invalidateResourceCaches?.();
380
+ switch (descriptor.resource) {
381
+ case 'task-layout':
382
+ try {
383
+ return { layout: await taskApi(() => requireTaskRooms().taskLayout(descriptor.entityId)) };
384
+ }
385
+ catch (error) {
386
+ if (error?.code === 'task_not_found' || error?.code === 'resource_not_found')
387
+ return { deleted: true, id: descriptor.entityId };
388
+ throw error;
389
+ }
390
+ case 'creation-action': return services.creation.get(descriptor.entityId) ?? { deleted: true, id: descriptor.entityId };
391
+ case 'action': return services.commands.get(descriptor.entityId) ?? { deleted: true, id: descriptor.entityId };
392
+ case 'member-addition':
393
+ try {
394
+ return await taskApi(() => requireTaskRooms().memberAddition(descriptor.entityId, descriptor.receiptId));
395
+ }
396
+ catch (error) {
397
+ if (['task_not_found', 'resource_not_found'].includes(error?.code) || error?.message === 'Member request not found')
398
+ return { deleted: true, id: descriptor.receiptId };
399
+ throw error;
400
+ }
401
+ case 'login':
402
+ // A foreign login must never be delivered, including its URL/device code.
403
+ try {
404
+ return subscriptions().login(descriptor.entityId, owner).view();
405
+ }
406
+ catch (error) {
407
+ if (error?.code === 'resource_not_found')
408
+ return { deleted: true, id: descriptor.entityId };
409
+ throw error;
410
+ }
411
+ case 'subscriptions': return { providers: await subscriptions().list(true) };
412
+ case 'onboarding-health': {
413
+ const revision = services.configuration?.read().revision;
414
+ if (onboardingObserved && onboardingObserved.revision !== revision)
415
+ onboardingObserved = undefined;
416
+ const observed = onboardingObserved?.owner === owner ? onboardingObserved : undefined;
417
+ return { ...observed?.payload, observationCurrent: !!observed, configured: onboardingConfigured(), providers: services.subscriptions ? await services.subscriptions.list(true) : [] };
418
+ }
419
+ case 'roles': return { roles: await services.query.list(descriptor.includeTemporary ?? false, true) };
420
+ case 'tasks': return projectTasks({ state: descriptor.state, list: descriptor.list, groupByList: String(descriptor.groupByList ?? false), includeDeleting: String(descriptor.includeDeleting ?? false) });
421
+ case 'task-lists': return { lists: requireTaskRooms().listTaskLists() };
422
+ case 'configuration':
423
+ if (!services.configuration)
424
+ throw new FleetError('capability_unavailable', 'fleet configuration editing is unavailable');
425
+ return { ...services.configuration.read(descriptor.includeDefinitions ?? false), provenance: services.presetProvenance?.read() ?? null };
426
+ case 'directory': {
427
+ const [roles, tasks] = await Promise.all([services.query.list(true, true), projectTasks({})]);
428
+ return { roles, tasks: tasks.tasks, lists: requireTaskRooms().listTaskLists() };
429
+ }
430
+ case 'previews': {
431
+ const roles = await services.repository.list(true), selected = new Map(roles.map(role => [role.id, role]));
432
+ const previews = {};
433
+ for (const id of descriptor.ids ?? []) {
434
+ signal.throwIfAborted();
435
+ const role = selected.get(id), dir = role && services.repository.stateDir(role);
436
+ if (!role)
437
+ continue;
438
+ try {
439
+ previews[id] = dir ? await conversationPreview(dir, signal) : { text: 'No recent agent output', at: '' };
440
+ }
441
+ catch {
442
+ signal.throwIfAborted();
443
+ // A damaged/unreadable ledger is unavailable, not empty or removed.
444
+ // Keep this selected row observable so a repair can refresh it,
445
+ // without retiring previews for the other selected roles.
446
+ previews[id] = { text: 'Preview unavailable', at: '', unavailable: true };
447
+ }
448
+ }
449
+ return { previews };
450
+ }
451
+ }
452
+ };
453
+ const resources = new FleetResourceViews(services.resourceScope ?? {}, project, () => services.configuration?.resourceValidationInputs?.() ?? []);
454
+ app.addHook('preClose', async () => resources.close());
455
+ app.get('/api/v1/resource-events', async (request, reply) => {
456
+ const browser = auth.authenticate(request);
457
+ reply.hijack();
458
+ // A hijacked reply bypasses Fastify headers: keep the exact-origin CORS and security headers.
459
+ for (const [name, value] of Object.entries(reply.getHeaders()))
460
+ if (value !== undefined)
461
+ reply.raw.setHeader(name, Array.isArray(value) ? value : String(value));
462
+ reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
463
+ reply.raw.flushHeaders();
464
+ const opened = resources.open(reply.raw, browser.id, () => auth.sessionValid(browser));
465
+ const binding = auth.bindTransport(browser, opened.close);
466
+ reply.raw.once('close', () => binding());
467
+ });
468
+ app.post('/api/v1/resource-subscriptions', async (request, reply) => {
469
+ const browser = auth.authenticate(request, true), body = request.body;
470
+ const binding = resources.register(browser.id, String(body?.connectionId ?? ''), String(body?.viewId ?? ''), body?.subscriptionEpoch, body?.descriptor);
471
+ const retire = () => {
472
+ if (reply.raw.writableFinished)
473
+ return;
474
+ try {
475
+ resources.remove(browser.id, String(body.connectionId), String(body.viewId), body.subscriptionEpoch);
476
+ }
477
+ catch { /* Connection may already be closed. */ }
478
+ };
479
+ reply.raw.once('close', retire);
480
+ reply.raw.once('finish', () => reply.raw.off('close', retire));
481
+ if (reply.raw.destroyed)
482
+ retire();
483
+ return binding;
484
+ });
485
+ app.delete('/api/v1/resource-subscriptions', async (request) => {
486
+ const browser = auth.authenticate(request, true), body = request.body;
487
+ resources.remove(browser.id, String(body?.connectionId ?? ''), String(body?.viewId ?? ''), body?.subscriptionEpoch);
488
+ return { removed: true };
489
+ });
490
+ // Bound reads return precisely the registered initial capture, never an unrelated GET.
491
+ app.addHook('preHandler', async (request, reply) => {
492
+ if (request.method !== 'GET' || !request.headers['x-resource-connection'])
493
+ return;
494
+ const browser = auth.authenticate(request), headers = request.headers;
495
+ const baseline = await resources.bound(browser.id, String(headers['x-resource-connection']), String(headers['x-resource-view']), Number(headers['x-resource-epoch']), String(headers['x-resource-baseline']));
496
+ const expected = '/api/v1/' + baseline.descriptor.resource;
497
+ if (request.url.split('?')[0] !== expected)
498
+ throw new ResourceBindingError(409, 'resource route mismatch');
499
+ for (const name of ['Connection', 'View', 'Epoch', 'Baseline'])
500
+ reply.header('X-Resource-' + name, String(headers['x-resource-' + name.toLowerCase()]));
501
+ reply.header('X-Resource-Producer', baseline.producerGeneration).header('X-Resource-Revision', baseline.revision).header('X-Resource-Source-Generation', baseline.source.producerGeneration).header('X-Resource-Source-Position', baseline.source.position);
502
+ return reply.send(baseline.payload);
503
+ });
504
+ for (const resource of ['task-layout', 'creation-action', 'action', 'member-addition', 'login', 'onboarding-health'])
505
+ app.get('/api/v1/' + resource, async () => { throw new ResourceBindingError(409, 'resource binding required'); });
506
+ app.get('/api/v1/directory', async (request) => { auth.authenticate(request); return project({ viewId: 'read', resource: 'directory' }, new AbortController().signal); });
507
+ app.get('/api/v1/previews', async (request) => { auth.authenticate(request); const ids = String(request.query.ids ?? '').split(',').filter(Boolean); return project(canonicalResourceDescriptor({ viewId: 'read', resource: 'previews', ids }), new AbortController().signal); });
160
508
  app.get('/api/v1/task-lists', async (request) => {
161
509
  auth.authenticate(request);
162
- return { lists: requireTaskRooms().listTaskLists() };
510
+ return project({ viewId: 'read', resource: 'task-lists' }, new AbortController().signal);
163
511
  });
164
512
  app.post('/api/v1/task-lists', async (request, reply) => {
165
513
  auth.authenticate(request, true);
@@ -182,20 +530,7 @@ export async function buildWebServer(services, boundary, options = {}) {
182
530
  });
183
531
  app.get('/api/v1/tasks', async (request) => {
184
532
  auth.authenticate(request);
185
- const query = request.query;
186
- const states = ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'];
187
- if (query.state && query.state !== 'all' && !states.includes(query.state))
188
- throw new FleetError('invalid_request', 'invalid task state filter');
189
- if (query.groupByList !== undefined && !['true', 'false'].includes(query.groupByList))
190
- throw new FleetError('invalid_request', 'groupByList must be true or false');
191
- if (query.includeDeleting !== undefined && !['true', 'false'].includes(query.includeDeleting))
192
- throw new FleetError('invalid_request', 'includeDeleting must be true or false');
193
- const state = query.state && query.state !== 'all' ? query.state : undefined;
194
- const filter = { ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
195
- ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
196
- return taskApi(() => query.groupByList === 'true'
197
- ? { groups: requireTaskRooms().groupedTasks(filter) }
198
- : { tasks: requireTaskRooms().listTasks(filter) });
533
+ return projectTasks(request.query, false);
199
534
  });
200
535
  app.delete('/api/v1/tasks/:id', async (request, reply) => {
201
536
  auth.authenticate(request, true);
@@ -226,30 +561,229 @@ export async function buildWebServer(services, boundary, options = {}) {
226
561
  const body = request.body;
227
562
  if (typeof body?.title !== 'string' || !body.title)
228
563
  throw new FleetError('invalid_request', 'title is required');
564
+ // Every task runs a saved Room Layout; rooms are never defined inline or from room templates here.
565
+ if (typeof body.layout !== 'string' || !body.layout)
566
+ throw new FleetError('invalid_request', 'layout is required: choose a room layout from Settings › Room layouts');
567
+ if (body.template !== undefined || body.noRoom !== undefined)
568
+ throw new FleetError('invalid_request', 'tasks use room layouts; template and noRoom are not accepted');
229
569
  const task = await taskApi(() => requireTaskRooms().createTask({
230
570
  actor: { kind: 'local_control', surface: 'web' }, title: body.title,
231
571
  brief: typeof body.brief === 'string' ? body.brief : undefined,
232
- template: typeof body.template === 'string' ? body.template : undefined,
233
- backlog: body.backlog === true, noRoom: body.noRoom === true,
572
+ backlog: body.backlog === true,
234
573
  list: typeof body.list === 'string' ? body.list : undefined,
574
+ layout: body.layout,
235
575
  idempotencyKey: typeof request.headers['idempotency-key'] === 'string'
236
576
  ? request.headers['idempotency-key'] : undefined,
237
577
  origin: { type: 'web' },
238
578
  }));
239
579
  reply.code(201);
240
- return { task };
580
+ return { task: wireTask(task) };
241
581
  });
242
582
  app.patch('/api/v1/tasks/:id/list', async (request) => {
243
583
  auth.authenticate(request, true);
244
584
  const taskId = request.params.id;
245
585
  const list = String(request.body?.list ?? '');
246
- return { task: await taskApi(() => requireTaskRooms().moveTask({ actor: { kind: 'local_control', surface: 'web' }, taskId, list })) };
586
+ return { task: wireTask(await taskApi(() => requireTaskRooms().moveTask({ actor: { kind: 'local_control', surface: 'web' }, taskId, list }))) };
587
+ });
588
+ app.patch('/api/v1/tasks/:id/description', async (request) => {
589
+ auth.authenticate(request, true);
590
+ const body = request.body;
591
+ if (typeof body?.brief !== 'string' || typeof body.expectedBrief !== 'string' || body.brief.length > 100_000)
592
+ throw new FleetError('invalid_request', 'brief and expectedBrief must be strings; brief maximum is 100000 characters');
593
+ return { task: wireTask(await taskApi(() => requireTaskRooms().editTaskDescription({
594
+ actor: { kind: 'local_control', surface: 'web' }, taskId: request.params.id,
595
+ brief: body.brief, expectedBrief: body.expectedBrief,
596
+ }))) };
597
+ });
598
+ app.get('/api/v1/tasks/:id', async (request) => {
599
+ auth.authenticate(request);
600
+ return wireTaskResult(await taskApi(() => requireTaskRooms().getTask(request.params.id)));
601
+ });
602
+ app.get('/api/v1/tasks/:id/progress', async (request) => {
603
+ auth.authenticate(request);
604
+ return { progress: (await reviewApi(() => listTaskProgress(request.params.id))).map(publicProgressEntry) };
605
+ });
606
+ app.post('/api/v1/tasks/:id/progress', async (request, reply) => {
607
+ auth.authenticate(request, true);
608
+ const body = request.body;
609
+ const header = request.headers['idempotency-key'];
610
+ const requestKey = typeof body?.requestKey === 'string' ? body.requestKey : typeof header === 'string' ? header : undefined;
611
+ if (typeof body?.text !== 'string' || (body.requestKey !== undefined && typeof body.requestKey !== 'string'))
612
+ throw new FleetError('invalid_request', 'text must be a string; requestKey, when given, a string');
613
+ const result = await reviewApi(() => appendTaskProgress(request.params.id, {
614
+ text: body.text, requestKey, author: { kind: 'local_control', surface: 'web' },
615
+ }));
616
+ if (!result.replayed)
617
+ reply.code(201);
618
+ return { entry: publicProgressEntry(result.entry), replayed: result.replayed };
619
+ });
620
+ app.get('/api/v1/tasks/:id/review', async (request) => {
621
+ auth.authenticate(request);
622
+ const review = await reviewApi(() => getTaskReview(request.params.id));
623
+ return { review: review ? publicReview(review) : null };
624
+ });
625
+ app.put('/api/v1/tasks/:id/review', async (request) => {
626
+ auth.authenticate(request, true);
627
+ const body = request.body;
628
+ if (typeof body?.markdown !== 'string' || typeof body.expectedMarkdown !== 'string')
629
+ throw new FleetError('invalid_request', 'markdown and expectedMarkdown must be strings');
630
+ const review = await reviewApi(() => setTaskReviewMarkdown(request.params.id, {
631
+ markdown: body.markdown, expectedMarkdown: body.expectedMarkdown,
632
+ author: { kind: 'local_control', surface: 'web' },
633
+ }));
634
+ return { review: publicReview(review) };
635
+ });
636
+ app.get('/api/v1/tasks/:id/review/artifacts/:artifactId', async (request, reply) => {
637
+ auth.authenticate(request);
638
+ const { artifact, bytes } = await reviewApi(() => readTaskReviewArtifact(request.params.id, request.params.artifactId));
639
+ reply.header('Cache-Control', 'private, no-store');
640
+ reply.header('X-Content-Type-Options', 'nosniff');
641
+ reply.header('Content-Security-Policy', "default-src 'none'; sandbox");
642
+ reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(artifact.name).replace(/'/g, '%27')}`);
643
+ return reply.type('application/octet-stream').send(bytes);
644
+ });
645
+ app.post('/api/v1/tasks/:id/members', async (request, reply) => {
646
+ auth.authenticate(request, true);
647
+ const body = request.body;
648
+ try {
649
+ const result = await taskApi(() => requireTaskRooms().addMember(request.params.id, body));
650
+ reply.code(result.state === 'running' ? 202 : 200);
651
+ return result;
652
+ }
653
+ catch (error) {
654
+ if (error instanceof Error && 'memberNotAccepted' in error)
655
+ return reply.code(409).send({ error: { code: 'member_not_accepted', message: error.message }, accepted: false });
656
+ throw error;
657
+ }
658
+ });
659
+ app.get('/api/v1/tasks/:id/member-additions/:requestId', async (request) => {
660
+ auth.authenticate(request);
661
+ const { id, requestId } = request.params;
662
+ return taskApi(() => requireTaskRooms().memberAddition(id, requestId));
663
+ });
664
+ app.patch('/api/v1/tasks/:id/layout', async (request) => {
665
+ const session = auth.authenticate(request, true);
666
+ const body = request.body;
667
+ const name = (v) => typeof v === 'string' && v.length > 0;
668
+ if (!name(body?.layout) || !(body.expectedLayout === null || name(body.expectedLayout)))
669
+ throw new FleetError('invalid_request', 'layout must be a layout name; expectedLayout a layout name or null');
670
+ const task = await taskApi(() => requireTaskRooms().setTaskLayout({ actor: { kind: 'local_control', surface: 'web' },
671
+ taskId: request.params.id, layout: body.layout, expectedLayout: body.expectedLayout }));
672
+ await audit.record({ requestId: request.id, browser: session.id, action: 'task.layout.set', result: 'succeeded' });
673
+ return { task: wireTask(requireTaskRooms().withLayoutRooms(task)) };
674
+ });
675
+ app.get('/api/v1/tasks/:id/layout', async (request) => {
676
+ auth.authenticate(request);
677
+ return { layout: await taskApi(async () => requireTaskRooms().taskLayout(request.params.id)) };
678
+ });
679
+ for (const operation of ['open', 'close']) {
680
+ app.post(`/api/v1/tasks/:id/layout/rooms/:room/${operation}`, async (request, reply) => {
681
+ const session = auth.authenticate(request, true);
682
+ const { id, room } = request.params;
683
+ const accepted = await taskApi(() => requireTaskRooms().launchTaskLayoutOperation({
684
+ actor: { kind: 'local_control', surface: 'web' }, taskId: id, room,
685
+ operation: operation === 'open' ? 'open' : 'close-room',
686
+ }));
687
+ await audit.record({ requestId: request.id, browser: session.id, action: `task.layout.${operation}`, result: 'succeeded' });
688
+ reply.code(202);
689
+ return { operation: accepted };
690
+ });
691
+ }
692
+ app.post('/api/v1/tasks/:id/layout/cleanup', async (request, reply) => {
693
+ auth.authenticate(request, true);
694
+ const accepted = await taskApi(() => requireTaskRooms().retryTaskLayoutCleanup({
695
+ actor: { kind: 'local_control', surface: 'web' }, taskId: request.params.id,
696
+ }));
697
+ reply.code(202);
698
+ return { operation: accepted };
699
+ });
700
+ app.get('/api/v1/task-templates', async (request) => {
701
+ auth.authenticate(request);
702
+ return { templates: requireTaskRooms().listTemplates() };
703
+ });
704
+ const layouts = () => {
705
+ if (!services.roomLayouts)
706
+ throw new FleetError('capability_unavailable', 'room layout editing is unavailable');
707
+ return services.roomLayouts;
708
+ };
709
+ app.get('/api/v1/room-layouts', async (request) => {
710
+ auth.authenticate(request);
711
+ const list = layouts().list(), defaultLayout = layouts().defaultLayout(list);
712
+ return { layouts: list, ...(defaultLayout ? { default_layout: defaultLayout } : {}) };
713
+ });
714
+ app.post('/api/v1/room-layouts/validate', async (request) => {
715
+ auth.authenticate(request, true);
716
+ return layouts().validate(request.body?.definition);
717
+ });
718
+ app.put('/api/v1/room-layouts/:name', async (request) => {
719
+ const session = auth.authenticate(request, true);
720
+ const body = request.body;
721
+ if (typeof body?.revision !== 'string')
722
+ throw new FleetError('invalid_request', 'revision is required');
723
+ const result = await layouts().save(request.params.name, body.revision, body.definition);
724
+ events.publish('room_layouts.changed', { name: result.name, revision: result.revision });
725
+ await audit.record({ requestId: request.id, browser: session.id, action: 'room_layout.save', result: 'succeeded' });
726
+ return result;
727
+ });
728
+ app.delete('/api/v1/room-layouts/:name', async (request) => {
729
+ const session = auth.authenticate(request, true);
730
+ const revision = request.query.revision;
731
+ if (typeof revision !== 'string' || !revision)
732
+ throw new FleetError('invalid_request', 'revision is required');
733
+ const result = await layouts().remove(request.params.name, revision);
734
+ events.publish('room_layouts.changed', { name: result.name });
735
+ await audit.record({ requestId: request.id, browser: session.id, action: 'room_layout.delete', result: 'succeeded' });
736
+ return result;
737
+ });
738
+ for (const action of ['start', 'block', 'unblock', 'review', 'finish', 'cancel']) {
739
+ app.post(`/api/v1/tasks/:id/${action}`, async (request, reply) => {
740
+ auth.authenticate(request, true);
741
+ const taskId = request.params.id;
742
+ const body = (request.body ?? {});
743
+ const input = { actor: { kind: 'local_control', surface: 'web' }, taskId };
744
+ const api = requireTaskRooms();
745
+ return wireTaskResult(await taskApi(async () => {
746
+ switch (action) {
747
+ case 'start': {
748
+ // A task's layout can be chosen or changed but never removed, so this check cannot go stale.
749
+ if (!api.getTask(taskId).task.layout)
750
+ throw new FleetError('conflict', 'choose a room layout for this task before starting it');
751
+ const task = await api.startTask(input);
752
+ const provisioning = await api.launchTaskProvisioning(taskId);
753
+ if (provisioning.kind !== 'ready')
754
+ reply.code(202);
755
+ return { task, provisioning };
756
+ }
757
+ case 'block':
758
+ if (typeof body.reason !== 'string' || !body.reason.trim())
759
+ throw new FleetError('invalid_request', 'reason is required');
760
+ return { task: api.blockTask({ ...input, reason: body.reason }) };
761
+ case 'unblock': return { task: api.unblockTask(input) };
762
+ case 'review': return { task: api.reviewTask(input) };
763
+ case 'finish':
764
+ case 'cancel': {
765
+ const plan = action === 'finish' ? await api.finishTask(input) : await api.cancelTask(input);
766
+ const task = plan.settlementRequired ? await api.launchTaskSettlement(taskId) : plan.task;
767
+ const pending = task.terminal_intent?.status === 'pending';
768
+ if (pending)
769
+ reply.code(202);
770
+ return { task, pending };
771
+ }
772
+ }
773
+ }));
774
+ });
775
+ }
776
+ app.get('/api/v1/configuration/provenance', async (request) => {
777
+ auth.authenticate(request);
778
+ if (!services.presetProvenance)
779
+ throw new FleetError('capability_unavailable', 'preset provenance is unavailable');
780
+ return { provenance: services.presetProvenance.read() };
247
781
  });
248
782
  app.get('/api/v1/configuration', async (request) => {
249
783
  auth.authenticate(request);
250
784
  if (!services.configuration)
251
785
  throw new FleetError('capability_unavailable', 'fleet configuration editing is unavailable');
252
- return services.configuration.read();
786
+ return project({ viewId: 'read', resource: 'configuration', includeDefinitions: request.query.includeDefinitions === 'true' }, new AbortController().signal);
253
787
  });
254
788
  app.post('/api/v1/configuration/preview', async (request) => {
255
789
  auth.authenticate(request, true);
@@ -270,6 +804,86 @@ export async function buildWebServer(services, boundary, options = {}) {
270
804
  });
271
805
  return result;
272
806
  });
807
+ // Claude Code / Codex subscription profiles. Responses carry account metadata
808
+ // only; token values never leave the CLI-owned credential files.
809
+ const subscriptions = () => {
810
+ if (!services.subscriptions)
811
+ throw new FleetError('capability_unavailable', 'subscription management is unavailable');
812
+ return services.subscriptions;
813
+ };
814
+ const provider = (raw) => {
815
+ if (!isProvider(raw))
816
+ throw new FleetError('invalid_request', 'unknown subscription provider');
817
+ return raw;
818
+ };
819
+ const subscriptionsChanged = () => events.publish('subscription.changed', {});
820
+ app.get('/api/v1/subscriptions', async (request) => {
821
+ auth.authenticate(request);
822
+ return { providers: await subscriptions().list() };
823
+ });
824
+ app.post('/api/v1/subscriptions/:provider/logins', async (request, reply) => {
825
+ const session = auth.authenticate(request, true);
826
+ const login = await subscriptions().startLogin(provider(request.params.provider), session.id);
827
+ await audit.record({ requestId: request.id, browser: session.id, action: `subscription.login.${request.params.provider}`, result: login.state });
828
+ reply.code(201);
829
+ return login;
830
+ });
831
+ app.get('/api/v1/subscriptions/:provider/logins/:loginId', async (request) => {
832
+ const session = auth.authenticate(request);
833
+ return subscriptions().login(request.params.loginId, session.id).view();
834
+ });
835
+ app.post('/api/v1/subscriptions/:provider/logins/:loginId/code', async (request) => {
836
+ const session = auth.authenticate(request, true);
837
+ const code = request.body?.code;
838
+ if (typeof code !== 'string')
839
+ throw new FleetError('invalid_request', 'code is required');
840
+ const login = subscriptions().login(request.params.loginId, session.id);
841
+ login.submitCode(code.trim());
842
+ await audit.record({ requestId: request.id, browser: session.id, action: `subscription.login.code.${request.params.provider}`, result: 'submitted' });
843
+ return login.view();
844
+ });
845
+ app.delete('/api/v1/subscriptions/:provider/logins/:loginId', async (request) => {
846
+ const session = auth.authenticate(request, true);
847
+ const login = subscriptions().login(request.params.loginId, session.id);
848
+ login.cancel();
849
+ return login.view();
850
+ });
851
+ app.post('/api/v1/subscriptions/:provider/active', async (request) => {
852
+ const session = auth.authenticate(request, true);
853
+ const profileId = request.body?.profileId;
854
+ if (typeof profileId !== 'string')
855
+ throw new FleetError('invalid_request', 'profileId is required');
856
+ const view = await subscriptions().setActive(provider(request.params.provider), profileId);
857
+ subscriptionsChanged();
858
+ await audit.record({ requestId: request.id, browser: session.id, action: `subscription.activate.${request.params.provider}`, result: 'succeeded' });
859
+ return view;
860
+ });
861
+ app.post('/api/v1/subscriptions/:provider/profiles/:profileId/check', async (request) => {
862
+ auth.authenticate(request, true);
863
+ return subscriptions().check(provider(request.params.provider), request.params.profileId);
864
+ });
865
+ app.post('/api/v1/subscriptions/claude/profiles/:profileId/probe', async (request) => {
866
+ const session = auth.authenticate(request, true);
867
+ const usage = await subscriptions().probeClaudeUsage(request.params.profileId);
868
+ await audit.record({ requestId: request.id, browser: session.id, action: 'subscription.usage.probe.claude', result: usage.source });
869
+ return { usage };
870
+ });
871
+ app.patch('/api/v1/subscriptions/:provider/profiles/:profileId', async (request) => {
872
+ auth.authenticate(request, true);
873
+ const label = request.body?.label;
874
+ if (typeof label !== 'string')
875
+ throw new FleetError('invalid_request', 'label is required');
876
+ await subscriptions().rename(provider(request.params.provider), request.params.profileId, label);
877
+ subscriptionsChanged();
878
+ return { ok: true };
879
+ });
880
+ app.delete('/api/v1/subscriptions/:provider/profiles/:profileId', async (request) => {
881
+ const session = auth.authenticate(request, true);
882
+ await subscriptions().remove(provider(request.params.provider), request.params.profileId);
883
+ subscriptionsChanged();
884
+ await audit.record({ requestId: request.id, browser: session.id, action: `subscription.remove.${request.params.provider}`, result: 'succeeded' });
885
+ return { ok: true };
886
+ });
273
887
  app.get('/api/v1/topology', async (request) => {
274
888
  auth.authenticate(request);
275
889
  if (!services.topology)
@@ -317,6 +931,20 @@ export async function buildWebServer(services, boundary, options = {}) {
317
931
  return result;
318
932
  });
319
933
  const oursTools = services.oursTools ?? new SupervisorOursTools();
934
+ app.get('/api/v1/roles/:id/contacts', async (request) => {
935
+ auth.authenticate(request);
936
+ const control = await services.session(request.params.id);
937
+ if (!control.agentContacts)
938
+ throw new FleetError('capability_unavailable', 'Supervisor contacts API is unavailable');
939
+ return control.agentContacts();
940
+ });
941
+ app.get('/api/v1/roles/:id/messages', async (request) => {
942
+ auth.authenticate(request);
943
+ const control = await services.session(request.params.id);
944
+ if (!control.agentHistory)
945
+ throw new FleetError('capability_unavailable', 'Supervisor history API is unavailable');
946
+ return control.agentHistory({ peer_cid: request.query.peer_cid, ...(request.query.limit !== undefined ? { limit: Number(request.query.limit) } : {}), ...(request.query.before_seq !== undefined ? { before_seq: Number(request.query.before_seq) } : {}) });
947
+ });
320
948
  app.get('/api/v1/roles/:id/ours/tools', async (request) => {
321
949
  auth.authenticate(request);
322
950
  return oursTools.list(request.params.id);
@@ -330,7 +958,13 @@ export async function buildWebServer(services, boundary, options = {}) {
330
958
  });
331
959
  app.get('/api/v1/roles/:id', async (request) => {
332
960
  auth.authenticate(request);
333
- return services.query.detail(request.params.id);
961
+ const detail = await services.query.detail(request.params.id);
962
+ return { ...detail, chatIdle: detail.role.lifetime === 'temporary' ? readChatIdle(agentDir(request.params.id, true)) ?? null : null };
963
+ });
964
+ app.get('/api/v1/roles/:id/voice-capabilities', async (request) => {
965
+ auth.authenticate(request);
966
+ const descriptor = await (await services.session(request.params.id)).describe();
967
+ return { supported: ['generation_bound_prompts', 'idle_bound_prompts', 'targeted_voice_interrupt'].every(f => descriptor.features.includes(f)) };
334
968
  });
335
969
  app.get('/api/v1/roles/:id/removal-preview', async (request) => {
336
970
  auth.authenticate(request);
@@ -360,20 +994,121 @@ export async function buildWebServer(services, boundary, options = {}) {
360
994
  limit: request.query.limit ? Number(request.query.limit) : undefined,
361
995
  });
362
996
  });
363
- app.get('/api/v1/roles/:id/conversation', async (request) => {
997
+ app.get('/api/v1/roles/:id/conversation', async (request, reply) => {
364
998
  auth.authenticate(request);
365
999
  const control = await services.session(request.params.id);
366
1000
  if (!control.conversationPage)
367
1001
  throw new FleetError('capability_unavailable', 'this role has no conversation ledger');
368
- return control.conversationPage({
369
- after: request.query.after,
370
- limit: request.query.limit ? Number(request.query.limit) : undefined,
371
- });
1002
+ const limit = request.query.limit ? Number(request.query.limit) : undefined;
1003
+ // `latest` / `before` page backwards from the newest event; `after` pages forward for live updates.
1004
+ const backward = request.query.latest === '1' || request.query.before !== undefined;
1005
+ if (backward && request.query.after !== undefined)
1006
+ throw new FleetError('invalid_request', 'after cannot be combined with latest or before');
1007
+ if (backward && !control.conversationTail)
1008
+ throw new FleetError('capability_unavailable', 'this role cannot page its conversation backwards');
1009
+ const role = await services.repository.get(request.params.id);
1010
+ const stateDir = role ? services.repository.stateDir(role) : undefined;
1011
+ const present = (event) => presentAttachmentEvent(stateDir, request.params.id, event);
1012
+ if (backward) {
1013
+ const owner = new AbortController();
1014
+ const cancel = () => owner.abort();
1015
+ request.raw.once('aborted', cancel);
1016
+ reply.raw.once('close', cancel);
1017
+ if (request.raw.aborted || reply.raw.destroyed)
1018
+ cancel();
1019
+ try {
1020
+ const page = await control.conversationTail({ before: request.query.before, limit, signal: owner.signal });
1021
+ owner.signal.throwIfAborted();
1022
+ return { ...page, events: page.events.map(present), context: page.context.map(present) };
1023
+ }
1024
+ finally {
1025
+ request.raw.off('aborted', cancel);
1026
+ reply.raw.off('close', cancel);
1027
+ }
1028
+ }
1029
+ const page = await control.conversationPage({ after: request.query.after, limit });
1030
+ return { ...page, events: page.events.map(present) };
1031
+ });
1032
+ app.get('/api/v1/roles/:id/artifacts/:artifactId', async (request, reply) => {
1033
+ auth.authenticate(request);
1034
+ const role = await services.repository.get(request.params.id);
1035
+ if (!role)
1036
+ throw new FleetError('role_not_found', 'Agent not found');
1037
+ const stateDir = services.repository.stateDir(role);
1038
+ if (!stateDir)
1039
+ throw new FleetError('resource_not_found', 'Agent file not found');
1040
+ try {
1041
+ const { record, bytes } = await readDeliveredFile(stateDir, request.params.artifactId);
1042
+ if (!await conversationHasAttachment(stateDir, record))
1043
+ throw Error('ARTIFACT_NOT_PUBLISHED');
1044
+ reply.header('Cache-Control', 'private, no-store');
1045
+ reply.header('X-Content-Type-Options', 'nosniff');
1046
+ reply.header('Content-Security-Policy', "default-src 'none'; sandbox");
1047
+ reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(record.name).replace(/'/g, '%27')}`);
1048
+ return reply.type('application/octet-stream').send(bytes);
1049
+ }
1050
+ catch {
1051
+ throw new FleetError('resource_not_found', 'Agent file unavailable');
1052
+ }
1053
+ });
1054
+ app.get('/api/v1/roles/:id/attachments/:attachmentId', async (request, reply) => {
1055
+ auth.authenticate(request);
1056
+ const role = await services.repository.get(request.params.id);
1057
+ if (!role)
1058
+ throw new FleetError('role_not_found', 'Agent not found');
1059
+ const stateDir = services.repository.stateDir(role);
1060
+ if (!stateDir)
1061
+ throw new FleetError('capability_unavailable', 'Agent file storage is unavailable');
1062
+ const { record, bytes } = readAgentAttachment(stateDir, request.params.attachmentId);
1063
+ const image = /^image\/(png|jpeg|gif|webp|avif)$/.test(record.mimeType);
1064
+ reply.header('Cache-Control', 'private, no-store');
1065
+ reply.header('X-Content-Type-Options', 'nosniff');
1066
+ reply.header('Content-Security-Policy', "default-src 'none'; sandbox");
1067
+ reply.header('Content-Disposition', `${image ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(record.name).replace(/'/g, '%27')}`);
1068
+ return reply.type(image ? record.mimeType : 'application/octet-stream').send(bytes);
1069
+ });
1070
+ app.post('/api/v1/roles/:id/attachments', { bodyLimit: MAX_ATTACHMENT_BODY }, async (request) => {
1071
+ auth.authenticate(request, true);
1072
+ const role = await services.repository.get(request.params.id);
1073
+ if (!role)
1074
+ throw new FleetError('role_not_found', 'Agent not found');
1075
+ const stateDir = services.repository.stateDir(role);
1076
+ if (!stateDir)
1077
+ throw new FleetError('capability_unavailable', 'Agent file storage is unavailable');
1078
+ const body = request.body;
1079
+ if (typeof body?.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim())
1080
+ throw new FleetError('invalid_request', 'expectedSessionGeneration is required for attachments');
1081
+ const control = await services.session(request.params.id);
1082
+ if (!control.conversationPage || !control.submitPromptV2)
1083
+ throw new FleetError('capability_unavailable', 'Session-bound attachments are unavailable');
1084
+ const page = await control.conversationPage({ limit: 1 });
1085
+ if (page.snapshot.sessionGeneration !== body.expectedSessionGeneration)
1086
+ throw new FleetError('stale_state', 'Agent session changed. Select the files again.');
1087
+ return storeAgentAttachment(stateDir, body.expectedSessionGeneration, request.body);
372
1088
  });
373
1089
  app.post('/api/v1/roles/:id/input', async (request, reply) => {
374
1090
  const session = auth.authenticate(request, true);
375
1091
  const body = request.body;
376
- const text = String(body?.text ?? '');
1092
+ if (body?.expectedSessionGeneration !== undefined && (typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim()))
1093
+ throw new FleetError('invalid_request', 'expectedSessionGeneration must be a nonempty string');
1094
+ if (body?.requireIdle !== undefined && typeof body.requireIdle !== 'boolean')
1095
+ throw new FleetError('invalid_request', 'requireIdle must be boolean');
1096
+ let text = String(body?.text ?? '');
1097
+ if (body?.attachments !== undefined) {
1098
+ if (typeof body.commandId !== 'string' || !body.commandId.trim())
1099
+ throw new FleetError('invalid_request', 'commandId is required for attachments');
1100
+ if (typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim())
1101
+ throw new FleetError('invalid_request', 'expectedSessionGeneration is required for attachments');
1102
+ const role = await services.repository.get(request.params.id);
1103
+ if (!role)
1104
+ throw new FleetError('role_not_found', 'Agent not found');
1105
+ const stateDir = services.repository.stateDir(role);
1106
+ if (!stateDir)
1107
+ throw new FleetError('capability_unavailable', 'Agent file storage is unavailable');
1108
+ const originalText = text;
1109
+ text = attachmentPrompt(stateDir, body.expectedSessionGeneration, body.attachments, text);
1110
+ prepareAttachmentPresentation(stateDir, body.expectedSessionGeneration, body.commandId, body.attachments, originalText, text);
1111
+ }
377
1112
  const commandId = typeof body?.commandId === 'string' && body.commandId.trim()
378
1113
  ? body.commandId : undefined;
379
1114
  const control = await services.session(request.params.id);
@@ -384,6 +1119,7 @@ export async function buildWebServer(services, boundary, options = {}) {
384
1119
  const admittedCommandId = commandId ?? randomBytes(16).toString('hex');
385
1120
  const receipt = await control.submitPromptV2({
386
1121
  commandId: admittedCommandId, text, source: 'owner_admin_console',
1122
+ expectedSessionGeneration: body.expectedSessionGeneration, requireIdle: body.requireIdle === true,
387
1123
  actorBrowserSession: createHmac('sha256', digestKey).update(session.id).digest('hex').slice(0, 24),
388
1124
  });
389
1125
  await audit.record({
@@ -395,6 +1131,8 @@ export async function buildWebServer(services, boundary, options = {}) {
395
1131
  reply.code(202);
396
1132
  return receipt;
397
1133
  }
1134
+ if (body.expectedSessionGeneration !== undefined || body.requireIdle)
1135
+ throw new FleetError('capability_unavailable', 'session-bound voice input is unavailable for this role');
398
1136
  const receipt = await control.sendText(text);
399
1137
  await audit.record({
400
1138
  requestId: request.id, browser: session.id, roleId: request.params.id,
@@ -410,6 +1148,16 @@ export async function buildWebServer(services, boundary, options = {}) {
410
1148
  const commandId = typeof body?.commandId === 'string' && body.commandId.trim()
411
1149
  ? body.commandId : undefined;
412
1150
  const control = await services.session(request.params.id);
1151
+ if (body?.expectedSessionGeneration !== undefined || body?.promptId !== undefined) {
1152
+ if (!commandId || typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim() || typeof body.promptId !== 'string' || !body.promptId.trim())
1153
+ throw new FleetError('invalid_request', 'Targeted interrupt requires commandId, expectedSessionGeneration and promptId');
1154
+ if (!control.interruptPromptV2)
1155
+ throw new FleetError('capability_unavailable', 'Targeted voice interrupt is unavailable');
1156
+ const receipt = await control.interruptPromptV2({ commandId, expectedSessionGeneration: body.expectedSessionGeneration, promptId: body.promptId });
1157
+ await audit.record({ requestId: request.id, browser: session.id, roleId: request.params.id, action: 'session.interrupt_voice', result: 'accepted' });
1158
+ reply.code(202);
1159
+ return receipt;
1160
+ }
413
1161
  if (commandId && control.interruptV2) {
414
1162
  const receipt = await control.interruptV2(commandId);
415
1163
  await audit.record({
@@ -460,10 +1208,22 @@ export async function buildWebServer(services, boundary, options = {}) {
460
1208
  const body = request.body;
461
1209
  if (!body.action || !['start', 'stop', 'restart_resume', 'restart_fresh'].includes(body.action))
462
1210
  throw new FleetError('invalid_request', 'invalid lifecycle action');
463
- const receipt = await services.commands.execute({
464
- roleId: request.params.id, action: body.action,
465
- actionId: body.actionId, confirmation: body.confirmation,
466
- });
1211
+ let receipt;
1212
+ try {
1213
+ receipt = await services.commands.execute({
1214
+ roleId: request.params.id, action: body.action,
1215
+ actionId: body.actionId, confirmation: body.confirmation,
1216
+ });
1217
+ }
1218
+ catch (error) {
1219
+ // An existing receipt always wins: a conflicting/repeated action is not a
1220
+ // proof that the original operation was never admitted.
1221
+ if (typeof body.actionId === 'string' && !services.commands.get(body.actionId)) {
1222
+ const normalized = normalizeError(error);
1223
+ return reply.code(409).send({ error: { code: 'action_not_accepted', message: normalized.message }, accepted: false });
1224
+ }
1225
+ throw error;
1226
+ }
467
1227
  events.publish('action.changed', receipt, request.params.id);
468
1228
  reply.code(202);
469
1229
  return receipt;
@@ -482,6 +1242,75 @@ export async function buildWebServer(services, boundary, options = {}) {
482
1242
  throw new FleetError('role_not_found', 'creation action not found');
483
1243
  return action;
484
1244
  });
1245
+ // A fetch stream works through the same authenticated workspace transport as
1246
+ // ordinary requests, and owns one ACP controller until its browser disconnects.
1247
+ app.post('/api/v1/roles/:id/live-presence', async (request, reply) => {
1248
+ const browser = auth.authenticate(request, true);
1249
+ const generation = request.body?.expectedSessionGeneration;
1250
+ if (typeof generation !== 'string' || !generation.trim())
1251
+ throw new FleetError('invalid_request', 'expectedSessionGeneration is required');
1252
+ const control = await services.session(request.params.id);
1253
+ if (!control.followConversation || !control.conversationPage)
1254
+ throw new FleetError('capability_unavailable', 'Live voice requires a conversation controller');
1255
+ const page = await control.conversationPage({ limit: 1 });
1256
+ if (page.snapshot.sessionGeneration !== generation)
1257
+ throw new FleetError('stale_state', 'Agent session changed. Start live voice again.');
1258
+ // Session/page discovery can await I/O; recheck revocation before attaching.
1259
+ auth.authenticate(request, true);
1260
+ if (reply.raw.destroyed)
1261
+ return;
1262
+ reply.hijack();
1263
+ for (const [name, value] of Object.entries(reply.getHeaders()))
1264
+ if (value !== undefined)
1265
+ reply.raw.setHeader(name, Array.isArray(value) ? value : String(value));
1266
+ reply.raw.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' });
1267
+ let ended = false;
1268
+ let follow;
1269
+ let heartbeat;
1270
+ let unbind;
1271
+ const close = () => { if (ended)
1272
+ return; ended = true; unbind?.(); if (heartbeat)
1273
+ clearInterval(heartbeat); follow?.close(); reply.raw.end(); };
1274
+ const write = (value) => { if (!ended && !reply.raw.write(JSON.stringify(value) + '\n'))
1275
+ close(); };
1276
+ reply.raw.once('close', close);
1277
+ // A voice session outlives one access credential; its page renews this stream by name.
1278
+ const renewal = browser.rotating ? randomBytes(32).toString('base64url') : undefined;
1279
+ unbind = auth.bindTransport(browser, close, renewal);
1280
+ try {
1281
+ follow = await control.followConversation({
1282
+ after: page.nextCursor,
1283
+ onPage: current => { if (current.snapshot.sessionGeneration !== generation) {
1284
+ write({ error: 'Agent session changed. Start live voice again.' });
1285
+ close();
1286
+ }
1287
+ else
1288
+ write({ ready: true, ...(renewal ? { renewal } : {}) }); },
1289
+ onEvent: event => { if (event.sessionGeneration !== generation) {
1290
+ write({ error: 'Agent session changed. Start live voice again.' });
1291
+ close();
1292
+ } },
1293
+ onClose: () => close(),
1294
+ });
1295
+ if (ended)
1296
+ follow.close();
1297
+ // A renewable stream is ended by its own deadline and by revocation, not by the first credential's expiry.
1298
+ else
1299
+ heartbeat = setInterval(() => { try {
1300
+ if (!renewal)
1301
+ auth.authenticate(request);
1302
+ write({ heartbeat: true });
1303
+ }
1304
+ catch {
1305
+ close();
1306
+ } }, 10_000);
1307
+ }
1308
+ catch {
1309
+ write({ error: 'Agent controller connection failed.' });
1310
+ close();
1311
+ }
1312
+ });
1313
+ app.post('/api/v1/transports/:id/renew', async (request) => auth.renewTransport(request, request.params.id));
485
1314
  app.post('/api/v1/ws-tickets', async (request) => {
486
1315
  const body = request.body;
487
1316
  if (!body?.purpose || !['events', 'conversation'].includes(body.purpose))
@@ -516,7 +1345,7 @@ export async function buildWebServer(services, boundary, options = {}) {
516
1345
  requireSubprotocol(request, 'ours-fleet-events.v1');
517
1346
  authorizeSocket(socket, request, async (hello) => {
518
1347
  const session = auth.consumeTicket(request, String(hello.ticket ?? ''), 'events');
519
- auth.bindSocket(session.id, socket);
1348
+ auth.bindSocket(session, socket);
520
1349
  const detach = events.attach(socket, typeof hello.lastEventId === 'string' ? hello.lastEventId : undefined);
521
1350
  socket.on('close', detach);
522
1351
  socket.send(JSON.stringify({ kind: 'ready', at: new Date().toISOString() }));
@@ -526,7 +1355,7 @@ export async function buildWebServer(services, boundary, options = {}) {
526
1355
  requireSubprotocol(request, 'ours-fleet-conversation.v1');
527
1356
  authorizeSocket(socket, request, async (hello) => {
528
1357
  const session = auth.consumeTicket(request, String(hello.ticket ?? ''), 'conversation', request.params.id);
529
- auth.bindSocket(session.id, socket);
1358
+ auth.bindSocket(session, socket);
530
1359
  const control = await services.session(request.params.id);
531
1360
  if (!control.followConversation)
532
1361
  throw new FleetError('capability_unavailable', 'this role has no conversation ledger');
@@ -551,6 +1380,9 @@ export async function buildWebServer(services, boundary, options = {}) {
551
1380
  }
552
1381
  socket.send(JSON.stringify(payload));
553
1382
  };
1383
+ const role = await services.repository.get(request.params.id);
1384
+ const stateDir = role ? services.repository.stateDir(role) : undefined;
1385
+ const present = (event) => presentAttachmentEvent(stateDir, request.params.id, event);
554
1386
  const follow = await control.followConversation({
555
1387
  after,
556
1388
  onPage: page => {
@@ -561,11 +1393,11 @@ export async function buildWebServer(services, boundary, options = {}) {
561
1393
  lastCursor: page.nextCursor ?? after,
562
1394
  });
563
1395
  if (page.events.length)
564
- guardedSend({ type: 'events', events: page.events });
1396
+ guardedSend({ type: 'events', events: page.events.map(present) });
565
1397
  if (page.hasMore)
566
1398
  guardedSend({ type: 'resync.required' });
567
1399
  },
568
- onEvent: event => guardedSend({ type: 'events', events: [event] }),
1400
+ onEvent: event => guardedSend({ type: 'events', events: [present(event)] }),
569
1401
  onClose: reason => {
570
1402
  if (socket.readyState === socket.OPEN)
571
1403
  socket.close(4409, (reason ?? 'conversation stream ended').slice(0, 120));
@@ -578,15 +1410,8 @@ export async function buildWebServer(services, boundary, options = {}) {
578
1410
  socket.on('close', () => { clearInterval(heartbeat); follow.close(); });
579
1411
  });
580
1412
  });
581
- const staticRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'web-app');
582
- if (existsSync(staticRoot)) {
583
- await app.register(fastifyStatic, { root: staticRoot, prefix: '/' });
584
- app.setNotFoundHandler((request, reply) => {
585
- if (request.url.startsWith('/api/'))
586
- return reply.code(404).send({ error: 'not found' });
587
- return reply.sendFile('index.html');
588
- });
589
- }
1413
+ // Fleet has no pages of its own; every screen belongs to the App.
1414
+ app.setNotFoundHandler((_request, reply) => reply.code(404).send({ error: 'not found' }));
590
1415
  return {
591
1416
  app, auth, audit, events,
592
1417
  async close() {
@@ -599,17 +1424,17 @@ export async function buildWebServer(services, boundary, options = {}) {
599
1424
  function setAuthCookies(reply, session, device, secure = false) {
600
1425
  const suffix = secure ? '; Secure' : '';
601
1426
  reply.header('Set-Cookie', [
602
- `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=28800${suffix}`,
603
- `ofs_device=${encodeURIComponent(device)}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=2592000${suffix}`,
1427
+ `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800${suffix}`,
1428
+ `ofs_device=${encodeURIComponent(device)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000${suffix}`,
604
1429
  ]);
605
1430
  }
606
1431
  function setSessionCookie(reply, session, secure = false) {
607
- reply.header('Set-Cookie', `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=28800${secure ? '; Secure' : ''}`);
1432
+ reply.header('Set-Cookie', `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800${secure ? '; Secure' : ''}`);
608
1433
  }
609
1434
  function clearAuthCookies(reply) {
610
1435
  reply.header('Set-Cookie', [
611
- 'ofs_session=; HttpOnly; SameSite=Strict; Path=/api; Max-Age=0',
612
- 'ofs_device=; HttpOnly; SameSite=Strict; Path=/api; Max-Age=0',
1436
+ 'ofs_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0',
1437
+ 'ofs_device=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0',
613
1438
  ]);
614
1439
  }
615
1440
  function promoteRequest(body) {
@@ -623,9 +1448,6 @@ function promoteRequest(body) {
623
1448
  };
624
1449
  }
625
1450
  function cryptoRandomId() { return randomBytes(12).toString('hex'); }
626
- function escapeHtml(value) {
627
- return value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
628
- }
629
1451
  function requireSubprotocol(request, expected) {
630
1452
  const protocols = String(request.headers['sec-websocket-protocol'] ?? '')
631
1453
  .split(',').map(value => value.trim());