@ours.network/fleet 1.2.0-nightly.6 → 1.2.0-nightly.60

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 (389) hide show
  1. package/README.md +287 -65
  2. package/dist/account-origin.d.ts +2 -0
  3. package/dist/account-origin.js +6 -0
  4. package/dist/agent-ours/bridge.js +35 -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 +4 -0
  11. package/dist/agent-ours/mcp-endpoint.js +5 -0
  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 +21 -1
  15. package/dist/agent-ours/service.js +94 -14
  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 +27 -0
  19. package/dist/application/conversation-history.js +212 -0
  20. package/dist/application/fleet-query-service.d.ts +1 -1
  21. package/dist/application/fleet-query-service.js +2 -2
  22. package/dist/application/legacy-supervisor-identity.d.ts +7 -0
  23. package/dist/application/legacy-supervisor-identity.js +43 -0
  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/role-creation-service.js +3 -2
  30. package/dist/application/role-removal-service.js +9 -1
  31. package/dist/application/role-repository.js +19 -2
  32. package/dist/application/room-layout-definitions.d.ts +45 -0
  33. package/dist/application/room-layout-definitions.js +130 -0
  34. package/dist/application/session-control.d.ts +30 -0
  35. package/dist/application/session-control.js +72 -3
  36. package/dist/application/supervisor-ours-tools.d.ts +159 -0
  37. package/dist/application/supervisor-ours-tools.js +115 -0
  38. package/dist/application/task-layouts.d.ts +91 -0
  39. package/dist/application/task-layouts.js +259 -0
  40. package/dist/application/task-room-service.d.ts +99 -3
  41. package/dist/application/task-room-service.js +285 -32
  42. package/dist/application/types.d.ts +2 -0
  43. package/dist/atomic-file.d.ts +5 -0
  44. package/dist/atomic-file.js +62 -1
  45. package/dist/briefing.d.ts +3 -1
  46. package/dist/briefing.js +70 -30
  47. package/dist/build-info.json +7 -4
  48. package/dist/capabilities.d.ts +6 -1
  49. package/dist/capabilities.js +8 -0
  50. package/dist/cli.js +407 -37
  51. package/dist/client-profile.d.ts +5 -17
  52. package/dist/client-profile.js +4 -127
  53. package/dist/config.d.ts +45 -0
  54. package/dist/config.js +57 -6
  55. package/dist/creation.d.ts +1 -1
  56. package/dist/creation.js +4 -4
  57. package/dist/daemon-recovery.d.ts +1 -8
  58. package/dist/daemon-recovery.js +8 -113
  59. package/dist/docs.d.ts +1 -1
  60. package/dist/docs.js +123 -19
  61. package/dist/doctor.d.ts +1 -1
  62. package/dist/doctor.js +55 -60
  63. package/dist/erased-resources.d.ts +9 -0
  64. package/dist/erased-resources.js +87 -0
  65. package/dist/file-delivery/reader.d.ts +15 -0
  66. package/dist/file-delivery/reader.js +63 -0
  67. package/dist/file-delivery/store.d.ts +15 -0
  68. package/dist/file-delivery/store.js +124 -0
  69. package/dist/file-delivery/types.d.ts +19 -0
  70. package/dist/file-delivery/types.js +1 -0
  71. package/dist/fleet-command-audit.d.ts +7 -0
  72. package/dist/fleet-command-audit.js +26 -9
  73. package/dist/harness/agent-session.d.ts +2 -0
  74. package/dist/harness/claude-code-session.js +1 -1
  75. package/dist/harness/claude-code.js +20 -11
  76. package/dist/harness/claude-runtime.d.ts +12 -0
  77. package/dist/harness/claude-runtime.js +43 -0
  78. package/dist/harness/codex-app-server-proxy.d.ts +4 -1
  79. package/dist/harness/codex-app-server-proxy.js +41 -5
  80. package/dist/harness/codex-reasoning.d.ts +5 -0
  81. package/dist/harness/codex-reasoning.js +12 -0
  82. package/dist/harness/codex-runtime.d.ts +11 -1
  83. package/dist/harness/codex-runtime.js +31 -4
  84. package/dist/harness/codex-session.js +2 -2
  85. package/dist/harness/codex.js +23 -12
  86. package/dist/init-wizard.d.ts +2 -0
  87. package/dist/init-wizard.js +38 -19
  88. package/dist/isolation/policy.js +6 -1
  89. package/dist/isolation/types.d.ts +5 -0
  90. package/dist/managed-cli.d.ts +253 -0
  91. package/dist/managed-cli.js +923 -0
  92. package/dist/managed-recovery.d.ts +13 -0
  93. package/dist/managed-recovery.js +31 -0
  94. package/dist/minimal-setup.d.ts +2 -0
  95. package/dist/minimal-setup.js +44 -0
  96. package/dist/monitor.d.ts +8 -34
  97. package/dist/monitor.js +89 -152
  98. package/dist/notifications/fleet-producer.d.ts +19 -0
  99. package/dist/notifications/fleet-producer.js +208 -0
  100. package/dist/notifications/outbox.d.ts +46 -0
  101. package/dist/notifications/outbox.js +158 -0
  102. package/dist/notifications/target-cleanup.d.ts +8 -0
  103. package/dist/notifications/target-cleanup.js +41 -0
  104. package/dist/notifications/task-producer.d.ts +18 -0
  105. package/dist/notifications/task-producer.js +67 -0
  106. package/dist/ops.d.ts +2 -0
  107. package/dist/ops.js +20 -3
  108. package/dist/owner-channel/channel.js +17 -6
  109. package/dist/owner-channel/commands.d.ts +4 -0
  110. package/dist/owner-channel/commands.js +8 -0
  111. package/dist/owner-channel/lifecycle-outbox.js +4 -3
  112. package/dist/owner-channel/ours-client.js +2 -17
  113. package/dist/paths.d.ts +2 -0
  114. package/dist/paths.js +2 -0
  115. package/dist/preset-migration.js +25 -0
  116. package/dist/resolved-plan.js +1 -0
  117. package/dist/rooms-tasks/add-member.d.ts +48 -0
  118. package/dist/rooms-tasks/add-member.js +83 -0
  119. package/dist/rooms-tasks/archived-absence.d.ts +8 -0
  120. package/dist/rooms-tasks/archived-absence.js +48 -0
  121. package/dist/rooms-tasks/cli.js +65 -5
  122. package/dist/rooms-tasks/close.d.ts +6 -2
  123. package/dist/rooms-tasks/close.js +246 -37
  124. package/dist/rooms-tasks/config.d.ts +1 -1
  125. package/dist/rooms-tasks/config.js +11 -4
  126. package/dist/rooms-tasks/cowork-adapter.d.ts +2 -4
  127. package/dist/rooms-tasks/cowork-adapter.js +7 -121
  128. package/dist/rooms-tasks/cowork-http.js +29 -1
  129. package/dist/rooms-tasks/deletion.d.ts +1 -1
  130. package/dist/rooms-tasks/deletion.js +84 -5
  131. package/dist/rooms-tasks/erasure.d.ts +6 -0
  132. package/dist/rooms-tasks/erasure.js +173 -0
  133. package/dist/rooms-tasks/external-worker.d.ts +1 -1
  134. package/dist/rooms-tasks/external-worker.js +3 -4
  135. package/dist/rooms-tasks/layout-binding-client.d.ts +11 -0
  136. package/dist/rooms-tasks/layout-binding-client.js +56 -0
  137. package/dist/rooms-tasks/layout-binding-grants.d.ts +21 -0
  138. package/dist/rooms-tasks/layout-binding-grants.js +128 -0
  139. package/dist/rooms-tasks/layout-cli.d.ts +4 -0
  140. package/dist/rooms-tasks/layout-cli.js +107 -0
  141. package/dist/rooms-tasks/layout-config.d.ts +11 -0
  142. package/dist/rooms-tasks/layout-config.js +89 -0
  143. package/dist/rooms-tasks/layout-control.d.ts +21 -0
  144. package/dist/rooms-tasks/layout-control.js +52 -0
  145. package/dist/rooms-tasks/layout-member-retirement.d.ts +10 -0
  146. package/dist/rooms-tasks/layout-member-retirement.js +90 -0
  147. package/dist/rooms-tasks/layout-service.d.ts +43 -0
  148. package/dist/rooms-tasks/layout-service.js +203 -0
  149. package/dist/rooms-tasks/layout.d.ts +121 -0
  150. package/dist/rooms-tasks/layout.js +285 -0
  151. package/dist/rooms-tasks/legacy-task-member.d.ts +19 -0
  152. package/dist/rooms-tasks/legacy-task-member.js +116 -0
  153. package/dist/rooms-tasks/live-readiness.d.ts +18 -0
  154. package/dist/rooms-tasks/live-readiness.js +85 -0
  155. package/dist/rooms-tasks/member-ownership.d.ts +5 -0
  156. package/dist/rooms-tasks/member-ownership.js +40 -0
  157. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  158. package/dist/rooms-tasks/member-startup.js +2 -0
  159. package/dist/rooms-tasks/provision.d.ts +3 -0
  160. package/dist/rooms-tasks/provision.js +101 -49
  161. package/dist/rooms-tasks/recovery.d.ts +19 -0
  162. package/dist/rooms-tasks/recovery.js +110 -0
  163. package/dist/rooms-tasks/room-state.d.ts +4 -2
  164. package/dist/rooms-tasks/room-state.js +47 -8
  165. package/dist/rooms-tasks/task-service-retirement.d.ts +11 -0
  166. package/dist/rooms-tasks/task-service-retirement.js +21 -0
  167. package/dist/rooms-tasks/task-state.d.ts +51 -8
  168. package/dist/rooms-tasks/task-state.js +245 -25
  169. package/dist/rooms-tasks/types.d.ts +40 -0
  170. package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
  171. package/dist/rooms-tasks/workspace-artifacts.js +93 -0
  172. package/dist/rooms-tasks/workspace.d.ts +35 -0
  173. package/dist/rooms-tasks/workspace.js +325 -0
  174. package/dist/runner.d.ts +25 -3
  175. package/dist/runner.js +291 -49
  176. package/dist/session/acp.d.ts +53 -1
  177. package/dist/session/acp.js +216 -40
  178. package/dist/session/arbiter.d.ts +11 -0
  179. package/dist/session/arbiter.js +43 -2
  180. package/dist/session/codex-app-server.d.ts +32 -0
  181. package/dist/session/codex-app-server.js +137 -23
  182. package/dist/session/control.d.ts +12 -1
  183. package/dist/session/control.js +68 -3
  184. package/dist/session/conversation-normalizer.d.ts +2 -1
  185. package/dist/session/conversation-normalizer.js +1 -1
  186. package/dist/session/conversation-store.d.ts +1 -1
  187. package/dist/session/conversation-store.js +7 -11
  188. package/dist/session/conversation-types.d.ts +28 -3
  189. package/dist/session/prompt-batch.d.ts +63 -0
  190. package/dist/session/prompt-batch.js +86 -0
  191. package/dist/session/task-notice.d.ts +14 -0
  192. package/dist/session/task-notice.js +13 -0
  193. package/dist/session/types.d.ts +14 -0
  194. package/dist/setup-tunnel-preflight.d.ts +34 -0
  195. package/dist/setup-tunnel-preflight.js +62 -0
  196. package/dist/socket-path.d.ts +2 -0
  197. package/dist/socket-path.js +22 -0
  198. package/dist/spawn.d.ts +5 -1
  199. package/dist/spawn.js +40 -4
  200. package/dist/startup-prompt.d.ts +4 -0
  201. package/dist/startup-prompt.js +36 -0
  202. package/dist/startup-readiness.d.ts +13 -0
  203. package/dist/startup-readiness.js +38 -0
  204. package/dist/startup-telemetry.d.ts +16 -0
  205. package/dist/startup-telemetry.js +46 -0
  206. package/dist/subscriptions/cli.d.ts +91 -0
  207. package/dist/subscriptions/cli.js +223 -0
  208. package/dist/subscriptions/launch.d.ts +37 -0
  209. package/dist/subscriptions/launch.js +93 -0
  210. package/dist/subscriptions/login.d.ts +75 -0
  211. package/dist/subscriptions/login.js +182 -0
  212. package/dist/subscriptions/observed.d.ts +7 -0
  213. package/dist/subscriptions/observed.js +44 -0
  214. package/dist/subscriptions/probe.d.ts +10 -0
  215. package/dist/subscriptions/probe.js +73 -0
  216. package/dist/subscriptions/service.d.ts +102 -0
  217. package/dist/subscriptions/service.js +364 -0
  218. package/dist/subscriptions/store.d.ts +71 -0
  219. package/dist/subscriptions/store.js +140 -0
  220. package/dist/supervisor/adoption.d.ts +8 -0
  221. package/dist/supervisor/adoption.js +78 -0
  222. package/dist/supervisor/catalog.d.ts +62 -0
  223. package/dist/supervisor/catalog.js +254 -0
  224. package/dist/supervisor/fleet.d.ts +8 -0
  225. package/dist/supervisor/fleet.js +89 -0
  226. package/dist/supervisor/index.js +3 -6
  227. package/dist/supervisor/launchd.d.ts +1 -1
  228. package/dist/supervisor/launchd.js +26 -13
  229. package/dist/supervisor/legacy.d.ts +35 -0
  230. package/dist/supervisor/legacy.js +524 -0
  231. package/dist/supervisor/manager.d.ts +15 -0
  232. package/dist/supervisor/manager.js +270 -0
  233. package/dist/supervisor/scope.d.ts +4 -0
  234. package/dist/supervisor/scope.js +10 -0
  235. package/dist/supervisor/systemd.d.ts +2 -2
  236. package/dist/supervisor/systemd.js +19 -33
  237. package/dist/task-supervision.d.ts +9 -0
  238. package/dist/task-supervision.js +145 -0
  239. package/dist/temp-idle.d.ts +17 -0
  240. package/dist/temp-idle.js +42 -0
  241. package/dist/temp-lifecycle.d.ts +23 -8
  242. package/dist/temp-lifecycle.js +113 -65
  243. package/dist/temp-supervisor-recovery.d.ts +1 -1
  244. package/dist/temp-supervisor-recovery.js +2 -2
  245. package/dist/tunnel-setup-completion.d.ts +7 -0
  246. package/dist/tunnel-setup-completion.js +13 -0
  247. package/dist/tunnel-setup-input.d.ts +19 -0
  248. package/dist/tunnel-setup-input.js +124 -0
  249. package/dist/web/agent-attachments.d.ts +29 -0
  250. package/dist/web/agent-attachments.js +162 -0
  251. package/dist/web/auth.d.ts +63 -2
  252. package/dist/web/auth.js +191 -10
  253. package/dist/web/available-port.d.ts +2 -0
  254. package/dist/web/available-port.js +23 -0
  255. package/dist/web/control.d.ts +3 -3
  256. package/dist/web/control.js +10 -8
  257. package/dist/web/fleet-config-service.d.ts +4 -1
  258. package/dist/web/fleet-config-service.js +46 -15
  259. package/dist/web/prefix-gateway.d.ts +32 -0
  260. package/dist/web/prefix-gateway.js +238 -0
  261. package/dist/web/runtime.d.ts +3 -1
  262. package/dist/web/runtime.js +116 -31
  263. package/dist/web/server.d.ts +16 -0
  264. package/dist/web/server.js +654 -55
  265. package/dist/web/service.d.ts +11 -2
  266. package/dist/web/service.js +30 -4
  267. package/dist/web/workspace-devices.d.ts +107 -0
  268. package/dist/web/workspace-devices.js +277 -0
  269. package/dist/web/workspace-tunnel.d.ts +1 -0
  270. package/dist/web/workspace-tunnel.js +34 -0
  271. package/dist/workspace-enrollment.d.ts +82 -0
  272. package/dist/workspace-enrollment.js +566 -0
  273. package/dist/workspace-replacement.d.ts +66 -0
  274. package/dist/workspace-replacement.js +220 -0
  275. package/docs/acp-files.md +146 -0
  276. package/docs/fleet-migration.md +54 -0
  277. package/docs/live-voice.md +13 -0
  278. package/docs/room-layouts.md +227 -0
  279. package/docs/task-workspaces.md +225 -0
  280. package/docs/validation/managed-cli-permissions.md +421 -0
  281. package/examples/room-layouts/fresh.yaml +27 -0
  282. package/examples/room-layouts/scoped.yaml +21 -0
  283. package/examples/room-layouts/shared.yaml +19 -0
  284. package/package.json +20 -19
  285. package/presets/README.md +78 -0
  286. package/presets/brain-catalog.json +8 -2
  287. package/presets/fleet/agent_templates/Critic.yaml +1 -1
  288. package/presets/fleet/agent_templates/Developer.yaml +1 -1
  289. package/presets/fleet/agent_templates/Engineer.yaml +5 -0
  290. package/presets/fleet/agent_templates/LocalCoordinator.yaml +1 -1
  291. package/presets/fleet/agents/FleetCoordinator.yaml +1 -1
  292. package/presets/fleet/brains/claude-fable-5-1-high.yaml +5 -0
  293. package/presets/fleet/brains/claude-fable-5-1-low.yaml +5 -0
  294. package/presets/fleet/brains/claude-fable-5-1-max.yaml +5 -0
  295. package/presets/fleet/brains/claude-fable-5-1-medium.yaml +5 -0
  296. package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +5 -0
  297. package/presets/fleet/brains/claude-fable-5-high.yaml +1 -1
  298. package/presets/fleet/brains/claude-fable-5-low.yaml +1 -1
  299. package/presets/fleet/brains/claude-fable-5-max.yaml +1 -1
  300. package/presets/fleet/brains/claude-fable-5-medium.yaml +1 -1
  301. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +1 -1
  302. package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +4 -0
  303. package/presets/fleet/brains/claude-opus-5-5-high.yaml +5 -0
  304. package/presets/fleet/brains/claude-opus-5-5-low.yaml +5 -0
  305. package/presets/fleet/brains/claude-opus-5-5-max.yaml +5 -0
  306. package/presets/fleet/brains/claude-opus-5-5-medium.yaml +5 -0
  307. package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +5 -0
  308. package/presets/fleet/brains/claude-opus-5-high.yaml +1 -1
  309. package/presets/fleet/brains/claude-opus-5-low.yaml +1 -1
  310. package/presets/fleet/brains/claude-opus-5-max.yaml +1 -1
  311. package/presets/fleet/brains/claude-opus-5-medium.yaml +1 -1
  312. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +1 -1
  313. package/presets/fleet/brains/claude-sonnet-5-high.yaml +1 -1
  314. package/presets/fleet/brains/claude-sonnet-5-low.yaml +1 -1
  315. package/presets/fleet/brains/claude-sonnet-5-max.yaml +1 -1
  316. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +1 -1
  317. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +1 -1
  318. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +1 -1
  319. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +1 -1
  320. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +1 -1
  321. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +1 -1
  322. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +1 -1
  323. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +1 -1
  324. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +1 -1
  325. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +1 -1
  326. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +1 -1
  327. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +1 -1
  328. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +1 -1
  329. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +1 -1
  330. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +1 -1
  331. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +1 -1
  332. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +1 -1
  333. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +1 -1
  334. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +1 -1
  335. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +1 -1
  336. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +1 -1
  337. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +1 -1
  338. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +1 -1
  339. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +1 -1
  340. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +1 -1
  341. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +1 -1
  342. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +1 -1
  343. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +1 -1
  344. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +1 -1
  345. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +1 -1
  346. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +1 -1
  347. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +1 -1
  348. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +1 -1
  349. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +1 -1
  350. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +1 -1
  351. package/presets/fleet/brains/codex-gpt-6-1-sol-high.yaml +5 -0
  352. package/presets/fleet/brains/codex-gpt-6-1-sol-low.yaml +5 -0
  353. package/presets/fleet/brains/codex-gpt-6-1-sol-max.yaml +5 -0
  354. package/presets/fleet/brains/codex-gpt-6-1-sol-medium.yaml +5 -0
  355. package/presets/fleet/brains/codex-gpt-6-1-sol-ultra.yaml +5 -0
  356. package/presets/fleet/brains/codex-gpt-6-1-sol-xhigh.yaml +5 -0
  357. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +1 -1
  358. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +1 -1
  359. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +1 -1
  360. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +1 -1
  361. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +1 -1
  362. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +1 -1
  363. package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +5 -0
  364. package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +5 -0
  365. package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +5 -0
  366. package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +5 -0
  367. package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +5 -0
  368. package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +5 -0
  369. package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +5 -0
  370. package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +5 -0
  371. package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +5 -0
  372. package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +5 -0
  373. package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +5 -0
  374. package/presets/fleet/roles/Coordinator.yaml +34 -11
  375. package/presets/fleet/roles/Engineer.yaml +40 -0
  376. package/presets/fleet/room_layouts/engineering.yaml +25 -0
  377. package/presets/fleet/room_layouts/pair.yaml +24 -0
  378. package/presets/fleet/room_layouts/single.yaml +17 -0
  379. package/presets/fleet/room_layouts/team.yaml +30 -0
  380. package/presets/fleet/room_templates/engineering.yaml +19 -0
  381. package/presets/manifest.json +2 -2
  382. package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
  383. package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
  384. package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
  385. package/dist/web-app/icons/ours-fleet.svg +0 -4
  386. package/dist/web-app/index.html +0 -17
  387. package/dist/web-app/manifest.webmanifest +0 -15
  388. package/dist/web-app/offline.html +0 -18
  389. package/dist/web-app/sw.js +0 -51
@@ -1,8 +1,16 @@
1
- import { existsSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
1
+ import { conversationHasAttachment } from '../application/conversation-history.js';
2
+ import { readDeliveredFile } from '../file-delivery/store.js';
3
+ import { WorkspaceDeviceAuthError } from './workspace-devices.js';
4
+ import { readFileSync } from 'node:fs';
5
+ import { packagedPresetRoot } from '../preset-bootstrap.js';
6
+ import { validateCatalog } from '../init-wizard.js';
7
+ import { getAdapter } from '../harness/registry.js';
8
+ import { storeAgentAttachment, attachmentPrompt, MAX_ATTACHMENT_BODY, readAgentAttachment, prepareAttachmentPresentation, presentAttachmentEvent } from './agent-attachments.js';
9
+ import { readChatIdle } from '../temp-idle.js';
10
+ import { agentDir } from '../paths.js';
11
+ import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
12
+ import { join } from 'node:path';
4
13
  import Fastify from 'fastify';
5
- import fastifyStatic from '@fastify/static';
6
14
  import fastifyWebsocket from '@fastify/websocket';
7
15
  import { createHmac, randomBytes } from 'node:crypto';
8
16
  import { FleetError, normalizeError } from '../application/errors.js';
@@ -13,6 +21,8 @@ import { FleetEventBus } from './events.js';
13
21
  import { ROLE_NAME_RE } from '../config.js';
14
22
  import { TaskListError } from '../rooms-tasks/task-lists.js';
15
23
  import { TaskStateError } from '../rooms-tasks/task-state.js';
24
+ import { isProvider } from '../subscriptions/store.js';
25
+ const ONBOARDING_WORK = ['coordination', 'development', 'review'];
16
26
  const statusFor = (code) => ({
17
27
  role_not_found: 404, resource_not_found: 404, unauthorized: 401, forbidden: 403, conflict: 409,
18
28
  idempotency_conflict: 409, stale_state: 409, rate_limited: 429,
@@ -33,29 +43,49 @@ export async function buildWebServer(services, boundary, options = {}) {
33
43
  app.addHook('onRequest', async (request, reply) => {
34
44
  reply.header('X-Content-Type-Options', 'nosniff');
35
45
  reply.header('Referrer-Policy', 'no-referrer');
36
- reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
37
- reply.header('X-Frame-Options', 'DENY');
38
- reply.header('Content-Security-Policy', `default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; ` +
39
- `connect-src 'self' ${auth.secureCookies ? 'wss' : 'ws'}://${auth.host}; object-src 'none'; base-uri 'none'; ` +
40
- `frame-ancestors 'none'; form-action 'self'; manifest-src 'self'; worker-src 'self'`);
46
+ reply.header('Permissions-Policy', 'camera=(), microphone=(self), geolocation=(), payment=()');
47
+ // Fleet serves an API only: nothing it returns may run, load or be framed as a page.
48
+ reply.header('Content-Security-Policy', "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
41
49
  if (request.url.startsWith('/api/'))
42
50
  reply.header('Cache-Control', 'no-store');
43
- try {
44
- auth.validateBoundary(request, false);
45
- }
46
- catch (error) {
47
- if (request.url.startsWith('/api/'))
48
- throw error;
49
- const message = normalizeError(error).message;
50
- 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>`);
51
+ if (request.headers.origin === auth.appOrigin && request.url.startsWith('/api/')) {
52
+ reply.header('Access-Control-Allow-Origin', auth.appOrigin).header('Vary', 'Origin');
53
+ reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
54
+ reply.header('Access-Control-Expose-Headers', 'X-Ours-Workspace-Auth, X-Ours-Request-Id');
55
+ reply.header('Access-Control-Allow-Headers', 'Authorization,Content-Type,X-CSRF-Token,Idempotency-Key');
56
+ if (request.method === 'OPTIONS') {
57
+ auth.validateBoundary(request, false);
58
+ return reply.code(204).send();
59
+ }
51
60
  }
61
+ auth.validateBoundary(request, false);
62
+ });
63
+ app.post('/api/v1/layout-bindings/:id/control', async (request) => {
64
+ // Initial support is direct same-host Fleet-to-Fleet transport.
65
+ const forwarded = Object.keys(request.headers).some(name => name === 'forwarded' || name.startsWith('x-forwarded-') || name === 'x-real-ip' || name === 'via');
66
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.ip) || forwarded)
67
+ throw new FleetError('forbidden', 'layout bindings require direct loopback access');
68
+ // Machine credentials cannot authenticate browser or lifecycle routes.
69
+ if (request.headers.origin)
70
+ auth.validateBoundary(request, true);
71
+ if (!services.layoutBindings)
72
+ throw new FleetError('capability_unavailable', 'layout bindings unavailable');
73
+ return services.layoutBindings.control(request.params.id, request.headers.authorization, request.body);
52
74
  });
53
75
  app.setErrorHandler(async (error, request, reply) => {
54
- const fleetError = normalizeError(error, request.id);
76
+ const privateToolParseError = ['/api/v1/roles/:id/ours/call', '/api/v1/layout-bindings/:id/control'].includes(request.routeOptions.url ?? '')
77
+ && error instanceof Error && 'code' in error
78
+ && typeof error.code === 'string' && error.code.startsWith('FST_ERR_CTP_');
79
+ const fleetError = normalizeError(privateToolParseError
80
+ ? new FleetError('invalid_request', 'expected a valid JSON tool request') : error, request.id);
55
81
  await audit.record({
56
82
  requestId: request.id, action: `${request.method} ${request.routeOptions.url ?? request.url}`,
57
83
  result: 'rejected', errorCode: fleetError.code,
58
84
  });
85
+ if (error instanceof WorkspaceDeviceAuthError)
86
+ reply.header('X-Ours-Workspace-Auth', 'rejected');
87
+ if (fleetError.code === 'unauthorized')
88
+ reply.header('X-Ours-Request-Id', request.id);
59
89
  reply.code(statusFor(fleetError.code)).send({ error: fleetError.toJSON() });
60
90
  });
61
91
  app.post('/api/v1/auth/exchange', async (request, reply) => {
@@ -96,6 +126,51 @@ export async function buildWebServer(services, boundary, options = {}) {
96
126
  const session = auth.authenticate(request);
97
127
  return { csrfToken: session.csrf, expiresAt: new Date(session.absoluteExpiresAt).toISOString() };
98
128
  });
129
+ app.get('/api/v1/workspace/identity', async () => auth.workspaceIdentity());
130
+ app.post('/api/v1/devices/enroll', async (request, reply) => {
131
+ reply.header('Cache-Control', 'no-store');
132
+ const input = request.body;
133
+ if (!input)
134
+ throw new FleetError('invalid_request', 'device enrollment body required');
135
+ return auth.enrollWorkspace(request, input);
136
+ });
137
+ // Refresh credentials travel between the account server and this host; these routes refuse browsers.
138
+ const refreshInput = (request, name) => {
139
+ const value = request.body?.[name];
140
+ if (typeof value !== 'string')
141
+ throw new FleetError('invalid_request', `${name} is required`);
142
+ return value;
143
+ };
144
+ app.post('/api/v1/devices/refresh', async (request, reply) => {
145
+ reply.header('Cache-Control', 'no-store');
146
+ return auth.refreshWorkspace(request, refreshInput(request, 'refreshToken'));
147
+ });
148
+ app.post('/api/v1/devices/exchange', async (request, reply) => {
149
+ reply.header('Cache-Control', 'no-store');
150
+ return auth.exchangeWorkspace(request);
151
+ });
152
+ app.post('/api/v1/devices/revoke', async (request) => ({ revoked: auth.revokeWorkspaceRefresh(request, refreshInput(request, 'refreshHash')) }));
153
+ app.post('/api/v1/devices/link', async (request, reply) => {
154
+ const proof = request.body?.refreshToken;
155
+ if (proof !== undefined) {
156
+ reply.header('Cache-Control', 'no-store');
157
+ const { enrollment, enrollmentExpiresAt, ...credentials } = auth.linkWorkspaceDevice(request, refreshInput(request, 'refreshToken'));
158
+ return { ...credentials, code: { version: 1, origin: auth.origin, enrollment, workspaceId: credentials.workspaceId, expiresAt: enrollmentExpiresAt } };
159
+ }
160
+ const session = auth.authenticate(request, true);
161
+ // A short-lived access credential must not be able to create a lasting one.
162
+ if (session.rotating)
163
+ throw new FleetError('forbidden', 'linking another device requires this link\'s refresh credential');
164
+ return { version: 1, origin: auth.origin, ...auth.workspaceEnrollment(session.id) };
165
+ });
166
+ app.get('/api/v1/devices/current', async (request) => {
167
+ const session = auth.authenticate(request);
168
+ if (!session.id.startsWith('workspace:'))
169
+ throw new FleetError('resource_not_found', 'no workspace device');
170
+ return { deviceId: session.id.slice(10), authorizedUntil: session.absoluteExpiresAt };
171
+ });
172
+ app.get('/api/v1/devices', async (request) => { auth.authenticate(request); return { devices: auth.listWorkspaceDevices() }; });
173
+ app.delete('/api/v1/devices/:id', async (request) => { auth.authenticate(request, true); auth.revokeWorkspaceDevice(request.params.id); return { revoked: true }; });
99
174
  app.get('/api/v1/meta', async (request) => {
100
175
  auth.authenticate(request);
101
176
  return {
@@ -106,6 +181,83 @@ export async function buildWebServer(services, boundary, options = {}) {
106
181
  auditDegraded: audit.degraded,
107
182
  };
108
183
  });
184
+ app.get('/api/v1/onboarding', async (request) => {
185
+ auth.authenticate(request);
186
+ // Opening an existing workspace needs configuration state, not CLI/account discovery.
187
+ // Keep the full report for the setup screens and older App clients.
188
+ if (request.query.view === 'state')
189
+ return { configured: onboardingConfigured() };
190
+ const harnesses = await Promise.all(['codex', 'claude-code'].map(async (harness) => {
191
+ try {
192
+ return { harness, ...await getAdapter(harness).checkPrereqs() };
193
+ }
194
+ catch {
195
+ return { harness, ok: false, checks: [{ name: 'availability', ok: false, detail: 'Harness checks unavailable' }] };
196
+ }
197
+ }));
198
+ const catalog = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8')));
199
+ // Codex models are offered only when the runtime itself listed them for the active account.
200
+ // When it could not be asked, none is offered and the reason is reported.
201
+ const codexModels = services.codexModels ? await services.codexModels() : undefined;
202
+ const offered = (model) => model.harness !== 'codex' || !codexModels || (codexModels.state === 'known' && codexModels.models.includes(model.model));
203
+ return { 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() };
204
+ });
205
+ // First-time setup is what creates the persistent coordinator. A configuration that loads and
206
+ // has it is set up, whatever its brains are called; its models are then changed by editing it.
207
+ const onboardingConfigured = () => {
208
+ if (!services.configuration)
209
+ return false;
210
+ try {
211
+ return Boolean(services.configuration.read(false).model.agents.FleetCoordinator);
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ };
217
+ let onboardingSetupQueue = Promise.resolve();
218
+ app.post('/api/v1/onboarding/setup', async (request) => {
219
+ auth.authenticate(request, true);
220
+ if (!services.configuration || !services.onboardingSetup)
221
+ throw new FleetError('capability_unavailable', 'first-time Fleet setup is unavailable');
222
+ const requested = request.body?.models;
223
+ const codexModels = services.codexModels ? await services.codexModels() : undefined;
224
+ const supported = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8'))).models
225
+ .filter(model => model.harness !== 'codex' || !codexModels || (codexModels.state === 'known' && codexModels.models.includes(model.model)));
226
+ const models = {};
227
+ for (const work of ONBOARDING_WORK) {
228
+ const matches = supported.filter(model => model.harness === requested?.[work]?.harness && model.model === requested?.[work]?.model);
229
+ if (matches.length !== 1)
230
+ throw new FleetError('invalid_request', `choose a supported model for ${work}`);
231
+ models[work] = matches[0];
232
+ }
233
+ const chosen = Object.values(models);
234
+ const subscriptions = [...new Set(chosen.map(model => model.harness === 'codex' ? 'codex' : 'claude'))];
235
+ const oneModel = new Set(chosen.map(model => `${model.harness}\0${model.model}`)).size === 1;
236
+ const configuration = services.configuration, setup = services.onboardingSetup;
237
+ // One setup at a time: the first-time check and the publication it guards must not interleave.
238
+ const run = onboardingSetupQueue.then(async () => {
239
+ if (onboardingConfigured())
240
+ throw new FleetError('conflict', 'Fleet is already set up; change its models in the configuration');
241
+ try {
242
+ await setup({ subscriptions, assignmentStrategy: oneModel ? 'one-model' : 'per-job', models, reasoning: 'balanced' });
243
+ }
244
+ catch (error) {
245
+ throw new FleetError('backend_failure', `Fleet setup did not complete: ${error.message}`);
246
+ }
247
+ // Setup preserves files it finds, so confirm the published models are the ones this request chose.
248
+ // The prepared agents reference packaged Brain presets, so resolve what each of them now points at:
249
+ // a preserved file of a packaged name may hold another model, session or reasoning than its name says.
250
+ const published = configuration.read(true).model, brains = published.brains ?? {};
251
+ const brainOf = (holder) => brains[String(holder?.brain?.ref ?? '')];
252
+ const assigned = { coordination: brainOf(published.agents.FleetCoordinator), development: brainOf(published.agent_templates?.Developer), review: brainOf(published.agent_templates?.Critic) };
253
+ for (const work of ONBOARDING_WORK)
254
+ if (assigned[work]?.harness !== models[work].harness || assigned[work]?.model !== models[work].model || assigned[work]?.session !== models[work].session || assigned[work]?.effort !== 'medium')
255
+ throw new FleetError('conflict', 'Fleet was set up with different models; review its configuration');
256
+ return { configured: true };
257
+ });
258
+ onboardingSetupQueue = run.catch(() => { });
259
+ return run;
260
+ });
109
261
  app.get('/api/v1/creation-capabilities', async (request) => {
110
262
  auth.authenticate(request);
111
263
  return services.creation.capabilities();
@@ -128,7 +280,8 @@ export async function buildWebServer(services, boundary, options = {}) {
128
280
  });
129
281
  app.get('/api/v1/roles', async (request) => {
130
282
  auth.authenticate(request);
131
- return { roles: await services.query.list() };
283
+ const query = request.query;
284
+ return { roles: await services.query.list(query.includeTemporary === 'true') };
132
285
  });
133
286
  const taskApi = async (fn) => {
134
287
  try {
@@ -141,6 +294,8 @@ export async function buildWebServer(services, boundary, options = {}) {
141
294
  ? 'conflict' : 'invalid_request';
142
295
  throw new FleetError(code, error.message);
143
296
  }
297
+ if (error instanceof TaskStateError && 'accepted' in error && error.accepted === false)
298
+ return Promise.reject(Object.assign(new FleetError('invalid_request', error.message), { memberNotAccepted: true }));
144
299
  if (error instanceof TaskStateError) {
145
300
  throw new FleetError(error.message.startsWith('task not found:') ? 'resource_not_found' : 'conflict', error.message);
146
301
  }
@@ -188,9 +343,10 @@ export async function buildWebServer(services, boundary, options = {}) {
188
343
  const state = query.state && query.state !== 'all' ? query.state : undefined;
189
344
  const filter = { ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
190
345
  ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
346
+ const api = requireTaskRooms();
191
347
  return taskApi(() => query.groupByList === 'true'
192
- ? { groups: requireTaskRooms().groupedTasks(filter) }
193
- : { tasks: requireTaskRooms().listTasks(filter) });
348
+ ? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => api.withLayoutRooms(task)) })) }
349
+ : { tasks: api.listTasks(filter).map(task => api.withLayoutRooms(task)) });
194
350
  });
195
351
  app.delete('/api/v1/tasks/:id', async (request, reply) => {
196
352
  auth.authenticate(request, true);
@@ -221,12 +377,17 @@ export async function buildWebServer(services, boundary, options = {}) {
221
377
  const body = request.body;
222
378
  if (typeof body?.title !== 'string' || !body.title)
223
379
  throw new FleetError('invalid_request', 'title is required');
380
+ // Every task runs a saved Room Layout; rooms are never defined inline or from room templates here.
381
+ if (typeof body.layout !== 'string' || !body.layout)
382
+ throw new FleetError('invalid_request', 'layout is required: choose a room layout from Settings › Room layouts');
383
+ if (body.template !== undefined || body.noRoom !== undefined)
384
+ throw new FleetError('invalid_request', 'tasks use room layouts; template and noRoom are not accepted');
224
385
  const task = await taskApi(() => requireTaskRooms().createTask({
225
386
  actor: { kind: 'local_control', surface: 'web' }, title: body.title,
226
387
  brief: typeof body.brief === 'string' ? body.brief : undefined,
227
- template: typeof body.template === 'string' ? body.template : undefined,
228
- backlog: body.backlog === true, noRoom: body.noRoom === true,
388
+ backlog: body.backlog === true,
229
389
  list: typeof body.list === 'string' ? body.list : undefined,
390
+ layout: body.layout,
230
391
  idempotencyKey: typeof request.headers['idempotency-key'] === 'string'
231
392
  ? request.headers['idempotency-key'] : undefined,
232
393
  origin: { type: 'web' },
@@ -240,11 +401,162 @@ export async function buildWebServer(services, boundary, options = {}) {
240
401
  const list = String(request.body?.list ?? '');
241
402
  return { task: await taskApi(() => requireTaskRooms().moveTask({ actor: { kind: 'local_control', surface: 'web' }, taskId, list })) };
242
403
  });
404
+ app.patch('/api/v1/tasks/:id/description', async (request) => {
405
+ auth.authenticate(request, true);
406
+ const body = request.body;
407
+ if (typeof body?.brief !== 'string' || typeof body.expectedBrief !== 'string' || body.brief.length > 100_000)
408
+ throw new FleetError('invalid_request', 'brief and expectedBrief must be strings; brief maximum is 100000 characters');
409
+ return { task: await taskApi(() => requireTaskRooms().editTaskDescription({
410
+ actor: { kind: 'local_control', surface: 'web' }, taskId: request.params.id,
411
+ brief: body.brief, expectedBrief: body.expectedBrief,
412
+ })) };
413
+ });
414
+ app.get('/api/v1/tasks/:id', async (request) => {
415
+ auth.authenticate(request);
416
+ return taskApi(() => requireTaskRooms().getTask(request.params.id));
417
+ });
418
+ app.post('/api/v1/tasks/:id/members', async (request, reply) => {
419
+ auth.authenticate(request, true);
420
+ const body = request.body;
421
+ try {
422
+ const result = await taskApi(() => requireTaskRooms().addMember(request.params.id, body));
423
+ reply.code(result.state === 'running' ? 202 : 200);
424
+ return result;
425
+ }
426
+ catch (error) {
427
+ if (error instanceof Error && 'memberNotAccepted' in error)
428
+ return reply.code(409).send({ error: { code: 'member_not_accepted', message: error.message }, accepted: false });
429
+ throw error;
430
+ }
431
+ });
432
+ app.get('/api/v1/tasks/:id/member-additions/:requestId', async (request) => {
433
+ auth.authenticate(request);
434
+ const { id, requestId } = request.params;
435
+ return taskApi(() => requireTaskRooms().memberAddition(id, requestId));
436
+ });
437
+ app.patch('/api/v1/tasks/:id/layout', async (request) => {
438
+ const session = auth.authenticate(request, true);
439
+ const body = request.body;
440
+ const name = (v) => typeof v === 'string' && v.length > 0;
441
+ if (!name(body?.layout) || !(body.expectedLayout === null || name(body.expectedLayout)))
442
+ throw new FleetError('invalid_request', 'layout must be a layout name; expectedLayout a layout name or null');
443
+ const task = await taskApi(() => requireTaskRooms().setTaskLayout({ actor: { kind: 'local_control', surface: 'web' },
444
+ taskId: request.params.id, layout: body.layout, expectedLayout: body.expectedLayout }));
445
+ await audit.record({ requestId: request.id, browser: session.id, action: 'task.layout.set', result: 'succeeded' });
446
+ return { task: requireTaskRooms().withLayoutRooms(task) };
447
+ });
448
+ app.get('/api/v1/tasks/:id/layout', async (request) => {
449
+ auth.authenticate(request);
450
+ return { layout: await taskApi(async () => requireTaskRooms().taskLayout(request.params.id)) };
451
+ });
452
+ for (const operation of ['open', 'close']) {
453
+ app.post(`/api/v1/tasks/:id/layout/rooms/:room/${operation}`, async (request, reply) => {
454
+ const session = auth.authenticate(request, true);
455
+ const { id, room } = request.params;
456
+ const accepted = await taskApi(() => requireTaskRooms().launchTaskLayoutOperation({
457
+ actor: { kind: 'local_control', surface: 'web' }, taskId: id, room,
458
+ operation: operation === 'open' ? 'open' : 'close-room',
459
+ }));
460
+ await audit.record({ requestId: request.id, browser: session.id, action: `task.layout.${operation}`, result: 'succeeded' });
461
+ reply.code(202);
462
+ return { operation: accepted };
463
+ });
464
+ }
465
+ app.post('/api/v1/tasks/:id/layout/cleanup', async (request, reply) => {
466
+ auth.authenticate(request, true);
467
+ const accepted = await taskApi(() => requireTaskRooms().retryTaskLayoutCleanup({
468
+ actor: { kind: 'local_control', surface: 'web' }, taskId: request.params.id,
469
+ }));
470
+ reply.code(202);
471
+ return { operation: accepted };
472
+ });
473
+ app.get('/api/v1/task-templates', async (request) => {
474
+ auth.authenticate(request);
475
+ return { templates: requireTaskRooms().listTemplates() };
476
+ });
477
+ const layouts = () => {
478
+ if (!services.roomLayouts)
479
+ throw new FleetError('capability_unavailable', 'room layout editing is unavailable');
480
+ return services.roomLayouts;
481
+ };
482
+ app.get('/api/v1/room-layouts', async (request) => {
483
+ auth.authenticate(request);
484
+ const list = layouts().list(), defaultLayout = layouts().defaultLayout(list);
485
+ return { layouts: list, ...(defaultLayout ? { default_layout: defaultLayout } : {}) };
486
+ });
487
+ app.post('/api/v1/room-layouts/validate', async (request) => {
488
+ auth.authenticate(request, true);
489
+ return layouts().validate(request.body?.definition);
490
+ });
491
+ app.put('/api/v1/room-layouts/:name', async (request) => {
492
+ const session = auth.authenticate(request, true);
493
+ const body = request.body;
494
+ if (typeof body?.revision !== 'string')
495
+ throw new FleetError('invalid_request', 'revision is required');
496
+ const result = await layouts().save(request.params.name, body.revision, body.definition);
497
+ events.publish('room_layouts.changed', { name: result.name, revision: result.revision });
498
+ await audit.record({ requestId: request.id, browser: session.id, action: 'room_layout.save', result: 'succeeded' });
499
+ return result;
500
+ });
501
+ app.delete('/api/v1/room-layouts/:name', async (request) => {
502
+ const session = auth.authenticate(request, true);
503
+ const revision = request.query.revision;
504
+ if (typeof revision !== 'string' || !revision)
505
+ throw new FleetError('invalid_request', 'revision is required');
506
+ const result = await layouts().remove(request.params.name, revision);
507
+ events.publish('room_layouts.changed', { name: result.name });
508
+ await audit.record({ requestId: request.id, browser: session.id, action: 'room_layout.delete', result: 'succeeded' });
509
+ return result;
510
+ });
511
+ for (const action of ['start', 'block', 'unblock', 'review', 'finish', 'cancel']) {
512
+ app.post(`/api/v1/tasks/:id/${action}`, async (request, reply) => {
513
+ auth.authenticate(request, true);
514
+ const taskId = request.params.id;
515
+ const body = (request.body ?? {});
516
+ const input = { actor: { kind: 'local_control', surface: 'web' }, taskId };
517
+ const api = requireTaskRooms();
518
+ return taskApi(async () => {
519
+ switch (action) {
520
+ case 'start': {
521
+ // A task's layout can be chosen or changed but never removed, so this check cannot go stale.
522
+ if (!api.getTask(taskId).task.layout)
523
+ throw new FleetError('conflict', 'choose a room layout for this task before starting it');
524
+ const task = await api.startTask(input);
525
+ const provisioning = await api.launchTaskProvisioning(taskId);
526
+ if (provisioning.kind !== 'ready')
527
+ reply.code(202);
528
+ return { task, provisioning };
529
+ }
530
+ case 'block':
531
+ if (typeof body.reason !== 'string' || !body.reason.trim())
532
+ throw new FleetError('invalid_request', 'reason is required');
533
+ return { task: api.blockTask({ ...input, reason: body.reason }) };
534
+ case 'unblock': return { task: api.unblockTask(input) };
535
+ case 'review': return { task: api.reviewTask(input) };
536
+ case 'finish':
537
+ case 'cancel': {
538
+ const plan = action === 'finish' ? await api.finishTask(input) : await api.cancelTask(input);
539
+ const task = plan.settlementRequired ? await api.launchTaskSettlement(taskId) : plan.task;
540
+ const pending = task.terminal_intent?.status === 'pending';
541
+ if (pending)
542
+ reply.code(202);
543
+ return { task, pending };
544
+ }
545
+ }
546
+ });
547
+ });
548
+ }
549
+ app.get('/api/v1/configuration/provenance', async (request) => {
550
+ auth.authenticate(request);
551
+ if (!services.presetProvenance)
552
+ throw new FleetError('capability_unavailable', 'preset provenance is unavailable');
553
+ return { provenance: services.presetProvenance.read() };
554
+ });
243
555
  app.get('/api/v1/configuration', async (request) => {
244
556
  auth.authenticate(request);
245
557
  if (!services.configuration)
246
558
  throw new FleetError('capability_unavailable', 'fleet configuration editing is unavailable');
247
- return services.configuration.read();
559
+ return services.configuration.read(request.query.includeDefinitions === 'true');
248
560
  });
249
561
  app.post('/api/v1/configuration/preview', async (request) => {
250
562
  auth.authenticate(request, true);
@@ -265,6 +577,86 @@ export async function buildWebServer(services, boundary, options = {}) {
265
577
  });
266
578
  return result;
267
579
  });
580
+ // Claude Code / Codex subscription profiles. Responses carry account metadata
581
+ // only; token values never leave the CLI-owned credential files.
582
+ const subscriptions = () => {
583
+ if (!services.subscriptions)
584
+ throw new FleetError('capability_unavailable', 'subscription management is unavailable');
585
+ return services.subscriptions;
586
+ };
587
+ const provider = (raw) => {
588
+ if (!isProvider(raw))
589
+ throw new FleetError('invalid_request', 'unknown subscription provider');
590
+ return raw;
591
+ };
592
+ const subscriptionsChanged = () => events.publish('subscription.changed', {});
593
+ app.get('/api/v1/subscriptions', async (request) => {
594
+ auth.authenticate(request);
595
+ return { providers: await subscriptions().list() };
596
+ });
597
+ app.post('/api/v1/subscriptions/:provider/logins', async (request, reply) => {
598
+ const session = auth.authenticate(request, true);
599
+ const login = await subscriptions().startLogin(provider(request.params.provider), session.id);
600
+ await audit.record({ requestId: request.id, browser: session.id, action: `subscription.login.${request.params.provider}`, result: login.state });
601
+ reply.code(201);
602
+ return login;
603
+ });
604
+ app.get('/api/v1/subscriptions/:provider/logins/:loginId', async (request) => {
605
+ const session = auth.authenticate(request);
606
+ return subscriptions().login(request.params.loginId, session.id).view();
607
+ });
608
+ app.post('/api/v1/subscriptions/:provider/logins/:loginId/code', async (request) => {
609
+ const session = auth.authenticate(request, true);
610
+ const code = request.body?.code;
611
+ if (typeof code !== 'string')
612
+ throw new FleetError('invalid_request', 'code is required');
613
+ const login = subscriptions().login(request.params.loginId, session.id);
614
+ login.submitCode(code.trim());
615
+ await audit.record({ requestId: request.id, browser: session.id, action: `subscription.login.code.${request.params.provider}`, result: 'submitted' });
616
+ return login.view();
617
+ });
618
+ app.delete('/api/v1/subscriptions/:provider/logins/:loginId', async (request) => {
619
+ const session = auth.authenticate(request, true);
620
+ const login = subscriptions().login(request.params.loginId, session.id);
621
+ login.cancel();
622
+ return login.view();
623
+ });
624
+ app.post('/api/v1/subscriptions/:provider/active', async (request) => {
625
+ const session = auth.authenticate(request, true);
626
+ const profileId = request.body?.profileId;
627
+ if (typeof profileId !== 'string')
628
+ throw new FleetError('invalid_request', 'profileId is required');
629
+ const view = await subscriptions().setActive(provider(request.params.provider), profileId);
630
+ subscriptionsChanged();
631
+ await audit.record({ requestId: request.id, browser: session.id, action: `subscription.activate.${request.params.provider}`, result: 'succeeded' });
632
+ return view;
633
+ });
634
+ app.post('/api/v1/subscriptions/:provider/profiles/:profileId/check', async (request) => {
635
+ auth.authenticate(request, true);
636
+ return subscriptions().check(provider(request.params.provider), request.params.profileId);
637
+ });
638
+ app.post('/api/v1/subscriptions/claude/profiles/:profileId/probe', async (request) => {
639
+ const session = auth.authenticate(request, true);
640
+ const usage = await subscriptions().probeClaudeUsage(request.params.profileId);
641
+ await audit.record({ requestId: request.id, browser: session.id, action: 'subscription.usage.probe.claude', result: usage.source });
642
+ return { usage };
643
+ });
644
+ app.patch('/api/v1/subscriptions/:provider/profiles/:profileId', async (request) => {
645
+ auth.authenticate(request, true);
646
+ const label = request.body?.label;
647
+ if (typeof label !== 'string')
648
+ throw new FleetError('invalid_request', 'label is required');
649
+ await subscriptions().rename(provider(request.params.provider), request.params.profileId, label);
650
+ subscriptionsChanged();
651
+ return { ok: true };
652
+ });
653
+ app.delete('/api/v1/subscriptions/:provider/profiles/:profileId', async (request) => {
654
+ const session = auth.authenticate(request, true);
655
+ await subscriptions().remove(provider(request.params.provider), request.params.profileId);
656
+ subscriptionsChanged();
657
+ await audit.record({ requestId: request.id, browser: session.id, action: `subscription.remove.${request.params.provider}`, result: 'succeeded' });
658
+ return { ok: true };
659
+ });
268
660
  app.get('/api/v1/topology', async (request) => {
269
661
  auth.authenticate(request);
270
662
  if (!services.topology)
@@ -311,9 +703,41 @@ export async function buildWebServer(services, boundary, options = {}) {
311
703
  });
312
704
  return result;
313
705
  });
706
+ const oursTools = services.oursTools ?? new SupervisorOursTools();
707
+ app.get('/api/v1/roles/:id/contacts', async (request) => {
708
+ auth.authenticate(request);
709
+ const control = await services.session(request.params.id);
710
+ if (!control.agentContacts)
711
+ throw new FleetError('capability_unavailable', 'Supervisor contacts API is unavailable');
712
+ return control.agentContacts();
713
+ });
714
+ app.get('/api/v1/roles/:id/messages', async (request) => {
715
+ auth.authenticate(request);
716
+ const control = await services.session(request.params.id);
717
+ if (!control.agentHistory)
718
+ throw new FleetError('capability_unavailable', 'Supervisor history API is unavailable');
719
+ 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) } : {}) });
720
+ });
721
+ app.get('/api/v1/roles/:id/ours/tools', async (request) => {
722
+ auth.authenticate(request);
723
+ return oursTools.list(request.params.id);
724
+ });
725
+ app.post('/api/v1/roles/:id/ours/call', async (request) => {
726
+ const session = auth.authenticate(request, true);
727
+ const result = await oursTools.call(request.params.id, request.body);
728
+ await audit.record({ requestId: request.id, browser: session.id,
729
+ action: 'ours.call', result: result.result.isError === true ? 'tool_error' : 'succeeded' });
730
+ return result;
731
+ });
314
732
  app.get('/api/v1/roles/:id', async (request) => {
315
733
  auth.authenticate(request);
316
- return services.query.detail(request.params.id);
734
+ const detail = await services.query.detail(request.params.id);
735
+ return { ...detail, chatIdle: detail.role.lifetime === 'temporary' ? readChatIdle(agentDir(request.params.id, true)) ?? null : null };
736
+ });
737
+ app.get('/api/v1/roles/:id/voice-capabilities', async (request) => {
738
+ auth.authenticate(request);
739
+ const descriptor = await (await services.session(request.params.id)).describe();
740
+ return { supported: ['generation_bound_prompts', 'idle_bound_prompts', 'targeted_voice_interrupt'].every(f => descriptor.features.includes(f)) };
317
741
  });
318
742
  app.get('/api/v1/roles/:id/removal-preview', async (request) => {
319
743
  auth.authenticate(request);
@@ -348,15 +772,103 @@ export async function buildWebServer(services, boundary, options = {}) {
348
772
  const control = await services.session(request.params.id);
349
773
  if (!control.conversationPage)
350
774
  throw new FleetError('capability_unavailable', 'this role has no conversation ledger');
351
- return control.conversationPage({
352
- after: request.query.after,
353
- limit: request.query.limit ? Number(request.query.limit) : undefined,
354
- });
775
+ const limit = request.query.limit ? Number(request.query.limit) : undefined;
776
+ // `latest` / `before` page backwards from the newest event; `after` pages forward for live updates.
777
+ const backward = request.query.latest === '1' || request.query.before !== undefined;
778
+ if (backward && request.query.after !== undefined)
779
+ throw new FleetError('invalid_request', 'after cannot be combined with latest or before');
780
+ if (backward && !control.conversationTail)
781
+ throw new FleetError('capability_unavailable', 'this role cannot page its conversation backwards');
782
+ const role = await services.repository.get(request.params.id);
783
+ const stateDir = role ? services.repository.stateDir(role) : undefined;
784
+ const present = (event) => presentAttachmentEvent(stateDir, request.params.id, event);
785
+ if (backward) {
786
+ const page = await control.conversationTail({ before: request.query.before, limit });
787
+ return { ...page, events: page.events.map(present), context: page.context.map(present) };
788
+ }
789
+ const page = await control.conversationPage({ after: request.query.after, limit });
790
+ return { ...page, events: page.events.map(present) };
791
+ });
792
+ app.get('/api/v1/roles/:id/artifacts/:artifactId', async (request, reply) => {
793
+ auth.authenticate(request);
794
+ const role = await services.repository.get(request.params.id);
795
+ if (!role)
796
+ throw new FleetError('role_not_found', 'Agent not found');
797
+ const stateDir = services.repository.stateDir(role);
798
+ if (!stateDir)
799
+ throw new FleetError('resource_not_found', 'Agent file not found');
800
+ try {
801
+ const { record, bytes } = await readDeliveredFile(stateDir, request.params.artifactId);
802
+ if (!await conversationHasAttachment(stateDir, record))
803
+ throw Error('ARTIFACT_NOT_PUBLISHED');
804
+ reply.header('Cache-Control', 'private, no-store');
805
+ reply.header('X-Content-Type-Options', 'nosniff');
806
+ reply.header('Content-Security-Policy', "default-src 'none'; sandbox");
807
+ reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(record.name).replace(/'/g, '%27')}`);
808
+ return reply.type('application/octet-stream').send(bytes);
809
+ }
810
+ catch {
811
+ throw new FleetError('resource_not_found', 'Agent file unavailable');
812
+ }
813
+ });
814
+ app.get('/api/v1/roles/:id/attachments/:attachmentId', async (request, reply) => {
815
+ auth.authenticate(request);
816
+ const role = await services.repository.get(request.params.id);
817
+ if (!role)
818
+ throw new FleetError('role_not_found', 'Agent not found');
819
+ const stateDir = services.repository.stateDir(role);
820
+ if (!stateDir)
821
+ throw new FleetError('capability_unavailable', 'Agent file storage is unavailable');
822
+ const { record, bytes } = readAgentAttachment(stateDir, request.params.attachmentId);
823
+ const image = /^image\/(png|jpeg|gif|webp|avif)$/.test(record.mimeType);
824
+ reply.header('Cache-Control', 'private, no-store');
825
+ reply.header('X-Content-Type-Options', 'nosniff');
826
+ reply.header('Content-Security-Policy', "default-src 'none'; sandbox");
827
+ reply.header('Content-Disposition', `${image ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(record.name).replace(/'/g, '%27')}`);
828
+ return reply.type(image ? record.mimeType : 'application/octet-stream').send(bytes);
829
+ });
830
+ app.post('/api/v1/roles/:id/attachments', { bodyLimit: MAX_ATTACHMENT_BODY }, async (request) => {
831
+ auth.authenticate(request, true);
832
+ const role = await services.repository.get(request.params.id);
833
+ if (!role)
834
+ throw new FleetError('role_not_found', 'Agent not found');
835
+ const stateDir = services.repository.stateDir(role);
836
+ if (!stateDir)
837
+ throw new FleetError('capability_unavailable', 'Agent file storage is unavailable');
838
+ const body = request.body;
839
+ if (typeof body?.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim())
840
+ throw new FleetError('invalid_request', 'expectedSessionGeneration is required for attachments');
841
+ const control = await services.session(request.params.id);
842
+ if (!control.conversationPage || !control.submitPromptV2)
843
+ throw new FleetError('capability_unavailable', 'Session-bound attachments are unavailable');
844
+ const page = await control.conversationPage({ limit: 1 });
845
+ if (page.snapshot.sessionGeneration !== body.expectedSessionGeneration)
846
+ throw new FleetError('stale_state', 'Agent session changed. Select the files again.');
847
+ return storeAgentAttachment(stateDir, body.expectedSessionGeneration, request.body);
355
848
  });
356
849
  app.post('/api/v1/roles/:id/input', async (request, reply) => {
357
850
  const session = auth.authenticate(request, true);
358
851
  const body = request.body;
359
- const text = String(body?.text ?? '');
852
+ if (body?.expectedSessionGeneration !== undefined && (typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim()))
853
+ throw new FleetError('invalid_request', 'expectedSessionGeneration must be a nonempty string');
854
+ if (body?.requireIdle !== undefined && typeof body.requireIdle !== 'boolean')
855
+ throw new FleetError('invalid_request', 'requireIdle must be boolean');
856
+ let text = String(body?.text ?? '');
857
+ if (body?.attachments !== undefined) {
858
+ if (typeof body.commandId !== 'string' || !body.commandId.trim())
859
+ throw new FleetError('invalid_request', 'commandId is required for attachments');
860
+ if (typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim())
861
+ throw new FleetError('invalid_request', 'expectedSessionGeneration is required for attachments');
862
+ const role = await services.repository.get(request.params.id);
863
+ if (!role)
864
+ throw new FleetError('role_not_found', 'Agent not found');
865
+ const stateDir = services.repository.stateDir(role);
866
+ if (!stateDir)
867
+ throw new FleetError('capability_unavailable', 'Agent file storage is unavailable');
868
+ const originalText = text;
869
+ text = attachmentPrompt(stateDir, body.expectedSessionGeneration, body.attachments, text);
870
+ prepareAttachmentPresentation(stateDir, body.expectedSessionGeneration, body.commandId, body.attachments, originalText, text);
871
+ }
360
872
  const commandId = typeof body?.commandId === 'string' && body.commandId.trim()
361
873
  ? body.commandId : undefined;
362
874
  const control = await services.session(request.params.id);
@@ -367,6 +879,7 @@ export async function buildWebServer(services, boundary, options = {}) {
367
879
  const admittedCommandId = commandId ?? randomBytes(16).toString('hex');
368
880
  const receipt = await control.submitPromptV2({
369
881
  commandId: admittedCommandId, text, source: 'owner_admin_console',
882
+ expectedSessionGeneration: body.expectedSessionGeneration, requireIdle: body.requireIdle === true,
370
883
  actorBrowserSession: createHmac('sha256', digestKey).update(session.id).digest('hex').slice(0, 24),
371
884
  });
372
885
  await audit.record({
@@ -378,6 +891,8 @@ export async function buildWebServer(services, boundary, options = {}) {
378
891
  reply.code(202);
379
892
  return receipt;
380
893
  }
894
+ if (body.expectedSessionGeneration !== undefined || body.requireIdle)
895
+ throw new FleetError('capability_unavailable', 'session-bound voice input is unavailable for this role');
381
896
  const receipt = await control.sendText(text);
382
897
  await audit.record({
383
898
  requestId: request.id, browser: session.id, roleId: request.params.id,
@@ -393,6 +908,16 @@ export async function buildWebServer(services, boundary, options = {}) {
393
908
  const commandId = typeof body?.commandId === 'string' && body.commandId.trim()
394
909
  ? body.commandId : undefined;
395
910
  const control = await services.session(request.params.id);
911
+ if (body?.expectedSessionGeneration !== undefined || body?.promptId !== undefined) {
912
+ if (!commandId || typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim() || typeof body.promptId !== 'string' || !body.promptId.trim())
913
+ throw new FleetError('invalid_request', 'Targeted interrupt requires commandId, expectedSessionGeneration and promptId');
914
+ if (!control.interruptPromptV2)
915
+ throw new FleetError('capability_unavailable', 'Targeted voice interrupt is unavailable');
916
+ const receipt = await control.interruptPromptV2({ commandId, expectedSessionGeneration: body.expectedSessionGeneration, promptId: body.promptId });
917
+ await audit.record({ requestId: request.id, browser: session.id, roleId: request.params.id, action: 'session.interrupt_voice', result: 'accepted' });
918
+ reply.code(202);
919
+ return receipt;
920
+ }
396
921
  if (commandId && control.interruptV2) {
397
922
  const receipt = await control.interruptV2(commandId);
398
923
  await audit.record({
@@ -443,10 +968,22 @@ export async function buildWebServer(services, boundary, options = {}) {
443
968
  const body = request.body;
444
969
  if (!body.action || !['start', 'stop', 'restart_resume', 'restart_fresh'].includes(body.action))
445
970
  throw new FleetError('invalid_request', 'invalid lifecycle action');
446
- const receipt = await services.commands.execute({
447
- roleId: request.params.id, action: body.action,
448
- actionId: body.actionId, confirmation: body.confirmation,
449
- });
971
+ let receipt;
972
+ try {
973
+ receipt = await services.commands.execute({
974
+ roleId: request.params.id, action: body.action,
975
+ actionId: body.actionId, confirmation: body.confirmation,
976
+ });
977
+ }
978
+ catch (error) {
979
+ // An existing receipt always wins: a conflicting/repeated action is not a
980
+ // proof that the original operation was never admitted.
981
+ if (typeof body.actionId === 'string' && !services.commands.get(body.actionId)) {
982
+ const normalized = normalizeError(error);
983
+ return reply.code(409).send({ error: { code: 'action_not_accepted', message: normalized.message }, accepted: false });
984
+ }
985
+ throw error;
986
+ }
450
987
  events.publish('action.changed', receipt, request.params.id);
451
988
  reply.code(202);
452
989
  return receipt;
@@ -465,6 +1002,75 @@ export async function buildWebServer(services, boundary, options = {}) {
465
1002
  throw new FleetError('role_not_found', 'creation action not found');
466
1003
  return action;
467
1004
  });
1005
+ // A fetch stream works through the same authenticated workspace transport as
1006
+ // ordinary requests, and owns one ACP controller until its browser disconnects.
1007
+ app.post('/api/v1/roles/:id/live-presence', async (request, reply) => {
1008
+ const browser = auth.authenticate(request, true);
1009
+ const generation = request.body?.expectedSessionGeneration;
1010
+ if (typeof generation !== 'string' || !generation.trim())
1011
+ throw new FleetError('invalid_request', 'expectedSessionGeneration is required');
1012
+ const control = await services.session(request.params.id);
1013
+ if (!control.followConversation || !control.conversationPage)
1014
+ throw new FleetError('capability_unavailable', 'Live voice requires a conversation controller');
1015
+ const page = await control.conversationPage({ limit: 1 });
1016
+ if (page.snapshot.sessionGeneration !== generation)
1017
+ throw new FleetError('stale_state', 'Agent session changed. Start live voice again.');
1018
+ // Session/page discovery can await I/O; recheck revocation before attaching.
1019
+ auth.authenticate(request, true);
1020
+ if (reply.raw.destroyed)
1021
+ return;
1022
+ reply.hijack();
1023
+ for (const [name, value] of Object.entries(reply.getHeaders()))
1024
+ if (value !== undefined)
1025
+ reply.raw.setHeader(name, Array.isArray(value) ? value : String(value));
1026
+ reply.raw.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' });
1027
+ let ended = false;
1028
+ let follow;
1029
+ let heartbeat;
1030
+ let unbind;
1031
+ const close = () => { if (ended)
1032
+ return; ended = true; unbind?.(); if (heartbeat)
1033
+ clearInterval(heartbeat); follow?.close(); reply.raw.end(); };
1034
+ const write = (value) => { if (!ended && !reply.raw.write(JSON.stringify(value) + '\n'))
1035
+ close(); };
1036
+ reply.raw.once('close', close);
1037
+ // A voice session outlives one access credential; its page renews this stream by name.
1038
+ const renewal = browser.rotating ? randomBytes(32).toString('base64url') : undefined;
1039
+ unbind = auth.bindTransport(browser, close, renewal);
1040
+ try {
1041
+ follow = await control.followConversation({
1042
+ after: page.nextCursor,
1043
+ onPage: current => { if (current.snapshot.sessionGeneration !== generation) {
1044
+ write({ error: 'Agent session changed. Start live voice again.' });
1045
+ close();
1046
+ }
1047
+ else
1048
+ write({ ready: true, ...(renewal ? { renewal } : {}) }); },
1049
+ onEvent: event => { if (event.sessionGeneration !== generation) {
1050
+ write({ error: 'Agent session changed. Start live voice again.' });
1051
+ close();
1052
+ } },
1053
+ onClose: () => close(),
1054
+ });
1055
+ if (ended)
1056
+ follow.close();
1057
+ // A renewable stream is ended by its own deadline and by revocation, not by the first credential's expiry.
1058
+ else
1059
+ heartbeat = setInterval(() => { try {
1060
+ if (!renewal)
1061
+ auth.authenticate(request);
1062
+ write({ heartbeat: true });
1063
+ }
1064
+ catch {
1065
+ close();
1066
+ } }, 10_000);
1067
+ }
1068
+ catch {
1069
+ write({ error: 'Agent controller connection failed.' });
1070
+ close();
1071
+ }
1072
+ });
1073
+ app.post('/api/v1/transports/:id/renew', async (request) => auth.renewTransport(request, request.params.id));
468
1074
  app.post('/api/v1/ws-tickets', async (request) => {
469
1075
  const body = request.body;
470
1076
  if (!body?.purpose || !['events', 'conversation'].includes(body.purpose))
@@ -499,7 +1105,7 @@ export async function buildWebServer(services, boundary, options = {}) {
499
1105
  requireSubprotocol(request, 'ours-fleet-events.v1');
500
1106
  authorizeSocket(socket, request, async (hello) => {
501
1107
  const session = auth.consumeTicket(request, String(hello.ticket ?? ''), 'events');
502
- auth.bindSocket(session.id, socket);
1108
+ auth.bindSocket(session, socket);
503
1109
  const detach = events.attach(socket, typeof hello.lastEventId === 'string' ? hello.lastEventId : undefined);
504
1110
  socket.on('close', detach);
505
1111
  socket.send(JSON.stringify({ kind: 'ready', at: new Date().toISOString() }));
@@ -509,7 +1115,7 @@ export async function buildWebServer(services, boundary, options = {}) {
509
1115
  requireSubprotocol(request, 'ours-fleet-conversation.v1');
510
1116
  authorizeSocket(socket, request, async (hello) => {
511
1117
  const session = auth.consumeTicket(request, String(hello.ticket ?? ''), 'conversation', request.params.id);
512
- auth.bindSocket(session.id, socket);
1118
+ auth.bindSocket(session, socket);
513
1119
  const control = await services.session(request.params.id);
514
1120
  if (!control.followConversation)
515
1121
  throw new FleetError('capability_unavailable', 'this role has no conversation ledger');
@@ -534,6 +1140,9 @@ export async function buildWebServer(services, boundary, options = {}) {
534
1140
  }
535
1141
  socket.send(JSON.stringify(payload));
536
1142
  };
1143
+ const role = await services.repository.get(request.params.id);
1144
+ const stateDir = role ? services.repository.stateDir(role) : undefined;
1145
+ const present = (event) => presentAttachmentEvent(stateDir, request.params.id, event);
537
1146
  const follow = await control.followConversation({
538
1147
  after,
539
1148
  onPage: page => {
@@ -544,11 +1153,11 @@ export async function buildWebServer(services, boundary, options = {}) {
544
1153
  lastCursor: page.nextCursor ?? after,
545
1154
  });
546
1155
  if (page.events.length)
547
- guardedSend({ type: 'events', events: page.events });
1156
+ guardedSend({ type: 'events', events: page.events.map(present) });
548
1157
  if (page.hasMore)
549
1158
  guardedSend({ type: 'resync.required' });
550
1159
  },
551
- onEvent: event => guardedSend({ type: 'events', events: [event] }),
1160
+ onEvent: event => guardedSend({ type: 'events', events: [present(event)] }),
552
1161
  onClose: reason => {
553
1162
  if (socket.readyState === socket.OPEN)
554
1163
  socket.close(4409, (reason ?? 'conversation stream ended').slice(0, 120));
@@ -561,15 +1170,8 @@ export async function buildWebServer(services, boundary, options = {}) {
561
1170
  socket.on('close', () => { clearInterval(heartbeat); follow.close(); });
562
1171
  });
563
1172
  });
564
- const staticRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'web-app');
565
- if (existsSync(staticRoot)) {
566
- await app.register(fastifyStatic, { root: staticRoot, prefix: '/' });
567
- app.setNotFoundHandler((request, reply) => {
568
- if (request.url.startsWith('/api/'))
569
- return reply.code(404).send({ error: 'not found' });
570
- return reply.sendFile('index.html');
571
- });
572
- }
1173
+ // Fleet has no pages of its own; every screen belongs to the App.
1174
+ app.setNotFoundHandler((_request, reply) => reply.code(404).send({ error: 'not found' }));
573
1175
  return {
574
1176
  app, auth, audit, events,
575
1177
  async close() {
@@ -582,17 +1184,17 @@ export async function buildWebServer(services, boundary, options = {}) {
582
1184
  function setAuthCookies(reply, session, device, secure = false) {
583
1185
  const suffix = secure ? '; Secure' : '';
584
1186
  reply.header('Set-Cookie', [
585
- `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=28800${suffix}`,
586
- `ofs_device=${encodeURIComponent(device)}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=2592000${suffix}`,
1187
+ `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800${suffix}`,
1188
+ `ofs_device=${encodeURIComponent(device)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000${suffix}`,
587
1189
  ]);
588
1190
  }
589
1191
  function setSessionCookie(reply, session, secure = false) {
590
- reply.header('Set-Cookie', `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=28800${secure ? '; Secure' : ''}`);
1192
+ reply.header('Set-Cookie', `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800${secure ? '; Secure' : ''}`);
591
1193
  }
592
1194
  function clearAuthCookies(reply) {
593
1195
  reply.header('Set-Cookie', [
594
- 'ofs_session=; HttpOnly; SameSite=Strict; Path=/api; Max-Age=0',
595
- 'ofs_device=; HttpOnly; SameSite=Strict; Path=/api; Max-Age=0',
1196
+ 'ofs_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0',
1197
+ 'ofs_device=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0',
596
1198
  ]);
597
1199
  }
598
1200
  function promoteRequest(body) {
@@ -606,9 +1208,6 @@ function promoteRequest(body) {
606
1208
  };
607
1209
  }
608
1210
  function cryptoRandomId() { return randomBytes(12).toString('hex'); }
609
- function escapeHtml(value) {
610
- return value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
611
- }
612
1211
  function requireSubprotocol(request, expected) {
613
1212
  const protocols = String(request.headers['sec-websocket-protocol'] ?? '')
614
1213
  .split(',').map(value => value.trim());