@open-mercato/cezar 0.9.1 → 0.9.2-pr710.1256

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 (393) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -32
  3. package/dist/agent-config/account-identity.d.ts +44 -0
  4. package/dist/agent-config/account-identity.js +128 -0
  5. package/dist/agent-config/account-identity.js.map +1 -0
  6. package/dist/agent-config/catalog.d.ts +9 -1
  7. package/dist/agent-config/catalog.js +18 -0
  8. package/dist/agent-config/catalog.js.map +1 -1
  9. package/dist/agent-config/files.d.ts +1 -1
  10. package/dist/agent-config/model-settings/claude.d.ts +2 -0
  11. package/dist/agent-config/model-settings/claude.js +11 -0
  12. package/dist/agent-config/model-settings/claude.js.map +1 -0
  13. package/dist/agent-config/model-settings/codex.d.ts +2 -0
  14. package/dist/agent-config/model-settings/codex.js +16 -0
  15. package/dist/agent-config/model-settings/codex.js.map +1 -0
  16. package/dist/agent-config/model-settings/opencode.d.ts +2 -0
  17. package/dist/agent-config/model-settings/opencode.js +8 -0
  18. package/dist/agent-config/model-settings/opencode.js.map +1 -0
  19. package/dist/agent-config/model-settings/pi.d.ts +12 -0
  20. package/dist/agent-config/model-settings/pi.js +18 -0
  21. package/dist/agent-config/model-settings/pi.js.map +1 -0
  22. package/dist/agent-config/model-settings/shared.d.ts +9 -0
  23. package/dist/agent-config/model-settings/shared.js +93 -0
  24. package/dist/agent-config/model-settings/shared.js.map +1 -0
  25. package/dist/agent-config/model-settings/types.d.ts +9 -0
  26. package/dist/agent-config/model-settings/types.js +2 -0
  27. package/dist/agent-config/model-settings/types.js.map +1 -0
  28. package/dist/agent-config/models.d.ts +9 -0
  29. package/dist/agent-config/models.js +32 -0
  30. package/dist/agent-config/models.js.map +1 -0
  31. package/dist/agent-config/service.d.ts +1 -1
  32. package/dist/agent-config/service.js +4 -4
  33. package/dist/agent-config/service.js.map +1 -1
  34. package/dist/agent-config/validate.d.ts +1 -1
  35. package/dist/automations/coordinator.d.ts +26 -0
  36. package/dist/automations/coordinator.js +66 -0
  37. package/dist/automations/coordinator.js.map +1 -0
  38. package/dist/automations/github-poller.d.ts +74 -0
  39. package/dist/automations/github-poller.js +234 -0
  40. package/dist/automations/github-poller.js.map +1 -0
  41. package/dist/automations/scheduler.d.ts +42 -0
  42. package/dist/automations/scheduler.js +192 -0
  43. package/dist/automations/scheduler.js.map +1 -0
  44. package/dist/automations/store.d.ts +65 -0
  45. package/dist/automations/store.js +298 -0
  46. package/dist/automations/store.js.map +1 -0
  47. package/dist/automations/task-template.d.ts +18 -0
  48. package/dist/automations/task-template.js +95 -0
  49. package/dist/automations/task-template.js.map +1 -0
  50. package/dist/automations/types.d.ts +255 -0
  51. package/dist/automations/types.js +159 -0
  52. package/dist/automations/types.js.map +1 -0
  53. package/dist/config.d.ts +19 -112
  54. package/dist/config.js +48 -5
  55. package/dist/config.js.map +1 -1
  56. package/dist/contract/agent-config.d.ts +153 -0
  57. package/dist/contract/agent-profiles.d.ts +347 -0
  58. package/dist/contract/automations.d.ts +935 -0
  59. package/dist/contract/events.d.ts +104 -0
  60. package/dist/contract/github.d.ts +467 -0
  61. package/dist/contract/health.d.ts +102 -0
  62. package/dist/contract/index.d.ts +16 -0
  63. package/dist/contract/index.js +1830 -0
  64. package/dist/contract/projects.d.ts +210 -0
  65. package/dist/contract/repo.d.ts +234 -0
  66. package/dist/contract/runs.d.ts +1407 -0
  67. package/dist/contract/skills.d.ts +232 -0
  68. package/dist/contract/workflows.d.ts +218 -0
  69. package/dist/contract/workspace.d.ts +577 -0
  70. package/dist/core/agent-env.d.ts +1 -1
  71. package/dist/core/agent-env.js +41 -16
  72. package/dist/core/agent-env.js.map +1 -1
  73. package/dist/core/agent-model-policy.d.ts +10 -0
  74. package/dist/core/agent-model-policy.js +36 -0
  75. package/dist/core/agent-model-policy.js.map +1 -0
  76. package/dist/core/agent-profiles.d.ts +62 -0
  77. package/dist/core/agent-profiles.js +90 -0
  78. package/dist/core/agent-profiles.js.map +1 -0
  79. package/dist/core/agent-runner.d.ts +49 -7
  80. package/dist/core/agent-runner.js +48 -3
  81. package/dist/core/agent-runner.js.map +1 -1
  82. package/dist/core/ask.d.ts +45 -124
  83. package/dist/core/ask.js +103 -13
  84. package/dist/core/ask.js.map +1 -1
  85. package/dist/core/backend-detect.d.ts +5 -5
  86. package/dist/core/backend-detect.js +44 -7
  87. package/dist/core/backend-detect.js.map +1 -1
  88. package/dist/core/claude-cli-runner.d.ts +2 -2
  89. package/dist/core/claude-cli-runner.js +61 -13
  90. package/dist/core/claude-cli-runner.js.map +1 -1
  91. package/dist/core/claude-ui-mapper.d.ts +1 -1
  92. package/dist/core/codex-app-server-runner.d.ts +1 -1
  93. package/dist/core/codex-app-server-runner.js +65 -3
  94. package/dist/core/codex-app-server-runner.js.map +1 -1
  95. package/dist/core/codex-app-server-transport.d.ts +6 -1
  96. package/dist/core/codex-app-server-transport.js +17 -3
  97. package/dist/core/codex-app-server-transport.js.map +1 -1
  98. package/dist/core/codex-model-catalog.d.ts +1 -1
  99. package/dist/core/codex-ui-mapper.d.ts +5 -1
  100. package/dist/core/codex-ui-mapper.js +74 -17
  101. package/dist/core/codex-ui-mapper.js.map +1 -1
  102. package/dist/core/model-identity.d.ts +24 -12
  103. package/dist/core/model-identity.js +41 -24
  104. package/dist/core/model-identity.js.map +1 -1
  105. package/dist/core/model-presets.d.ts +10 -4
  106. package/dist/core/model-presets.js +52 -9
  107. package/dist/core/model-presets.js.map +1 -1
  108. package/dist/core/opencode-model-catalog.d.ts +30 -0
  109. package/dist/core/opencode-model-catalog.js +126 -0
  110. package/dist/core/opencode-model-catalog.js.map +1 -0
  111. package/dist/core/opencode-server-runner.d.ts +2 -2
  112. package/dist/core/opencode-ui-mapper.d.ts +4 -1
  113. package/dist/core/opencode-ui-mapper.js +45 -7
  114. package/dist/core/opencode-ui-mapper.js.map +1 -1
  115. package/dist/core/pi-runner.d.ts +24 -0
  116. package/dist/core/pi-runner.js +349 -0
  117. package/dist/core/pi-runner.js.map +1 -0
  118. package/dist/core/pi-ui-mapper.d.ts +34 -0
  119. package/dist/core/pi-ui-mapper.js +270 -0
  120. package/dist/core/pi-ui-mapper.js.map +1 -0
  121. package/dist/core/provider-auth.d.ts +154 -0
  122. package/dist/core/provider-auth.js +509 -0
  123. package/dist/core/provider-auth.js.map +1 -0
  124. package/dist/core/provider-availability.d.ts +3 -0
  125. package/dist/core/provider-availability.js +14 -0
  126. package/dist/core/provider-availability.js.map +1 -0
  127. package/dist/core/runner-factory.d.ts +1 -1
  128. package/dist/core/runner-factory.js +3 -0
  129. package/dist/core/runner-factory.js.map +1 -1
  130. package/dist/core/runner-model-catalog.d.ts +1 -1
  131. package/dist/core/shell-env.d.ts +28 -0
  132. package/dist/core/shell-env.js +56 -0
  133. package/dist/core/shell-env.js.map +1 -0
  134. package/dist/core/tool-display.d.ts +1 -1
  135. package/dist/core/ui-events.d.ts +2 -2
  136. package/dist/core/ui-events.js +1 -1
  137. package/dist/core/usage-limit.d.ts +42 -0
  138. package/dist/core/usage-limit.js +209 -0
  139. package/dist/core/usage-limit.js.map +1 -0
  140. package/dist/git-diff-base.d.ts +85 -0
  141. package/dist/git-diff-base.js +180 -0
  142. package/dist/git-diff-base.js.map +1 -0
  143. package/dist/git-worktree.d.ts +46 -6
  144. package/dist/git-worktree.js +46 -9
  145. package/dist/git-worktree.js.map +1 -1
  146. package/dist/handoff.d.ts +2 -2
  147. package/dist/handoff.js +1 -1
  148. package/dist/index.js +66 -8
  149. package/dist/index.js.map +1 -1
  150. package/dist/paths.d.ts +69 -6
  151. package/dist/paths.js +86 -9
  152. package/dist/paths.js.map +1 -1
  153. package/dist/planner.d.ts +2 -2
  154. package/dist/planner.js +7 -1
  155. package/dist/planner.js.map +1 -1
  156. package/dist/release/manifests.d.ts +81 -0
  157. package/dist/release/manifests.js +74 -0
  158. package/dist/release/manifests.js.map +1 -0
  159. package/dist/release/snapshot.d.ts +9 -23
  160. package/dist/release/snapshot.js +9 -22
  161. package/dist/release/snapshot.js.map +1 -1
  162. package/dist/release/stable.d.ts +15 -29
  163. package/dist/release/stable.js +14 -27
  164. package/dist/release/stable.js.map +1 -1
  165. package/dist/runs/agent-tmpdir.d.ts +41 -0
  166. package/dist/runs/agent-tmpdir.js +183 -0
  167. package/dist/runs/agent-tmpdir.js.map +1 -0
  168. package/dist/runs/auto-name.d.ts +1 -1
  169. package/dist/runs/auto-name.js +6 -1
  170. package/dist/runs/auto-name.js.map +1 -1
  171. package/dist/runs/event-history.d.ts +50 -0
  172. package/dist/runs/event-history.js +613 -0
  173. package/dist/runs/event-history.js.map +1 -0
  174. package/dist/runs/retention.d.ts +1 -1
  175. package/dist/runs/run-index.d.ts +19 -0
  176. package/dist/runs/run-index.js +43 -0
  177. package/dist/runs/run-index.js.map +1 -0
  178. package/dist/runs/store.d.ts +209 -344
  179. package/dist/runs/store.js +298 -39
  180. package/dist/runs/store.js.map +1 -1
  181. package/dist/runs/ui-event-sink.d.ts +1 -1
  182. package/dist/server/app-type.d.ts +21 -0
  183. package/dist/server/app-type.js +2 -0
  184. package/dist/server/app-type.js.map +1 -0
  185. package/dist/server/capabilities.d.ts +21 -6
  186. package/dist/server/capabilities.js +27 -1
  187. package/dist/server/capabilities.js.map +1 -1
  188. package/dist/server/forge/github.d.ts +58 -151
  189. package/dist/server/forge/github.js +530 -6
  190. package/dist/server/forge/github.js.map +1 -1
  191. package/dist/server/forge/index.d.ts +19 -3
  192. package/dist/server/forge/index.js +28 -1
  193. package/dist/server/forge/index.js.map +1 -1
  194. package/dist/server/forge/types.d.ts +86 -1
  195. package/dist/server/git-changes.d.ts +10 -8
  196. package/dist/server/git-changes.js +19 -17
  197. package/dist/server/git-changes.js.map +1 -1
  198. package/dist/server/git.d.ts +2 -0
  199. package/dist/server/git.js +9 -0
  200. package/dist/server/git.js.map +1 -1
  201. package/dist/server/github.d.ts +3 -3
  202. package/dist/server/github.js +1 -1
  203. package/dist/server/github.js.map +1 -1
  204. package/dist/server/open-in-app.d.ts +2 -1
  205. package/dist/server/open-in-app.js +3 -1
  206. package/dist/server/open-in-app.js.map +1 -1
  207. package/dist/server/open-in-terminal.d.ts +46 -1
  208. package/dist/server/open-in-terminal.js +72 -13
  209. package/dist/server/open-in-terminal.js.map +1 -1
  210. package/dist/server/pr.d.ts +2 -2
  211. package/dist/server/project-context.d.ts +20 -3
  212. package/dist/server/project-context.js +30 -1
  213. package/dist/server/project-context.js.map +1 -1
  214. package/dist/server/provider-action-gate.d.ts +8 -0
  215. package/dist/server/provider-action-gate.js +56 -0
  216. package/dist/server/provider-action-gate.js.map +1 -0
  217. package/dist/server/provider-auth-runtime.d.ts +21 -0
  218. package/dist/server/provider-auth-runtime.js +66 -0
  219. package/dist/server/provider-auth-runtime.js.map +1 -0
  220. package/dist/server/server.d.ts +12802 -47
  221. package/dist/server/server.js +2857 -949
  222. package/dist/server/server.js.map +1 -1
  223. package/dist/server/validators.d.ts +97 -0
  224. package/dist/server/validators.js +86 -0
  225. package/dist/server/validators.js.map +1 -0
  226. package/dist/server/ws.d.ts +2 -2
  227. package/dist/server/ws.js +2 -2
  228. package/dist/server/ws.js.map +1 -1
  229. package/dist/server-install/engine.d.ts +1 -1
  230. package/dist/server-install/engine.js +5 -18
  231. package/dist/server-install/engine.js.map +1 -1
  232. package/dist/server-install/platforms/macosx-ngrok.d.ts +1 -1
  233. package/dist/server-install/platforms/ubuntu-vps.d.ts +16 -1
  234. package/dist/server-install/platforms/ubuntu-vps.js +83 -1
  235. package/dist/server-install/platforms/ubuntu-vps.js.map +1 -1
  236. package/dist/server-install/state.d.ts +1 -1
  237. package/dist/server-install/steps.d.ts +2 -2
  238. package/dist/server-install/steps.js +6 -2
  239. package/dist/server-install/steps.js.map +1 -1
  240. package/dist/server-install/strategies.d.ts +1 -1
  241. package/dist/server-install/types.d.ts +38 -424
  242. package/dist/server-install/ui.d.ts +1 -1
  243. package/dist/skills-banner.d.ts +7 -6
  244. package/dist/skills-banner.js +7 -6
  245. package/dist/skills-banner.js.map +1 -1
  246. package/dist/skills-remote.d.ts +2 -2
  247. package/dist/skills-update.d.ts +88 -0
  248. package/dist/skills-update.js +392 -0
  249. package/dist/skills-update.js.map +1 -0
  250. package/dist/skills.d.ts +2 -0
  251. package/dist/skills.js +7 -2
  252. package/dist/skills.js.map +1 -1
  253. package/dist/todos.d.ts +2 -28
  254. package/dist/todos.js +1 -1
  255. package/dist/ui-state.d.ts +16 -2
  256. package/dist/ui-state.js +14 -1
  257. package/dist/ui-state.js.map +1 -1
  258. package/dist/workflows/load.d.ts +1 -1
  259. package/dist/workflows/run.d.ts +276 -15
  260. package/dist/workflows/run.js +1261 -159
  261. package/dist/workflows/run.js.map +1 -1
  262. package/dist/workflows/types.d.ts +62 -224
  263. package/dist/workflows/types.js +25 -2
  264. package/dist/workflows/types.js.map +1 -1
  265. package/dist/workspace/agent-accounts.d.ts +153 -0
  266. package/dist/workspace/agent-accounts.js +304 -0
  267. package/dist/workspace/agent-accounts.js.map +1 -0
  268. package/dist/workspace/agent-profiles.d.ts +78 -0
  269. package/dist/workspace/agent-profiles.js +115 -0
  270. package/dist/workspace/agent-profiles.js.map +1 -0
  271. package/dist/workspace/config.d.ts +94 -206
  272. package/dist/workspace/config.js +226 -14
  273. package/dist/workspace/config.js.map +1 -1
  274. package/dist/workspace/migrations.d.ts +1 -1
  275. package/dist/workspace/migrations.js +19 -4
  276. package/dist/workspace/migrations.js.map +1 -1
  277. package/dist/workspace/projects-cli.js +54 -4
  278. package/dist/workspace/projects-cli.js.map +1 -1
  279. package/dist/workspace/projects.d.ts +26 -2
  280. package/dist/workspace/projects.js +42 -3
  281. package/dist/workspace/projects.js.map +1 -1
  282. package/dist/workspace/semaphore.d.ts +51 -0
  283. package/dist/workspace/semaphore.js +53 -3
  284. package/dist/workspace/semaphore.js.map +1 -1
  285. package/dist/workspace/ui-state.d.ts +16 -5
  286. package/dist/workspace/ui-state.js +18 -7
  287. package/dist/workspace/ui-state.js.map +1 -1
  288. package/package.json +17 -39
  289. package/scripts/inline-contract.mjs +112 -0
  290. package/scripts/mock-claude.mjs +59 -1
  291. package/scripts/mock-pi-rpc.mjs +83 -0
  292. package/scripts/sync-readme.mjs +20 -0
  293. package/web/dist/assets/alert-dialog-BVs3QSmP.js +1 -0
  294. package/web/dist/assets/arrow-down-D7T7XuF6.js +1 -0
  295. package/web/dist/assets/arrow-left-B1dRFd8y.js +1 -0
  296. package/web/dist/assets/bundle-mjs-BT31bpU6.js +1 -0
  297. package/web/dist/assets/centered-state-DdRLYi9u.js +43 -0
  298. package/web/dist/assets/chevron-right-Dh0t3AL8.js +1 -0
  299. package/web/dist/assets/{chunk-BO2N2NFS-DtrdTCWa.js → chunk-BO2N2NFS-DE6qKn3r.js} +8 -8
  300. package/web/dist/assets/circle-check-4FDOlh9u.js +1 -0
  301. package/web/dist/assets/circle-x-CgTVbqX5.js +1 -0
  302. package/web/dist/assets/collapsible-BoADzTok.js +1 -0
  303. package/web/dist/assets/commit-list-BCcCkRbU.js +1 -0
  304. package/web/dist/assets/compare-variants-B7gTgGqR.js +1 -0
  305. package/web/dist/assets/{core-DePtBHcl.js → core-BCsw8oQw.js} +1 -1
  306. package/web/dist/assets/diff-ecfV3-op.js +3 -0
  307. package/web/dist/assets/diff-stat-C2KGLVnZ.js +1 -0
  308. package/web/dist/assets/{diff-view-DCeSegKk.js → diff-view-CQtonQ4L.js} +4 -4
  309. package/web/dist/assets/dropdown-menu-WcGoirf2.js +1 -0
  310. package/web/dist/assets/editable-title-SqNK-wuI.js +1 -0
  311. package/web/dist/assets/{ellipsis-vertical-CNnSLn5a.js → ellipsis-vertical-ClTFrwit.js} +1 -1
  312. package/web/dist/assets/{file-D4GBh1ao.js → file-BU0mWida.js} +1 -1
  313. package/web/dist/assets/folder-rsgCF0aw.js +1 -0
  314. package/web/dist/assets/{git-pull-request-DnR8Xh4_.js → git-pull-request-BFhIKb83.js} +1 -1
  315. package/web/dist/assets/git-toolbar-CF1_tzqz.js +1 -0
  316. package/web/dist/assets/github-CyjPz-Ks.js +1 -0
  317. package/web/dist/assets/highlighted-body-OFNGDK62-BL-MfFgl.js +1 -0
  318. package/web/dist/assets/highlighter-CdHqIwFr.js +3 -0
  319. package/web/dist/assets/image-preview-iEzCyEmz.js +1 -0
  320. package/web/dist/assets/index-BN09RpYY.css +2 -0
  321. package/web/dist/assets/index-CyG7aE1W.js +7 -0
  322. package/web/dist/assets/lib-BQXq3kEf.js +1 -0
  323. package/web/dist/assets/lib-BxQXEXDF.js +1 -0
  324. package/web/dist/assets/markdown-CNAa22LB.js +2 -0
  325. package/web/dist/assets/mermaid-GHXKKRXX-C4jxqUUb.js +1 -0
  326. package/web/dist/assets/new-task-form-CWhQZbsX.js +1 -0
  327. package/web/dist/assets/pill-BO5s2x3N.js +1 -0
  328. package/web/dist/assets/project-router-Dm4OhAPE.js +1 -0
  329. package/web/dist/assets/prompt-templates-CP__OCDQ.js +15 -0
  330. package/web/dist/assets/react-runtime-CCIEwYL0.js +9 -0
  331. package/web/dist/assets/{refresh-cw-BT8E96Tm.js → refresh-cw-DyiysCoP.js} +1 -1
  332. package/web/dist/assets/repo-git-B1WM6N8T.js +1 -0
  333. package/web/dist/assets/rolldown-runtime-QTnfLwEv.js +1 -0
  334. package/web/dist/assets/run-diff-CKx4d9Oo.js +3 -0
  335. package/web/dist/assets/run-header-DpbfOtNi.js +1 -0
  336. package/web/dist/assets/{search-x-BDjaMrzG.js → search-x-DRvk-6x4.js} +1 -1
  337. package/web/dist/assets/skill-empty-hint-D5vsHfMr.js +1 -0
  338. package/web/dist/assets/skills-BiWfHmCR.js +1 -0
  339. package/web/dist/assets/skills-XGltyIJ_.js +1 -0
  340. package/web/dist/assets/sparkles-BugkDxWO.js +1 -0
  341. package/web/dist/assets/{square-terminal-Ckjjn0vb.js → square-terminal-Drp2h-o8.js} +1 -1
  342. package/web/dist/assets/tab-link-CiRLRlnY.js +1 -0
  343. package/web/dist/assets/task-changes-C2hKkEtc.js +1 -0
  344. package/web/dist/assets/task-commits-CwGs_Opa.js +1 -0
  345. package/web/dist/assets/task-files-5KUgFzX3.js +2 -0
  346. package/web/dist/assets/task-thread-B3AFO3SS.js +9 -0
  347. package/web/dist/assets/textarea-Dn90Eun6.js +1 -0
  348. package/web/dist/assets/thread-loading-_1hPuk76.js +1 -0
  349. package/web/dist/assets/{trash-2-DAyZ0VSi.js → trash-2-BKEc5Sp2.js} +1 -1
  350. package/web/dist/assets/{triangle-alert-Uy8rokUh.js → triangle-alert-BGGPY2C2.js} +1 -1
  351. package/web/dist/assets/{upload-CDdyPPo_.js → upload-B0AQw-mO.js} +1 -1
  352. package/web/dist/assets/use-desktop-CegyTp3X.js +1 -0
  353. package/web/dist/assets/use-submit-shortcut-Mle-9uJp.js +1 -0
  354. package/web/dist/assets/utils-D9GHDNza.js +64 -0
  355. package/web/dist/assets/workflows-3PCz57Z6.js +11 -0
  356. package/web/dist/assets/zoomable-image-DTq7s8CZ.js +1 -0
  357. package/web/dist/index.html +33 -13
  358. package/scripts/dev.mjs +0 -74
  359. package/scripts/release-snapshot.mjs +0 -130
  360. package/scripts/release.mjs +0 -119
  361. package/web/dist/assets/arrow-left-CnSdp92h.js +0 -1
  362. package/web/dist/assets/bundle-mjs-BegPhL5c.js +0 -1
  363. package/web/dist/assets/centered-state-DOsTcJZF.js +0 -43
  364. package/web/dist/assets/commit-list-BrxBEzRK.js +0 -1
  365. package/web/dist/assets/compare-variants-Bv2c9ORF.js +0 -1
  366. package/web/dist/assets/dist-B6XQOhgM.js +0 -1
  367. package/web/dist/assets/git-toolbar-pIC1ubPM.js +0 -1
  368. package/web/dist/assets/github-DTTSG0ht.js +0 -1
  369. package/web/dist/assets/highlighted-body-OFNGDK62-BuFJwunK.js +0 -1
  370. package/web/dist/assets/image-preview-DEhW0TFz.js +0 -1
  371. package/web/dist/assets/index-Cijmlz7q.js +0 -30
  372. package/web/dist/assets/index-DzroxqBG.css +0 -2
  373. package/web/dist/assets/lib-DPEZDBUN.js +0 -1
  374. package/web/dist/assets/lib-uHpeUPzK.js +0 -1
  375. package/web/dist/assets/mermaid-GHXKKRXX-CN2zQue0.js +0 -1
  376. package/web/dist/assets/open-mercato-toBr6SOa.svg +0 -11
  377. package/web/dist/assets/project-router-BMx4e8m-.js +0 -1
  378. package/web/dist/assets/repo-git-BusYfa3t.js +0 -1
  379. package/web/dist/assets/run-diff-wOJ3odA-.js +0 -3
  380. package/web/dist/assets/run-header-Cg5incil.js +0 -1
  381. package/web/dist/assets/skill-empty-hint-DTUSp6Vl.js +0 -1
  382. package/web/dist/assets/skills-PpP2owbM.js +0 -1
  383. package/web/dist/assets/tab-link-F3uQrdm2.js +0 -1
  384. package/web/dist/assets/task-changes-iD9kS79I.js +0 -1
  385. package/web/dist/assets/task-commits-COkLaUri.js +0 -1
  386. package/web/dist/assets/task-files-Ckttz7XH.js +0 -2
  387. package/web/dist/assets/task-thread-CQD91VPt.js +0 -9
  388. package/web/dist/assets/textarea-Bj_LCreg.js +0 -1
  389. package/web/dist/assets/use-desktop-DUh0STzq.js +0 -3
  390. package/web/dist/assets/utils-B8s5qIcK.js +0 -1
  391. package/web/dist/assets/workflows-BFk8C0co.js +0 -11
  392. package/web/dist/assets/zoomable-image-PEDOqyLQ.js +0 -1
  393. /package/web/{open-mercato.svg → dist/open-mercato.svg} +0 -0
@@ -1,4 +1,11 @@
1
1
  import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { AutomationStore } from '../automations/store.js';
4
+ import { AutomationCoordinator } from '../automations/coordinator.js';
5
+ import { GithubPoller } from '../automations/github-poller.js';
6
+ import { ProjectAutomationScheduler, WorkspaceAutomationScheduler } from '../automations/scheduler.js';
7
+ import { launchAutomationRun, reconcileAutomationReceipts, validateAutomationPrompt } from '../automations/task-template.js';
8
+ import { automationEventSchema, automationFiltersSchema, automationLogResultSchema, automationTaskSchema, } from '../automations/types.js';
2
9
  import { access, constants as fsConstants, mkdir, readFile, realpath, stat, unlink, writeFile } from 'node:fs/promises';
3
10
  import { basename, dirname, join, resolve, sep } from 'node:path';
4
11
  import { fileURLToPath } from 'node:url';
@@ -6,67 +13,191 @@ import { Hono } from 'hono';
6
13
  import { serve } from '@hono/node-server';
7
14
  import { bodyLimit } from 'hono/body-limit';
8
15
  import { streamSSE } from 'hono/streaming';
16
+ import { jsonZodValidator, paramZodValidator, queryZodValidator } from './validators.js';
9
17
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
10
18
  import { z } from 'zod';
19
+ import { setWorkspaceUiStateInputSchema, } from '../contract/index.js';
20
+ // A contract VALUE, like `workspaceUiStateSchema` in workspace/migrations.ts — the request
21
+ // schema this route validates with is the same one the client compiles against.
22
+ import { modelDiscoveryRunnerSchema, openProjectInSchema, updateProjectInputSchema, } from '../contract/index.js';
11
23
  import { detectEnvironment } from '../core/backend-detect.js';
24
+ import { RUNNER_IDS } from '../core/agent-runner.js';
25
+ import { AGENT_MODELS_LOCKED_ERROR, agentModelsLocked } from '../core/agent-model-policy.js';
12
26
  import { discoverCodexModels } from '../core/codex-model-catalog.js';
27
+ import { discoverOpencodeModels } from '../core/opencode-model-catalog.js';
28
+ import { PROVIDER_IDS, ProviderAuthService, providerAuthChecksDisabled, } from '../core/provider-auth.js';
29
+ import { applyProviderEnablement } from '../core/provider-availability.js';
13
30
  import { RunnerModelCatalog } from '../core/runner-model-catalog.js';
14
31
  import { currentUsage, onUsage } from '../core/process-usage.js';
15
32
  import { WORKFLOWS_DIR, loadWorkflows } from '../workflows/load.js';
16
33
  import { QUICK_TASK_WORKFLOW, normalizeWorkflowDoc, skillStackOf, skillsToSteps, stepsIssue, workflowFileSchema, workflowStepSchema, } from '../workflows/types.js';
17
34
  import { planChain, slugify } from '../planner.js';
18
35
  import { discoverSkills } from '../skills.js';
36
+ import { SkillsUpdateConflictError, SkillsUpdateCoordinator, SkillsUpdateService } from '../skills-update.js';
19
37
  import { getTeamSkillsCached, refreshTeamSkills, waitForTeamSkills } from '../skills-remote.js';
20
38
  import { appendHandoffHeartbeat, handoffProgressExcerpt, readHandoff } from '../handoff.js';
21
39
  import { markStarted, onTodosChanged, readTodos, removeTodo, todoTaskText } from '../todos.js';
40
+ import { HistoryCursorError, deriveRunContextEvents, readEventsAfterLiveCursor, readRunHistoryPage, validateLiveCursor, } from '../runs/event-history.js';
41
+ import { readRunIndexFromDisk } from '../runs/run-index.js';
22
42
  import { isV2WireEventType } from '../runs/ui-event-sink.js';
43
+ import { runEventsQuerySchema, runHistoryQuerySchema, runIdParamSchema, } from '../contract/index.js';
23
44
  import { removeWorktree, worktreeDiff, worktreeDiffStat, worktreeSizeBytes } from '../git-worktree.js';
24
45
  import { isReclaimable, reclaimWorktrees } from '../runs/retention.js';
25
46
  import { getBranches, getCommit, getDiff, getLog, getRepoInfo, getStatus } from './git.js';
26
47
  import { collectChanges, collectCommitChanges, collectRunCommits, commitAll, createOrSwitchBranch, imageMimeType, isOsOpenableImage, pushCurrentBranch, readWorktreePath, } from './git-changes.js';
27
48
  import { gatedSkillsRepos, loadConfig, resolveWorktreeRetention } from '../config.js';
28
49
  import { findConfigFile } from '../agent-config/catalog.js';
29
- import { readConfigFile, writeConfigFile } from '../agent-config/files.js';
50
+ import { readConfigFile, statConfigPath, writeConfigFile } from '../agent-config/files.js';
51
+ import { readAgentModelDefaults } from '../agent-config/models.js';
30
52
  import { listAgentConfig } from '../agent-config/service.js';
31
- import { PROJECT_ID_RE, defaultWorkspaceConfig, loadWorkspaceConfig, mergeWriteWorkspaceConfig, } from '../workspace/config.js';
32
- import { allocateProjectSlug, listProjects, probeProjectStatus, registerProject, removeProject, shouldRegisterProject, } from '../workspace/projects.js';
53
+ import { listConfigFiles } from '../agent-config/catalog.js';
54
+ import { readAccountIdentity } from '../agent-config/account-identity.js';
55
+ import { PROJECT_ID_RE, defaultWorkspaceConfig, effectiveSkillsAutoUpdate, loadWorkspaceConfig, mergeWriteWorkspaceConfig, effectiveComposerDefault, } from '../workspace/config.js';
56
+ import { CONTROL_CHARS_RE, DEFAULT_AGENT_ACCOUNT_ID, defaultAgentAccountStore, isAbsoluteConfigDir, loadAgentAccounts, mergeWriteAgentAccounts, } from '../workspace/agent-accounts.js';
57
+ import { defaultAgentProfile, listAgentProfiles, profileDirState, resolveProfileEnvForRoot, resolveStoredProfile, sameProfileDir, } from '../workspace/agent-profiles.js';
58
+ import { PROFILE_CAPABLE_PROVIDERS, profileEnv, supportsProfiles } from '../core/agent-profiles.js';
59
+ import { withEnvPrefix } from '../core/shell-env.js';
60
+ import { allocateProjectSlug, listProjects, normalizeProjectTags, probeProjectStatus, registerProject, removeProject, shouldRegisterProject, } from '../workspace/projects.js';
33
61
  import { mergeWriteWorkspaceUiState, readWorkspaceUiState } from '../workspace/ui-state.js';
34
62
  import { checkoutRepo } from './checkout.js';
35
63
  import { ProjectContextError, ProjectContexts } from './project-context.js';
36
64
  import { reviewGateEnabled } from '../runs/review-gate.js';
37
65
  import { readUiState, uiStatePath } from '../ui-state.js';
38
- import { expandTilde } from '../paths.js';
66
+ import { agentHomePaths, expandTilde } from '../paths.js';
39
67
  import { isLoopbackHostHeader, normalizeHostname, resolveCapabilities } from './capabilities.js';
40
68
  import { createSocketHub } from './ws.js';
41
69
  import { browseDirectory, isInsideBrowseRoot, isLexicallyInsideBrowseRoot, resolveBrowseRoot } from './fs-browse.js';
42
- import { resolveForge } from './forge/index.js';
43
- import { fetchGithub, fetchGithubComments } from './github.js';
70
+ import { parseRemote, resolveForge } from './forge/index.js';
71
+ import { fetchGithub, fetchGithubChecks, fetchGithubComments, fetchGithubPrDiff, GithubPrNotFoundError, GH_CHECKS_MAX } from './github.js';
44
72
  import { ensureLaunchKey } from './launch-key.js';
45
73
  import { openInTerminal } from './open-in-terminal.js';
46
74
  import { agentCliRunner, detectOpenTargets, openFileInDefaultApp, openInApp } from './open-in-app.js';
47
75
  import { createDraftPr } from './pr.js';
76
+ import { ProviderRuntimeAuthObserver } from './provider-auth-runtime.js';
77
+ import { providerForActiveRun, providerForExistingRun, providersRequiredByWorkflow, unavailableProviderMessage, } from './provider-action-gate.js';
48
78
  import { ASSET_CACHE_CONTROL, BUILD_HINT_HTML, assetContentType, isSafeAssetFilename, resolveGetRequest, } from './static-ui.js';
49
79
  /** `projectId` gate at the route boundary (spec "Project identity"): the slug
50
80
  * shape or the reserved `default` alias — validated BEFORE touching any map
51
81
  * or path. (`default` matches the slug regex too; the literal keeps the
52
82
  * contract explicit.) */
53
83
  const projectIdSchema = z.union([z.literal('default'), z.string().regex(PROJECT_ID_RE)]);
54
- const SCOPED_PREFIX = '/api/p/:projectId';
84
+ const providerConnectSchema = z.object({
85
+ provider: z.enum(PROVIDER_IDS),
86
+ /** Which agent account to sign in (spec 2026-07-29-agent-profiles). Absent = the discovered
87
+ * default, which is what every pre-profiles client sends. Without this, "Connect" on a second
88
+ * Claude account would open a login for the FIRST one and report success. */
89
+ profileId: z.string().max(64).optional(),
90
+ }).strict();
91
+ /** Agent-account bodies (spec 2026-07-29-agent-profiles). Bounds mirror `agentProfileSchema` in
92
+ * src/workspace/config.ts exactly, so a value these accept can never be degraded away by the
93
+ * next load's `.catch`. The id is allocated server-side and is never a request field. */
94
+ const createAgentProfileSchema = z.object({
95
+ provider: z.enum(PROVIDER_IDS),
96
+ label: z.string().trim().max(200).optional(),
97
+ configDir: z.string().trim().min(1).max(4096),
98
+ }).strict();
99
+ /** `POST …/agent-profiles/:id/open` — a catalog id (or `folder`) plus an optional open target. */
100
+ const openAgentAccountFileSchema = z.object({
101
+ file: z.string().min(1).max(200),
102
+ target: z.string().min(1).max(64).optional(),
103
+ }).strict();
104
+ const updateAgentProfileSchema = z.object({
105
+ label: z.string().trim().max(200).optional(),
106
+ configDir: z.string().trim().min(1).max(4096).optional(),
107
+ }).strict().refine((value) => value.label !== undefined || value.configDir !== undefined, 'send label or configDir');
108
+ /** `PUT …/agent-profiles/selection` — which account a project uses for one provider. `null`
109
+ * clears it back to the discovered account. */
110
+ const selectAgentProfileSchema = z.object({
111
+ /** `null` targets the machine-wide default rather than one repo. */
112
+ projectId: z.string().min(1).max(64).nullable(),
113
+ provider: z.enum(PROVIDER_IDS),
114
+ profileId: z.string().max(64).nullable(),
115
+ }).strict();
116
+ /** The hosted-mode refusal, worded like the agent-config one it mirrors. */
117
+ const hostedProfileRefusal = {
118
+ error: 'agent accounts are managed from the machine that owns the checkout (this cockpit runs in hosted mode)',
119
+ };
120
+ /**
121
+ * Allocate a profile id from the label (or, with no label, the folder name).
122
+ *
123
+ * Reuses the project allocator's shape, and deliberately its reserved set too: `default` is the
124
+ * discovered profile here exactly as it is the boot alias there, so a folder called `default/`
125
+ * becomes `default-2` and can never shadow it.
126
+ */
127
+ function allocateAgentProfileId(source, taken) {
128
+ // `allocateProjectSlug` already basenames its argument and enforces the shared
129
+ // `^[a-z0-9][a-z0-9-]{0,63}$` shape, so `~/.claude-klaudiusz` slugs to `claude-klaudiusz`.
130
+ return allocateProjectSlug(source, taken);
131
+ }
132
+ const providerParamSchema = z.enum(PROVIDER_IDS);
133
+ const providerEnabledSchema = z.object({ enabled: z.boolean() }).strict();
134
+ const providerRetrySchema = z.object({
135
+ authFailureId: z.string().min(1).max(128),
136
+ }).strict();
137
+ const automationEditableSchema = z
138
+ .object({
139
+ name: z.string().trim().min(1).max(200),
140
+ description: z.string().max(2_000).optional(),
141
+ enabled: z.boolean().optional(),
142
+ events: z.array(automationEventSchema).min(1).max(4),
143
+ intervalSeconds: z.number().int().min(60).max(86_400),
144
+ filters: automationFiltersSchema,
145
+ task: automationTaskSchema,
146
+ })
147
+ .strict();
148
+ const automationCreateSchema = automationEditableSchema.extend({ enable: z.boolean().optional() });
149
+ const automationUpdateSchema = automationEditableSchema.extend({ expectedRevision: z.number().int().positive() });
150
+ const automationCheckRequestSchema = z.object({ mode: z.enum(['preview', 'execute']) }).strict();
151
+ const automationLogQuerySchema = z.object({
152
+ automationId: z.string().optional(),
153
+ result: automationLogResultSchema.optional(),
154
+ event: automationEventSchema.optional(),
155
+ since: z.string().datetime().optional(),
156
+ cursor: z.coerce.number().int().positive().optional(),
157
+ // Optional, not `.default(100)`: `AutomationStore.logs` already clamps `limit ?? 100` into
158
+ // 1..100, so the default here was a second copy of it — and a defaulted key is REQUIRED in the
159
+ // request type `queryZodValidator` publishes (see validators.ts on why the request side falls
160
+ // back to the schema's output), which would have made every caller send a `?limit=` this route
161
+ // never needed.
162
+ limit: z.coerce.number().int().min(1).max(100).optional(),
163
+ });
164
+ function editableAutomation(definition) {
165
+ return {
166
+ name: definition.name,
167
+ description: definition.description,
168
+ enabled: definition.enabled,
169
+ events: definition.events,
170
+ intervalSeconds: definition.intervalSeconds,
171
+ filters: definition.filters,
172
+ task: definition.task,
173
+ };
174
+ }
175
+ /**
176
+ * The public API surface (spec 2026-07-23-independent-server-web-packages).
177
+ *
178
+ * Every route lives under this one prefix. The unversioned `/api/*` spelling the cockpit used
179
+ * to speak was removed once the whole API was reachable here — carrying two spellings meant two
180
+ * surfaces to keep working, and only one of them could be the typed contract. Bumping to `v2`
181
+ * means mounting a second table beside this one, not editing route paths.
182
+ */
183
+ const V1_PREFIX = '/api/v1';
184
+ /** Project scoping inside the versioned surface. The version is the OUTER dimension, so a
185
+ * consumer picks its API version once and then addresses projects inside it. */
186
+ const V1_SCOPED_PREFIX = `${V1_PREFIX}/p/:projectId`;
55
187
  /**
56
- * The project-scoped route table of a `createApp()` app, derived from its
57
- * actual registrations (so it can never drift from the code): every
58
- * method+path mounted under `/api/p/:projectId/…`, minus the scope-resolver
59
- * middleware (method ALL), deduped. The alias-parity suite iterates this to
60
- * assert unprefixed `/api/<path>` ≡ `/api/p/<boot>/<path>` ≡
61
- * `/api/p/default/<path>`.
188
+ * The project-scoped route table of a `createApp()` app, derived from its actual registrations
189
+ * (so it can never drift from the code): every method+path mounted under
190
+ * `/api/v1/p/:projectId/…`, minus the scope-resolver middleware (method ALL), deduped. The
191
+ * alias-parity suite iterates this to assert `/api/v1/<path>` ≡ `/api/v1/p/<boot>/<path>` ≡
192
+ * `/api/v1/p/default/<path>`.
62
193
  */
63
194
  export function projectRouteManifest(app) {
64
195
  const seen = new Set();
65
196
  const manifest = [];
66
197
  for (const route of app.routes) {
67
- if (route.method === 'ALL' || !route.path.startsWith(`${SCOPED_PREFIX}/`))
198
+ if (route.method === 'ALL' || !route.path.startsWith(`${V1_SCOPED_PREFIX}/`))
68
199
  continue;
69
- const path = route.path.slice(SCOPED_PREFIX.length);
200
+ const path = route.path.slice(V1_SCOPED_PREFIX.length);
70
201
  const key = `${route.method} ${path}`;
71
202
  if (seen.has(key))
72
203
  continue;
@@ -77,14 +208,17 @@ export function projectRouteManifest(app) {
77
208
  }
78
209
  /** 409 body for the inbox mutators while the follow-up inbox is off (#471). */
79
210
  const FOLLOWUPS_OFF = 'the follow-up inbox is disabled — set CEZ_FOLLOWUPS=1 to enable it';
211
+ /** 409 body for every automations route while GitHub automations are off (#801). */
212
+ const AUTOMATIONS_OFF = 'GitHub automations are disabled — set CEZ_AUTOMATIONS=1 to enable them';
80
213
  /**
81
214
  * The in-process bus for workspace-level SSE events. The registry-mutating
82
215
  * routes (`POST /api/projects` — step 4.2, emits `project-added` for a
83
216
  * genuinely new entry; `DELETE /api/projects/:projectId` — step 4.4) and the
84
- * checkout flow (step 4.3) call `emit()`; every open `/api/workspace/events`
85
- * stream relays the
86
- * event verbatim under its name. Injectable via `ServerDeps.workspaceEvents`
87
- * so tests (and any out-of-createApp emitter) can drive the stream.
217
+ * checkout flow (step 4.3) call `emit()`; runtime provider auth observation
218
+ * emits host-wide `provider-status`; every open `/api/workspace/events` stream
219
+ * relays the event verbatim under its name. Injectable via
220
+ * `ServerDeps.workspaceEvents` so tests (and any out-of-createApp emitter) can
221
+ * drive the stream.
88
222
  */
89
223
  export class WorkspaceEventBus {
90
224
  listeners = new Set();
@@ -120,15 +254,19 @@ const startRunSchema = z
120
254
  // other prompt fields (`systemPrompt` 20k, message `text` 100k) so an
121
255
  // unbounded body can't be piped into a spawned process (#429). 100k chars
122
256
  // (~25k tokens) is well past any hand-written task.
123
- task: z.string().min(1).max(100_000, 'task must be at most 100000 characters'),
257
+ task: z.string().min(1).max(100_000, 'must be at most 100000 characters'),
124
258
  model: z.string().optional(),
125
259
  // Agent backend for this task (falls back to config `defaultRunner`).
126
- runner: z.enum(['claude', 'codex', 'opencode']).optional(),
260
+ runner: z.enum(RUNNER_IDS).optional(),
261
+ // Agent account for this task (spec 2026-07-29-agent-profiles). Falls back to the project's
262
+ // own selection, then the discovered default. Bounded like a profile id in the workspace
263
+ // schema, so a value this route accepts can never be degraded away by the next load.
264
+ agentProfile: z.string().max(64).optional(),
127
265
  // Parallel variants (spec 010): ×2/×3 runs the task as 2–3 competing
128
266
  // agents in separate worktrees; the user compares diffs and picks one.
129
267
  variants: z.number().int().min(1).max(3).optional(),
130
268
  // Composer worktree opt-out (#worktree-toggle): false runs in the repo
131
- // working tree (read-only skills). Ignored when variants > 1.
269
+ // working tree. Ignored when variants > 1.
132
270
  worktree: z.boolean().optional(),
133
271
  // Autonomous mode (#autonomous): the run never parks at `waiting` — it
134
272
  // auto-continues until the agent signals done. No "needs you" is raised.
@@ -145,7 +283,7 @@ const startRunSchema = z
145
283
  systemPrompt: z
146
284
  .string()
147
285
  .trim()
148
- .max(20_000, 'systemPrompt must be at most 20000 characters')
286
+ .max(20_000, 'must be at most 20000 characters')
149
287
  .optional()
150
288
  .transform((s) => (s ? s : undefined)),
151
289
  // Screenshots pasted into the new-task form — same shape and limits as a
@@ -163,7 +301,7 @@ const startRunSchema = z
163
301
  // started — the same bookkeeping POST /api/todos/:id/start does, so the
164
302
  // audit trail survives the composer detour. Bounded like every other
165
303
  // string here; a todo id is a short generated key.
166
- todoId: z.string().min(1).max(200, 'todoId must be at most 200 characters').optional(),
304
+ todoId: z.string().min(1).max(200, 'must be at most 200 characters').optional(),
167
305
  })
168
306
  .refine((b) => Boolean(b.workflow) !== Boolean(b.steps), {
169
307
  message: 'provide either "workflow" or "steps", not both',
@@ -173,7 +311,7 @@ const pickSchema = z.object({
173
311
  });
174
312
  const planSchema = z.object({
175
313
  // Same bound as `startRunSchema.task` — this flows into `planChain` (#429).
176
- task: z.string().trim().min(1).max(100_000, 'task must be at most 100000 characters'),
314
+ task: z.string().trim().min(1).max(100_000, 'must be at most 100000 characters'),
177
315
  });
178
316
  // A saved workflow carries full `steps` OR the builder's `skills` stack
179
317
  // (spec 012). `overwrite: true` is the builder's Save on an existing file —
@@ -183,7 +321,7 @@ const saveWorkflowSchema = z
183
321
  name: z.string().trim().min(1).max(80),
184
322
  // Written into a YAML file on disk (#429) — a workflow description is a
185
323
  // short blurb, so a 2k cap is generous without allowing a file-bloat write.
186
- description: z.string().max(2_000, 'description must be at most 2000 characters').optional(),
324
+ description: z.string().max(2_000, 'must be at most 2000 characters').optional(),
187
325
  steps: z.array(workflowStepSchema).min(1).max(8).optional(),
188
326
  skills: z.array(z.string().trim().min(1)).min(1).max(8).optional(),
189
327
  overwrite: z.boolean().optional(),
@@ -206,51 +344,25 @@ const SKILL_USAGE_MAX_ENTRIES = 200;
206
344
  // generous for GUI prefs; over-limit is a 400, never a silent strip. Shared by
207
345
  // BOTH ui-state routes (per-repo and workspace) via `parseUiStateBody`.
208
346
  const UI_STATE_MAX_KEYS = 200;
209
- /** Settings → Appearance (redesign R6): accent + density. ONE schema for both
210
- * ui-state files — per-repo (the legacy home, kept so an older cezar in the
211
- * same repo still honours it) and workspace (`~/.cezar/ui-state.json`, its
212
- * post-migration home — multi-project spec, Data Model). */
347
+ /** Settings → Appearance (redesign R6): accent + density + reading width. ONE
348
+ * schema for both ui-state files — per-repo (the legacy home, kept so an older
349
+ * cezar in the same repo still honours it) and workspace
350
+ * (`~/.cezar/ui-state.json`, its post-migration home — multi-project spec,
351
+ * Data Model).
352
+ *
353
+ * Every key is `.optional()` so an older ui-state.json parses unchanged, but
354
+ * each one must be listed HERE: the enclosing `workspaceUiStateSchema` is
355
+ * `.passthrough()` at the top level only, so an unlisted key inside
356
+ * `appearance` is stripped by zod and then wiped from the file by the shallow
357
+ * merge-on-write. The cockpit adopts the PUT response as authoritative, so a
358
+ * stripped key does not merely fail to persist — it visibly reverts the
359
+ * control the user just touched. Adding an appearance preference means adding
360
+ * it here in the same change. */
213
361
  const appearanceSchema = z.object({
214
362
  accent: z.enum(['lime', 'violet']).optional(),
215
363
  density: z.enum(['comfortable', 'compact', 'ultra']).optional(),
364
+ width: z.enum(['narrow', 'wide']).optional(),
216
365
  });
217
- /** Global GUI state (`~/.cezar/ui-state.json`, step 2.7) — the workspace twin
218
- * of `uiStateSchema` below, sharing its `.passthrough()` + key-cap + shallow
219
- * merge-on-write semantics via `parseUiStateBody`. Known keys are the
220
- * cross-project prefs from the spec's Data Model; everything project-scoped
221
- * (githubView, prompt templates, dismissed banners…) stays per-repo. */
222
- const workspaceUiStateSchema = z
223
- .object({
224
- appearance: appearanceSchema.optional(),
225
- notifications: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
226
- // Sidebar per-project collapse map, keyed by project id (slug ≤ 64 chars).
227
- // Entry-capped like `skillUsage`: the map is written straight to a file the
228
- // cockpit GETs on every load, so it must stay bounded on every axis.
229
- sidebar: z
230
- .object({
231
- collapsed: z
232
- .record(z.string().min(1).max(64), z.boolean())
233
- .refine((map) => Object.keys(map).length <= UI_STATE_MAX_KEYS, {
234
- message: `sidebar.collapsed must have at most ${UI_STATE_MAX_KEYS} entries`,
235
- })
236
- .optional(),
237
- })
238
- .passthrough()
239
- .optional(),
240
- // The user's curated selection of default (vendor) skills — `open-mercato/skills` — so the
241
- // catalog is no longer forced in full. GLOBAL (here, not per-repo) because "which skills I
242
- // want" describes the person, not a checkout, and must not depend on where cezar was launched
243
- // (multi-project workspace). Tri-state, enforced in `discoverSkills`: an ABSENT key means "not
244
- // curated" and every default skill still shows (opt-out default — no silent break on upgrade);
245
- // a PRESENT array (even `[]`) shows only those names. Bounded like the `skillUsage` map: the
246
- // file is GET/PUT wholesale, so an unbounded array is an unbounded write. Names match
247
- // `lastTask.ref` (`.min(1).max(200)`). The client PUTs the whole array (shallow top-level merge).
248
- importedSkills: z
249
- .array(z.string().min(1).max(200))
250
- .max(SKILL_USAGE_MAX_ENTRIES)
251
- .optional(),
252
- })
253
- .passthrough();
254
366
  const uiStateSchema = z
255
367
  .object({
256
368
  lastTask: z
@@ -330,7 +442,7 @@ const patchRunSchema = z.object({
330
442
  });
331
443
  // Session commit (redesign R5 — §"Git/session API additions").
332
444
  const gitCommitSchema = z.object({
333
- message: z.string().trim().min(1, 'commit message must not be empty').max(5_000),
445
+ message: z.string().trim().min(1, 'must not be empty').max(5_000),
334
446
  });
335
447
  // "Open in…" (#open-in / #365): `target` selects the app; `path` (optional, worktree-relative)
336
448
  // narrows the target's own worktree/repo-root default to one file — used by the diff pane's
@@ -387,9 +499,9 @@ function foldedLength(task, stack) {
387
499
  // follow-up composer is a full composer, so a screenshot pasted into it must reach the reopened
388
500
  // session rather than being silently dropped.
389
501
  const continueSchema = z.object({
390
- text: z.string().max(100_000, 'text must be at most 100000 characters').optional(),
502
+ text: z.string().max(100_000, 'must be at most 100000 characters').optional(),
391
503
  images: z.array(imageInputSchema).max(4).optional(),
392
- runner: z.enum(['claude', 'codex', 'opencode']).optional(),
504
+ runner: z.enum(RUNNER_IDS).optional(),
393
505
  model: z.string().max(200).optional(),
394
506
  });
395
507
  // Inbox "▶ Run" body (spec 007 / #401 / #413): every field optional, and the whole body is
@@ -401,12 +513,12 @@ const continueSchema = z.object({
401
513
  // absent so it never touches `task`.
402
514
  const startTodoSchema = z
403
515
  .object({
404
- runner: z.enum(['claude', 'codex', 'opencode']).optional(),
516
+ runner: z.enum(RUNNER_IDS).optional(),
405
517
  model: z.string().max(200).optional(),
406
518
  prompt: z
407
519
  .string()
408
520
  .trim()
409
- .max(20_000, 'prompt must be at most 20000 characters')
521
+ .max(20_000, 'must be at most 20000 characters')
410
522
  .optional()
411
523
  .transform((s) => (s ? s : undefined)),
412
524
  })
@@ -432,21 +544,99 @@ function stripHostPort(host) {
432
544
  return bracketed[1];
433
545
  return host.replace(/:\d+$/, '');
434
546
  }
435
- /** The shared write-side half of BOTH ui-state routes (per-repo `/api/ui-state`
436
- * and workspace `/api/workspace/ui-state`) — the factored split the
437
- * multi-project spec calls for instead of a copy: parse with the route's own
438
- * schema, then cap the top-level key count so a `.passthrough()` schema can't
439
- * accumulate an unbounded key set (#429). The merge-on-write stays with each
440
- * route (they write different files) but is shallow in both. */
441
- function parseUiStateBody(schema, body) {
442
- const parsed = schema.safeParse(body);
443
- if (!parsed.success)
444
- return { error: parsed.error.issues.map((i) => i.message).join('; ') };
445
- if (Object.keys(parsed.data).length > UI_STATE_MAX_KEYS) {
446
- return { error: `ui-state has too many keys (max ${UI_STATE_MAX_KEYS})` };
547
+ /** The shared write-side half of BOTH ui-state routes (per-repo `/api/v1/ui-state`
548
+ * and workspace `/api/v1/workspace/ui-state`) — the factored split the
549
+ * multi-project spec calls for instead of a copy: the route's own schema, plus a
550
+ * cap on the top-level key count so a `.passthrough()` schema can't accumulate an
551
+ * unbounded key set (#429). The cap rides as a refinement so the whole thing is one
552
+ * schema and can go through `jsonBody` like every other mutating route. The
553
+ * merge-on-write stays with each route (they write different files) but is shallow
554
+ * in both. */
555
+ function capUiStateKeys(data, ctx) {
556
+ if (Object.keys(data).length > UI_STATE_MAX_KEYS) {
557
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `ui-state has too many keys (max ${UI_STATE_MAX_KEYS})` });
558
+ }
559
+ }
560
+ // Derived once per route instead of by a generic `wrap(schema)` helper called at the route: a
561
+ // generic wrapper leaves the schema type unresolved where `jsonBody` needs it, and Hono answers
562
+ // that by dropping the whole PUT from the route schema rather than erroring — both ui-state PUTs
563
+ // silently vanished from `AppType`. Concrete consts keep them visible to `hc`.
564
+ const uiStateBody = uiStateSchema.superRefine(capUiStateKeys);
565
+ /**
566
+ * Query schemas for the READ routes — deliberately permissive.
567
+ *
568
+ * These exist to make a query key VISIBLE to the route type (`hc` refuses a `query` argument for
569
+ * a key no validator declares), NOT to narrow what the route accepts. Every one of these handlers
570
+ * compares `=== '1'` and treats everything else as false, so `?refresh=0` is a successful request
571
+ * today; a literal schema would silently turn it into a 400. The comparison stays in the handler
572
+ * and the validator stays out of its way.
573
+ *
574
+ * The one route that really is strict on the wire — `GET /github/prs/:number/changes`, which 400s
575
+ * on `?refresh=true` — keeps its own `z.enum(['1'])` schema next to the route.
576
+ */
577
+ /**
578
+ * One query value, matching what `c.req.query('k')` did before these routes were validated.
579
+ *
580
+ * Hono's query validator hands a REPEATED key as an array (`?wait=1&wait=1` → `['1','1']`), where
581
+ * `c.req.query()` silently took the first. A plain `z.string()` therefore turns a request that
582
+ * used to answer 200 into a 400 — a wire change no caller asked for. Collapsing to the first
583
+ * value keeps the old behaviour, and the client still sees a plain `key?: string`.
584
+ */
585
+ const queryValue = z.union([z.string(), z.array(z.string()).transform((v) => v[0])]).optional();
586
+ const refreshQuery = z.object({ refresh: queryValue });
587
+ const waitQuery = z.object({ wait: queryValue });
588
+ function parseAccept(header) {
589
+ const ranges = [];
590
+ for (const part of header.split(',')) {
591
+ const [range = '', ...params] = part.split(';');
592
+ const [type = '', subtype = ''] = range.trim().toLowerCase().split('/');
593
+ if (type === '' || subtype === '')
594
+ continue;
595
+ const weight = params.map((p) => p.trim().toLowerCase()).find((p) => p.startsWith('q='));
596
+ const parsed = weight === undefined ? 1 : Number.parseFloat(weight.slice(2));
597
+ const q = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 0), 1) : 0;
598
+ if (q > 0)
599
+ ranges.push({ type, subtype, q });
600
+ }
601
+ return ranges;
602
+ }
603
+ /**
604
+ * The best of `offers` for this `Accept`, or `null` for "no preference expressed" — which is what
605
+ * an absent header, a `*<slash>*`-only header and a header naming nothing on offer all mean, and
606
+ * what leaves the caller on its route's default.
607
+ *
608
+ * `offers` is the server's own preference order: an offer may itself be a wildcard (`image/*`,
609
+ * since the concrete image type is not known until the file is read), and equal q-values go to the
610
+ * EARLIER offer, so each route lists its default representation first.
611
+ */
612
+ function negotiate(accept, offers) {
613
+ if (accept === undefined)
614
+ return null;
615
+ const ranges = parseAccept(accept);
616
+ let best = null;
617
+ for (const offer of offers) {
618
+ const [type = '', subtype = ''] = offer.split('/');
619
+ let q = 0;
620
+ for (const range of ranges) {
621
+ if (range.type === '*' && range.subtype === '*')
622
+ continue; // "anything" is not a preference
623
+ const typeMatches = range.type === type || range.type === '*' || type === '*';
624
+ const subtypeMatches = range.subtype === subtype || range.subtype === '*' || subtype === '*';
625
+ if (typeMatches && subtypeMatches && range.q > q)
626
+ q = range.q;
627
+ }
628
+ if (q > 0 && (best === null || q > best.q))
629
+ best = { offer, q };
447
630
  }
448
- return { data: parsed.data };
631
+ return best?.offer ?? null;
449
632
  }
633
+ /** What `GET /repo/commit/:sha` offers, DEFAULT FIRST: the legacy `text/plain` blob is what a
634
+ * request with no opinion has always received (§2), so it also wins an Accept tie. */
635
+ const COMMIT_FORMATS = ['text/plain', 'application/json'];
636
+ /** What `GET /runs/:id/files` offers, DEFAULT FIRST. `image/*` rather than a concrete type: which
637
+ * image type the bytes are is only known once the path resolves, and the raw branch refuses
638
+ * everything that is not an image anyway. */
639
+ const FILE_FORMATS = ['application/json', 'image/*'];
450
640
  /** Workspace-root writability probe (multi-project spec, "API Contracts"):
451
641
  * optional `mkdir -p`, `access W_OK`, then a real create/delete round-trip — W_OK alone
452
642
  * can lie (e.g. a read-only mount still reports writable permission bits).
@@ -469,6 +659,10 @@ async function probeWritableDir(dir, create) {
469
659
  return err instanceof Error ? err.message : String(err);
470
660
  }
471
661
  }
662
+ // The return type is INFERRED on purpose: it is the chained app type built at the bottom of
663
+ // this function, and `AppType` (src/server/app-type.ts) is `ReturnType<typeof createApp>`.
664
+ // Annotating it `Hono` here would erase every route from the type and leave the typed client
665
+ // with nothing to offer. See the `routed` assembly at the end of the function.
472
666
  export function createApp(deps) {
473
667
  const { version, update, bindHost, bootProjectId } = deps;
474
668
  // Boot singletons keep DELIBERATELY distinct names (`boot*`): every
@@ -479,8 +673,60 @@ export function createApp(deps) {
479
673
  const bootRoot = deps.repoRoot;
480
674
  const bootDataDir = join(bootRoot, '.ai/cezar');
481
675
  const modelCatalog = deps.modelCatalog ?? new RunnerModelCatalog({
482
- adapters: { codex: { discover: () => discoverCodexModels({ cwd: bootRoot }) } },
676
+ adapters: {
677
+ codex: { discover: () => discoverCodexModels({ cwd: bootRoot }) },
678
+ opencode: { discover: () => discoverOpencodeModels({ cwd: bootRoot }) },
679
+ },
483
680
  });
681
+ const providerAuth = deps.providerAuth ?? new ProviderAuthService();
682
+ const workspaceConfig = deps.workspaceConfig ?? {
683
+ load: loadWorkspaceConfig,
684
+ mergeWrite: mergeWriteWorkspaceConfig,
685
+ };
686
+ const providerStatus = async (options) => {
687
+ if (providerAuthChecksDisabled()) {
688
+ return applyProviderEnablement(await providerAuth.status(options?.refresh ? { refresh: true } : undefined), []);
689
+ }
690
+ const [discovered, workspace] = await Promise.all([
691
+ providerAuth.status(options?.refresh ? { refresh: true } : undefined),
692
+ workspaceConfig.load(),
693
+ ]);
694
+ return applyProviderEnablement(discovered, workspace.disabledProviders);
695
+ };
696
+ /**
697
+ * The gate: why a run cannot start against `required`, or null when it can.
698
+ *
699
+ * VERIFY BEFORE YOU REFUSE. Auth state is served stale-while-revalidate (see
700
+ * `ProviderAuthService.status`), so a cached "disconnected" may predate a login cezar could not
701
+ * observe — someone running `claude auth login` in a terminal. Refusing on that would lock a user
702
+ * out of their own cockpit with no way back but waiting. So a believed-unavailable provider is
703
+ * re-probed, and only a refusal that survives the fresh answer is returned.
704
+ *
705
+ * The cost lands where it belongs: the common path (connected, warm) pays nothing at all, and the
706
+ * probe is only spawned when cezar is about to say no — a rare, interactive moment. This is also
707
+ * what lets the cache hold a negative for a minute instead of five seconds, which is what made
708
+ * every reader of `GET /providers/status` periodically pay for a CLI spawn.
709
+ *
710
+ * A runtime auth latch is deliberately NOT escaped by this: `withRuntimeFailures` keeps forcing
711
+ * the row disconnected until the user acknowledges that exact incident, so the re-probe cannot
712
+ * talk cezar out of a rejection it actually observed.
713
+ */
714
+ const providerActionError = async (required) => {
715
+ const known = await providerStatus();
716
+ const message = unavailableProviderMessage(required, known);
717
+ if (message === null)
718
+ return null;
719
+ // A DISABLED provider is a settings fact, not a probe result — re-probing it learns nothing and
720
+ // would spawn a CLI to re-read something the user typed.
721
+ const disabled = required.some((provider) => known.providers.find((row) => row.provider === provider)?.enabled === false);
722
+ if (disabled)
723
+ return message;
724
+ return unavailableProviderMessage(required, await providerStatus({ refresh: true }));
725
+ };
726
+ const openTerminal = deps.openTerminal ?? openInTerminal;
727
+ const openFile = deps.openFile ?? openFileInDefaultApp;
728
+ const openApp = deps.openApp ?? openInApp;
729
+ const skillsUpdate = deps.skillsUpdate ?? new SkillsUpdateService();
484
730
  // ---- workspace boot-project identity (multi-project spec) ----------------
485
731
  // The boot flow (`initWorkspace` in src/index.ts) registers the boot repo
486
732
  // and plumbs its registry id in via `deps.bootProjectId`. Legacy callers and
@@ -551,6 +797,7 @@ export function createApp(deps) {
551
797
  dataDir: bootDataDir,
552
798
  store: deps.store,
553
799
  manager: deps.manager,
800
+ automationStore: deps.automationStore ?? AutomationStore.open(bootDataDir),
554
801
  launchKey: ensureLaunchKey(bootDataDir), // bookmarklet auto-start secret (spec 011)
555
802
  };
556
803
  // Non-boot projects build lazily on first scoped request; their managers
@@ -567,6 +814,25 @@ export function createApp(deps) {
567
814
  // Workspace-level SSE bus (step 2.8) — the registry mutators and the
568
815
  // checkout flow (Phase 4) emit here; /api/workspace/events relays.
569
816
  const workspaceEvents = deps.workspaceEvents ?? new WorkspaceEventBus();
817
+ const emitAutomationChange = (project, automationId, revision, deleted = false) => workspaceEvents.emit('automation-change', {
818
+ project: project.id,
819
+ automationId,
820
+ revision,
821
+ ...(deleted ? { deleted: true } : {}),
822
+ });
823
+ const automationsChanged = () => deps.automationsChanged?.();
824
+ const providerRuntimeAuth = deps.providerRuntimeAuth
825
+ ?? new ProviderRuntimeAuthObserver(providerAuth, (status) => {
826
+ workspaceEvents.emit('provider-status', status);
827
+ });
828
+ providerRuntimeAuth.watch(bootContext.store);
829
+ for (const id of contexts.ids()) {
830
+ const ctx = contexts.peek(id);
831
+ if (ctx)
832
+ providerRuntimeAuth.watch(ctx.store);
833
+ }
834
+ contexts.onStoreCreated((store) => providerRuntimeAuth.watch(store));
835
+ contexts.onContextBuilt((ctx) => providerRuntimeAuth.watch(ctx.store));
570
836
  const app = new Hono();
571
837
  // Reject oversized request bodies before they reach any handler (#429). GETs
572
838
  // and SSE carry no body, so this only ever gates the mutating routes.
@@ -577,7 +843,7 @@ export function createApp(deps) {
577
843
  // own: any page the user visits can still POST to us (CSRF), and DNS
578
844
  // rebinding can point a foreign domain at loopback and read our responses.
579
845
  // Two zero-config checks close both holes on every /api route EXCEPT
580
- // /api/health (the intentional cross-origin discovery endpoint, spec 011 —
846
+ // /api/v1/health (the intentional cross-origin discovery endpoint, spec 011 —
581
847
  // it exposes nothing sensitive, see #431):
582
848
  // 1. Host allowlist (loopback deployments only) — a request whose Host is
583
849
  // not a loopback name did not really originate from this machine. A
@@ -600,7 +866,7 @@ export function createApp(deps) {
600
866
  // Scope note: check 2 covers writes only. A cross-origin GET from any site
601
867
  // still reaches the read routes — but its Host is ours, so it is a *forced
602
868
  // request*, not a read: the same-origin policy stops the attacker seeing any
603
- // response body (we send CORS headers on /api/health alone), and no GET
869
+ // response body (we send CORS headers on /api/v1/health alone), and no GET
604
870
  // handler mutates state. Rebinding, which WOULD make those reads legible, is
605
871
  // what check 1 stops.
606
872
  const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
@@ -615,9 +881,12 @@ export function createApp(deps) {
615
881
  error: 'forbidden: unexpected Host header — this request did not originate from this machine (see #426)',
616
882
  }, 403);
617
883
  }
618
- // /api/health stays CORS-open for bookmarklet discovery, but its Host is
619
- // still checked above: cross-origin is legitimate, DNS rebinding is not.
620
- if (c.req.path === '/api/health')
884
+ // /api/v1/health stays CORS-open for cross-origin discovery, but its Host is
885
+ // still checked above: cross-origin is legitimate, DNS rebinding is not. The
886
+ // path is spelled through V1_PREFIX rather than inline — an unversioned
887
+ // literal here silently stopped matching when the API moved and left health
888
+ // relying on the mutating-methods gate below to let its GETs through.
889
+ if (c.req.path === `${V1_PREFIX}/health`)
621
890
  return next();
622
891
  if (MUTATING_METHODS.has(c.req.method)) {
623
892
  const origin = c.req.header('origin');
@@ -661,15 +930,16 @@ export function createApp(deps) {
661
930
  });
662
931
  // The mirrored project-route table (spec "API Contracts → Project-scoped").
663
932
  // Every route below registers ONCE on this sub-app; `createApp` mounts it
664
- // twice — under `/api/p/:projectId` (scoped) and under `/api` (the legacy
665
- // aliases, protected surfaces) — so both spellings share one handler and
666
- // can never drift. The resolver middleware binds `c.get('project')`:
667
- // no `projectId` param (legacy mount) → the boot context, byte-identical to
933
+ // twice — under `/api/v1/p/:projectId` (scoped) and under `/api/v1` (bound to
934
+ // the boot project) — so both spellings share one handler and can never
935
+ // drift. The resolver middleware binds `c.get('project')`:
936
+ // no `projectId` param (unscoped mount) → the boot context, byte-identical to
668
937
  // the pre-workspace closures; `default` or the boot project's own id → the
669
938
  // boot context too; anything else → the lazy context map, with
670
939
  // `ProjectContextError` mapped to 404 (unknown) / 409 (missing root).
671
- const api = new Hono();
672
- api.use('*', async (c, next) => {
940
+ // Named rather than inlined because both mounts share it — one function, so
941
+ // the two spellings can never disagree about what `default` means.
942
+ const resolveProjectScope = async (c, next) => {
673
943
  const raw = c.req.param('projectId');
674
944
  if (raw === undefined) {
675
945
  c.set('project', bootContext);
@@ -694,15 +964,23 @@ export function createApp(deps) {
694
964
  throw err;
695
965
  }
696
966
  return next();
697
- });
967
+ };
698
968
  // ---- static GUI ----------------------------------------------------------
699
969
  const webDir = resolveWebDir();
700
970
  const distDir = join(webDir, 'dist');
701
971
  const HTML_TYPE = 'text/html; charset=utf-8';
702
- const staticFile = (name, type) => () => {
972
+ const staticFile = (name, type) => (c) => {
703
973
  // Read per request — the files are tiny and this keeps dev iteration live.
704
- const body = readFileSync(join(webDir, name));
705
- return new Response(body, { headers: { 'content-type': type } });
974
+ //
975
+ // Served out of the Vite build: the file is a `public/` asset of the web package, which the
976
+ // build copies verbatim into `web/dist`. One home, one URL — the same bytes this route
977
+ // hands out are what the bundle's own `<img src="/open-mercato.svg">` asks for.
978
+ // Without a build there is nothing to serve, which is a 404 rather than a crash (the shell
979
+ // route answers the same dev-only state with its build hint).
980
+ const path = join(distDir, name);
981
+ if (!existsSync(path))
982
+ return c.json({ error: 'not found' }, 404);
983
+ return new Response(readFileSync(path), { headers: { 'content-type': type } });
706
984
  };
707
985
  let hintLogged = false;
708
986
  const serveShell = (c) => {
@@ -749,14 +1027,14 @@ export function createApp(deps) {
749
1027
  },
750
1028
  });
751
1029
  });
752
- // The favicon web/app/index.html points at (`../open-mercato.svg`).
1030
+ // The favicon packages/web/index.html points at (`/open-mercato.svg`).
753
1031
  app.get('/open-mercato.svg', staticFile('open-mercato.svg', 'image/svg+xml'));
754
1032
  // ---- meta ----------------------------------------------------------------
755
1033
  // CORS — deliberately for /api/health ONLY (spec 011): the bookmarklets
756
1034
  // fetch it cross-origin from github.com to discover which local ports run a
757
1035
  // cockpit and which repo each serves. Health exposes no secrets beyond the
758
1036
  // repo path/remote; every other endpoint stays same-origin.
759
- app.use('/api/health', async (c, next) => {
1037
+ const healthCors = async (c, next) => {
760
1038
  c.header('access-control-allow-origin', '*');
761
1039
  if (c.req.method === 'OPTIONS') {
762
1040
  // Preflight (e.g. Chrome Private Network Access) — allow the plain GET.
@@ -765,10 +1043,16 @@ export function createApp(deps) {
765
1043
  return c.body(null, 204);
766
1044
  }
767
1045
  await next();
768
- });
1046
+ };
1047
+ app.use(`${V1_PREFIX}/health`, healthCors);
769
1048
  // One builder for both transports: `GET /api/health` (the authoritative,
770
- // CORS-open discovery endpoint) and the `health` topic on `/api/ws` below
1049
+ // CORS-open discovery endpoint) and the `health` topic on `/api/v1/ws` below
771
1050
  // push the byte-identical shape, so the two can never drift.
1051
+ // Deliberately UNANNOTATED: this literal is the source of the `/health` shape. Annotating it
1052
+ // with the api-client's `HealthResponse` made the contract circular — the DTO was declared by
1053
+ // hand, the handler was checked against it, and `AppType` then reported the hand-written type
1054
+ // back as if the server had proven it. Inferring here means the route says what it actually
1055
+ // sends, which is what lets the DTO be derived instead of maintained.
772
1056
  const healthSnapshot = async () => {
773
1057
  const [checks, repo, config, workspace] = await Promise.all([
774
1058
  detectEnvironment(),
@@ -782,7 +1066,11 @@ export function createApp(deps) {
782
1066
  const caps = capabilities();
783
1067
  return {
784
1068
  version,
785
- latestVersion: update?.latest,
1069
+ // Spread rather than `latestVersion: update?.latest`: an `undefined` VALUE is dropped by
1070
+ // JSON.stringify, so the key is absent on the wire — but writing it unconditionally types
1071
+ // the key as always-present, which is a shape no client ever receives. The contract schema
1072
+ // says `.optional()`, and contract-parity.test.ts holds the two together.
1073
+ ...(update?.latest !== undefined ? { latestVersion: update.latest } : {}),
786
1074
  // Health is CORS-open and, in hosted mode, reachable off the loopback —
787
1075
  // so any site/host that reads it would learn the developer's absolute
788
1076
  // checkout path and username (#431). Local mode keeps the full path (the
@@ -874,7 +1162,13 @@ export function createApp(deps) {
874
1162
  void refreshHealth(); // stale: refresh, don't wait on it
875
1163
  return healthCache.payload;
876
1164
  };
877
- app.get('/api/health', async (c) => c.json(await readHealth()));
1165
+ // ---- chained family: health (workspace-level) ----------------------------
1166
+ // Written as ONE chained expression rather than a loose `app.get(...)`
1167
+ // statement because Hono accumulates its route types through the chain: a
1168
+ // statement's return value is discarded, so `typeof app` would record nothing
1169
+ // and `hc<AppType>` would have no endpoint to offer. `createApp` mounts this
1170
+ // under both `/api` (the frozen legacy spelling) and `/api/v1`.
1171
+ const healthRoutes = new Hono().get('/health', async (c) => c.json(await readHealth()));
878
1172
  // The push twin of the poll it replaced (#369): while at least one cockpit
879
1173
  // holds the `health` topic the server re-reads the snapshot on the old 5 s
880
1174
  // cadence and broadcasts ONLY when it changed — a `git checkout` in a
@@ -906,14 +1200,679 @@ export function createApp(deps) {
906
1200
  // `GET /api/health` reads a warm value instead of the cold ~1 s compute.
907
1201
  if (deps.socketHub)
908
1202
  void refreshHealth();
909
- // Host capability, not project state: one installed/authenticated Codex CLI
910
- // and one in-memory cache are shared by every registered workspace.
911
- app.get('/api/models', async (c) => {
912
- const query = z.object({ runner: z.literal('codex') }).safeParse(c.req.query());
913
- if (!query.success)
914
- return c.json({ error: 'runner must be codex' }, 400);
1203
+ /**
1204
+ * Warm the whole of cezar's agent knowledge — the three discovered defaults AND every extra
1205
+ * account — so no reader ever pays the first shell-out.
1206
+ *
1207
+ * Which login each agent is signed into is operating knowledge, not a settings-page detail: the
1208
+ * composer, the action gate, the accounts pane and every run resolution ask for it. So the server
1209
+ * learns it once, at boot, and keeps it (see the asymmetric cache lifetime in
1210
+ * `core/provider-auth.ts`); on-demand refresh rides on `?refresh=1` and on the explicit
1211
+ * invalidations — connect, repoint, remove, runtime rejection.
1212
+ *
1213
+ * Extra accounts are warmed ONE AT A TIME, after the defaults. Each is a CLI spawn, and a machine
1214
+ * with several accounts would otherwise fan out a spawn storm at exactly the moment the browser is
1215
+ * fetching the bundle; nothing is waiting on this, so sequential costs nothing that matters.
1216
+ *
1217
+ * Hosted mode warms only the defaults: the agent-profiles family is refused there, so there are
1218
+ * no accounts to learn about.
1219
+ */
1220
+ const warmAgentKnowledge = async () => {
1221
+ await providerAuth.status().catch(() => { });
1222
+ if (!capabilities().localHandoff)
1223
+ return;
1224
+ const store = await loadAgentAccounts().catch(() => defaultAgentAccountStore());
1225
+ for (const account of listAgentProfiles(store, PROVIDER_IDS)) {
1226
+ if (account.isDefault)
1227
+ continue; // covered by `status()` above
1228
+ await providerAuth
1229
+ .profileStatus(account.provider, { id: account.id, configDir: account.path })
1230
+ .catch(() => { });
1231
+ }
1232
+ };
1233
+ // Same gate as `refreshHealth`, same reason (startServer injects the hub; a bare app in tests does
1234
+ // not, so tests never spawn probes here). Fire-and-forget: a probe that fails leaves that row
1235
+ // cold, which is exactly the state every reader already handles.
1236
+ if (deps.socketHub)
1237
+ void warmAgentKnowledge();
1238
+ // ---- chained family: host model catalog (workspace-level) ----
1239
+ const modelsRoutes = new Hono()
1240
+ // `modelDiscoveryRunnerSchema` is the contract's own list of the runners with an
1241
+ // authoritative host-local catalog (#794), so the client compiles against exactly what this
1242
+ // validates. Claude has no such source: its picker stays on static presets and this 400s.
1243
+ .get('/models', queryZodValidator(z.object({ runner: z.union([z.string(), z.array(z.string()).transform((v) => v[0])]).pipe(modelDiscoveryRunnerSchema) }), { message: 'runner must be codex or opencode' }), async (c) => {
1244
+ const query = { data: c.req.valid('query') };
915
1245
  return c.json(await modelCatalog.get(query.data.runner));
916
1246
  });
1247
+ /**
1248
+ * Resolve `profileId` (absent = the discovered default) into a concrete account for `provider`.
1249
+ *
1250
+ * A dangling id is an ERROR here rather than the silent fall-back to the default that run
1251
+ * resolution performs. The difference is who is asking: a run is replaying a stored reference
1252
+ * and the default is the only safe answer it can act on, whereas a route is answering a user
1253
+ * who just named an account — telling them "unknown account" is honest, and quietly connecting
1254
+ * or reporting on a different one is not.
1255
+ */
1256
+ const resolveWorkspaceProfile = async (provider, profileId) => {
1257
+ if (profileId === undefined || profileId === DEFAULT_AGENT_ACCOUNT_ID) {
1258
+ return { profile: defaultAgentProfile(provider) };
1259
+ }
1260
+ let accounts;
1261
+ try {
1262
+ accounts = (await loadAgentAccounts()).accounts;
1263
+ }
1264
+ catch {
1265
+ return { error: `unknown ${provider} account: ${profileId}` };
1266
+ }
1267
+ const stored = accounts.find((a) => a.id === profileId && a.provider === provider);
1268
+ if (!stored)
1269
+ return { error: `unknown ${provider} account: ${profileId}` };
1270
+ return { profile: resolveStoredProfile(stored) };
1271
+ };
1272
+ /**
1273
+ * The environment a terminal handoff must carry so the CLI lands on the right account.
1274
+ *
1275
+ * `profileId` is the one RECORDED on the step that owns the session, never the project's
1276
+ * current selection: `claude --resume <id>` reads `<configDir>/sessions`, so a handoff for a
1277
+ * run started on the work account and resumed after the project was switched back would find
1278
+ * nothing and silently open a fresh conversation.
1279
+ */
1280
+ const handoffEnv = async (provider, profileId) => {
1281
+ const resolved = await resolveWorkspaceProfile(provider, profileId);
1282
+ if ('error' in resolved) {
1283
+ return { error: `this session belongs to an account that no longer exists (${profileId})` };
1284
+ }
1285
+ const { profile } = resolved;
1286
+ return { env: profile.isDefault ? {} : profileEnv(provider, profile.path) };
1287
+ };
1288
+ /** The copy-paste fallback shown when no terminal could be opened — same account, spelled for
1289
+ * this platform's shell. `null` when the dir cannot be embedded safely, which is a refusal. */
1290
+ const handoffFallbackCommand = (cwd, command, env) => {
1291
+ const prefixed = withEnvPrefix(command, env, process.platform);
1292
+ return prefixed === null ? null : `cd '${cwd}' && ${prefixed}`;
1293
+ };
1294
+ /** A registered project's realpath'd root, or null when the id is unknown. */
1295
+ const projectRootFor = async (projectId) => {
1296
+ try {
1297
+ return (await loadWorkspaceConfig()).projects.find((p) => p.id === projectId)?.root ?? null;
1298
+ }
1299
+ catch {
1300
+ return null;
1301
+ }
1302
+ };
1303
+ // ---- chained family: agent providers (workspace-level) ----
1304
+ const providersRoutes = new Hono()
1305
+ .get('/providers/status', queryZodValidator(z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') }), { message: 'refresh must be 1 when provided' }), async (c) => {
1306
+ const query = { data: c.req.valid('query') };
1307
+ return c.json(await providerStatus({ refresh: query.data.refresh === '1' }));
1308
+ })
1309
+ .put('/providers/:provider/enabled', paramZodValidator(z.object({ provider: providerParamSchema }), { message: 'provider and enabled boolean are required' }), jsonZodValidator(providerEnabledSchema, { message: 'provider and enabled boolean are required' }), async (c) => {
1310
+ const provider = { data: c.req.valid('param').provider };
1311
+ const body = { data: c.req.valid('json') };
1312
+ let workspace;
1313
+ try {
1314
+ workspace = await workspaceConfig.mergeWrite((config) => {
1315
+ const disabled = new Set(config.disabledProviders);
1316
+ if (body.data.enabled)
1317
+ disabled.delete(provider.data);
1318
+ else
1319
+ disabled.add(provider.data);
1320
+ config.disabledProviders = PROVIDER_IDS.filter((id) => disabled.has(id));
1321
+ });
1322
+ }
1323
+ catch {
1324
+ return c.json({ error: 'Provider preference could not be saved.' }, 500);
1325
+ }
1326
+ const result = applyProviderEnablement(await providerAuth.status(), workspace.disabledProviders);
1327
+ const row = result.providers.find(({ provider: id }) => id === provider.data);
1328
+ if (row)
1329
+ workspaceEvents.emit('provider-status', row);
1330
+ return c.json(result);
1331
+ })
1332
+ .post('/providers/:provider/retry', paramZodValidator(z.object({ provider: providerParamSchema }), { message: 'provider and current authFailureId are required' }), jsonZodValidator(providerRetrySchema, { message: 'provider and current authFailureId are required' }), async (c) => {
1333
+ const provider = { data: c.req.valid('param').provider };
1334
+ const body = { data: c.req.valid('json') };
1335
+ if (!providerAuth.clearRuntimeAuthFailure(provider.data, body.data.authFailureId)) {
1336
+ return c.json({ error: 'Authentication incident changed. Refresh and try again.' }, 409);
1337
+ }
1338
+ const result = await providerStatus({ refresh: true });
1339
+ const row = result.providers.find(({ provider: id }) => id === provider.data);
1340
+ if (row)
1341
+ workspaceEvents.emit('provider-status', row);
1342
+ return c.json(result);
1343
+ })
1344
+ .post('/providers/connect', jsonZodValidator(providerConnectSchema, { message: 'provider must be claude, codex, opencode, or pi' }), async (c) => {
1345
+ const body = { data: c.req.valid('json') };
1346
+ const provider = body.data.provider;
1347
+ // A NAMED account is refused in hosted mode before anything is resolved, exactly like every
1348
+ // sibling route in the agent-profiles family. Checking later would already have read
1349
+ // `~/.cezar/agent-accounts.json`, built a command carrying the account's absolute path (which
1350
+ // both the success body and the hosted 409 echo), and — for a stored account — spawned a
1351
+ // probe. It would also answer `unknown account: <id>` for a wrong id, which is an enumeration
1352
+ // oracle for the very ids the hosted listing withholds. The bare-provider spelling keeps its
1353
+ // existing behaviour: it names no host path and is how the Providers card has always worked.
1354
+ if (body.data.profileId !== undefined
1355
+ && body.data.profileId !== DEFAULT_AGENT_ACCOUNT_ID
1356
+ && !capabilities().localHandoff) {
1357
+ return c.json(hostedProfileRefusal, 409);
1358
+ }
1359
+ // Resolve the account BEFORE anything else: both the command and the status probe below
1360
+ // must describe the same one, or the pane reports on the personal login while the terminal
1361
+ // signs into the work login.
1362
+ const resolved = await resolveWorkspaceProfile(provider, body.data.profileId);
1363
+ if ('error' in resolved)
1364
+ return c.json({ error: resolved.error }, 400);
1365
+ const { profile } = resolved;
1366
+ const command = providerAuth.loginCommand(provider, profile.isDefault ? null : profile.path);
1367
+ // Fail closed: a config dir that cannot be embedded safely in this platform's shell has no
1368
+ // safe degradation. Running the bare command would sign the user into a DIFFERENT account
1369
+ // than the one they clicked, and nothing in the terminal would say so.
1370
+ if (command === null) {
1371
+ return c.json({ error: `This account's folder cannot be used in a terminal command: ${profile.configDir}` }, 409);
1372
+ }
1373
+ // BOTH branches must mean "is this account signed in NOW". The default branch refreshes; the
1374
+ // account branch has to evict first, because `profileStatus` serves the per-account cache and
1375
+ // a connected answer there stands for CONNECTED_TTL_MS. Without this, Connect after a
1376
+ // `claude /logout` answers "already connected", opens nothing, and the user is stuck — and it
1377
+ // would contradict this module's own invariant that opening a login is one of the things
1378
+ // cezar CAN observe and therefore invalidates explicitly rather than waiting out a window.
1379
+ if (!profile.isDefault)
1380
+ providerAuth.forgetProfileStatus(provider, profile.id);
1381
+ const row = profile.isDefault
1382
+ ? (await providerAuth.status({ refresh: true })).providers.find((candidate) => candidate.provider === provider)
1383
+ : await providerAuth.profileStatus(provider, { id: profile.id, configDir: profile.path });
1384
+ if (!row) {
1385
+ return c.json({ error: 'Authentication could not be verified. Try again.' }, 500);
1386
+ }
1387
+ if (row.status === 'connected') {
1388
+ return c.json({ opened: false, connected: true, command });
1389
+ }
1390
+ if (row.status === 'not-installed') {
1391
+ return c.json({ error: row.hint ?? providerAuth.installHint(provider), command }, 409);
1392
+ }
1393
+ if (row.status === 'unknown') {
1394
+ return c.json({ error: row.hint ?? 'Authentication could not be verified. Try again.', command }, 409);
1395
+ }
1396
+ if (!capabilities().localHandoff) {
1397
+ return c.json({ error: 'Run this command on the machine hosting cezar.', command }, 409);
1398
+ }
1399
+ let opened = false;
1400
+ try {
1401
+ // No `env` argument: `loginCommand` already rendered the account's config dir INTO the
1402
+ // command, because this string is also the copy-paste fallback the pane shows. Passing
1403
+ // it again here would set the variable twice.
1404
+ opened = await openTerminal(bootRoot, command);
1405
+ }
1406
+ catch {
1407
+ // Terminal handoff is best-effort; the exact command remains the safe fallback.
1408
+ }
1409
+ if (!opened) {
1410
+ return c.json({ error: 'No terminal emulator could be opened. Run this command manually.', command }, 409);
1411
+ }
1412
+ return c.json({ opened: true, command });
1413
+ });
1414
+ // ---- chained family: agent profiles / accounts (workspace-level) ----------
1415
+ // Extra config dirs for a SECOND login of the same agent CLI (spec
1416
+ // 2026-07-29-agent-profiles). Workspace-level and therefore SINGLE-MOUNT: an account belongs to
1417
+ // the person and the machine, never to a repo, and a project-scoped spelling would be a second
1418
+ // surface to protect with no consumer. Which account a project uses is a field on
1419
+ // `PATCH /api/v1/projects/:projectId` instead.
1420
+ //
1421
+ // Writing is a LOCAL-MACHINE capability, exactly like `PUT /api/v1/agent-config/:id`: a profile
1422
+ // points an agent at a directory on the host, and the listing echoes absolute paths carrying
1423
+ // the username — the same disclosure `/api/v1/health` trims in hosted mode (#431).
1424
+ /**
1425
+ * This agent's own USER-scope config files, resolved inside ONE account's folder.
1426
+ *
1427
+ * Straight from the catalog — the single home of config-file vendor knowledge — with an
1428
+ * `AgentHomePaths` whose slot for this provider is the account's dir. That is what makes a second
1429
+ * login's `settings.json` the file you open rather than the default account's, and it keeps the
1430
+ * ids opaque and stable so the open route below never takes a path from the client.
1431
+ */
1432
+ const accountFiles = async (profile) => {
1433
+ const home = {
1434
+ ...agentHomePaths(),
1435
+ ...(profile.provider === 'claude' ? { claude: profile.path } : {}),
1436
+ ...(profile.provider === 'codex' ? { codex: profile.path } : {}),
1437
+ ...(profile.provider === 'opencode' ? { opencodeConfig: profile.path } : {}),
1438
+ };
1439
+ const defs = listConfigFiles().filter((def) => def.scope === 'user' && def.runners.includes(profile.provider));
1440
+ return Promise.all(defs.map(async (def) => {
1441
+ const path = def.resolve(bootRoot, home);
1442
+ return { id: def.id, label: basename(path), path, exists: (await statConfigPath(path)).exists };
1443
+ }));
1444
+ };
1445
+ /** Build the wire row for one resolved profile: its dir state plus whatever auth is cached. */
1446
+ const agentProfileBody = async (profile) => ({
1447
+ id: profile.id,
1448
+ provider: profile.provider,
1449
+ label: profile.label,
1450
+ configDir: profile.configDir,
1451
+ path: profile.path,
1452
+ ...(await profileDirState(profile.provider, profile.path)),
1453
+ isDefault: profile.isDefault,
1454
+ // CACHED auth only — this listing must never pay a CLI spawn.
1455
+ //
1456
+ // Probing here cost a shell-out per provider PLUS one per extra account: ~0.7s with no extra
1457
+ // accounts and over 2s with a few, on every cold load, for a route whose real job (what
1458
+ // accounts exist) is a JSON read and a handful of stats. `GET /api/v1/health` already
1459
+ // established the rule — it serves whatever the cache holds and never pays a `gh` shell-out.
1460
+ // An absent `status` means "not determined yet", which the cockpit renders as Checking… and
1461
+ // then fills in from the per-account status route below.
1462
+ //
1463
+ // SPREAD, not `status: maybeUndefined`: hono would type the key as always-present while
1464
+ // `JSON.stringify` drops it, which is exactly the drift `contract-parity` catches (AGENTS.md
1465
+ // names this as one of the two recurring mismatches).
1466
+ ...(() => {
1467
+ const cached = profile.isDefault
1468
+ ? providerAuth.peekStatus()?.providers.find((row) => row.provider === profile.provider)
1469
+ : providerAuth.peekProfileStatus(profile.provider, profile.id);
1470
+ return cached ? { status: cached } : {};
1471
+ })(),
1472
+ files: await accountFiles(profile),
1473
+ });
1474
+ /**
1475
+ * Resolve `:id` to an account for the per-account reads below. Unlike `resolveWorkspaceProfile`
1476
+ * this accepts the reserved `default` for a NAMED provider, which these routes cannot infer — so
1477
+ * the id may be `default:<provider>` as well as a stored account id.
1478
+ */
1479
+ const accountById = async (id) => {
1480
+ const [head, tail] = id.split(':');
1481
+ if (head === DEFAULT_AGENT_ACCOUNT_ID) {
1482
+ const provider = PROVIDER_IDS.find((p) => p === tail);
1483
+ return provider ? defaultAgentProfile(provider) : null;
1484
+ }
1485
+ try {
1486
+ const stored = (await loadAgentAccounts()).accounts.find((a) => a.id === id);
1487
+ return stored ? resolveStoredProfile(stored) : null;
1488
+ }
1489
+ catch {
1490
+ return null;
1491
+ }
1492
+ };
1493
+ /** Validate a client-supplied config dir. Returns the error text, or null when it is usable. */
1494
+ const checkProfileDir = (configDir) => {
1495
+ if (CONTROL_CHARS_RE.test(configDir))
1496
+ return 'folder must not contain control characters';
1497
+ const expanded = expandTilde(configDir);
1498
+ // Absolute after expansion: a relative dir would resolve against whatever cwd the agent
1499
+ // happens to be spawned in, which for a task is a throwaway worktree. Through
1500
+ // `isAbsoluteConfigDir`, never a leading-`/` test — see its note: a string test refuses every
1501
+ // real Windows path, and this is the only gate the Add-account dialog has.
1502
+ if (!isAbsoluteConfigDir(expanded))
1503
+ return `folder must be an absolute path: ${configDir}`;
1504
+ return null;
1505
+ };
1506
+ /** Refuse a dir that is already some other account's (or the default's), compared through
1507
+ * `realpath` — two spellings of one directory would be two accounts silently sharing one
1508
+ * session store, and "which one am I logged into?" would stop having an answer. */
1509
+ const conflictingProfile = async (profiles, provider, path, exceptId) => {
1510
+ if (await sameProfileDir(path, defaultAgentProfile(provider).path)) {
1511
+ return 'that is already this agent\'s default folder';
1512
+ }
1513
+ for (const candidate of profiles) {
1514
+ if (candidate.provider !== provider || candidate.id === exceptId)
1515
+ continue;
1516
+ if (await sameProfileDir(path, expandTilde(candidate.configDir))) {
1517
+ return `that folder is already used by "${candidate.label || candidate.id}"`;
1518
+ }
1519
+ }
1520
+ return null;
1521
+ };
1522
+ const agentProfilesRoutes = new Hono()
1523
+ .get('/workspace/agent-profiles', async (c) => {
1524
+ const editable = capabilities().localHandoff;
1525
+ // Hosted mode withholds the listing entirely rather than serving it read-only: the paths
1526
+ // are the host disclosure, so an empty list is the only honest hosted answer.
1527
+ //
1528
+ // ONE body object, never a hosted `return` and a local `return`: two returns let hono
1529
+ // narrow `editable` to the literal `false`/`true` of each branch, and the contract's
1530
+ // honest `z.boolean()` then reads as wider than the route. Same shape as
1531
+ // `listAgentConfig`, which carries the same flag for the same reason.
1532
+ let store = defaultAgentAccountStore();
1533
+ if (editable) {
1534
+ try {
1535
+ store = await loadAgentAccounts();
1536
+ }
1537
+ catch {
1538
+ // an unreadable home degrades to "no extra accounts", never a failed request
1539
+ }
1540
+ }
1541
+ const profiles = editable
1542
+ ? await Promise.all(listAgentProfiles(store, PROVIDER_IDS).map(agentProfileBody))
1543
+ : [];
1544
+ return c.json({
1545
+ editable,
1546
+ profiles,
1547
+ profileCapableProviders: [...PROFILE_CAPABLE_PROVIDERS],
1548
+ // Which account each project uses, keyed by repo root. Served here rather than on the
1549
+ // project registry because it lives in the same file as the accounts it names.
1550
+ selections: editable ? store.selections : {},
1551
+ /** The machine-wide fallback, for repos that have chosen nothing. Withheld in hosted mode
1552
+ * on the same terms as the rest of this family. */
1553
+ defaults: editable ? store.defaults : {},
1554
+ });
1555
+ })
1556
+ .post('/workspace/agent-profiles', jsonZodValidator(() => createAgentProfileSchema), async (c) => {
1557
+ if (!capabilities().localHandoff)
1558
+ return c.json(hostedProfileRefusal, 409);
1559
+ const { provider, configDir, label } = c.req.valid('json');
1560
+ if (!supportsProfiles(provider)) {
1561
+ return c.json({ error: `${provider} cannot carry more than one account` }, 400);
1562
+ }
1563
+ const dirError = checkProfileDir(configDir);
1564
+ if (dirError)
1565
+ return c.json({ error: dirError }, 400);
1566
+ // Read-first, exactly like `POST /projects`: the duplicate check needs `realpath`, and the
1567
+ // merge-write mutator is deliberately SYNCHRONOUS so the read→rename window stays as small
1568
+ // as it is for every other writer of this file. Two processes adding the same folder in
1569
+ // that window would both win — a cosmetic duplicate, not a correctness problem, since the
1570
+ // schema's id dedupe is what keeps resolution deterministic.
1571
+ let existing = [];
1572
+ try {
1573
+ existing = (await loadAgentAccounts()).accounts;
1574
+ }
1575
+ catch {
1576
+ // unreadable store — the merge-write below reports the real failure
1577
+ }
1578
+ const conflict = await conflictingProfile(existing, provider, expandTilde(configDir));
1579
+ if (conflict !== null)
1580
+ return c.json({ error: conflict }, 409);
1581
+ let created;
1582
+ try {
1583
+ await mergeWriteAgentAccounts((store) => {
1584
+ const id = allocateAgentProfileId(label ?? configDir, store.accounts.map((a) => a.id));
1585
+ created = {
1586
+ id,
1587
+ provider,
1588
+ configDir,
1589
+ label: label?.trim() || id,
1590
+ addedAt: new Date().toISOString(),
1591
+ };
1592
+ store.accounts.push(created);
1593
+ });
1594
+ }
1595
+ catch (err) {
1596
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1597
+ }
1598
+ if (!created)
1599
+ return c.json({ error: 'account could not be saved' }, 500);
1600
+ // A brand-new account is the one thing the boot warm could not have known about, so learn it
1601
+ // now rather than only when something asks. Still off the response: the row is returned with
1602
+ // `status` absent (the listing's rule), the pane shows Checking…, and its follow-up request
1603
+ // joins this same in-flight probe.
1604
+ const account = resolveStoredProfile(created);
1605
+ const created201 = await agentProfileBody(account);
1606
+ void providerAuth
1607
+ .profileStatus(account.provider, { id: account.id, configDir: account.path })
1608
+ .catch(() => { });
1609
+ return c.json({ profile: created201 }, 201);
1610
+ })
1611
+ .patch('/workspace/agent-profiles/:id', paramZodValidator(z.object({ id: z.string() })), jsonZodValidator(() => updateAgentProfileSchema), async (c) => {
1612
+ if (!capabilities().localHandoff)
1613
+ return c.json(hostedProfileRefusal, 409);
1614
+ const id = c.req.param('id');
1615
+ const { label, configDir } = c.req.valid('json');
1616
+ if (configDir !== undefined) {
1617
+ const dirError = checkProfileDir(configDir);
1618
+ if (dirError)
1619
+ return c.json({ error: dirError }, 400);
1620
+ }
1621
+ // Read-first (see POST above, and `PATCH /projects`): a well-formed but unknown id must
1622
+ // 404 WITHOUT rewriting the config, and the duplicate check needs async `realpath`.
1623
+ let existing = [];
1624
+ try {
1625
+ existing = (await loadAgentAccounts()).accounts;
1626
+ }
1627
+ catch {
1628
+ // unreadable store — treated as unknown, like DELETE and PATCH /projects
1629
+ }
1630
+ const current = existing.find((a) => a.id === id);
1631
+ if (!current)
1632
+ return c.json({ error: `unknown account: ${id}` }, 404);
1633
+ if (configDir !== undefined) {
1634
+ const conflict = await conflictingProfile(existing, current.provider, expandTilde(configDir), id);
1635
+ if (conflict !== null)
1636
+ return c.json({ error: conflict }, 409);
1637
+ }
1638
+ let updated;
1639
+ try {
1640
+ await mergeWriteAgentAccounts((store) => {
1641
+ const entry = store.accounts.find((a) => a.id === id);
1642
+ if (!entry)
1643
+ return; // lost a race with a concurrent delete — answered below
1644
+ // Mutated in place so `.passthrough()` keys on the row survive.
1645
+ if (label !== undefined)
1646
+ entry.label = label.trim() || entry.id;
1647
+ if (configDir !== undefined)
1648
+ entry.configDir = configDir;
1649
+ updated = entry;
1650
+ });
1651
+ }
1652
+ catch (err) {
1653
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1654
+ }
1655
+ if (!updated)
1656
+ return c.json({ error: `unknown account: ${id}` }, 404);
1657
+ // The dir may have moved under a cached probe — drop THIS account's answer so the response
1658
+ // reports the new folder's state rather than the old one's, and leave every other account's
1659
+ // warm answer alone (it is still true). Then re-learn it in the background, so the server's
1660
+ // knowledge is complete again whether or not a cockpit is open to ask; the pane's own
1661
+ // request for this row joins the same in-flight probe rather than spawning a second.
1662
+ const repointed = resolveStoredProfile(updated);
1663
+ providerAuth.forgetProfileStatus(repointed.provider, repointed.id);
1664
+ const body = await agentProfileBody(repointed);
1665
+ void providerAuth
1666
+ .profileStatus(repointed.provider, { id: repointed.id, configDir: repointed.path })
1667
+ .catch(() => { });
1668
+ return c.json({ profile: body });
1669
+ })
1670
+ /**
1671
+ * One account's authentication state, probed for real.
1672
+ *
1673
+ * Separate from the listing because a probe shells out to an agent CLI: keeping it here lets
1674
+ * the pane paint immediately and fill each row in as its answer lands, instead of every cold
1675
+ * load blocking on N spawns. `refresh=1` drops this account's cached answer and re-probes, for
1676
+ * the "Check again" affordance — the cache itself holds a connected answer for minutes and an
1677
+ * unsettled one for a minute (`cacheTtlFor` in `core/provider-auth.ts`).
1678
+ */
1679
+ .get('/workspace/agent-profiles/:id/status', paramZodValidator(z.object({ id: z.string() })), queryZodValidator(z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') }), { message: 'refresh must be 1 when provided' }), async (c) => {
1680
+ if (!capabilities().localHandoff)
1681
+ return c.json(hostedProfileRefusal, 409);
1682
+ const account = await accountById(c.req.param('id'));
1683
+ if (!account)
1684
+ return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
1685
+ const refresh = c.req.valid('query').refresh === '1';
1686
+ // The discovered account's row is the one `GET /api/v1/providers/status` owns, so it comes
1687
+ // from there — enablement included, which a bare probe does not know about.
1688
+ if (account.isDefault) {
1689
+ const all = await providerStatus(refresh ? { refresh: true } : undefined);
1690
+ const row = all.providers.find((candidate) => candidate.provider === account.provider);
1691
+ return c.json({ status: row ?? { provider: account.provider, status: 'unknown' } });
1692
+ }
1693
+ // "Check again" re-probes THIS account only — the other accounts' warm answers are not
1694
+ // invalidated by asking about this one.
1695
+ if (refresh)
1696
+ providerAuth.forgetProfileStatus(account.provider, account.id);
1697
+ return c.json({
1698
+ status: await providerAuth.profileStatus(account.provider, {
1699
+ id: account.id,
1700
+ configDir: account.path,
1701
+ }),
1702
+ });
1703
+ })
1704
+ /**
1705
+ * Who this account is signed in AS — the pane's "Show details".
1706
+ *
1707
+ * A separate, on-demand route rather than a field on the listing, and that is what makes
1708
+ * "hidden by default" real: an email carried by the listing would already be in the response,
1709
+ * the query cache and devtools, whatever the UI chose to render. `provider-auth.ts` keeps
1710
+ * identity out of ITS boundary on purpose; this is the deliberate, local-only, opt-in
1711
+ * exception — not a widening of that rule. Never logged, never persisted.
1712
+ */
1713
+ .get('/workspace/agent-profiles/:id/details', paramZodValidator(z.object({ id: z.string() })), async (c) => {
1714
+ if (!capabilities().localHandoff)
1715
+ return c.json(hostedProfileRefusal, 409);
1716
+ const account = await accountById(c.req.param('id'));
1717
+ if (!account)
1718
+ return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
1719
+ return c.json(await readAccountIdentity(account.provider, account.path));
1720
+ })
1721
+ /**
1722
+ * Open one of this account's own config files — or its folder — in a local app.
1723
+ *
1724
+ * `file` is a catalog ID from the account's own `files`, never a path: the client cannot name a
1725
+ * location, so there is no traversal surface here at all (the same rule
1726
+ * `/api/v1/agent-config/:id` follows). `folder` is the one extra keyword, and it resolves to
1727
+ * the account's dir rather than anything the caller spelled.
1728
+ */
1729
+ .post('/workspace/agent-profiles/:id/open', paramZodValidator(z.object({ id: z.string() })), jsonZodValidator(() => openAgentAccountFileSchema), async (c) => {
1730
+ if (!capabilities().localHandoff)
1731
+ return c.json(hostedProfileRefusal, 409);
1732
+ const account = await accountById(c.req.param('id'));
1733
+ if (!account)
1734
+ return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
1735
+ const { file, target } = c.req.valid('json');
1736
+ let path;
1737
+ if (file === 'folder') {
1738
+ path = account.path;
1739
+ }
1740
+ else {
1741
+ const match = (await accountFiles(account)).find((f) => f.id === file);
1742
+ if (!match)
1743
+ return c.json({ error: `unknown file: ${file}` }, 404);
1744
+ path = match.path;
1745
+ }
1746
+ // A file the agent has not written yet has nothing to open; say so rather than hand the OS
1747
+ // a missing path and report success.
1748
+ if (!(await statConfigPath(path)).exists && file !== 'folder') {
1749
+ return c.json({ error: `this account has no ${basename(path)} yet` }, 409);
1750
+ }
1751
+ // `target` names a detected app; absent means the OS default handler. Either way the PATH
1752
+ // came from the catalog, so an editor is only ever pointed inside this account's folder.
1753
+ //
1754
+ // Which targets APPLY is checked here rather than left to the UI, because two of them are
1755
+ // actively wrong rather than merely useless: `terminal` runs `cd <path>`, which fails on a
1756
+ // file, and a `cli:<runner>` handoff would start an agent session inside the config folder.
1757
+ // A route is a surface of its own; it refuses what it cannot do correctly.
1758
+ if (target !== undefined) {
1759
+ if (agentCliRunner(target) !== null) {
1760
+ return c.json({ error: 'agent CLIs open a task worktree, not a config folder' }, 400);
1761
+ }
1762
+ if (target === 'terminal' && file !== 'folder') {
1763
+ return c.json({ error: 'a terminal opens a folder, not a file' }, 400);
1764
+ }
1765
+ if (!detectOpenTargets().some((candidate) => candidate.id === target)) {
1766
+ return c.json({ error: `no such app on this machine: ${target}` }, 400);
1767
+ }
1768
+ }
1769
+ const opened = target === undefined
1770
+ ? await openFile(path)
1771
+ : await openApp(target, path);
1772
+ if (!opened)
1773
+ return c.json({ error: `could not open ${basename(path)}`, path }, 409);
1774
+ return c.json({ opened: true, path });
1775
+ })
1776
+ // Which account a PROJECT uses. On the accounts family rather than `PATCH /api/v1/projects`
1777
+ // because the selection is stored beside the accounts it names — one file, one atomic write,
1778
+ // and nothing about it can be dropped by a cezar version that never heard of accounts.
1779
+ .put('/workspace/agent-profiles/selection', jsonZodValidator(() => selectAgentProfileSchema), async (c) => {
1780
+ if (!capabilities().localHandoff)
1781
+ return c.json(hostedProfileRefusal, 409);
1782
+ const { projectId, provider, profileId } = c.req.valid('json');
1783
+ // `null` writes the MACHINE-WIDE default instead of one repo's selection: the account any
1784
+ // repo that has chosen nothing uses, so a second login is set up once rather than per
1785
+ // checkout. No project to resolve, and therefore no 404 path.
1786
+ let root = null;
1787
+ if (projectId !== null) {
1788
+ const resolvedId = projectId === 'default' ? await resolveBootProject() : projectId;
1789
+ // Keyed by repo ROOT, so the selection survives the registry being rebuilt and needs no
1790
+ // cross-reference into config.json. An unknown project is a 404 rather than an orphan
1791
+ // entry nobody will ever read.
1792
+ root = await projectRootFor(resolvedId);
1793
+ if (root === null)
1794
+ return c.json({ error: `unknown project: ${projectId}` }, 404);
1795
+ }
1796
+ // A user naming an account that does not exist gets told so — the opposite of how RUN
1797
+ // resolution treats a dangling stored id, and deliberately: a run has no better answer
1798
+ // than the default, a person does.
1799
+ if (profileId !== null && profileId !== DEFAULT_AGENT_ACCOUNT_ID) {
1800
+ const account = await resolveWorkspaceProfile(provider, profileId);
1801
+ if ('error' in account)
1802
+ return c.json({ error: account.error }, 400);
1803
+ }
1804
+ let store;
1805
+ try {
1806
+ store = await mergeWriteAgentAccounts((current) => {
1807
+ // One rule, two targets: the machine default is the same per-provider shape as a repo's
1808
+ // selection, so it clears the same way rather than growing its own spelling.
1809
+ const selection = root === null ? current.defaults : current.selections[root] ?? {};
1810
+ // `null` and the reserved `default` both mean "back to the discovered account", which
1811
+ // is stored as ABSENCE — the default id is never written to the file.
1812
+ if (profileId === null || profileId === DEFAULT_AGENT_ACCOUNT_ID)
1813
+ delete selection[provider];
1814
+ else
1815
+ selection[provider] = profileId;
1816
+ // The machine default keeps an emptied object where a repo selection is deleted, and
1817
+ // that asymmetry is the schema's, not an oversight: `defaults` is one FIXED field with
1818
+ // `.default(() => ({}))`, so `mergeWriteAgentAccounts` re-materializes it on the next
1819
+ // write no matter what this one omits (verified). `selections` is a growing MAP, where
1820
+ // an emptied entry is a row per repo ever touched that says nothing. The rule stated
1821
+ // for `agentDefaults.models` — "absence is the same answer" — applies there because
1822
+ // that key is `.optional()`, so deleting it actually sticks.
1823
+ if (root === null)
1824
+ current.defaults = selection;
1825
+ else if (Object.keys(selection).length === 0)
1826
+ delete current.selections[root];
1827
+ else
1828
+ current.selections[root] = selection;
1829
+ });
1830
+ }
1831
+ catch (err) {
1832
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1833
+ }
1834
+ return c.json({ selections: store.selections, defaults: store.defaults });
1835
+ })
1836
+ .delete('/workspace/agent-profiles/:id', paramZodValidator(z.object({ id: z.string() })), async (c) => {
1837
+ if (!capabilities().localHandoff)
1838
+ return c.json(hostedProfileRefusal, 409);
1839
+ const id = c.req.param('id');
1840
+ let removed = false;
1841
+ // Captured inside the mutator, because after the write there is nothing left to ask which
1842
+ // provider this account belonged to — and the eviction below is keyed by it.
1843
+ let removedProvider;
1844
+ try {
1845
+ await mergeWriteAgentAccounts((store) => {
1846
+ const before = store.accounts.length;
1847
+ removedProvider = store.accounts.find((a) => a.id === id)?.provider;
1848
+ store.accounts = store.accounts.filter((a) => a.id !== id);
1849
+ removed = store.accounts.length < before;
1850
+ if (!removed)
1851
+ return;
1852
+ // Scrub every reference IN THE SAME MUTATOR — the reason selections share this file.
1853
+ // A two-call delete-then-scrub can be observed mid-way by another cezar process on
1854
+ // this machine, which would then resolve a dangling id; harmless today (it degrades
1855
+ // to the default) but only by luck.
1856
+ for (const [root, selection] of Object.entries(store.selections)) {
1857
+ for (const key of Object.keys(selection)) {
1858
+ if (selection[key] === id)
1859
+ delete selection[key];
1860
+ }
1861
+ if (Object.keys(selection).length === 0)
1862
+ delete store.selections[root];
1863
+ }
1864
+ });
1865
+ }
1866
+ catch (err) {
1867
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1868
+ }
1869
+ if (!removed)
1870
+ return c.json({ error: `unknown account: ${id}` }, 404);
1871
+ // Only this account's answer: it is about to stop existing, and holding it would let a
1872
+ // re-added account with the same id read the deleted one's state.
1873
+ providerAuth.forgetProfileStatus(removedProvider, id);
1874
+ return c.json({ removed: true, id });
1875
+ });
917
1876
  // ---- workspace projects (multi-project spec) -----------------------------
918
1877
  // The registered-project list for the cockpit sidebar. Same-origin (unlike
919
1878
  // health), so absolute `root`s are fine here. `listProjects()` TTL-caches
@@ -939,7 +1898,9 @@ export function createApp(deps) {
939
1898
  return defaultWorkspaceConfig().browseRoot;
940
1899
  }
941
1900
  };
942
- app.get('/api/projects', async (c) => {
1901
+ // ---- chained family: project registry (workspace-level) ----
1902
+ const projectsRoutes = new Hono()
1903
+ .get('/projects', async (c) => {
943
1904
  let projects = [];
944
1905
  let projectsDir = defaultWorkspaceConfig().projectsDir;
945
1906
  try {
@@ -958,36 +1919,223 @@ export function createApp(deps) {
958
1919
  projectsDir,
959
1920
  };
960
1921
  return c.json(body);
961
- });
962
- // Register an existing folder (multi-project spec, "Add project" — the
963
- // folder-browser dialog's commit step, step 4.2). Workspace-level like its
964
- // GET twin. Everything here is a guard; the registry write itself is one
965
- // idempotent `registerProject` call.
966
- const registerProjectSchema = z.object({
967
- root: z.string().trim().min(1).max(4096),
968
- });
969
- /**
970
- * The register-a-folder half of `POST /api/projects`, factored out so the
971
- * checkout route (step 4.3) commits its fresh clone through the SAME guards
972
- * and the same `project-added` emission rather than a second copy of them.
973
- * Returns the status + body for the caller to answer with.
974
- */
975
- const registerFolder = async (spelled, source) => {
1922
+ })
1923
+ .post('/projects', jsonZodValidator(() => registerProjectSchema, { message: 'root must be a non-empty path' }), async (c) => {
1924
+ const parsed = { data: c.req.valid('json') };
1925
+ const registered = await registerFolder(parsed.data.root, 'local');
1926
+ if (registered.status !== 200)
1927
+ return c.json(registered.body, registered.status);
1928
+ return c.json(registered.body, 200);
1929
+ })
1930
+ .delete('/projects/:projectId', async (c) => {
976
1931
  if (capabilities().singleProject) {
977
- return { status: 409, body: singleProjectRefusal('adding projects') };
978
- }
979
- // `~` is expanded for the same reason `/api/fs/browse` expands it: the
980
- // dialog hands back absolute paths, but a hand-written body (curl, a
981
- // future CLI) spells home the way a shell does.
982
- const requested = expandTilde(spelled);
983
- if (!requested.startsWith('/')) {
984
- return {
985
- status: 400,
986
- body: { error: `not a folder: ${spelled} is not an absolute path` },
987
- };
1932
+ return c.json(singleProjectRefusal('removing projects'), 409);
988
1933
  }
989
- // Hosted mode: the same root the picker is narrowed to, re-checked — see
990
- // `isInsideBrowseRoot`. Local mode deliberately has NO containment: a
1934
+ const raw = c.req.param('projectId');
1935
+ // Same gate the scoped-route resolver applies, and the same 404 wording —
1936
+ // a malformed id is an unknown project, not a validation essay.
1937
+ if (!projectIdSchema.safeParse(raw).success) {
1938
+ return c.json({ error: `unknown project: ${raw}` }, 404);
1939
+ }
1940
+ const bootId = await resolveBootProject();
1941
+ // `default` is the boot alias everywhere else in the API; honour it here
1942
+ // too rather than 404ing a spelling the cockpit is allowed to use.
1943
+ const id = raw === 'default' ? bootId : raw;
1944
+ let entry;
1945
+ try {
1946
+ entry = (await loadWorkspaceConfig()).projects.find((p) => p.id === id);
1947
+ }
1948
+ catch {
1949
+ // unreadable workspace — there is nothing to remove, and saying so is
1950
+ // more useful than a 500 the user cannot act on
1951
+ }
1952
+ if (!entry)
1953
+ return c.json({ error: `unknown project: ${id}` }, 404);
1954
+ // The boot project is refused, not removed: `cezar serve` re-registers the
1955
+ // repo it was started in on every boot, so "removing" it would undo itself
1956
+ // at the next restart while breaking this session's sidebar in the
1957
+ // meantime. The pane disables the button and says the same thing.
1958
+ if (id === bootId) {
1959
+ return c.json({
1960
+ error: `cezar is serving ${entry.name} right now — it re-registers itself at every start, so it cannot be removed from here`,
1961
+ }, 409);
1962
+ }
1963
+ const active = activeRunCount(id);
1964
+ if (active > 0) {
1965
+ return c.json({
1966
+ error: `${entry.name} has ${active} running task${active === 1 ? '' : 's'} — cancel or finish ${active === 1 ? 'it' : 'them'} before removing the project`,
1967
+ runningTasks: active,
1968
+ }, 409);
1969
+ }
1970
+ let removed;
1971
+ try {
1972
+ removed = await removeProject(id);
1973
+ }
1974
+ catch (err) {
1975
+ // e.g. a read-only home — nothing was persisted (atomic tmp+rename).
1976
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1977
+ }
1978
+ // Lost a race with another writer (or another cezar process): the entry is
1979
+ // gone, which is what the caller wanted, but say it honestly.
1980
+ if (!removed)
1981
+ return c.json({ error: `unknown project: ${id}` }, 404);
1982
+ // In-process handles for a project no route can reach any more: store
1983
+ // closed (index flushed), manager's timers and usage subscription dropped.
1984
+ contexts.dispose(id);
1985
+ workspaceEvents.emit('project-removed', { id });
1986
+ const body = { removed: true, id };
1987
+ return c.json(body);
1988
+ })
1989
+ // Edit the per-project registry fields the cockpit owns: the concurrency ceiling (spec
1990
+ // 2026-07-22-per-project-concurrency) and the grouping tags (spec
1991
+ // 2026-08-10-global-tasks-and-project-tags). A PATCH (not PUT) because it touches named
1992
+ // fields and leaves the rest of the entry alone, and a distinct route from POST
1993
+ // (register-a-folder) to keep register vs. edit semantics clear.
1994
+ //
1995
+ // The body schema is the CONTRACT's, passed directly rather than restated behind a thunk:
1996
+ // it is already in scope, and a second copy is a second thing to keep in step. Its bounds
1997
+ // mirror `workspaceProjectSchema` (config.ts) exactly, so a value this route accepts can
1998
+ // never be degraded away by the next load's `.catch`.
1999
+ //
2000
+ // Deliberately NOT the home of the agent-account selection: that lives in
2001
+ // `~/.cezar/agent-accounts.json` beside the accounts it names, so a cezar version that has
2002
+ // never heard of accounts cannot drop it (see workspace/agent-accounts.ts).
2003
+ .patch('/projects/:projectId', jsonZodValidator(updateProjectInputSchema), async (c) => {
2004
+ if (capabilities().singleProject) {
2005
+ return c.json(singleProjectRefusal('editing projects'), 409);
2006
+ }
2007
+ const raw = c.req.param('projectId');
2008
+ // Same gate + 404 wording as DELETE: a malformed id is an unknown project.
2009
+ if (!projectIdSchema.safeParse(raw).success) {
2010
+ return c.json({ error: `unknown project: ${raw}` }, 404);
2011
+ }
2012
+ const parsed = { data: c.req.valid('json') };
2013
+ // `default` is the boot alias the cockpit is allowed to use everywhere else.
2014
+ const id = raw === 'default' ? await resolveBootProject() : raw;
2015
+ const { maxParallel, tags } = parsed.data;
2016
+ // Read-first (mirroring DELETE, server.ts:1252-1258): a well-formed but
2017
+ // unknown id must 404 WITHOUT rewriting the config — otherwise it would both
2018
+ // do a needless full-config tmp+rename and, on a read-only home, surface the
2019
+ // write failure as a 500 where the honest answer is 404.
2020
+ let known = false;
2021
+ try {
2022
+ known = (await loadWorkspaceConfig()).projects.some((p) => p.id === id);
2023
+ }
2024
+ catch {
2025
+ // unreadable workspace — treat as unknown; the read-only case answers 404,
2026
+ // not a 500 the caller cannot act on (same reasoning as DELETE).
2027
+ }
2028
+ if (!known)
2029
+ return c.json({ error: `unknown project: ${id}` }, 404);
2030
+ let updated;
2031
+ try {
2032
+ await mergeWriteWorkspaceConfig((config) => {
2033
+ const entry = config.projects.find((p) => p.id === id);
2034
+ if (!entry)
2035
+ return; // lost a race with a concurrent remove — answered below
2036
+ // Each key is applied only when the body NAMED it: a PATCH that says
2037
+ // nothing about a field must leave it exactly as it was, which is what
2038
+ // keeps the tags editor from clearing a concurrency ceiling (and the
2039
+ // pre-tags `{ maxParallel }` body from clearing tags). null clears;
2040
+ // a value sets. Mutated in place so `.passthrough()` keys survive.
2041
+ if (maxParallel !== undefined) {
2042
+ if (maxParallel === null)
2043
+ delete entry.maxParallel;
2044
+ else
2045
+ entry.maxParallel = maxParallel;
2046
+ }
2047
+ if (tags !== undefined) {
2048
+ // Normalized on the way IN, so every reader — this API, the CLI, the
2049
+ // global Tasks page — sees one spelling per tag and never has to
2050
+ // fold case itself.
2051
+ const normalized = normalizeProjectTags(tags);
2052
+ if (normalized === undefined)
2053
+ delete entry.tags;
2054
+ else
2055
+ entry.tags = normalized;
2056
+ }
2057
+ updated = entry;
2058
+ });
2059
+ }
2060
+ catch (err) {
2061
+ // e.g. a read-only home — nothing was persisted (atomic tmp+rename).
2062
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2063
+ }
2064
+ // Raced with a concurrent removal between the read and the write.
2065
+ if (!updated)
2066
+ return c.json({ error: `unknown project: ${id}` }, 404);
2067
+ // The new ceiling takes effect WITHOUT a restart: refresh the shared
2068
+ // semaphore's snapshot and pump every manager — the same live-apply hook
2069
+ // `PUT /api/workspace/config` fires for a workspace-cap change.
2070
+ await deps.semaphore?.refresh();
2071
+ const body = {
2072
+ project: { ...updated, ...(await probeProjectStatus(updated.root)) },
2073
+ };
2074
+ return c.json(body);
2075
+ })
2076
+ .post('/projects/checkout', jsonZodValidator(() => checkoutSchema, { message: 'url must be a GitHub repository' }), async (c) => {
2077
+ if (capabilities().singleProject) {
2078
+ return c.json(singleProjectRefusal('adding projects'), 409);
2079
+ }
2080
+ const parsed = { data: c.req.valid('json') };
2081
+ const { url, name, checkoutId } = parsed.data;
2082
+ const result = await checkoutRepo({
2083
+ url,
2084
+ name,
2085
+ checkoutId,
2086
+ projectsDir: expandTilde(await workspaceProjectsDir()),
2087
+ onProgress: (event) => workspaceEvents.emit('checkout-progress', event),
2088
+ // A closed dialog / navigated-away tab aborts the request; the clone is
2089
+ // killed and its partial directory removed rather than left running.
2090
+ signal: c.req.raw.signal,
2091
+ ...(deps.cloneRunner ? { run: deps.cloneRunner } : {}),
2092
+ });
2093
+ if (!result.ok) {
2094
+ // `reason` rides along on the 503 (`gh` unavailable) — the spec's
2095
+ // `{ error, reason }` degradation, mirroring the GitHub pane.
2096
+ return c.json('reason' in result ? { error: result.error, reason: result.reason } : { error: result.error }, result.status);
2097
+ }
2098
+ const registered = await registerFolder(result.target, 'checkout');
2099
+ if (registered.status !== 200) {
2100
+ // The clone SUCCEEDED and its files are legitimately the user's, so this
2101
+ // path deliberately does NOT clean up — an unregisterable checkout is a
2102
+ // registry problem, not a reason to delete a repo we just fetched. Say
2103
+ // where it is so they can register it by hand.
2104
+ const { body } = registered;
2105
+ const error = 'error' in body && body.error ? body.error : 'could not register the checkout';
2106
+ return c.json({ error: `${error} (the clone is at ${result.target})` }, registered.status);
2107
+ }
2108
+ return c.json(registered.body, 200);
2109
+ });
2110
+ // Register an existing folder (multi-project spec, "Add project" — the
2111
+ // folder-browser dialog's commit step, step 4.2). Workspace-level like its
2112
+ // GET twin. Everything here is a guard; the registry write itself is one
2113
+ // idempotent `registerProject` call.
2114
+ const registerProjectSchema = z.object({
2115
+ root: z.string().trim().min(1).max(4096),
2116
+ });
2117
+ /**
2118
+ * The register-a-folder half of `POST /api/projects`, factored out so the
2119
+ * checkout route (step 4.3) commits its fresh clone through the SAME guards
2120
+ * and the same `project-added` emission rather than a second copy of them.
2121
+ * Returns the status + body for the caller to answer with.
2122
+ */
2123
+ const registerFolder = async (spelled, source) => {
2124
+ if (capabilities().singleProject) {
2125
+ return { status: 409, body: singleProjectRefusal('adding projects') };
2126
+ }
2127
+ // `~` is expanded for the same reason `/api/fs/browse` expands it: the
2128
+ // dialog hands back absolute paths, but a hand-written body (curl, a
2129
+ // future CLI) spells home the way a shell does.
2130
+ const requested = expandTilde(spelled);
2131
+ if (!requested.startsWith('/')) {
2132
+ return {
2133
+ status: 400,
2134
+ body: { error: `not a folder: ${spelled} is not an absolute path` },
2135
+ };
2136
+ }
2137
+ // Hosted mode: the same root the picker is narrowed to, re-checked — see
2138
+ // `isInsideBrowseRoot`. Local mode deliberately has NO containment: a
991
2139
  // project under `/srv/code` is a normal local setup and `cezar serve`
992
2140
  // registers it today.
993
2141
  //
@@ -1079,13 +2227,6 @@ export function createApp(deps) {
1079
2227
  workspaceEvents.emit('project-added', { project });
1080
2228
  return { status: 200, body: { project } };
1081
2229
  };
1082
- app.post('/api/projects', async (c) => {
1083
- const parsed = registerProjectSchema.safeParse(await c.req.json().catch(() => null));
1084
- if (!parsed.success)
1085
- return c.json({ error: 'root must be a non-empty path' }, 400);
1086
- const { status, body } = await registerFolder(parsed.data.root, 'local');
1087
- return c.json(body, status);
1088
- });
1089
2230
  // Deregister a project (multi-project spec, step 4.4 — Settings → Projects,
1090
2231
  // the per-row "Remove"). READ THIS BEFORE TOUCHING THE HANDLER: the ONLY
1091
2232
  // durable effect allowed here is dropping one entry from
@@ -1120,136 +2261,59 @@ export function createApp(deps) {
1120
2261
  return 0;
1121
2262
  return ctx.store.listRuns().filter((run) => ACTIVE_RUN_STATUSES.has(run.status)).length;
1122
2263
  };
1123
- app.delete('/api/projects/:projectId', async (c) => {
1124
- if (capabilities().singleProject) {
1125
- return c.json(singleProjectRefusal('removing projects'), 409);
1126
- }
1127
- const raw = c.req.param('projectId');
1128
- // Same gate the scoped-route resolver applies, and the same 404 wording —
1129
- // a malformed id is an unknown project, not a validation essay.
1130
- if (!projectIdSchema.safeParse(raw).success) {
1131
- return c.json({ error: `unknown project: ${raw}` }, 404);
1132
- }
2264
+ // Workspace-level by design: update state spans project and global installs,
2265
+ // but the selected registered project supplies the safe, server-owned cwd.
2266
+ const skillsUpdateInputSchema = z.object({ projectId: projectIdSchema }).strict();
2267
+ const resolveSkillsUpdateRoot = async (raw) => {
2268
+ if (!projectIdSchema.safeParse(raw).success)
2269
+ return { status: 404, error: `unknown project: ${raw}` };
1133
2270
  const bootId = await resolveBootProject();
1134
- // `default` is the boot alias everywhere else in the API; honour it here
1135
- // too rather than 404ing a spelling the cockpit is allowed to use.
1136
- const id = raw === 'default' ? bootId : raw;
1137
- let entry;
1138
- try {
1139
- entry = (await loadWorkspaceConfig()).projects.find((p) => p.id === id);
1140
- }
1141
- catch {
1142
- // unreadable workspace — there is nothing to remove, and saying so is
1143
- // more useful than a 500 the user cannot act on
1144
- }
1145
- if (!entry)
1146
- return c.json({ error: `unknown project: ${id}` }, 404);
1147
- // The boot project is refused, not removed: `cezar serve` re-registers the
1148
- // repo it was started in on every boot, so "removing" it would undo itself
1149
- // at the next restart while breaking this session's sidebar in the
1150
- // meantime. The pane disables the button and says the same thing.
1151
- if (id === bootId) {
1152
- return c.json({
1153
- error: `cezar is serving ${entry.name} right now — it re-registers itself at every start, so it cannot be removed from here`,
1154
- }, 409);
1155
- }
1156
- const active = activeRunCount(id);
1157
- if (active > 0) {
1158
- return c.json({
1159
- error: `${entry.name} has ${active} running task${active === 1 ? '' : 's'} — cancel or finish ${active === 1 ? 'it' : 'them'} before removing the project`,
1160
- runningTasks: active,
1161
- }, 409);
1162
- }
1163
- let removed;
1164
- try {
1165
- removed = await removeProject(id);
2271
+ if (raw === 'default' || raw === bootId)
2272
+ return { root: bootRoot };
2273
+ const project = (await loadWorkspaceConfig()).projects.find((entry) => entry.id === raw);
2274
+ if (!project)
2275
+ return { status: 404, error: `unknown project: ${raw}` };
2276
+ if ((await probeProjectStatus(project.root)).status === 'missing') {
2277
+ return { status: 409, error: `project folder not found: ${raw}` };
1166
2278
  }
1167
- catch (err) {
1168
- // e.g. a read-only home — nothing was persisted (atomic tmp+rename).
1169
- return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1170
- }
1171
- // Lost a race with another writer (or another cezar process): the entry is
1172
- // gone, which is what the caller wanted, but say it honestly.
1173
- if (!removed)
1174
- return c.json({ error: `unknown project: ${id}` }, 404);
1175
- // In-process handles for a project no route can reach any more: store
1176
- // closed (index flushed), manager's timers and usage subscription dropped.
1177
- contexts.dispose(id);
1178
- workspaceEvents.emit('project-removed', { id });
1179
- const body = { removed: true, id };
1180
- return c.json(body);
1181
- });
1182
- // Edit one field of an existing registry entry (spec
1183
- // 2026-07-22-per-project-concurrency): the per-project concurrency ceiling.
1184
- // A PATCH (not PUT) because it touches a single field, and a distinct route
1185
- // from POST (register-a-folder) to keep register vs. edit semantics clear.
1186
- // `maxParallel: null` clears the override back to "inherit the workspace
1187
- // cap". Bounds mirror `workspaceProjectSchema` (config.ts) exactly, so a
1188
- // value this route accepts can never be degraded away by the next load's
1189
- // `.catch`.
1190
- const updateProjectSchema = z.object({
1191
- maxParallel: z.number().int().min(1).max(16).nullable(),
1192
- });
1193
- app.patch('/api/projects/:projectId', async (c) => {
1194
- if (capabilities().singleProject) {
1195
- return c.json(singleProjectRefusal('editing projects'), 409);
1196
- }
1197
- const raw = c.req.param('projectId');
1198
- // Same gate + 404 wording as DELETE: a malformed id is an unknown project.
1199
- if (!projectIdSchema.safeParse(raw).success) {
1200
- return c.json({ error: `unknown project: ${raw}` }, 404);
1201
- }
1202
- const parsed = updateProjectSchema.safeParse(await c.req.json().catch(() => null));
1203
- if (!parsed.success) {
1204
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
1205
- }
1206
- // `default` is the boot alias the cockpit is allowed to use everywhere else.
1207
- const id = raw === 'default' ? await resolveBootProject() : raw;
1208
- const { maxParallel } = parsed.data;
1209
- // Read-first (mirroring DELETE, server.ts:1252-1258): a well-formed but
1210
- // unknown id must 404 WITHOUT rewriting the config — otherwise it would both
1211
- // do a needless full-config tmp+rename and, on a read-only home, surface the
1212
- // write failure as a 500 where the honest answer is 404.
1213
- let known = false;
1214
- try {
1215
- known = (await loadWorkspaceConfig()).projects.some((p) => p.id === id);
1216
- }
1217
- catch {
1218
- // unreadable workspace — treat as unknown; the read-only case answers 404,
1219
- // not a 500 the caller cannot act on (same reasoning as DELETE).
1220
- }
1221
- if (!known)
1222
- return c.json({ error: `unknown project: ${id}` }, 404);
1223
- let updated;
2279
+ return { root: project.root };
2280
+ };
2281
+ const skillsUpdateResponse = async (state) => {
2282
+ const config = await loadWorkspaceConfig();
2283
+ return { ...state, autoUpdateEnabled: effectiveSkillsAutoUpdate(config), inherited: config.skillsAutoUpdate === undefined };
2284
+ };
2285
+ // ---- chained family: skills updates (workspace-level) ----
2286
+ const skillsUpdateRoutes = new Hono()
2287
+ .get('/workspace/skills-update', queryZodValidator(skillsUpdateInputSchema, { message: 'projectId is required' }), async (c) => {
2288
+ const parsed = { data: c.req.valid('query') };
2289
+ const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
2290
+ if ('error' in resolved)
2291
+ return c.json({ error: resolved.error }, resolved.status);
2292
+ const state = skillsUpdate.snapshot(resolved.root);
2293
+ void skillsUpdate.check(resolved.root).catch(() => { });
2294
+ return c.json(await skillsUpdateResponse(state));
2295
+ })
2296
+ .post('/workspace/skills-update/check', jsonZodValidator(skillsUpdateInputSchema, { message: 'body must contain only projectId' }), async (c) => {
2297
+ const parsed = { data: c.req.valid('json') };
2298
+ const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
2299
+ if ('error' in resolved)
2300
+ return c.json({ error: resolved.error }, resolved.status);
2301
+ return c.json(await skillsUpdateResponse(await skillsUpdate.check(resolved.root, true)));
2302
+ })
2303
+ .post('/workspace/skills-update/apply', jsonZodValidator(skillsUpdateInputSchema, { message: 'body must contain only projectId' }), async (c) => {
2304
+ const parsed = { data: c.req.valid('json') };
2305
+ const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
2306
+ if ('error' in resolved)
2307
+ return c.json({ error: resolved.error }, resolved.status);
1224
2308
  try {
1225
- await mergeWriteWorkspaceConfig((config) => {
1226
- const entry = config.projects.find((p) => p.id === id);
1227
- if (!entry)
1228
- return; // lost a race with a concurrent remove — answered below
1229
- // null clears the override; a number sets it. Mutated in place so
1230
- // `.passthrough()` keys on the entry survive.
1231
- if (maxParallel === null)
1232
- delete entry.maxParallel;
1233
- else
1234
- entry.maxParallel = maxParallel;
1235
- updated = entry;
1236
- });
1237
- }
1238
- catch (err) {
1239
- // e.g. a read-only home — nothing was persisted (atomic tmp+rename).
1240
- return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2309
+ return c.json(await skillsUpdateResponse(await skillsUpdate.update(resolved.root, true)));
2310
+ }
2311
+ catch (error) {
2312
+ if (error instanceof SkillsUpdateConflictError) {
2313
+ return c.json({ error: 'another skills update operation is running', state: await skillsUpdateResponse(skillsUpdate.snapshot(resolved.root)) }, 409);
2314
+ }
2315
+ throw error;
1241
2316
  }
1242
- // Raced with a concurrent removal between the read and the write.
1243
- if (!updated)
1244
- return c.json({ error: `unknown project: ${id}` }, 404);
1245
- // The new ceiling takes effect WITHOUT a restart: refresh the shared
1246
- // semaphore's snapshot and pump every manager — the same live-apply hook
1247
- // `PUT /api/workspace/config` fires for a workspace-cap change.
1248
- await deps.semaphore?.refresh();
1249
- const body = {
1250
- project: { ...updated, ...(await probeProjectStatus(updated.root)) },
1251
- };
1252
- return c.json(body);
1253
2317
  });
1254
2318
  // ---- GUI clone (multi-project spec, step 4.3) ----------------------------
1255
2319
  // "Add project → Clone from GitHub": clone into the checkout root, then
@@ -1267,41 +2331,6 @@ export function createApp(deps) {
1267
2331
  name: z.string().trim().max(128).optional(),
1268
2332
  checkoutId: z.string().trim().max(128).optional(),
1269
2333
  });
1270
- app.post('/api/projects/checkout', async (c) => {
1271
- if (capabilities().singleProject) {
1272
- return c.json(singleProjectRefusal('adding projects'), 409);
1273
- }
1274
- const parsed = checkoutSchema.safeParse(await c.req.json().catch(() => null));
1275
- if (!parsed.success)
1276
- return c.json({ error: 'url must be a GitHub repository' }, 400);
1277
- const { url, name, checkoutId } = parsed.data;
1278
- const result = await checkoutRepo({
1279
- url,
1280
- name,
1281
- checkoutId,
1282
- projectsDir: expandTilde(await workspaceProjectsDir()),
1283
- onProgress: (event) => workspaceEvents.emit('checkout-progress', event),
1284
- // A closed dialog / navigated-away tab aborts the request; the clone is
1285
- // killed and its partial directory removed rather than left running.
1286
- signal: c.req.raw.signal,
1287
- ...(deps.cloneRunner ? { run: deps.cloneRunner } : {}),
1288
- });
1289
- if (!result.ok) {
1290
- // `reason` rides along on the 503 (`gh` unavailable) — the spec's
1291
- // `{ error, reason }` degradation, mirroring the GitHub pane.
1292
- return c.json('reason' in result ? { error: result.error, reason: result.reason } : { error: result.error }, result.status);
1293
- }
1294
- const { status, body } = await registerFolder(result.target, 'checkout');
1295
- if (status !== 200) {
1296
- // The clone SUCCEEDED and its files are legitimately the user's, so this
1297
- // path deliberately does NOT clean up — an unregisterable checkout is a
1298
- // registry problem, not a reason to delete a repo we just fetched. Say
1299
- // where it is so they can register it by hand.
1300
- const error = 'error' in body && body.error ? body.error : 'could not register the checkout';
1301
- return c.json({ error: `${error} (the clone is at ${result.target})` }, status);
1302
- }
1303
- return c.json(body, 200);
1304
- });
1305
2334
  // ---- workspace settings (multi-project spec, step 2.7) -------------------
1306
2335
  // WORKSPACE-level routes: single-mount (never mirrored under /api/p/),
1307
2336
  // same-origin. The config routes carry the settings UI's slice of
@@ -1311,33 +2340,40 @@ export function createApp(deps) {
1311
2340
  const workspaceConfigBody = (config) => ({
1312
2341
  browseRoot: config.browseRoot,
1313
2342
  projectsDir: config.projectsDir,
2343
+ skillsAutoUpdate: config.skillsAutoUpdate ?? null,
2344
+ effectiveSkillsAutoUpdate: effectiveSkillsAutoUpdate(config),
2345
+ composerDefaults: {
2346
+ autonomous: config.composerDefaults.autonomous ?? null,
2347
+ worktree: config.composerDefaults.worktree ?? null,
2348
+ inheritedAutonomous: process.env.CEZ_AUTONOMOUS_DEFAULT === '0'
2349
+ ? false
2350
+ : process.env.CEZ_AUTONOMOUS_DEFAULT === '1'
2351
+ ? true
2352
+ : 'source-dependent',
2353
+ inheritedWorktree: effectiveComposerDefault(undefined, process.env.CEZ_WORKTREE_DEFAULT, true),
2354
+ },
1314
2355
  resources: {
1315
2356
  maxParallel: config.resources.maxParallel,
2357
+ maxMonitoringSessions: config.resources.maxMonitoringSessions,
2358
+ monitoringWakeIntervalMinutes: config.resources.monitoringWakeIntervalMinutes,
2359
+ autoResumeOnUsageLimit: config.resources.autoResumeOnUsageLimit,
1316
2360
  memoryLimitMb: config.resources.memoryLimitMb,
1317
2361
  worktreeRetentionDefault: config.resources.worktreeRetentionDefault,
1318
2362
  },
2363
+ // SPREAD, never `runner: maybeUndefined`: hono would type the key as always-present while
2364
+ // `JSON.stringify` drops it, which is the exact drift `contract-parity` catches. And absent has
2365
+ // to keep meaning "no opinion" here, or the fallback collapses into "always claude".
2366
+ agentDefaults: {
2367
+ ...(config.agentDefaults.runner !== undefined ? { runner: config.agentDefaults.runner } : {}),
2368
+ ...(config.agentDefaults.models !== undefined ? { models: config.agentDefaults.models } : {}),
2369
+ },
1319
2370
  });
1320
- app.get('/api/workspace/config', async (c) => c.json(workspaceConfigBody(await loadWorkspaceConfig())));
1321
- // Partial updates only — absent keys stay untouched. Bounds mirror the
1322
- // workspace schema (src/workspace/config.ts, step 1.2) exactly, so a value
1323
- // this route accepts can never be degraded away by the next load's `.catch`.
1324
- const workspaceConfigUpdateSchema = z.object({
1325
- browseRoot: z.string().trim().min(1).max(4096).optional(),
1326
- projectsDir: z.string().trim().min(1).max(4096).optional(),
1327
- resources: z
1328
- .object({
1329
- maxParallel: z.number().int().min(1).max(16).optional(),
1330
- memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
1331
- worktreeRetentionDefault: z.number().int().min(0).max(1000).optional(),
1332
- })
1333
- .optional(),
1334
- });
1335
- app.put('/api/workspace/config', async (c) => {
1336
- const parsed = workspaceConfigUpdateSchema.safeParse(await c.req.json().catch(() => null));
1337
- if (!parsed.success) {
1338
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
1339
- }
1340
- const { browseRoot, projectsDir, resources } = parsed.data;
2371
+ // ---- chained family: workspace settings + GUI prefs (workspace-level) ----
2372
+ const workspaceConfigRoutes = new Hono()
2373
+ .get('/workspace/config', async (c) => c.json(workspaceConfigBody(await loadWorkspaceConfig())))
2374
+ .put('/workspace/config', jsonZodValidator(() => workspaceConfigUpdateSchema), async (c) => {
2375
+ const parsed = { data: c.req.valid('json') };
2376
+ const { browseRoot, projectsDir, skillsAutoUpdate, composerDefaults, resources, agentDefaults } = parsed.data;
1341
2377
  for (const [configuredRoot, create] of [
1342
2378
  [browseRoot, false],
1343
2379
  [projectsDir, true],
@@ -1376,13 +2412,57 @@ export function createApp(deps) {
1376
2412
  config.browseRoot = browseRoot;
1377
2413
  if (projectsDir !== undefined)
1378
2414
  config.projectsDir = projectsDir;
2415
+ if (skillsAutoUpdate === null)
2416
+ delete config.skillsAutoUpdate;
2417
+ else if (skillsAutoUpdate !== undefined)
2418
+ config.skillsAutoUpdate = skillsAutoUpdate;
2419
+ if (composerDefaults?.autonomous === null)
2420
+ delete config.composerDefaults.autonomous;
2421
+ else if (composerDefaults?.autonomous !== undefined) {
2422
+ config.composerDefaults.autonomous = composerDefaults.autonomous;
2423
+ }
2424
+ if (composerDefaults?.worktree === null)
2425
+ delete config.composerDefaults.worktree;
2426
+ else if (composerDefaults?.worktree !== undefined) {
2427
+ config.composerDefaults.worktree = composerDefaults.worktree;
2428
+ }
1379
2429
  if (resources?.maxParallel !== undefined)
1380
2430
  config.resources.maxParallel = resources.maxParallel;
2431
+ if (resources?.maxMonitoringSessions !== undefined) {
2432
+ config.resources.maxMonitoringSessions = resources.maxMonitoringSessions;
2433
+ }
2434
+ if (resources?.monitoringWakeIntervalMinutes !== undefined) {
2435
+ config.resources.monitoringWakeIntervalMinutes = resources.monitoringWakeIntervalMinutes;
2436
+ }
2437
+ if (resources?.autoResumeOnUsageLimit !== undefined) {
2438
+ config.resources.autoResumeOnUsageLimit = resources.autoResumeOnUsageLimit;
2439
+ }
1381
2440
  if (resources?.memoryLimitMb !== undefined)
1382
2441
  config.resources.memoryLimitMb = resources.memoryLimitMb;
1383
2442
  if (resources?.worktreeRetentionDefault !== undefined) {
1384
2443
  config.resources.worktreeRetentionDefault = resources.worktreeRetentionDefault;
1385
2444
  }
2445
+ // `null` CLEARS back to "no opinion" — a partial patch cannot say that by omission,
2446
+ // and leaving a stale runner behind would keep overriding repos that never chose.
2447
+ if (agentDefaults?.runner === null)
2448
+ delete config.agentDefaults.runner;
2449
+ else if (agentDefaults?.runner !== undefined)
2450
+ config.agentDefaults.runner = agentDefaults.runner;
2451
+ for (const runner of PROVIDER_IDS) {
2452
+ const model = agentDefaults?.models?.[runner];
2453
+ if (model === undefined)
2454
+ continue;
2455
+ const models = config.agentDefaults.models ?? {};
2456
+ if (model === null)
2457
+ delete models[runner];
2458
+ else
2459
+ models[runner] = model;
2460
+ // An empty object would persist a key that says nothing; absence is the same answer.
2461
+ if (Object.keys(models).length === 0)
2462
+ delete config.agentDefaults.models;
2463
+ else
2464
+ config.agentDefaults.models = models;
2465
+ }
1386
2466
  });
1387
2467
  }
1388
2468
  catch (err) {
@@ -1394,15 +2474,19 @@ export function createApp(deps) {
1394
2474
  if (resources !== undefined)
1395
2475
  await deps.semaphore?.refresh();
1396
2476
  return c.json(workspaceConfigBody(written));
1397
- });
1398
- // Global GUI state (`~/.cezar/ui-state.json`) — same parse/key-cap/shallow-
1399
- // merge semantics as the per-repo /api/ui-state route below (the shared half
1400
- // is `parseUiStateBody`), but backed by the workspace file.
1401
- app.get('/api/workspace/ui-state', async (c) => c.json(await readWorkspaceUiState()));
1402
- app.put('/api/workspace/ui-state', bodyLimit({ maxSize: UI_STATE_BODY_LIMIT }), async (c) => {
1403
- const parsed = parseUiStateBody(workspaceUiStateSchema, await c.req.json().catch(() => null));
1404
- if ('error' in parsed)
1405
- return c.json({ error: parsed.error }, 400);
2477
+ })
2478
+ // Global GUI state (`~/.cezar/ui-state.json`) — same parse/key-cap/shallow-
2479
+ // merge semantics as the per-repo /api/v1/ui-state route below (the shared half
2480
+ // is `uiStateBodySchema`), but backed by the workspace file.
2481
+ .get('/workspace/ui-state', async (c) => c.json(await readWorkspaceUiState()))
2482
+ // The tighter body cap rides on `use` rather than inline on the route: `bodyLimit` is typed
2483
+ // as a bare MiddlewareHandler, and passing one to `.put()` collapses the route's schema, so
2484
+ // the PUT went missing from `AppType` and `hc` could not see its body at all. `use` runs at
2485
+ // the same point (before the handler, so the cap still precedes any read) and leaves the
2486
+ // chain's type accumulation alone. Method-agnostic here, which the GET does not mind.
2487
+ .use('/workspace/ui-state', bodyLimit({ maxSize: UI_STATE_BODY_LIMIT }))
2488
+ .put('/workspace/ui-state', jsonZodValidator(setWorkspaceUiStateInputSchema), async (c) => {
2489
+ const parsed = { data: c.req.valid('json') };
1406
2490
  try {
1407
2491
  return c.json(await mergeWriteWorkspaceUiState((state) => ({
1408
2492
  ...state,
@@ -1414,67 +2498,119 @@ export function createApp(deps) {
1414
2498
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1415
2499
  }
1416
2500
  });
1417
- // ---- filesystem browse (multi-project spec, step 4.1) --------------------
1418
- // WORKSPACE-level and same-origin: the directory picker behind "Add project
1419
- // → open local folder". Directories only, and every answer is contained in a
1420
- // single root — see src/server/fs-browse.ts for the containment rule and why
1421
- // it is realpath-based. The independently configured browse root is read per
1422
- // request, so a successful settings save applies without a restart.
1423
- app.get('/api/fs/browse', async (c) => {
2501
+ // Partial updates only — absent keys stay untouched. Bounds mirror the
2502
+ // workspace schema (src/workspace/config.ts, step 1.2) exactly, so a value
2503
+ // this route accepts can never be degraded away by the next load's `.catch`.
2504
+ const workspaceConfigUpdateSchema = z.object({
2505
+ browseRoot: z.string().trim().min(1).max(4096).optional(),
2506
+ projectsDir: z.string().trim().min(1).max(4096).optional(),
2507
+ skillsAutoUpdate: z.boolean().nullable().optional(),
2508
+ composerDefaults: z
2509
+ .object({
2510
+ autonomous: z.boolean().nullable().optional(),
2511
+ worktree: z.boolean().nullable().optional(),
2512
+ })
2513
+ .optional(),
2514
+ resources: z
2515
+ .object({
2516
+ maxParallel: z.number().int().min(1).max(16).optional(),
2517
+ maxMonitoringSessions: z.number().int().min(0).max(16).optional(),
2518
+ monitoringWakeIntervalMinutes: z.number().int().min(1).max(60).nullable().optional(),
2519
+ autoResumeOnUsageLimit: z.boolean().optional(),
2520
+ memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
2521
+ worktreeRetentionDefault: z.number().int().min(0).max(1000).optional(),
2522
+ })
2523
+ .optional(),
2524
+ // Bounds mirror `src/workspace/config.ts`, so a value this accepts is never degraded away by
2525
+ // the next load's `.catch`. `null` clears a key back to "no opinion".
2526
+ agentDefaults: z
2527
+ .object({
2528
+ runner: z.enum(PROVIDER_IDS).nullable().optional(),
2529
+ models: z
2530
+ .object({
2531
+ claude: z.string().trim().min(1).max(200).nullable().optional(),
2532
+ codex: z.string().trim().min(1).max(200).nullable().optional(),
2533
+ opencode: z.string().trim().min(1).max(200).nullable().optional(),
2534
+ pi: z.string().trim().min(1).max(200).nullable().optional(),
2535
+ })
2536
+ .optional(),
2537
+ })
2538
+ .optional(),
2539
+ });
2540
+ // ---- chained family: filesystem browse (workspace-level) ----
2541
+ const fsBrowseRoutes = new Hono()
2542
+ .get('/fs/browse', queryZodValidator(z.object({ path: queryValue, showHidden: queryValue })), async (c) => {
1424
2543
  if (capabilities().singleProject) {
1425
2544
  return c.json(singleProjectRefusal('folder browsing'), 409);
1426
2545
  }
2546
+ const query = c.req.valid('query');
1427
2547
  const root = resolveBrowseRoot(await workspaceBrowseRoot());
1428
2548
  const result = await browseDirectory({
1429
2549
  root,
1430
- path: c.req.query('path'),
1431
- showHidden: c.req.query('showHidden') === '1',
2550
+ path: query.path,
2551
+ showHidden: query.showHidden === '1',
1432
2552
  });
1433
2553
  if (!result.ok)
1434
2554
  return c.json({ error: result.error }, result.status);
1435
2555
  return c.json(result.body);
1436
2556
  });
1437
- // The bookmarklet generator bakes this key into the `javascript:` URLs —
1438
- // `/new?auto=1` is honored only with it (spec 011). Same-origin only.
1439
- api.get('/launch-key', (c) => c.json({ key: c.get('project').launchKey }));
1440
- api.get('/skills', async (c) => {
2557
+ // ---- chained family: launch-key (project-scoped) ----
2558
+ const launchKeyRoutes = new Hono()
2559
+ .get('/launch-key', (c) => c.json({ key: c.get('project').launchKey }));
2560
+ // ---- chained family: skills (project-scoped) ----
2561
+ const skillsRoutes = new Hono()
2562
+ .get('/skills', queryZodValidator(waitQuery), async (c) => {
1441
2563
  const repoRoot = c.get('project').root;
1442
2564
  // The default read stays fast and starts the team load in the background.
1443
2565
  // The cockpit follows it with `wait=1`, off the render path, so a cold
1444
2566
  // cache converges without polling or a manual reload (spec 005 / #555).
1445
- if (c.req.query('wait') === '1')
2567
+ if (c.req.valid('query').wait === '1')
1446
2568
  await waitForTeamSkills(repoRoot);
1447
2569
  return c.json(await discoverSkills(repoRoot));
1448
- });
1449
- // The opt-in catalog for the "Import skills" panel: every skill a default
1450
- // (vendor) repo offers — `open-mercato/skills` — regardless of import state,
1451
- // so the panel can present them all with a per-skill toggle. Empty once a repo
1452
- // configures its own `skillsRepos` (nothing is gated then). `wait=1` lets the
1453
- // panel wait out a cold team-skill cache, same as `GET /skills` (spec 005).
1454
- api.get('/skills/importable', async (c) => {
2570
+ })
2571
+ // The opt-in catalog for the "Import skills" panel: every skill a default
2572
+ // (vendor) repo offers — `open-mercato/skills` — regardless of import state,
2573
+ // so the panel can present them all with a per-skill toggle. Empty once a repo
2574
+ // configures its own `skillsRepos` (nothing is gated then). `wait=1` lets the
2575
+ // panel wait out a cold team-skill cache, same as `GET /skills` (spec 005).
2576
+ .get('/skills/importable', queryZodValidator(waitQuery), async (c) => {
1455
2577
  const repoRoot = c.get('project').root;
1456
2578
  const gated = await gatedSkillsRepos(repoRoot);
1457
2579
  if (gated.size === 0)
1458
2580
  return c.json([]);
1459
- if (c.req.query('wait') === '1')
2581
+ if (c.req.valid('query').wait === '1')
1460
2582
  await waitForTeamSkills(repoRoot);
1461
2583
  const importable = getTeamSkillsCached(repoRoot)
1462
2584
  .filter((skill) => skill.team && gated.has(skill.team.repo))
1463
- .map((skill) => ({ name: skill.name, description: skill.description }));
2585
+ // Spread `description` rather than writing it unconditionally: an undefined VALUE is
2586
+ // dropped by JSON.stringify, so the key is absent on the wire, and writing it always
2587
+ // typed the route as sending a key it does not. contract/skills.ts says `.optional()`,
2588
+ // which is what the client actually receives.
2589
+ .map((skill) => ({
2590
+ name: skill.name,
2591
+ ...(skill.description !== undefined ? { description: skill.description } : {}),
2592
+ }));
1464
2593
  return c.json(importable);
2594
+ })
2595
+ // Refresh team skills (spec 005): clone/fetch the configured skills repos,
2596
+ // then return the merged catalog. Degrades quietly — offline just means the
2597
+ // team entries stay as they were (or absent).
2598
+ .post('/skills/refresh', async (c) => {
2599
+ const { root: repoRoot } = c.get('project');
2600
+ await refreshTeamSkills(repoRoot);
2601
+ return c.json(await discoverSkills(repoRoot));
1465
2602
  });
1466
- // ---- GUI prefs (ui-state.json) --------------------------------------------
1467
- // The read path is shared with the CLI (`src/ui-state.ts`) so `cezar serve` can honour a
1468
- // preference set here — #391's dismissed skills banner — from one notion of the file.
1469
- api.get('/ui-state', async (c) => c.json(await readUiState(c.get('project').root)));
1470
- api.put('/ui-state', bodyLimit({ maxSize: UI_STATE_BODY_LIMIT }), async (c) => {
2603
+ // ---- chained family: GUI prefs / ui-state (project-scoped) ----
2604
+ const uiStateRoutes = new Hono()
2605
+ .get('/ui-state', async (c) => c.json(await readUiState(c.get('project').root)))
2606
+ // On `use`, not inline on the route — see the workspace ui-state PUT above.
2607
+ .use('/ui-state', bodyLimit({ maxSize: UI_STATE_BODY_LIMIT }))
2608
+ .put('/ui-state', jsonZodValidator(uiStateBody), async (c) => {
1471
2609
  const { root: repoRoot, dataDir } = c.get('project');
1472
2610
  // `.passthrough()` keeps unknown prefs (BACKWARD_COMPATIBILITY §3), but a
1473
2611
  // single request may not stuff an unbounded key set (#429) — the shared
1474
- // parse+cap half of both ui-state routes lives in parseUiStateBody.
1475
- const parsed = parseUiStateBody(uiStateSchema, await c.req.json().catch(() => null));
1476
- if ('error' in parsed)
1477
- return c.json({ error: parsed.error }, 400);
2612
+ // schema+cap half of both ui-state routes lives in `uiStateBody`.
2613
+ const parsed = { data: c.req.valid('json') };
1478
2614
  const merged = { ...(await readUiState(repoRoot)), ...parsed.data };
1479
2615
  try {
1480
2616
  await mkdir(dataDir, { recursive: true });
@@ -1485,24 +2621,18 @@ export function createApp(deps) {
1485
2621
  }
1486
2622
  return c.json(merged);
1487
2623
  });
1488
- // Refresh team skills (spec 005): clone/fetch the configured skills repos,
1489
- // then return the merged catalog. Degrades quietly — offline just means the
1490
- // team entries stay as they were (or absent).
1491
- api.post('/skills/refresh', async (c) => {
1492
- const { root: repoRoot } = c.get('project');
1493
- await refreshTeamSkills(repoRoot);
1494
- return c.json(await discoverSkills(repoRoot));
1495
- });
1496
- api.get('/workflows', async (c) => c.json(await loadWorkflows(c.get('project').root)));
1497
- // Save an approved plan as a reusable chain (spec 008): YAML in
1498
- // `.ai/cezar/workflows/<slug>.yaml` — from then on it's in the dropdown
1499
- // like any other workflow.
1500
- api.post('/workflows', async (c) => {
2624
+ // ---- chained family: workflows (project-scoped) --------------------------
2625
+ // One chained expression, mounted by `createApp` into BOTH the legacy `api`
2626
+ // table and the versioned `v1` one — see `healthRoutes` for why the chain
2627
+ // shape (not the statement shape) is what carries the types.
2628
+ const workflowsRoutes = new Hono()
2629
+ .get('/workflows', async (c) => c.json(await loadWorkflows(c.get('project').root)))
2630
+ // Save an approved plan as a reusable chain (spec 008): YAML in
2631
+ // `.ai/cezar/workflows/<slug>.yaml` — from then on it's in the dropdown
2632
+ // like any other workflow.
2633
+ .post('/workflows', jsonZodValidator(saveWorkflowSchema), async (c) => {
1501
2634
  const { root: repoRoot } = c.get('project');
1502
- const parsed = saveWorkflowSchema.safeParse(await c.req.json().catch(() => null));
1503
- if (!parsed.success) {
1504
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
1505
- }
2635
+ const parsed = { data: c.req.valid('json') };
1506
2636
  const steps = parsed.data.steps ?? skillsToSteps(parsed.data.skills ?? []);
1507
2637
  const issue = stepsIssue(steps);
1508
2638
  if (issue)
@@ -1534,10 +2664,10 @@ export function createApp(deps) {
1534
2664
  return c.json({ error: message }, 500);
1535
2665
  }
1536
2666
  return c.json({ path, name: parsed.data.name }, 201);
1537
- });
1538
- // Delete a saved workflow (spec 012 follow-up): file workflows only —
1539
- // built-ins have no file and always come back.
1540
- api.delete('/workflows/:name', async (c) => {
2667
+ })
2668
+ // Delete a saved workflow (spec 012 follow-up): file workflows only —
2669
+ // built-ins have no file and always come back.
2670
+ .delete('/workflows/:name', async (c) => {
1541
2671
  const { root: repoRoot } = c.get('project');
1542
2672
  const name = c.req.param('name');
1543
2673
  const { workflows } = await loadWorkflows(repoRoot);
@@ -1559,15 +2689,12 @@ export function createApp(deps) {
1559
2689
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1560
2690
  }
1561
2691
  return c.json({ ok: true, path: target });
1562
- });
1563
- // Import support for the builder (spec 012): parse + validate a pasted
1564
- // workflow YAML (either form) and hand back the normalized definition. The
1565
- // server owns YAML parsing — the GUI stays dependency-free.
1566
- api.post('/workflows/parse', async (c) => {
1567
- const parsed = parseWorkflowSchema.safeParse(await c.req.json().catch(() => null));
1568
- if (!parsed.success) {
1569
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
1570
- }
2692
+ })
2693
+ // Import support for the builder (spec 012): parse + validate a pasted
2694
+ // workflow YAML (either form) and hand back the normalized definition. The
2695
+ // server owns YAML parsing — the GUI stays dependency-free.
2696
+ .post('/workflows/parse', jsonZodValidator(parseWorkflowSchema), async (c) => {
2697
+ const parsed = { data: c.req.valid('json') };
1571
2698
  let raw;
1572
2699
  try {
1573
2700
  raw = parseYaml(parsed.data.yaml);
@@ -1586,17 +2713,274 @@ export function createApp(deps) {
1586
2713
  return c.json({ error: issue }, 400);
1587
2714
  return c.json(normalized);
1588
2715
  });
1589
- // Chain-from-prompt (spec 008): one cheap claude call proposes a chain of
1590
- // steps for the task. Never blocks — degraded answers come back as a
1591
- // one-step quick-task plan with `fallback: true`.
1592
- api.post('/plan', async (c) => {
2716
+ // ---- chained family: plan (project-scoped) ----
2717
+ const planRoutes = new Hono()
2718
+ .post('/plan', jsonZodValidator(planSchema), async (c) => {
1593
2719
  const { root: repoRoot } = c.get('project');
1594
- const parsed = planSchema.safeParse(await c.req.json().catch(() => null));
1595
- if (!parsed.success) {
1596
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
1597
- }
2720
+ const parsed = { data: c.req.valid('json') };
2721
+ const blocked = await providerActionError([(await loadConfig(repoRoot)).defaultRunner]);
2722
+ if (blocked)
2723
+ return c.json({ error: blocked }, 409);
1598
2724
  return c.json(await planChain(repoRoot, parsed.data.task));
1599
2725
  });
2726
+ const manualChecks = new Map();
2727
+ /**
2728
+ * The automations gate (#801): with `CEZ_AUTOMATIONS` unset, every route of the feature
2729
+ * answers 409 before touching a store, a lease or GitHub.
2730
+ *
2731
+ * Written as MIDDLEWARE rather than a line in each handler so the family cannot drift: a route
2732
+ * added to either chain below inherits the gate from its path, where a per-handler check is one
2733
+ * omission away from an ungated endpoint.
2734
+ *
2735
+ * Registered against EXPLICIT paths, never `use('*')`. Both chains are mounted with
2736
+ * `.route('/', …)` alongside a dozen unrelated sub-apps, and `route()` re-registers a sub-app's
2737
+ * middleware under the mount prefix — so a `'*'` here would gate the entire `/api/v1` surface,
2738
+ * including `/health`. The two-line pairing (`/automations` and `/automations/*`) is what makes
2739
+ * a path match both the collection and everything under it.
2740
+ */
2741
+ const requireAutomations = async (c, next) => {
2742
+ if (!capabilities().automations)
2743
+ return c.json({ error: AUTOMATIONS_OFF }, 409);
2744
+ await next();
2745
+ };
2746
+ // ---- chained family: GitHub automations (project-scoped) ----
2747
+ // Every handler below reads `c.get('project')` — the definitions, their runtime state and the
2748
+ // execution log are per-project files — so the family is project-scoped and mounted with the
2749
+ // rest of the mirrored table. The one exception is the manual-check read, which touches no
2750
+ // project at all; it is its own workspace-level family below.
2751
+ const automationsRoutes = new Hono()
2752
+ .use('/automations', requireAutomations)
2753
+ .use('/automations/*', requireAutomations)
2754
+ .use('/automation-log', requireAutomations)
2755
+ .use('/automation-log/*', requireAutomations)
2756
+ .get('/automations', async (c) => {
2757
+ const { root, automationStore } = c.get('project');
2758
+ const forge = resolveForge(await getRepoInfo(root));
2759
+ // Annotated, so the two branches are ONE shape rather than a union of two: the fallback
2760
+ // literal always carries `reason`, the cached answer only sometimes does, and the route
2761
+ // type is what `contract/src/automations.ts` has to describe.
2762
+ const availability = forge?.detectCached() ?? {
2763
+ available: false,
2764
+ reason: forge ? 'GitHub availability is still being checked' : 'No GitHub remote is configured',
2765
+ };
2766
+ const automations = automationStore.list().map((automation) => {
2767
+ const logs = automationStore.logs({ automationId: automation.id, limit: 100 });
2768
+ const state = automationStore.state(automation.id);
2769
+ const latestLog = logs[0];
2770
+ return {
2771
+ ...automation,
2772
+ // Spread conditionally, never `state: maybeUndefined`: the latter types the key as
2773
+ // always-present while `JSON.stringify` drops it from the wire, so the contract would
2774
+ // have to describe a key consumers never receive.
2775
+ ...(state ? { state } : {}),
2776
+ ...(latestLog ? { latestLog } : {}),
2777
+ counts: {
2778
+ matches: logs.filter((row) => row.result === 'launched' || row.result === 'duplicate').length,
2779
+ launched: logs.filter((row) => row.result === 'launched').length,
2780
+ duplicates: logs.filter((row) => row.result === 'duplicate').length,
2781
+ errors: logs.filter((row) => row.result === 'error' || row.result === 'rate-limited').length,
2782
+ },
2783
+ };
2784
+ });
2785
+ const nextDue = automations.map((item) => item.state?.nextCheckAt).filter(Boolean).sort()[0];
2786
+ return c.json({
2787
+ ...availability,
2788
+ scheduler: {
2789
+ // `as const` on both arms: in an object literal a conditional of two string literals
2790
+ // widens to `string`, which would erase the two states this key can hold.
2791
+ state: automations.some((item) => item.enabled) ? 'scheduled' : 'idle',
2792
+ ...(nextDue ? { nextDue } : {}),
2793
+ },
2794
+ automations,
2795
+ });
2796
+ })
2797
+ .post('/automations', jsonZodValidator(() => automationCreateSchema), async (c) => {
2798
+ const { automationStore } = c.get('project');
2799
+ const parsed = { data: c.req.valid('json') };
2800
+ const promptIssue = validateAutomationPrompt(parsed.data.task.prompt);
2801
+ if (promptIssue)
2802
+ return c.json({ error: promptIssue }, 400);
2803
+ const { enable, ...input } = parsed.data;
2804
+ try {
2805
+ const automation = automationStore.create({ ...input, enabled: enable === true });
2806
+ if (enable) {
2807
+ const baselineAt = new Date().toISOString();
2808
+ automationStore.setState(automation.id, {
2809
+ revision: automation.revision,
2810
+ baselineAt,
2811
+ cursor: { timestamp: baselineAt },
2812
+ nextCheckAt: new Date(Date.now() + automation.intervalSeconds * 1_000).toISOString(),
2813
+ });
2814
+ }
2815
+ emitAutomationChange(c.get('project'), automation.id, automation.revision);
2816
+ automationsChanged();
2817
+ return c.json({ automation }, 201);
2818
+ }
2819
+ catch (error) {
2820
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
2821
+ }
2822
+ })
2823
+ .get('/automations/:id', (c) => {
2824
+ const { automationStore } = c.get('project');
2825
+ const automation = automationStore.get(c.req.param('id'));
2826
+ if (!automation)
2827
+ return c.json({ error: 'not found' }, 404);
2828
+ const state = automationStore.state(automation.id);
2829
+ const latestLog = automationStore.logs({ automationId: automation.id, limit: 1 })[0];
2830
+ return c.json({
2831
+ automation,
2832
+ ...(state ? { state } : {}),
2833
+ ...(latestLog ? { latestLog } : {}),
2834
+ });
2835
+ })
2836
+ .put('/automations/:id', jsonZodValidator(() => automationUpdateSchema), async (c) => {
2837
+ const { automationStore } = c.get('project');
2838
+ const parsed = { data: c.req.valid('json') };
2839
+ const promptIssue = validateAutomationPrompt(parsed.data.task.prompt);
2840
+ if (promptIssue)
2841
+ return c.json({ error: promptIssue }, 400);
2842
+ const { expectedRevision, ...input } = parsed.data;
2843
+ if (!automationStore.get(c.req.param('id')))
2844
+ return c.json({ error: 'not found' }, 404);
2845
+ try {
2846
+ const automation = automationStore.update(c.req.param('id'), expectedRevision, { ...input, enabled: input.enabled ?? false });
2847
+ emitAutomationChange(c.get('project'), automation.id, automation.revision);
2848
+ automationsChanged();
2849
+ return c.json({ automation });
2850
+ }
2851
+ catch (error) {
2852
+ const message = error instanceof Error ? error.message : String(error);
2853
+ return c.json({ error: message }, message.includes('conflict') ? 409 : 400);
2854
+ }
2855
+ })
2856
+ .delete('/automations/:id', (c) => {
2857
+ const id = c.req.param('id');
2858
+ const store = c.get('project').automationStore;
2859
+ const current = store.get(id);
2860
+ if (!current || !store.delete(id))
2861
+ return c.json({ error: 'not found' }, 404);
2862
+ emitAutomationChange(c.get('project'), id, current.revision, true);
2863
+ automationsChanged();
2864
+ return c.body(null, 204);
2865
+ })
2866
+ .post('/automations/:id/enable', (c) => {
2867
+ const store = c.get('project').automationStore;
2868
+ const current = store.get(c.req.param('id'));
2869
+ if (!current)
2870
+ return c.json({ error: 'not found' }, 404);
2871
+ const automation = store.update(current.id, current.revision, { ...editableAutomation(current), enabled: true });
2872
+ const baselineAt = new Date().toISOString();
2873
+ store.setState(automation.id, {
2874
+ ...store.state(automation.id),
2875
+ revision: automation.revision,
2876
+ baselineAt,
2877
+ cursor: { timestamp: baselineAt },
2878
+ nextCheckAt: new Date(Date.now() + automation.intervalSeconds * 1_000).toISOString(),
2879
+ });
2880
+ store.appendLog({ automationId: automation.id, revision: automation.revision, result: 'baseline', reason: 'Enabled from a current-time baseline; existing records were not launched.' });
2881
+ emitAutomationChange(c.get('project'), automation.id, automation.revision);
2882
+ automationsChanged();
2883
+ return c.json({ automation });
2884
+ })
2885
+ .post('/automations/:id/pause', (c) => {
2886
+ const store = c.get('project').automationStore;
2887
+ const current = store.get(c.req.param('id'));
2888
+ if (!current)
2889
+ return c.json({ error: 'not found' }, 404);
2890
+ const automation = store.update(current.id, current.revision, { ...editableAutomation(current), enabled: false });
2891
+ emitAutomationChange(c.get('project'), automation.id, automation.revision);
2892
+ automationsChanged();
2893
+ return c.json({ automation });
2894
+ })
2895
+ // The body is validated as MIDDLEWARE, which is what puts it in the route type — and moves
2896
+ // the 400 ahead of this route's 404: `POST /automations/<unknown>/check` with a malformed
2897
+ // body now answers 400 rather than 404. Nothing else about either answer changed.
2898
+ .post('/automations/:id/check', jsonZodValidator(() => automationCheckRequestSchema), async (c) => {
2899
+ const project = c.get('project');
2900
+ const store = project.automationStore;
2901
+ const automation = store.get(c.req.param('id'));
2902
+ if (!automation)
2903
+ return c.json({ error: 'not found' }, 404);
2904
+ const parsed = { data: c.req.valid('json') };
2905
+ // `string`, not `randomUUID`'s template-literal type: the wire carries an opaque id, and
2906
+ // leaking `${string}-${string}-…` into the route type would make the contract describe the
2907
+ // generator rather than the answer.
2908
+ const id = randomUUID();
2909
+ const check = { id, automationId: automation.id, mode: parsed.data.mode, status: 'queued', createdAt: new Date().toISOString() };
2910
+ if (manualChecks.size >= 200)
2911
+ manualChecks.delete(manualChecks.keys().next().value);
2912
+ manualChecks.set(id, check);
2913
+ void (async () => {
2914
+ check.status = 'running';
2915
+ try {
2916
+ const remote = parseRemote((await getRepoInfo(project.root))?.remote ?? '');
2917
+ if (!remote || remote.host !== 'github.com')
2918
+ throw new Error('No GitHub remote is configured');
2919
+ const scheduler = new ProjectAutomationScheduler({
2920
+ projectId: project.id,
2921
+ owner: remote.owner,
2922
+ repo: remote.repo,
2923
+ store,
2924
+ poller: new GithubPoller(),
2925
+ launch: parsed.data.mode === 'execute'
2926
+ ? (definition, candidate, receiptId) => launchAutomationRun({ root: project.root, manager: project.manager, store: project.store, definition, candidate, receiptId })
2927
+ : undefined,
2928
+ onChange: (automationId, revision) => emitAutomationChange(project, automationId, revision),
2929
+ });
2930
+ const result = await scheduler.check(automation, parsed.data.mode);
2931
+ Object.assign(check, { status: 'complete', completedAt: new Date().toISOString(), matches: result.candidates.length, truncated: result.truncated });
2932
+ }
2933
+ catch (error) {
2934
+ Object.assign(check, { status: 'error', completedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) });
2935
+ }
2936
+ })();
2937
+ return c.json({ checkId: id }, 202);
2938
+ })
2939
+ .get('/automation-log', queryZodValidator(automationLogQuerySchema), (c) => {
2940
+ return c.json({ records: c.get('project').automationStore.logs(c.req.valid('query')) });
2941
+ })
2942
+ .post('/automation-log/:receiptId/retry', async (c) => {
2943
+ const project = c.get('project');
2944
+ const store = project.automationStore;
2945
+ const receipt = [...store.latestReceipts().values()].find((row) => row.receiptId === c.req.param('receiptId'));
2946
+ if (!receipt)
2947
+ return c.json({ error: 'not found' }, 404);
2948
+ if (receipt.status !== 'launch-error' || receipt.runId)
2949
+ return c.json({ error: 'receipt is not retryable' }, 409);
2950
+ if (!receipt.candidate)
2951
+ return c.json({ error: 'receipt predates retry context and cannot be retried safely' }, 409);
2952
+ const definition = store.get(receipt.automationId);
2953
+ if (!definition)
2954
+ return c.json({ error: 'automation not found' }, 404);
2955
+ const lease = store.acquireLease();
2956
+ if (!lease)
2957
+ return c.json({ error: 'automation polling lease is held by another process' }, 409);
2958
+ const reserved = { ...receipt, status: 'reserved', error: undefined, updatedAt: new Date().toISOString() };
2959
+ store.appendReceipt(reserved);
2960
+ try {
2961
+ const launched = await launchAutomationRun({ root: project.root, manager: project.manager, store: project.store, definition, candidate: receipt.candidate, receiptId: receipt.receiptId });
2962
+ store.appendReceipt({ ...reserved, status: 'launched', runId: launched.runId, updatedAt: new Date().toISOString() });
2963
+ emitAutomationChange(project, definition.id, definition.revision);
2964
+ return c.json({ receiptId: receipt.receiptId, runId: launched.runId }, 202);
2965
+ }
2966
+ catch (error) {
2967
+ store.appendReceipt({ ...reserved, status: 'launch-error', error: error instanceof Error ? error.message : String(error), updatedAt: new Date().toISOString() });
2968
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
2969
+ }
2970
+ finally {
2971
+ lease.release();
2972
+ }
2973
+ });
2974
+ // ---- chained family: manual automation checks (workspace-level) ----
2975
+ // Workspace-level because the handler reads no project: a check lives in the server-memory map
2976
+ // above, keyed by an unguessable id that the project-scoped POST hands back. Mounting it under
2977
+ // `/api/v1/p/:projectId` too would be a second spelling of a lookup that consults no project.
2978
+ const automationChecksRoutes = new Hono()
2979
+ .use('/automation-checks/*', requireAutomations)
2980
+ .get('/automation-checks/:checkId', (c) => {
2981
+ const check = manualChecks.get(c.req.param('checkId'));
2982
+ return check ? c.json(check) : c.json({ error: 'not found' }, 404);
2983
+ });
1600
2984
  // ---- runs ----------------------------------------------------------------
1601
2985
  // Additive `usage` field (#348): the latest CPU/RSS/proc-count sample of the
1602
2986
  // run's live process tree — absent for finished runs and when `ps` yields
@@ -1625,27 +3009,56 @@ export function createApp(deps) {
1625
3009
  console.warn(`[cezar] could not mark inbox entry ${todoId} started: ${String(err)}`);
1626
3010
  }
1627
3011
  };
1628
- api.get('/runs', (c) => c.json(c.get('project').store.listRuns().map(withUsage)));
1629
- // Registered before the `/:id/...` routes so "archive-finished" never
1630
- // matches as a run id.
1631
- api.post('/runs/archive-finished', (c) => c.json({ archived: c.get('project').store.archiveFinished() }));
1632
- api.post('/runs/:id/archive', async (c) => {
3012
+ // ---- chained family: runs lifecycle + artifacts (project-scoped) ----
3013
+ const runsRoutes = new Hono()
3014
+ .get('/runs', (c) => c.json(c.get('project').store.listRuns().map(withUsage)))
3015
+ // Registered before the `/:id/...` routes so "archive-finished" and "read-all"
3016
+ // never match as a run id.
3017
+ .post('/runs/archive-finished', (c) => c.json({ archived: c.get('project').store.archiveFinished() }))
3018
+ // The read-receipt sweep (#unread-done-items) — the mark-read twin of the archive
3019
+ // sweep above, and under the same registration-order guard.
3020
+ .post('/runs/read-all', (c) => c.json({ read: c.get('project').store.markAllRead() }))
3021
+ .post('/runs/:id/archive', jsonZodValidator(archiveSchema, { absent: ({}) }), async (c) => {
1633
3022
  const { store } = c.get('project');
1634
3023
  const id = c.req.param('id');
1635
3024
  // An empty/absent body archives (the common case); a malformed body degrades
1636
3025
  // to `{}` just as before, but a wrong-typed `archived` is now a 400 (#429).
1637
- const parsed = archiveSchema.safeParse(await c.req.json().catch(() => ({})));
1638
- if (!parsed.success) {
1639
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
1640
- }
3026
+ // Archiving also retires any pending usage-limit resume, but that rule belongs to
3027
+ // `setArchived` itself — the bulk sweep must obey it too (spec
3028
+ // 2026-08-03-auto-resume-after-usage-limit).
3029
+ const parsed = { data: c.req.valid('json') };
1641
3030
  const run = store.setArchived(id, parsed.data.archived !== false);
1642
3031
  return run ? c.json(run) : c.json({ error: 'not found' }, 404);
1643
- });
1644
- api.post('/runs', async (c) => {
3032
+ })
3033
+ // The per-task off switch for that resume (the workspace setting is Settings → Resources).
3034
+ // Idempotent: a run with nothing pending answers 200 too, because "this task will not
3035
+ // resume itself" is equally true either way.
3036
+ .delete('/runs/:id/auto-resume', (c) => {
3037
+ const { store, manager } = c.get('project');
3038
+ const id = c.req.param('id');
3039
+ if (!store.getRun(id))
3040
+ return c.json({ error: 'not found' }, 404);
3041
+ manager.cancelAutoResume(id);
3042
+ return c.json({ cancelled: true });
3043
+ })
3044
+ .post('/runs/:id/read', (c) => {
3045
+ // No body: opening a thread marks it read, full stop. Stamps `seenAt = now` and
3046
+ // returns the updated record (which also rides the `run` SSE via `touch`).
3047
+ const run = c.get('project').store.setRead(c.req.param('id'));
3048
+ return run ? c.json(run) : c.json({ error: 'not found' }, 404);
3049
+ })
3050
+ .post('/runs/:id/unread', (c) => {
3051
+ // The mark-unread twin (#775) — bodyless like its read counterpart: clearing the
3052
+ // receipt is the whole action, so there is nothing to say about it. Sits under
3053
+ // `/runs/:id/`, so the `read-all` registration-order caveat above does not apply.
3054
+ const run = c.get('project').store.setUnread(c.req.param('id'));
3055
+ return run ? c.json(run) : c.json({ error: 'not found' }, 404);
3056
+ })
3057
+ .post('/runs', jsonZodValidator(startRunSchema), async (c) => {
1645
3058
  const { root: repoRoot, dataDir, manager } = c.get('project');
1646
- const parsed = startRunSchema.safeParse(await c.req.json().catch(() => null));
1647
- if (!parsed.success) {
1648
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
3059
+ const parsed = { data: c.req.valid('json') };
3060
+ if (agentModelsLocked(repoRoot) && parsed.data.model?.trim()) {
3061
+ return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
1649
3062
  }
1650
3063
  let workflow;
1651
3064
  if (parsed.data.steps) {
@@ -1665,6 +3078,19 @@ export function createApp(deps) {
1665
3078
  if (!workflow)
1666
3079
  return c.json({ error: `unknown workflow: ${parsed.data.workflow}` }, 404);
1667
3080
  }
3081
+ const fallback = parsed.data.runner ?? (await loadConfig(repoRoot)).defaultRunner;
3082
+ const blocked = await providerActionError(providersRequiredByWorkflow(workflow, fallback));
3083
+ if (blocked)
3084
+ return c.json({ error: blocked }, 409);
3085
+ // A composer override names an account the user just picked, so a stale id (deleted since
3086
+ // the page loaded) is answered honestly instead of quietly running on the default — the
3087
+ // opposite of how RESOLUTION treats a dangling reference, and deliberately so: a run
3088
+ // replaying a stored id has no better answer than the default, a user does.
3089
+ if (parsed.data.agentProfile !== undefined) {
3090
+ const account = await resolveWorkspaceProfile(fallback, parsed.data.agentProfile);
3091
+ if ('error' in account)
3092
+ return c.json({ error: account.error }, 400);
3093
+ }
1668
3094
  const images = parsed.data.images?.map((img) => ({
1669
3095
  type: 'image',
1670
3096
  source: { type: 'base64', media_type: img.mediaType, data: img.data },
@@ -1673,146 +3099,79 @@ export function createApp(deps) {
1673
3099
  task: parsed.data.task,
1674
3100
  model: parsed.data.model,
1675
3101
  runner: parsed.data.runner,
3102
+ agentProfile: parsed.data.agentProfile,
1676
3103
  images,
1677
3104
  systemPrompt: parsed.data.systemPrompt,
1678
3105
  worktree: parsed.data.worktree,
1679
3106
  autonomous: parsed.data.autonomous,
1680
3107
  // Opt-in inbox (#471): the capability is the ceiling, so a client asking
1681
3108
  // for follow-ups on a server that has them off gets a plain `false`
1682
- // rather than an error — the run is still perfectly valid without them.
1683
- // One decision here feeds the run record, the system prompt and
1684
- // CEZ_TODOS_FILE alike (RunManager.agentEnv).
1685
- generateFollowups: capabilities().followups ? parsed.data.generateFollowups : false,
1686
- };
1687
- const variants = parsed.data.variants ?? 1;
1688
- if (variants > 1) {
1689
- // Variants live in worktrees — without git there's nothing to isolate
1690
- // them with, so this degrades to a clear 400 instead of stepping on
1691
- // one shared working tree.
1692
- const repo = await getRepoInfo(repoRoot);
1693
- if (!repo) {
1694
- return c.json({
1695
- error: 'parallel variants need a git repository (each variant runs in its own worktree) — run ×1 here, or start cezar inside a git repo',
1696
- }, 400);
1697
- }
1698
- const runs = manager.startVariants(workflow, input, variants);
1699
- // The entry points at the first variant — the thread the composer navigates to.
1700
- const first = runs[0];
1701
- if (parsed.data.todoId && first)
1702
- await noteTodoStarted(dataDir, parsed.data.todoId, first.id);
1703
- return c.json({ runs }, 201);
1704
- }
1705
- const run = manager.startRun(workflow, input);
1706
- if (parsed.data.todoId)
1707
- await noteTodoStarted(dataDir, parsed.data.todoId, run.id);
1708
- return c.json(run, 201);
1709
- });
1710
- // ---- parallel variants (spec 010) -----------------------------------------
1711
- const groupRuns = (store, groupId) => store
1712
- .listRuns()
1713
- .filter((r) => r.groupId === groupId)
1714
- .sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
1715
- // Comparison data: per variant the status, cost, `git diff --stat` and the
1716
- // first Progress-log lines from the handoff. The full diff is fetched per
1717
- // variant via the existing GET /api/runs/:id/diff.
1718
- api.get('/groups/:groupId', async (c) => {
1719
- const { dataDir, store } = c.get('project');
1720
- const runs = groupRuns(store, c.req.param('groupId'));
1721
- if (runs.length === 0)
1722
- return c.json({ error: 'not found' }, 404);
1723
- const detailed = await Promise.all(runs.map(async (r) => ({
1724
- id: r.id,
1725
- variant: r.variant ?? '?',
1726
- title: r.title,
1727
- status: r.status,
1728
- archived: r.archived,
1729
- tokensUsed: r.tokensUsed,
1730
- costUsd: r.costUsd,
1731
- diffStat: r.worktreePath && existsSync(r.worktreePath)
1732
- ? await worktreeDiffStat(r.worktreePath, r.baseBranch ?? 'HEAD')
1733
- : '',
1734
- handoffExcerpt: handoffProgressExcerpt(readHandoff(dataDir, r.id)),
1735
- })));
1736
- return c.json({
1737
- groupId: c.req.param('groupId'),
1738
- runs: detailed,
1739
- });
1740
- });
1741
- // "Pick this one": the winner rests at `review` (spec 009 takes it from
1742
- // there — send back / draft PR / finish); the losers are cancelled if
1743
- // alive, archived, and their worktrees + branches removed.
1744
- api.post('/groups/:groupId/pick', async (c) => {
1745
- const { root: repoRoot, dataDir, store, manager } = c.get('project');
1746
- const runs = groupRuns(store, c.req.param('groupId'));
1747
- if (runs.length === 0)
1748
- return c.json({ error: 'not found' }, 404);
1749
- const parsed = pickSchema.safeParse(await c.req.json().catch(() => null));
1750
- if (!parsed.success) {
1751
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
1752
- }
1753
- const winner = runs.find((r) => r.id === parsed.data.runId);
1754
- if (!winner)
1755
- return c.json({ error: 'runId is not part of this group' }, 404);
1756
- if (manager.isActive(winner.id)) {
1757
- return c.json({ error: 'this variant is still active — wait for it to finish first' }, 409);
1758
- }
1759
- // Winner: a non-review terminal state with a non-empty diff flips to
1760
- // `review` (the settleSuccess rule) — but only when the review gate applies
1761
- // (#489): it is enabled (`reviewGateEnabled`, default off) AND the winner is
1762
- // not autonomous. An autonomous / gate-off winner keeps its `done` state with
1763
- // the diff left in the worktree; an empty diff (or no worktree) stays too.
1764
- if (winner.status !== 'review' &&
1765
- winner.worktreePath &&
1766
- existsSync(winner.worktreePath) &&
1767
- winner.autonomous !== true &&
1768
- reviewGateEnabled(await loadConfig(repoRoot))) {
1769
- const diff = await worktreeDiff(winner.worktreePath, winner.baseBranch ?? 'HEAD');
1770
- if (diff.trim().length > 0 && !diff.startsWith('(diff failed')) {
1771
- store.updateRun(winner.id, { status: 'review' });
1772
- }
1773
- }
1774
- const losers = runs.filter((r) => r.id !== winner.id);
1775
- store.appendEvent(winner.id, {
1776
- type: 'lifecycle',
1777
- message: `picked from ${runs.length} variants — ${losers.length} other variant(s) archived`,
1778
- });
1779
- appendHandoffHeartbeat(dataDir, winner.id, `picked from ${runs.length} variants`);
1780
- for (const loser of losers) {
1781
- if (manager.isActive(loser.id))
1782
- manager.cancel(loser.id);
1783
- if (loser.worktreePath)
1784
- await removeWorktree(repoRoot, loser.worktreePath, loser.branch);
1785
- store.updateRun(loser.id, { worktreePath: undefined, branch: undefined });
1786
- store.setArchived(loser.id, true);
1787
- store.appendEvent(loser.id, {
1788
- type: 'lifecycle',
1789
- message: `variant ${winner.variant ?? '?'} was picked — this variant is archived, its worktree removed`,
1790
- });
3109
+ // rather than an error — the run is still perfectly valid without them.
3110
+ // One decision here feeds the run record, the system prompt and
3111
+ // CEZ_TODOS_FILE alike (RunManager.agentEnv).
3112
+ generateFollowups: capabilities().followups ? parsed.data.generateFollowups : false,
3113
+ };
3114
+ const variants = parsed.data.variants ?? 1;
3115
+ if (variants > 1) {
3116
+ // Variants live in worktrees — without git there's nothing to isolate
3117
+ // them with, so this degrades to a clear 400 instead of stepping on
3118
+ // one shared working tree.
3119
+ const repo = await getRepoInfo(repoRoot);
3120
+ if (!repo) {
3121
+ return c.json({
3122
+ error: 'parallel variants need a git repository (each variant runs in its own worktree) — run ×1 here, or start cezar inside a git repo',
3123
+ }, 400);
3124
+ }
3125
+ const runs = manager.startVariants(workflow, input, variants);
3126
+ // The entry points at the first variant — the thread the composer navigates to.
3127
+ const first = runs[0];
3128
+ if (parsed.data.todoId && first)
3129
+ await noteTodoStarted(dataDir, parsed.data.todoId, first.id);
3130
+ return c.json({ runs }, 201);
1791
3131
  }
1792
- return c.json({
1793
- winner: store.getRun(winner.id),
1794
- });
1795
- });
1796
- api.get('/runs/:id', (c) => {
3132
+ const run = manager.startRun(workflow, input);
3133
+ if (parsed.data.todoId)
3134
+ await noteTodoStarted(dataDir, parsed.data.todoId, run.id);
3135
+ return c.json(run, 201);
3136
+ })
3137
+ .get('/runs/:id', (c) => {
1797
3138
  const { store } = c.get('project');
1798
3139
  const run = store.getRun(c.req.param('id'));
1799
3140
  return run ? c.json(withUsage(run)) : c.json({ error: 'not found' }, 404);
1800
- });
1801
- // Editable titles (#389). The UI displays `titleSummary ?? title`, so a
1802
- // user edit sets BOTH: `title` (the record's own name — the raw task stops
1803
- // being it the moment the user renames the run) and `titleSummary` (what
1804
- // actually displays). The auto-summarizer only ever fills an *unset*
1805
- // titleSummary (RunManager.recordTurnEnd), so an edit wins over any past or
1806
- // future auto-summary. Answers the updated record.
1807
- api.patch('/runs/:id', async (c) => {
3141
+ })
3142
+ .get('/runs/:id/history', paramZodValidator(runIdParamSchema), queryZodValidator(runHistoryQuerySchema), async (c) => {
3143
+ const { store, dataDir } = c.get('project');
3144
+ const { id } = c.req.valid('param');
3145
+ if (!store.getRun(id))
3146
+ return c.json({ error: 'not found' }, 404);
3147
+ try {
3148
+ return c.json(await readRunHistoryPage(join(dataDir, 'runs', `${id}.ndjson`), c.req.valid('query').cursor));
3149
+ }
3150
+ catch (error) {
3151
+ if (error instanceof HistoryCursorError)
3152
+ return c.json({ error: error.message }, error.status);
3153
+ throw error;
3154
+ }
3155
+ })
3156
+ .get('/runs/:id/history-context', paramZodValidator(runIdParamSchema), async (c) => {
3157
+ const { store, dataDir } = c.get('project');
3158
+ const { id } = c.req.valid('param');
3159
+ if (!store.getRun(id))
3160
+ return c.json({ error: 'not found' }, 404);
3161
+ return c.json(await deriveRunContextEvents(join(dataDir, 'runs', `${id}.ndjson`)));
3162
+ })
3163
+ // Editable titles (#389). The UI displays `titleSummary ?? title`, so a
3164
+ // user edit sets BOTH: `title` (the record's own name — the raw task stops
3165
+ // being it the moment the user renames the run) and `titleSummary` (what
3166
+ // actually displays). The auto-summarizer only ever fills an *unset*
3167
+ // titleSummary (RunManager.recordTurnEnd), so an edit wins over any past or
3168
+ // future auto-summary. Answers the updated record.
3169
+ .patch('/runs/:id', jsonZodValidator(patchRunSchema), async (c) => {
1808
3170
  const { store, manager } = c.get('project');
1809
3171
  const id = c.req.param('id');
1810
3172
  if (!store.getRun(id))
1811
3173
  return c.json({ error: 'not found' }, 404);
1812
- const parsed = patchRunSchema.safeParse(await c.req.json().catch(() => null));
1813
- if (!parsed.success) {
1814
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
1815
- }
3174
+ const parsed = { data: c.req.valid('json') };
1816
3175
  // The prompt is editable only while the run is still queued (#472). Checked
1817
3176
  // BEFORE the title write so a rejected PATCH is a no-op rather than a partial
1818
3177
  // one. `title` itself keeps working on any status — no regression to #389.
@@ -1837,25 +3196,33 @@ export function createApp(deps) {
1837
3196
  });
1838
3197
  }
1839
3198
  return c.json(store.getRun(id));
1840
- });
1841
- api.post('/runs/:id/cancel', (c) => {
3199
+ })
3200
+ .post('/runs/:id/cancel', (c) => {
1842
3201
  const { store, manager } = c.get('project');
1843
3202
  const id = c.req.param('id');
1844
3203
  if (!store.getRun(id))
1845
3204
  return c.json({ error: 'not found' }, 404);
1846
3205
  const cancelled = manager.cancel(id);
1847
3206
  return c.json({ cancelled });
1848
- });
1849
- // Live-session participation (spec 002): deliver a user message (text +
1850
- // pasted screenshots) into the run's open claude session.
1851
- api.post('/runs/:id/messages', async (c) => {
3207
+ })
3208
+ // Live-session participation (spec 002): deliver a user message (text +
3209
+ // pasted screenshots) into the run's open claude session.
3210
+ .post('/runs/:id/messages', jsonZodValidator(messageSchema), async (c) => {
1852
3211
  const { store, manager } = c.get('project');
1853
3212
  const id = c.req.param('id');
1854
- if (!store.getRun(id))
3213
+ const run = store.getRun(id);
3214
+ if (!run)
1855
3215
  return c.json({ error: 'not found' }, 404);
1856
- const parsed = messageSchema.safeParse(await c.req.json().catch(() => null));
1857
- if (!parsed.success) {
1858
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
3216
+ const parsed = { data: c.req.valid('json') };
3217
+ // Stacking onto a queued prompt mutates an existing task and invokes no provider.
3218
+ // Provider availability still gates live delivery after the record leaves `queued`, but
3219
+ // must not strand prompt authoring just because an unrelated fallback provider is
3220
+ // disconnected (provider-auth spec: disabling never blocks existing-task mutations).
3221
+ // In the dequeue race, the ladder below safely turns this into a starting-state buffer.
3222
+ if (run.status !== 'queued') {
3223
+ const blocked = await providerActionError([providerForActiveRun(run)]);
3224
+ if (blocked)
3225
+ return c.json({ error: blocked }, 409);
1859
3226
  }
1860
3227
  const content = [
1861
3228
  ...parsed.data.images.map((img) => ({
@@ -1871,14 +3238,14 @@ export function createApp(deps) {
1871
3238
  // starting up → buffered · anything else → 409, exactly as before
1872
3239
  if (manager.sendMessage(id, content))
1873
3240
  return c.json({ delivered: true });
1874
- const run = store.getRun(id);
1875
- const stack = run?.queuedMessages ?? [];
3241
+ const currentRun = store.getRun(id);
3242
+ const stack = currentRun?.queuedMessages ?? [];
1876
3243
  // Bounds apply only to a message that is actually about to be stacked. Without this
1877
3244
  // gate an over-long message posted to a *finished* run would answer `400 prompt too
1878
3245
  // long` when the truthful answer is `409 session closed`. The status read is safe
1879
3246
  // here because it only decides whether to reject EARLY — `enqueueMessage` still
1880
3247
  // re-checks against the engine's own queue before writing anything.
1881
- if (run?.status === 'queued') {
3248
+ if (currentRun?.status === 'queued') {
1882
3249
  if (stack.length >= MAX_QUEUED_MESSAGES) {
1883
3250
  return c.json({ error: `too many queued messages — ${MAX_QUEUED_MESSAGES} message limit` }, 400);
1884
3251
  }
@@ -1886,7 +3253,7 @@ export function createApp(deps) {
1886
3253
  if (stackedImages + parsed.data.images.length > MAX_QUEUED_IMAGES) {
1887
3254
  return c.json({ error: `too many queued images — ${MAX_QUEUED_IMAGES} image limit across the stack` }, 400);
1888
3255
  }
1889
- const prospective = foldedLength(run.task, [...stack, { text: parsed.data.text }]);
3256
+ const prospective = foldedLength(currentRun.task, [...stack, { text: parsed.data.text }]);
1890
3257
  if (prospective > MAX_FOLDED_TASK_CHARS) {
1891
3258
  return c.json({
1892
3259
  error: `prompt too long — ${MAX_FOLDED_TASK_CHARS} character limit across the task and its queued messages (would be ${prospective})`,
@@ -1899,19 +3266,16 @@ export function createApp(deps) {
1899
3266
  if (manager.deferMessage(id, content))
1900
3267
  return c.json({ deferred: true });
1901
3268
  return c.json({ error: 'session closed' }, 409);
1902
- });
1903
- // Edit / remove a stacked message (#472). Registered before any conflicting
1904
- // `/:id` route so `queued-messages` never matches as a run id.
1905
- api.patch('/runs/:id/queued-messages/:msgId', async (c) => {
3269
+ })
3270
+ // Edit / remove a stacked message (#472). Registered before any conflicting
3271
+ // `/:id` route so `queued-messages` never matches as a run id.
3272
+ .patch('/runs/:id/queued-messages/:msgId', jsonZodValidator(queuedMessagePatchSchema), async (c) => {
1906
3273
  const { store, manager } = c.get('project');
1907
3274
  const id = c.req.param('id');
1908
3275
  const run = store.getRun(id);
1909
3276
  if (!run)
1910
3277
  return c.json({ error: 'not found' }, 404);
1911
- const parsed = queuedMessagePatchSchema.safeParse(await c.req.json().catch(() => null));
1912
- if (!parsed.success) {
1913
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
1914
- }
3278
+ const parsed = { data: c.req.valid('json') };
1915
3279
  const msgId = c.req.param('msgId');
1916
3280
  const stack = run.queuedMessages ?? [];
1917
3281
  const existing = stack.find((m) => m.id === msgId);
@@ -1944,8 +3308,8 @@ export function createApp(deps) {
1944
3308
  if (!message)
1945
3309
  return c.json({ error: 'run already started' }, 409);
1946
3310
  return c.json({ message });
1947
- });
1948
- api.delete('/runs/:id/queued-messages/:msgId', (c) => {
3311
+ })
3312
+ .delete('/runs/:id/queued-messages/:msgId', (c) => {
1949
3313
  const { store, manager } = c.get('project');
1950
3314
  const id = c.req.param('id');
1951
3315
  const run = store.getRun(id);
@@ -1958,9 +3322,9 @@ export function createApp(deps) {
1958
3322
  if (!manager.removeQueuedMessage(id, msgId))
1959
3323
  return c.json({ error: 'run already started' }, 409);
1960
3324
  return c.json({ removed: true });
1961
- });
1962
- // "Finish": gracefully close a waiting session — the run completes as done.
1963
- api.post('/runs/:id/finish', (c) => {
3325
+ })
3326
+ // "Finish": gracefully close a waiting session — the run completes as done.
3327
+ .post('/runs/:id/finish', (c) => {
1964
3328
  const { store, manager } = c.get('project');
1965
3329
  const id = c.req.param('id');
1966
3330
  if (!store.getRun(id))
@@ -1969,19 +3333,23 @@ export function createApp(deps) {
1969
3333
  if (!finished)
1970
3334
  return c.json({ error: 'no open session' }, 409);
1971
3335
  return c.json({ finished: true });
1972
- });
1973
- // "Continue" (spec 003): reopen a finished run's session in-process.
1974
- api.post('/runs/:id/continue', async (c) => {
1975
- const { store, manager } = c.get('project');
3336
+ })
3337
+ // "Continue" (spec 003): reopen a finished run's session in-process.
3338
+ .post('/runs/:id/continue', jsonZodValidator(continueSchema, { absent: ({}) }), async (c) => {
3339
+ const { root: repoRoot, store, manager } = c.get('project');
1976
3340
  const id = c.req.param('id');
1977
- if (!store.getRun(id))
3341
+ const run = store.getRun(id);
3342
+ if (!run)
1978
3343
  return c.json({ error: 'not found' }, 404);
1979
3344
  // Bounded resume text (#429); an empty/absent body still just re-runs on the
1980
3345
  // run's current backend, and a runner/model override reopens on that engine (#401).
1981
- const parsed = continueSchema.safeParse(await c.req.json().catch(() => ({})));
1982
- if (!parsed.success) {
1983
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
3346
+ const parsed = { data: c.req.valid('json') };
3347
+ if (agentModelsLocked(repoRoot) && parsed.data.model?.trim()) {
3348
+ return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
1984
3349
  }
3350
+ const blocked = await providerActionError([providerForExistingRun(run, parsed.data.runner)]);
3351
+ if (blocked)
3352
+ return c.json({ error: blocked }, 409);
1985
3353
  const result = manager.continueRun(id, {
1986
3354
  text: parsed.data.text,
1987
3355
  images: parsed.data.images?.map((img) => ({
@@ -1994,10 +3362,10 @@ export function createApp(deps) {
1994
3362
  if (!result.ok)
1995
3363
  return c.json({ error: result.error }, 409);
1996
3364
  return c.json({ continued: true });
1997
- });
1998
- // "Open in terminal" (spec 003): hand the session off to a real terminal —
1999
- // in the task's worktree when it still exists (spec 006).
2000
- api.post('/runs/:id/open-in-cli', async (c) => {
3365
+ })
3366
+ // "Open in terminal" (spec 003): hand the session off to a real terminal —
3367
+ // in the task's worktree when it still exists (spec 006).
3368
+ .post('/runs/:id/open-in-cli', async (c) => {
2001
3369
  const { root: repoRoot, store } = c.get('project');
2002
3370
  const id = c.req.param('id');
2003
3371
  const run = store.getRun(id);
@@ -2010,28 +3378,36 @@ export function createApp(deps) {
2010
3378
  error: 'local handoff is disabled — this cockpit runs in hosted mode (CEZ_REMOTE); resume the session from a machine that has the checkout',
2011
3379
  }, 409);
2012
3380
  }
2013
- const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
3381
+ const sessionStep = [...run.steps].reverse().find((s) => s.sessionId);
3382
+ const sessionId = sessionStep?.sessionId;
2014
3383
  if (!sessionId)
2015
3384
  return c.json({ error: 'no agent session to resume' }, 409);
3385
+ const blocked = await providerActionError([providerForExistingRun(run)]);
3386
+ if (blocked)
3387
+ return c.json({ error: blocked }, 409);
2016
3388
  const cwd = run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : repoRoot;
2017
3389
  const command = resumeCommand(run.runner, sessionId);
2018
3390
  // Fails closed on an id we do not recognise — see resumeCommand (#431).
2019
3391
  if (!command)
2020
3392
  return c.json({ error: 'the recorded session id has an unexpected shape' }, 409);
2021
- const opened = await openInTerminal(cwd, command);
3393
+ // The account that OWNS this session, not the project's current one (spec 2026-07-29).
3394
+ const account = await handoffEnv(run.runner ?? 'claude', sessionStep?.profileId);
3395
+ if ('error' in account)
3396
+ return c.json({ error: account.error }, 409);
3397
+ const fallback = handoffFallbackCommand(cwd, command, account.env);
3398
+ // Fail closed for the same reason as the session id: a terminal opened without the
3399
+ // account's config dir resumes nothing and says nothing about why.
3400
+ if (fallback === null) {
3401
+ return c.json({ error: 'this account\'s folder cannot be used in a terminal command' }, 409);
3402
+ }
3403
+ const opened = await openTerminal(cwd, command, account.env);
2022
3404
  if (!opened) {
2023
- return c.json({
2024
- error: 'no terminal emulator found',
2025
- command: `cd '${cwd}' && ${command}`,
2026
- }, 409);
3405
+ return c.json({ error: 'no terminal emulator found', command: fallback }, 409);
2027
3406
  }
2028
3407
  return c.json({ opened: true, command });
2029
- });
2030
- // "Open in…" session takeover (#open-in): the editors/file-manager/terminal
2031
- // detected on THIS machine. Empty in hosted mode (no local desktop to open).
2032
- api.get('/open-targets', (c) => c.json({ targets: capabilities().localHandoff ? detectOpenTargets() : [] }));
2033
- // Open a run's worktree (or the repo root) in the chosen local app.
2034
- api.post('/runs/:id/open-in', async (c) => {
3408
+ })
3409
+ // Open a run's worktree (or the repo root) in the chosen local app.
3410
+ .post('/runs/:id/open-in', jsonZodValidator(openInSchema), async (c) => {
2035
3411
  const { root: repoRoot, store } = c.get('project');
2036
3412
  const id = c.req.param('id');
2037
3413
  const run = store.getRun(id);
@@ -2044,10 +3420,7 @@ export function createApp(deps) {
2044
3420
  }
2045
3421
  // Follows the safeParse convention (#429); the downstream allowlist match is the real
2046
3422
  // injection guard, this just validates the shape.
2047
- const parsedBody = openInSchema.safeParse(await c.req.json().catch(() => null));
2048
- if (!parsedBody.success) {
2049
- return c.json({ error: parsedBody.error.issues.map((i) => i.message).join('; ') }, 400);
2050
- }
3423
+ const parsedBody = { data: c.req.valid('json') };
2051
3424
  const { target, path: relPath } = parsedBody.data;
2052
3425
  const dir = run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : repoRoot;
2053
3426
  // Diff pane "open in OS default app" (#365): one worktree file, opened with the platform's
@@ -2086,7 +3459,7 @@ export function createApp(deps) {
2086
3459
  }, 409);
2087
3460
  }
2088
3461
  const filePath = join(run.worktreePath, result.path);
2089
- const opened = await openFileInDefaultApp(filePath);
3462
+ const opened = await openFile(filePath);
2090
3463
  if (!opened)
2091
3464
  return c.json({ error: `could not open ${result.path}`, path: filePath }, 409);
2092
3465
  return c.json({ opened: true, path: filePath });
@@ -2105,29 +3478,43 @@ export function createApp(deps) {
2105
3478
  // and what the client's cliTargetResumes now labels. Resume-after-finish is untouched.
2106
3479
  const cliRunner = agentCliRunner(target);
2107
3480
  if (cliRunner) {
3481
+ const blocked = await providerActionError([cliRunner]);
3482
+ if (blocked)
3483
+ return c.json({ error: blocked }, 409);
2108
3484
  const engineOwnsSession = run.status === 'running' || run.status === 'queued' || run.status === 'waiting';
2109
- const sessionId = engineOwnsSession ? undefined : [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
3485
+ const sessionStep = engineOwnsSession ? undefined : [...run.steps].reverse().find((s) => s.sessionId);
3486
+ const sessionId = sessionStep?.sessionId;
2110
3487
  // An id resumeCommand refuses (#431) degrades to a fresh CLI in the worktree,
2111
3488
  // exactly like a run that never recorded a session.
2112
3489
  const resume = sessionId && cliRunner === (run.runner ?? 'claude') ? resumeCommand(cliRunner, sessionId) : null;
2113
3490
  const command = resume ?? cliRunner;
2114
- const opened = await openInTerminal(dir, command);
3491
+ // BOTH branches carry the account (spec 2026-07-29-agent-profiles): a resume needs the
3492
+ // config dir that holds its session, and a FRESH CLI in this worktree should still open
3493
+ // on the account the project works under — otherwise "Open in → Claude CLI" quietly
3494
+ // hands the user a different subscription than every task in the same project uses.
3495
+ const account = resume
3496
+ ? await handoffEnv(cliRunner, sessionStep?.profileId)
3497
+ : { env: (await resolveProfileEnvForRoot(repoRoot, cliRunner)).env };
3498
+ if ('error' in account)
3499
+ return c.json({ error: account.error }, 409);
3500
+ const fallback = handoffFallbackCommand(dir, command, account.env);
3501
+ if (fallback === null) {
3502
+ return c.json({ error: 'this account\'s folder cannot be used in a terminal command' }, 409);
3503
+ }
3504
+ const opened = await openTerminal(dir, command, account.env);
2115
3505
  if (!opened) {
2116
- return c.json({
2117
- error: 'no terminal emulator found',
2118
- command: `cd '${dir}' && ${command}`,
2119
- }, 409);
3506
+ return c.json({ error: 'no terminal emulator found', command: fallback }, 409);
2120
3507
  }
2121
3508
  return c.json({ opened: true, path: dir, command });
2122
3509
  }
2123
- const opened = await openInApp(target, dir);
3510
+ const opened = await openApp(target, dir);
2124
3511
  if (!opened)
2125
3512
  return c.json({ error: `could not open ${target}`, path: dir }, 409);
2126
3513
  return c.json({ opened: true, path: dir });
2127
- });
2128
- // Handoff journal (spec 007): the per-task handoff.md as markdown. 404 only
2129
- // when the task is unknown; a task without a (yet) seeded file returns ''.
2130
- api.get('/runs/:id/handoff', (c) => {
3514
+ })
3515
+ // Handoff journal (spec 007): the per-task handoff.md as markdown. 404 only
3516
+ // when the task is unknown; a task without a (yet) seeded file returns ''.
3517
+ .get('/runs/:id/handoff', (c) => {
2131
3518
  const { dataDir, store } = c.get('project');
2132
3519
  const run = store.getRun(c.req.param('id'));
2133
3520
  if (!run)
@@ -2135,16 +3522,8 @@ export function createApp(deps) {
2135
3522
  return c.text(readHandoff(dataDir, run.id), 200, {
2136
3523
  'content-type': 'text/markdown; charset=utf-8',
2137
3524
  });
2138
- });
2139
- // Agent screenshots — image blocks the run manager persisted out of tool
2140
- // results (persistImage). `basename` pins reads inside the run's own dir.
2141
- const IMAGE_TYPES = {
2142
- png: 'image/png',
2143
- jpg: 'image/jpeg',
2144
- webp: 'image/webp',
2145
- gif: 'image/gif',
2146
- };
2147
- api.get('/runs/:id/images/:file', (c) => {
3525
+ })
3526
+ .get('/runs/:id/images/:file', (c) => {
2148
3527
  const { dataDir, store } = c.get('project');
2149
3528
  const run = store.getRun(c.req.param('id'));
2150
3529
  if (!run)
@@ -2160,9 +3539,9 @@ export function createApp(deps) {
2160
3539
  'cache-control': 'private, max-age=31536000, immutable',
2161
3540
  },
2162
3541
  });
2163
- });
2164
- // Task diff (spec 006): what this run changed — its worktree vs its base.
2165
- api.get('/runs/:id/diff', async (c) => {
3542
+ })
3543
+ // Task diff (spec 006): what this run changed — its worktree vs its base.
3544
+ .get('/runs/:id/diff', async (c) => {
2166
3545
  const { store } = c.get('project');
2167
3546
  const run = store.getRun(c.req.param('id'));
2168
3547
  if (!run)
@@ -2171,94 +3550,113 @@ export function createApp(deps) {
2171
3550
  return c.text('(no worktree — this task ran directly in the repo working tree)');
2172
3551
  }
2173
3552
  return c.text(await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD'));
2174
- });
2175
- // ---- session git view (redesign R5 Step 1.2 — §"Git/session API additions").
2176
- // Structured sibling of the text-blob /diff above (which stays untouched —
2177
- // protected surface). Same worktree/base resolution; every predictable git
2178
- // failure degrades to 409 + human-readable reason, 404 only for unknown ids.
2179
- const worktreeOf = (run) => run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : null;
2180
- const NO_WORKTREE = 'no worktree — this task ran directly in the repo working tree';
2181
- api.get('/runs/:id/changes', async (c) => {
2182
- const { store } = c.get('project');
3553
+ })
3554
+ .get('/runs/:id/changes', async (c) => {
3555
+ const { root: repoRoot, store } = c.get('project');
2183
3556
  const run = store.getRun(c.req.param('id'));
2184
3557
  if (!run)
2185
3558
  return c.json({ error: 'not found' }, 404);
2186
- const worktree = worktreeOf(run);
2187
- if (!worktree)
3559
+ const workingDirectory = workingDirectoryOf(run, repoRoot);
3560
+ if (!workingDirectory)
2188
3561
  return c.json({ error: NO_WORKTREE }, 409);
2189
- const result = await collectChanges(worktree, run.baseBranch ?? 'HEAD', { taskBranch: run.branch });
3562
+ const result = await collectChanges(workingDirectory, run.baseBranch ?? 'HEAD', {
3563
+ taskBranch: run.branch,
3564
+ // Anchors a repointed worktree at the branch as this run found it (#751).
3565
+ runStartedAt: run.startedAt,
3566
+ // A read-only GET against the user's real checkout must never modify its index.
3567
+ intentToAdd: run.worktreePath ? undefined : false,
3568
+ });
2190
3569
  if (!result.ok)
2191
3570
  return c.json({ error: result.error }, 409);
2192
3571
  return c.json(result.changes);
2193
- });
2194
- // The run's own commits (<base>..HEAD on the worktree branch) — the Commits tab.
2195
- api.get('/runs/:id/commits', async (c) => {
2196
- const { store } = c.get('project');
3572
+ })
3573
+ // The run's own commits (<base>..HEAD on the worktree branch) — the Commits tab.
3574
+ .get('/runs/:id/commits', async (c) => {
3575
+ const { root: repoRoot, store } = c.get('project');
2197
3576
  const run = store.getRun(c.req.param('id'));
2198
3577
  if (!run)
2199
3578
  return c.json({ error: 'not found' }, 404);
2200
- const worktree = worktreeOf(run);
2201
- if (!worktree)
3579
+ const workingDirectory = workingDirectoryOf(run, repoRoot);
3580
+ if (!workingDirectory)
2202
3581
  return c.json({ error: NO_WORKTREE }, 409);
2203
- const result = await collectRunCommits(worktree, run.baseBranch ?? 'HEAD');
3582
+ const result = await collectRunCommits(workingDirectory, run.baseBranch ?? 'HEAD');
2204
3583
  if (!result.ok)
2205
3584
  return c.json({ error: result.error }, 409);
2206
3585
  return c.json({ commits: result.commits });
2207
- });
2208
- // One of the run's commits, structured like the Changes tab (reuses collectCommitChanges).
2209
- api.get('/runs/:id/commit/:sha', async (c) => {
2210
- const { store } = c.get('project');
3586
+ })
3587
+ // One of the run's commits, structured like the Changes tab (reuses collectCommitChanges).
3588
+ .get('/runs/:id/commit/:sha', async (c) => {
3589
+ const { root: repoRoot, store } = c.get('project');
2211
3590
  const run = store.getRun(c.req.param('id'));
2212
3591
  if (!run)
2213
3592
  return c.json({ error: 'not found' }, 404);
2214
- const worktree = worktreeOf(run);
2215
- if (!worktree)
3593
+ const workingDirectory = workingDirectoryOf(run, repoRoot);
3594
+ if (!workingDirectory)
2216
3595
  return c.json({ error: NO_WORKTREE }, 409);
2217
- const result = await collectCommitChanges(worktree, c.req.param('sha'));
3596
+ const result = await collectCommitChanges(workingDirectory, c.req.param('sha'));
2218
3597
  if (!result.ok)
2219
3598
  return c.json({ error: result.error }, 409);
2220
3599
  return c.json(result.commit);
2221
- });
2222
- // Files tab: directory listing (path omitted or a dir) or file content
2223
- // (size-capped, binary flagged). Traversal-safe — readWorktreePath rejects
2224
- // anything escaping the worktree. `raw=1` (R5 Step 1.6) serves the BYTES of
2225
- // image files only, for the preview's inline <img> — never HTML/JS/etc., so
2226
- // no worktree file can become a same-origin document, and never past the
2227
- // size cap. The no-script CSP neutralizes SVG opened as a top-level URL.
2228
- api.get('/runs/:id/files', async (c) => {
2229
- const { store } = c.get('project');
3600
+ })
3601
+ // Files tab: directory listing (path omitted or a dir) or file content
3602
+ // (size-capped, binary flagged). Traversal-safe — readWorktreePath rejects
3603
+ // anything escaping the worktree. `raw=1` (R5 Step 1.6) serves the BYTES of
3604
+ // image files only, for the preview's inline <img> — never HTML/JS/etc., so
3605
+ // no worktree file can become a same-origin document, and never past the
3606
+ // size cap. The no-script CSP neutralizes SVG opened as a top-level URL.
3607
+ //
3608
+ // An `Accept` that asks for images reaches the same raw branch without the flag — which is
3609
+ // what an `<img>` sends — while the flag still wins whenever it is present and `*<slash>*`
3610
+ // (every `fetch`) still gets the JSON listing. See `negotiate`.
3611
+ .get('/runs/:id/files', queryZodValidator(z.object({ path: queryValue, raw: queryValue })), async (c) => {
3612
+ const { root: repoRoot, store } = c.get('project');
3613
+ const query = c.req.valid('query');
3614
+ c.header('vary', 'Accept');
3615
+ const wantsRaw = query.raw !== undefined
3616
+ ? query.raw === '1'
3617
+ : negotiate(c.req.header('accept'), FILE_FORMATS) === 'image/*';
2230
3618
  const run = store.getRun(c.req.param('id'));
2231
3619
  if (!run)
2232
3620
  return c.json({ error: 'not found' }, 404);
2233
- const worktree = worktreeOf(run);
2234
- if (!worktree)
3621
+ const workingDirectory = workingDirectoryOf(run, repoRoot);
3622
+ if (!workingDirectory)
2235
3623
  return c.json({ error: NO_WORKTREE }, 409);
2236
- const result = await readWorktreePath(worktree, c.req.query('path') ?? '');
3624
+ const result = await readWorktreePath(workingDirectory, query.path ?? '');
2237
3625
  if (result.kind === 'invalid' || result.kind === 'missing') {
2238
3626
  return c.json({ error: result.error }, 409);
2239
3627
  }
2240
3628
  if (result.kind === 'dir') {
2241
3629
  return c.json({
3630
+ // `as const` or the literal widens to `string` during Hono's route-type inference,
3631
+ // which erases the discriminant a consumer narrows on — `entry.type === 'dir'` then
3632
+ // leaves `never` and every field access on it fails. The wire was always 'dir'.
2242
3633
  type: 'dir',
2243
3634
  path: result.path,
2244
3635
  entries: result.entries,
2245
3636
  });
2246
3637
  }
2247
- if (c.req.query('raw') === '1') {
3638
+ if (wantsRaw) {
2248
3639
  const mime = imageMimeType(result.path);
2249
- if (!mime)
2250
- return c.json({ error: `raw serving is limited to images: ${result.path}` }, 409);
2251
- if (result.tooLarge) {
2252
- return c.json({
2253
- error: `file too large to serve raw (${result.size} bytes): ${result.path}`,
2254
- }, 409);
3640
+ if (mime === null || result.tooLarge) {
3641
+ // `?raw=1` ASKED for bytes, so it hears why it cannot have them — that 409 and its
3642
+ // wording are the protected surface (§2). An `Accept` is only a preference, so a
3643
+ // resource with no image representation falls THROUGH to the JSON answer below rather
3644
+ // than turning a browser's navigation to a text file into an error.
3645
+ if (query.raw !== undefined) {
3646
+ const error = mime === null
3647
+ ? `raw serving is limited to images: ${result.path}`
3648
+ : `file too large to serve raw (${result.size} bytes): ${result.path}`;
3649
+ return c.json({ error }, 409);
3650
+ }
3651
+ }
3652
+ else {
3653
+ const bytes = await readFile(join(workingDirectory, result.path));
3654
+ return c.body(new Uint8Array(bytes).buffer, 200, {
3655
+ 'content-type': mime,
3656
+ 'x-content-type-options': 'nosniff',
3657
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
3658
+ });
2255
3659
  }
2256
- const bytes = await readFile(join(worktree, result.path));
2257
- return c.body(new Uint8Array(bytes).buffer, 200, {
2258
- 'content-type': mime,
2259
- 'x-content-type-options': 'nosniff',
2260
- 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
2261
- });
2262
3660
  }
2263
3661
  return c.json({
2264
3662
  type: 'file',
@@ -2268,8 +3666,8 @@ export function createApp(deps) {
2268
3666
  tooLarge: result.tooLarge,
2269
3667
  ...(result.content !== undefined ? { content: result.content } : {}),
2270
3668
  });
2271
- });
2272
- api.post('/runs/:id/git/commit', async (c) => {
3669
+ })
3670
+ .post('/runs/:id/git/commit', jsonZodValidator(gitCommitSchema), async (c) => {
2273
3671
  const { store } = c.get('project');
2274
3672
  const run = store.getRun(c.req.param('id'));
2275
3673
  if (!run)
@@ -2277,16 +3675,13 @@ export function createApp(deps) {
2277
3675
  const worktree = worktreeOf(run);
2278
3676
  if (!worktree)
2279
3677
  return c.json({ error: NO_WORKTREE }, 409);
2280
- const parsed = gitCommitSchema.safeParse(await c.req.json().catch(() => null));
2281
- if (!parsed.success) {
2282
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
2283
- }
3678
+ const parsed = { data: c.req.valid('json') };
2284
3679
  const result = await commitAll(worktree, parsed.data.message);
2285
3680
  if (!result.ok)
2286
3681
  return c.json({ error: result.error }, 409);
2287
3682
  return c.json({ committed: true, sha: result.sha });
2288
- });
2289
- api.post('/runs/:id/git/push', async (c) => {
3683
+ })
3684
+ .post('/runs/:id/git/push', async (c) => {
2290
3685
  const { store } = c.get('project');
2291
3686
  const run = store.getRun(c.req.param('id'));
2292
3687
  if (!run)
@@ -2303,12 +3698,12 @@ export function createApp(deps) {
2303
3698
  remote: result.remote,
2304
3699
  upstreamSet: result.upstreamSet,
2305
3700
  });
2306
- });
2307
- // Draft PR from the review gate (spec 009): final autosave → push →
2308
- // `gh pr create --draft`; on success the run completes as done with the PR
2309
- // badge. Failures come back as 409 with a `manual` merge command the GUI
2310
- // shows next to the toast. CEZ_DRY_RUN=1 fakes the URL (no push, no gh).
2311
- api.post('/runs/:id/pr', async (c) => {
3701
+ })
3702
+ // Draft PR from the review gate (spec 009): final autosave → push →
3703
+ // `gh pr create --draft`; on success the run completes as done with the PR
3704
+ // badge. Failures come back as 409 with a `manual` merge command the GUI
3705
+ // shows next to the toast. CEZ_DRY_RUN=1 fakes the URL (no push, no gh).
3706
+ .post('/runs/:id/pr', async (c) => {
2312
3707
  const { root: repoRoot, dataDir, store, manager } = c.get('project');
2313
3708
  const id = c.req.param('id');
2314
3709
  const run = store.getRun(id);
@@ -2339,10 +3734,10 @@ export function createApp(deps) {
2339
3734
  message: `draft PR created: ${outcome.url}${outcome.dryRun ? ' (dry run — no real PR)' : ''}`,
2340
3735
  });
2341
3736
  return c.json({ url: outcome.url, dryRun: outcome.dryRun }, 201);
2342
- });
2343
- // Archived tasks keep their worktree for inspection; this is the explicit
2344
- // "🧹 Remove worktree" cleanup (spec 006).
2345
- api.post('/runs/:id/remove-worktree', async (c) => {
3737
+ })
3738
+ // Archived tasks keep their worktree for inspection; this is the explicit
3739
+ // "🧹 Remove worktree" cleanup (spec 006).
3740
+ .post('/runs/:id/remove-worktree', async (c) => {
2346
3741
  const { root: repoRoot, store, manager } = c.get('project');
2347
3742
  const id = c.req.param('id');
2348
3743
  const run = store.getRun(id);
@@ -2354,8 +3749,8 @@ export function createApp(deps) {
2354
3749
  await removeWorktree(repoRoot, run.worktreePath, run.branch);
2355
3750
  store.updateRun(id, { worktreePath: undefined, branch: undefined });
2356
3751
  return c.json({ removed: true });
2357
- });
2358
- api.delete('/runs/:id', async (c) => {
3752
+ })
3753
+ .delete('/runs/:id', async (c) => {
2359
3754
  const { root: repoRoot, store, manager } = c.get('project');
2360
3755
  const id = c.req.param('id');
2361
3756
  if (manager.isActive(id))
@@ -2368,11 +3763,140 @@ export function createApp(deps) {
2368
3763
  await removeWorktree(repoRoot, run.worktreePath, run.branch);
2369
3764
  return store.deleteRun(id) ? c.json({ deleted: true }) : c.json({ error: 'not found' }, 404);
2370
3765
  });
2371
- // ---- worktree management panel (#483) --------------------------------------
2372
- // List materialized task worktrees with disk usage + retention state, and a
2373
- // "Reclaim now" action. Both additive; the per-row delete reuses the existing
2374
- // /api/runs/:id/remove-worktree route above.
2375
- api.get('/worktrees', async (c) => {
3766
+ // ---- parallel variants (spec 010) -----------------------------------------
3767
+ const groupRuns = (store, groupId) => store
3768
+ .listRuns()
3769
+ .filter((r) => r.groupId === groupId)
3770
+ .sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
3771
+ // ---- chained family: variant groups (project-scoped) ----
3772
+ const groupsRoutes = new Hono()
3773
+ .get('/groups/:groupId', async (c) => {
3774
+ const { dataDir, store } = c.get('project');
3775
+ const runs = groupRuns(store, c.req.param('groupId'));
3776
+ if (runs.length === 0)
3777
+ return c.json({ error: 'not found' }, 404);
3778
+ const detailed = await Promise.all(runs.map(async (r) => ({
3779
+ id: r.id,
3780
+ variant: r.variant ?? '?',
3781
+ title: r.title,
3782
+ status: r.status,
3783
+ archived: r.archived,
3784
+ tokensUsed: r.tokensUsed,
3785
+ ...(r.inputTokens !== undefined ? { inputTokens: r.inputTokens } : {}),
3786
+ ...(r.outputTokens !== undefined ? { outputTokens: r.outputTokens } : {}),
3787
+ ...(r.costUsd !== undefined ? { costUsd: r.costUsd } : {}),
3788
+ diffStat: r.worktreePath && existsSync(r.worktreePath)
3789
+ ? await worktreeDiffStat(r.worktreePath, r.baseBranch ?? 'HEAD')
3790
+ : '',
3791
+ handoffExcerpt: handoffProgressExcerpt(readHandoff(dataDir, r.id)),
3792
+ })));
3793
+ return c.json({
3794
+ groupId: c.req.param('groupId'),
3795
+ runs: detailed,
3796
+ });
3797
+ })
3798
+ // "Pick this one": the winner rests at `review` (spec 009 takes it from
3799
+ // there — send back / draft PR / finish); the losers are cancelled if
3800
+ // alive, archived, and their worktrees + branches removed.
3801
+ .post('/groups/:groupId/pick', jsonZodValidator(pickSchema), async (c) => {
3802
+ const { root: repoRoot, dataDir, store, manager } = c.get('project');
3803
+ const runs = groupRuns(store, c.req.param('groupId'));
3804
+ if (runs.length === 0)
3805
+ return c.json({ error: 'not found' }, 404);
3806
+ const parsed = { data: c.req.valid('json') };
3807
+ const winner = runs.find((r) => r.id === parsed.data.runId);
3808
+ if (!winner)
3809
+ return c.json({ error: 'runId is not part of this group' }, 404);
3810
+ if (manager.isActive(winner.id)) {
3811
+ return c.json({ error: 'this variant is still active — wait for it to finish first' }, 409);
3812
+ }
3813
+ // Winner: a non-review terminal state with a non-empty diff flips to
3814
+ // `review` (the settleSuccess rule) — but only when the review gate applies
3815
+ // (#489): it is enabled (`reviewGateEnabled`, default off) AND the winner is
3816
+ // not autonomous. An autonomous / gate-off winner keeps its `done` state with
3817
+ // the diff left in the worktree; an empty diff (or no worktree) stays too.
3818
+ if (winner.status !== 'review' &&
3819
+ winner.worktreePath &&
3820
+ existsSync(winner.worktreePath) &&
3821
+ winner.autonomous !== true &&
3822
+ reviewGateEnabled(await loadConfig(repoRoot))) {
3823
+ const diff = await worktreeDiff(winner.worktreePath, winner.baseBranch ?? 'HEAD');
3824
+ if (diff.trim().length > 0 && !diff.startsWith('(diff failed')) {
3825
+ store.updateRun(winner.id, { status: 'review' });
3826
+ }
3827
+ }
3828
+ const losers = runs.filter((r) => r.id !== winner.id);
3829
+ store.appendEvent(winner.id, {
3830
+ type: 'lifecycle',
3831
+ message: `picked from ${runs.length} variants — ${losers.length} other variant(s) archived`,
3832
+ });
3833
+ appendHandoffHeartbeat(dataDir, winner.id, `picked from ${runs.length} variants`);
3834
+ for (const loser of losers) {
3835
+ if (manager.isActive(loser.id))
3836
+ manager.cancel(loser.id);
3837
+ if (loser.worktreePath)
3838
+ await removeWorktree(repoRoot, loser.worktreePath, loser.branch);
3839
+ store.updateRun(loser.id, { worktreePath: undefined, branch: undefined });
3840
+ store.setArchived(loser.id, true);
3841
+ store.appendEvent(loser.id, {
3842
+ type: 'lifecycle',
3843
+ message: `variant ${winner.variant ?? '?'} was picked — this variant is archived, its worktree removed`,
3844
+ });
3845
+ }
3846
+ // Spread: `getRun` may answer undefined, and an undefined VALUE is dropped by
3847
+ // JSON.stringify — so writing the key unconditionally typed the route as sending a key it
3848
+ // does not. contract/workflows.ts says `.optional()`, which is what a client receives.
3849
+ const picked = store.getRun(winner.id);
3850
+ return c.json({ ...(picked !== undefined ? { winner: picked } : {}) });
3851
+ });
3852
+ // ---- chained family: open-targets (project-scoped) ----
3853
+ const openTargetsRoutes = new Hono()
3854
+ .get('/open-targets', (c) => c.json({ targets: capabilities().localHandoff ? detectOpenTargets() : [] }))
3855
+ // Open the PROJECT ROOT itself (Settings → "Project folder" → Open with). The run route
3856
+ // above opens a task worktree and needs a run to name one; this is the repo the cockpit is
3857
+ // scoped to, which the scope middleware has already resolved — so no path is accepted from
3858
+ // the client and there is nothing to contain.
3859
+ .post('/open-in', jsonZodValidator(openProjectInSchema), async (c) => {
3860
+ const { root } = c.get('project');
3861
+ if (!capabilities().localHandoff) {
3862
+ return c.json({ error: 'local handoff is disabled — this cockpit runs in hosted mode (CEZ_REMOTE)' }, 409);
3863
+ }
3864
+ const { target } = c.req.valid('json');
3865
+ // Refused here rather than left to the menu, on the same principle as the accounts route:
3866
+ // a `cli:<runner>` handoff would START AN AGENT in the checkout everything else runs in a
3867
+ // worktree to protect. Which app APPLIES is a property of the route, not of one client.
3868
+ if (agentCliRunner(target) !== null) {
3869
+ return c.json({ error: 'agent CLIs open a task worktree, not the project folder' }, 400);
3870
+ }
3871
+ if (!detectOpenTargets().some((candidate) => candidate.id === target)) {
3872
+ return c.json({ error: `no such app on this machine: ${target}` }, 400);
3873
+ }
3874
+ const opened = await openApp(target, root);
3875
+ if (!opened)
3876
+ return c.json({ error: `could not open ${target}`, path: root }, 409);
3877
+ return c.json({ opened: true, path: root });
3878
+ });
3879
+ // Agent screenshots — image blocks the run manager persisted out of tool
3880
+ // results (persistImage). `basename` pins reads inside the run's own dir.
3881
+ const IMAGE_TYPES = {
3882
+ png: 'image/png',
3883
+ jpg: 'image/jpeg',
3884
+ webp: 'image/webp',
3885
+ gif: 'image/gif',
3886
+ };
3887
+ // ---- session git view (redesign R5 Step 1.2 — §"Git/session API additions").
3888
+ // Structured sibling of the text-blob /diff above (which stays untouched —
3889
+ // protected surface). Isolated runs read their worktree; worktree-off runs
3890
+ // read the repo checkout they executed in. Every predictable git failure
3891
+ // degrades to 409 + human-readable reason, 404 only for unknown ids.
3892
+ const worktreeOf = (run) => run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : null;
3893
+ const workingDirectoryOf = (run, repoRoot) => run.worktree === false
3894
+ ? repoRoot
3895
+ : worktreeOf(run);
3896
+ const NO_WORKTREE = 'no worktree — this task ran directly in the repo working tree';
3897
+ // ---- chained family: worktrees (project-scoped) ----
3898
+ const worktreesRoutes = new Hono()
3899
+ .get('/worktrees', async (c) => {
2376
3900
  const { root: repoRoot, store } = c.get('project');
2377
3901
  // The keep-limit the panel reports is the one the enforcer will actually
2378
3902
  // apply — inherited from the workspace default when this repo sets none.
@@ -2393,60 +3917,68 @@ export function createApp(deps) {
2393
3917
  ? null
2394
3918
  : worktrees.reduce((sum, w) => sum + (w.sizeBytes ?? 0), 0);
2395
3919
  return c.json({ worktrees, totalBytes, keep });
2396
- });
2397
- const reclaimBodySchema = z.object({}).passthrough();
2398
- api.post('/worktrees/reclaim', async (c) => {
3920
+ })
3921
+ .post('/worktrees/reclaim', jsonZodValidator(() => reclaimBodySchema, { absent: ({}), message: 'invalid body' }), async (c) => {
2399
3922
  const { root: repoRoot, store } = c.get('project');
2400
- // Accept an empty or `{}` body; retention is best-effort, so 200 always.
2401
- const parsed = reclaimBodySchema.safeParse(await c.req.json().catch(() => ({})));
2402
- if (!parsed.success)
2403
- return c.json({ error: 'invalid body' }, 400);
3923
+ // The body is validated (an empty or `{}` one is accepted) but carries nothing this
3924
+ // handler reads; retention is best-effort, so 200 always.
2404
3925
  const reclaimed = await reclaimWorktrees(repoRoot, store, await resolveWorktreeRetention(repoRoot));
2405
3926
  return c.json({ reclaimed });
2406
3927
  });
2407
- // ---- inbox (spec 007) ------------------------------------------------------
2408
- // Opt-in via CEZ_FOLLOWUPS=1 (#471). Off, the reader degrades to an empty
2409
- // inbox (a 404 would make old clients surface an error for a feature that is
2410
- // merely switched off) and the mutators 409 as defense in depth — the shape
2411
- // the hosted-mode open-in-* handlers already use. Existing todos.json entries
2412
- // are never touched, so flipping the env back on restores them.
2413
- api.get('/todos', async (c) => c.json(capabilities().followups ? await readTodos(c.get('project').dataDir) : []));
2414
- // Check off = delete the entry.
2415
- api.delete('/todos/:id', async (c) => {
3928
+ const reclaimBodySchema = z.object({}).passthrough();
3929
+ /**
3930
+ * "The inbox is on and this entry exists" — the 409/404 half of `POST /todos/:id/start`, lifted
3931
+ * out of the handler and in FRONT of the body validator.
3932
+ *
3933
+ * That position is the whole point. The route's contract is that an unknown id 404s before the
3934
+ * body is looked at, and Hono only records a body in the route type when it is validated as
3935
+ * MIDDLEWARE — which necessarily runs before the handler. Registering this guard first satisfies
3936
+ * both: the documented status order is unchanged, and `startTodoSchema` becomes visible to
3937
+ * `AppType` (and so to `hc`) instead of being parsed invisibly inside the handler.
3938
+ *
3939
+ * Deliberately NOT annotated with a return type: the inferred one carries the two typed
3940
+ * responses, which is what keeps the 409 and 404 branches in the route's schema for the client.
3941
+ */
3942
+ const todoMustExist = async (c, next) => {
3943
+ if (!capabilities().followups)
3944
+ return c.json({ error: FOLLOWUPS_OFF }, 409);
3945
+ const todo = (await readTodos(c.get('project').dataDir)).find((t) => t.id === c.req.param('id'));
3946
+ if (!todo)
3947
+ return c.json({ error: 'not found' }, 404);
3948
+ c.set('todo', todo);
3949
+ await next();
3950
+ };
3951
+ // ---- chained family: follow-up inbox / todos (project-scoped) ----
3952
+ const todosRoutes = new Hono()
3953
+ .get('/todos', async (c) => c.json(capabilities().followups ? await readTodos(c.get('project').dataDir) : []))
3954
+ // Check off = delete the entry.
3955
+ .delete('/todos/:id', async (c) => {
2416
3956
  const { dataDir } = c.get('project');
2417
3957
  if (!capabilities().followups)
2418
3958
  return c.json({ error: FOLLOWUPS_OFF }, 409);
2419
3959
  const removed = await removeTodo(dataDir, c.req.param('id'));
2420
3960
  return removed ? c.json({ removed: true }) : c.json({ error: 'not found' }, 404);
2421
- });
2422
- // "▶ Run": turn an inbox entry into a task — a one-off single-step workflow
2423
- // around the suggested skill when it exists, plain quick-task otherwise.
2424
- api.post('/todos/:id/start', async (c) => {
3961
+ })
3962
+ // "▶ Run": turn an inbox entry into a task — a one-off single-step workflow
3963
+ // around the suggested skill when it exists, plain quick-task otherwise.
3964
+ //
3965
+ // TWO middlewares, and their ORDER is the contract. This route's documented status order
3966
+ // (pinned by todos-start.test.ts) is that a disabled inbox 409s and an unknown id 404s BEFORE
3967
+ // the body is looked at — which is why the body used to be parsed inline, invisible to `hc`.
3968
+ // Hono runs route middleware in registration order, so `todoMustExist` FIRST keeps that order
3969
+ // exactly while `jsonZodValidator` second is what records the body in the route type.
3970
+ //
3971
+ // The two no-body cases the old inline parse distinguished are carried by the validator's
3972
+ // `absent`/`malformed` options: no body at all is `undefined` (the pre-#401 bodyless POST,
3973
+ // which the optional schema accepts → 201), a truncated payload is `null` (which it rejects
3974
+ // → 400, rather than passing as "no body" and silently starting a run).
3975
+ .post('/todos/:id/start', todoMustExist, jsonZodValidator(startTodoSchema, { absent: undefined, malformed: null }), async (c) => {
2425
3976
  const { root: repoRoot, dataDir, manager } = c.get('project');
2426
- if (!capabilities().followups)
2427
- return c.json({ error: FOLLOWUPS_OFF }, 409);
2428
3977
  const id = c.req.param('id');
2429
- const todo = (await readTodos(dataDir)).find((t) => t.id === id);
2430
- if (!todo)
2431
- return c.json({ error: 'not found' }, 404);
2432
- // Body is optional and read exactly once (runner/model #401 + prompt #413 share it). A
2433
- // request with none at all (the pre-pills/pre-composer client) stays `undefined`, same as an
2434
- // empty `{}`. A body that IS present but is not valid JSON becomes `null`, which the schema
2435
- // rejects → 400 (the `.catch(() => null)` pattern every other mutating route uses); mapping
2436
- // it to `undefined` too would let a broken payload pass as "no body" and silently 201.
2437
- const rawBody = await c.req.text().catch(() => '');
2438
- let body;
2439
- if (rawBody.trim().length > 0) {
2440
- try {
2441
- body = JSON.parse(rawBody);
2442
- }
2443
- catch {
2444
- body = null;
2445
- }
2446
- }
2447
- const parsed = startTodoSchema.safeParse(body);
2448
- if (!parsed.success) {
2449
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
3978
+ const todo = c.get('todo');
3979
+ const parsed = { data: c.req.valid('json') };
3980
+ if (agentModelsLocked(repoRoot) && parsed.data?.model?.trim()) {
3981
+ return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
2450
3982
  }
2451
3983
  if (todo.startedTaskId)
2452
3984
  return c.json({ error: 'already started' }, 409);
@@ -2476,6 +4008,10 @@ export function createApp(deps) {
2476
4008
  const { workflows } = await loadWorkflows(repoRoot);
2477
4009
  workflow = workflows.find((w) => w.name === 'quick-task') ?? QUICK_TASK_WORKFLOW;
2478
4010
  }
4011
+ const fallback = parsed.data?.runner ?? (await loadConfig(repoRoot)).defaultRunner;
4012
+ const blocked = await providerActionError(providersRequiredByWorkflow(workflow, fallback));
4013
+ if (blocked)
4014
+ return c.json({ error: blocked }, 409);
2479
4015
  const run = manager.startRun(workflow, {
2480
4016
  task,
2481
4017
  runner: parsed.data?.runner,
@@ -2483,18 +4019,31 @@ export function createApp(deps) {
2483
4019
  });
2484
4020
  await markStarted(dataDir, id, run.id);
2485
4021
  return c.json({ run }, 201);
2486
- });
2487
- // Per-run SSE: full replay from the NDJSON file, then live events. The
2488
- // listener attaches before the replay and buffers, so nothing emitted
2489
- // during the replay is lost or duplicated (dedup by seq).
2490
- api.get('/runs/:id/events', (c) => {
2491
- const { store } = c.get('project');
2492
- const id = c.req.param('id');
4022
+ });
4023
+ // ---- chained family: SSE streams (project-scoped) ----
4024
+ const sseRoutes = new Hono()
4025
+ .get('/runs/:id/events', paramZodValidator(runIdParamSchema), queryZodValidator(runEventsQuerySchema), async (c) => {
4026
+ const { store, dataDir } = c.get('project');
4027
+ const { id } = c.req.valid('param');
2493
4028
  if (!store.getRun(id))
2494
4029
  return c.json({ error: 'not found' }, 404);
4030
+ const query = c.req.valid('query');
4031
+ const eventsPath = join(dataDir, 'runs', `${id}.ndjson`);
4032
+ if (query.cursor) {
4033
+ try {
4034
+ await validateLiveCursor(eventsPath, query.cursor);
4035
+ }
4036
+ catch (error) {
4037
+ if (error instanceof HistoryCursorError)
4038
+ return c.json({ error: error.message }, error.status);
4039
+ throw error;
4040
+ }
4041
+ }
4042
+ const lastEventId = Number.parseInt(c.req.header('Last-Event-ID') ?? '', 10);
4043
+ const requestedAfter = Math.max(query.afterSeq ?? 0, Number.isSafeInteger(lastEventId) && lastEventId >= 0 ? lastEventId : 0);
2495
4044
  return streamSSENoBuffer(c, async (stream) => {
2496
4045
  let replaying = true;
2497
- let maxSeq = 0;
4046
+ let maxSeq = requestedAfter;
2498
4047
  const buffered = [];
2499
4048
  // One endpoint, two SSE event names: v1 lines stay `run-event` (the name
2500
4049
  // the legacy UI listened to — its default branch JSON-dumped unknown
@@ -2504,6 +4053,7 @@ export function createApp(deps) {
2504
4053
  // ride `ui-event`, which only v2-aware clients subscribe to.
2505
4054
  // EventSource ignores names it has no listener for.
2506
4055
  const writeEvent = (event) => stream.writeSSE({
4056
+ id: String(event.seq),
2507
4057
  event: isV2WireEventType(event.type) ? 'ui-event' : 'run-event',
2508
4058
  data: JSON.stringify(event),
2509
4059
  });
@@ -2526,9 +4076,15 @@ export function createApp(deps) {
2526
4076
  store.off('event', onEvent);
2527
4077
  store.off('run', onRun);
2528
4078
  });
2529
- for (const event of store.readEvents(id)) {
2530
- maxSeq = Math.max(maxSeq, event.seq);
4079
+ const replay = query.cursor
4080
+ ? await readEventsAfterLiveCursor(eventsPath, query.cursor)
4081
+ : { events: store.readEvents(id), boundarySeq: 0 };
4082
+ maxSeq = Math.max(maxSeq, replay.boundarySeq);
4083
+ for (const event of replay.events) {
4084
+ if (event.seq <= maxSeq)
4085
+ continue;
2531
4086
  await writeEvent(event);
4087
+ maxSeq = event.seq;
2532
4088
  }
2533
4089
  replaying = false;
2534
4090
  for (const event of buffered) {
@@ -2543,13 +4099,13 @@ export function createApp(deps) {
2543
4099
  await stream.sleep(15_000);
2544
4100
  }
2545
4101
  });
2546
- });
2547
- // Global SSE: run-summary updates for the list view + inbox changes.
2548
- // Scoped `/p/:projectId/events` carries that project's stream in today's
2549
- // shape; the legacy unprefixed alias stays bound to the boot project ONLY
2550
- // (spec "Legacy aliases" — widening it would be a silent behavioral break;
2551
- // the all-project stream arrives as `/api/workspace/events` in step 2.8).
2552
- api.get('/events', (c) => {
4102
+ })
4103
+ // Global SSE: run-summary updates for the list view + inbox changes.
4104
+ // Scoped `/p/:projectId/events` carries that project's stream in today's
4105
+ // shape; the legacy unprefixed alias stays bound to the boot project ONLY
4106
+ // (spec "Legacy aliases" — widening it would be a silent behavioral break;
4107
+ // the all-project stream arrives as `/api/workspace/events` in step 2.8).
4108
+ .get('/events', (c) => {
2553
4109
  const { dataDir, store } = c.get('project');
2554
4110
  return streamSSENoBuffer(c, async (stream) => {
2555
4111
  const onRun = (run) => void stream.writeSSE({ event: 'run', data: JSON.stringify(run) });
@@ -2595,22 +4151,9 @@ export function createApp(deps) {
2595
4151
  }
2596
4152
  });
2597
4153
  });
2598
- // ---- workspace SSE (multi-project spec, step 2.8) ------------------------
2599
- // The cockpit's future single EventSource: one stream, every project.
2600
- // WORKSPACE-level (single-mount on `app`, never mirrored under /api/p/).
2601
- // Same event names as the per-project stream, but every payload is stamped
2602
- // with the owning `project` id — additively where the legacy payload is an
2603
- // object (`run` grows a `project` key, `run-deleted` becomes
2604
- // `{id, project}`), wrapped where it is not (`todos` → `{project, items}`,
2605
- // `usage` → `{project, usage}` — a bare array/record has nowhere to carry a
2606
- // stamp). The legacy `/api/events` alias above keeps its UN-stamped,
2607
- // boot-filtered shape — that stream is a protected surface.
2608
- //
2609
- // Subscribing NEVER force-instantiates a project: only the boot context
2610
- // (always live) and already-built lazy contexts are attached at connect;
2611
- // contexts built later join via the `onContextBuilt` hook, so a project's
2612
- // first API touch makes its events flow to streams already open.
2613
- app.get('/api/workspace/events', (c) => {
4154
+ // ---- chained family: workspace SSE stream (workspace-level) ----
4155
+ const workspaceEventsRoutes = new Hono()
4156
+ .get('/workspace/events', (c) => {
2614
4157
  return streamSSENoBuffer(c, async (stream) => {
2615
4158
  // One detach bundle per attached project — the id guard makes a double
2616
4159
  // attach (connect-time snapshot vs. the built hook) impossible.
@@ -2682,11 +4225,12 @@ export function createApp(deps) {
2682
4225
  }
2683
4226
  });
2684
4227
  // Workspace-level events (project-added / project-removed /
2685
- // checkout-progress) — relayed verbatim under their own names. A
2686
- // removal also drops the project's attach entry: the id guard in
2687
- // `attach` would otherwise pin the DISPOSED context forever, so a
2688
- // project removed and re-added on the same slug would rebuild a fresh
2689
- // context whose events never reach this already-open stream.
4228
+ // checkout-progress plus host-wide unstamped provider-status) — relayed
4229
+ // verbatim under their own names. A removal also drops the project's
4230
+ // attach entry: the id guard in `attach` would otherwise pin the
4231
+ // DISPOSED context forever, so a project removed and re-added on the
4232
+ // same slug would rebuild a fresh context whose events never reach this
4233
+ // already-open stream.
2690
4234
  const offWorkspace = workspaceEvents.on((event, data) => {
2691
4235
  if (event === 'project-removed') {
2692
4236
  const removed = data.id;
@@ -2711,23 +4255,25 @@ export function createApp(deps) {
2711
4255
  }
2712
4256
  });
2713
4257
  });
2714
- // ---- GitHub tab ------------------------------------------------------------
2715
- // Issues + PRs of the repo's origin, read through the logged-in `gh` CLI.
2716
- // Degrades to `{available:false, reason}` — no gh / no remote / offline all
2717
- // just render as a hint in the tab, never an error.
2718
- api.get('/github', async (c) => {
4258
+ // ---- chained family: GitHub (project-scoped) ----
4259
+ // These sit ABOVE the routes rather than with the family's other schemas below it, because a
4260
+ // validator argument is evaluated when the route is REGISTERED — a schema declared further down
4261
+ // would be in its temporal dead zone. (The schemas below are all read inside a handler, or
4262
+ // passed as a thunk, which defers them past that point.)
4263
+ const mergeNumberParams = z.object({ number: z.coerce.number().int().positive() });
4264
+ const prChangesParams = z.object({ number: z.coerce.number().int().positive().safe() });
4265
+ const prChangesQuery = z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') });
4266
+ const githubRoutes = new Hono()
4267
+ .get('/github',
4268
+ // `limit` stays a bare string: the handler's `Number.parseInt`/`Number.isFinite` fallback to
4269
+ // 30 already accepts `?limit=banana`, and a numeric schema would 400 it instead.
4270
+ queryZodValidator(z.object({ limit: queryValue, refresh: queryValue })), async (c) => {
2719
4271
  const { root: repoRoot } = c.get('project');
2720
- const limit = Number.parseInt(c.req.query('limit') ?? '', 10);
2721
- return c.json(await fetchGithub(repoRoot, c.req.query('refresh') === '1', Number.isFinite(limit) ? limit : 30));
2722
- });
2723
- // The full comment thread for one issue/PR (#499). Additive sibling of /api/github — lazy
2724
- // (fetched only while a detail view is open), zod-validated params, 400 on garbage, and the
2725
- // same in-payload availability degrade (gh missing / offline / 404 all render as a hint).
2726
- const commentsParams = z.object({
2727
- kind: z.enum(['issue', 'pr']),
2728
- number: z.coerce.number().int().positive(),
2729
- });
2730
- api.get('/github/comments/:kind/:number', async (c) => {
4272
+ const query = c.req.valid('query');
4273
+ const limit = Number.parseInt(query.limit ?? '', 10);
4274
+ return c.json(await fetchGithub(repoRoot, query.refresh === '1', Number.isFinite(limit) ? limit : 30));
4275
+ })
4276
+ .get('/github/comments/:kind/:number', queryZodValidator(refreshQuery), async (c) => {
2731
4277
  const { root: repoRoot } = c.get('project');
2732
4278
  const parsed = commentsParams.safeParse({
2733
4279
  kind: c.req.param('kind'),
@@ -2735,10 +4281,86 @@ export function createApp(deps) {
2735
4281
  });
2736
4282
  if (!parsed.success)
2737
4283
  return c.json({ error: 'invalid kind or number' }, 400);
2738
- return c.json(await fetchGithubComments(repoRoot, parsed.data.kind, parsed.data.number, c.req.query('refresh') === '1'));
4284
+ return c.json(await fetchGithubComments(repoRoot, parsed.data.kind, parsed.data.number, c.req.valid('query').refresh === '1'));
4285
+ })
4286
+ // Lazy checks glyphs for on-screen PR rows (#664). Additive sibling of /api/github — the list
4287
+ // call dropped `statusCheckRollup` (the dominant cost), so the glyph is hydrated here per
4288
+ // visible row. `prs` is a comma-separated list of positive integers, capped at GH_CHECKS_MAX;
4289
+ // anything malformed is a 400. Same in-payload availability degrade as the list (never a 5xx).
4290
+ // `prs` is the one genuinely REQUIRED query key on this server, so it is the one validated
4291
+ // strictly — `.min(1)` because `?prs=` answered `missing prs query` before it answered
4292
+ // `invalid prs query`, and both spellings must keep their own words.
4293
+ .get('/github/checks', queryZodValidator(z.object({ prs: z.string().min(1) }), { message: 'missing prs query' }), async (c) => {
4294
+ const { root: repoRoot } = c.get('project');
4295
+ const raw = c.req.valid('query').prs;
4296
+ const parts = raw.split(',').map((p) => p.trim()).filter(Boolean);
4297
+ if (parts.length === 0 || parts.length > GH_CHECKS_MAX)
4298
+ return c.json({ error: 'invalid prs query' }, 400);
4299
+ const numbers = [];
4300
+ for (const part of parts) {
4301
+ const n = Number(part);
4302
+ if (!Number.isInteger(n) || n <= 0 || String(n) !== part)
4303
+ return c.json({ error: 'invalid prs query' }, 400);
4304
+ numbers.push(n);
4305
+ }
4306
+ return c.json(await fetchGithubChecks(repoRoot, numbers));
4307
+ })
4308
+ .get('/github/prs/:number/merge-state', paramZodValidator(mergeNumberParams, { message: 'invalid pull request number' }), queryZodValidator(refreshQuery), async (c) => {
4309
+ const { root: repoRoot } = c.get('project');
4310
+ const parsed = { data: c.req.valid('param') };
4311
+ const forge = resolveForge(await getRepoInfo(repoRoot));
4312
+ if (!forge?.prMergeState)
4313
+ return c.json({ available: false, reason: 'GitHub merge state is unavailable' });
4314
+ return c.json(await forge.prMergeState(parsed.data.number, { refresh: c.req.valid('query').refresh === '1' }));
4315
+ })
4316
+ .post('/github/prs/:number/merge', paramZodValidator(mergeNumberParams, { message: 'invalid pull request number' }), jsonZodValidator(() => mergeBodySchema, { message: 'invalid merge request' }), async (c) => {
4317
+ const { root: repoRoot } = c.get('project');
4318
+ const parsedNumber = { data: c.req.valid('param') };
4319
+ const body = { data: c.req.valid('json') };
4320
+ const forge = resolveForge(await getRepoInfo(repoRoot));
4321
+ if (!forge?.mergePR)
4322
+ return c.json({ error: 'GitHub merge is unavailable' }, 409);
4323
+ const result = await forge.mergePR(parsedNumber.data.number, body.data);
4324
+ if (result.merged)
4325
+ return c.json(result);
4326
+ return c.json({
4327
+ error: result.error,
4328
+ ...(result.code ? { code: result.code } : {}),
4329
+ ...(result.current ? { current: result.current } : {}),
4330
+ }, result.status);
4331
+ })
4332
+ .get('/github/prs/:number/changes',
4333
+ // Split out of one `safeParse` over both inputs, because a path param and the query string
4334
+ // are separate validation targets to Hono and only a split makes each visible to the route
4335
+ // type. Both keep the single 400 sentence the combined parse answered. `refresh` stays
4336
+ // STRICT here (`?refresh=true` is a 400 today, unlike everywhere else on this server).
4337
+ paramZodValidator(prChangesParams, { message: 'invalid pull request number or refresh flag' }), queryZodValidator(prChangesQuery, { message: 'invalid pull request number or refresh flag' }), async (c) => {
4338
+ const { root: repoRoot } = c.get('project');
4339
+ const parsed = { data: c.req.valid('param') };
4340
+ try {
4341
+ return c.json(await fetchGithubPrDiff(repoRoot, parsed.data.number, c.req.valid('query').refresh === '1'));
4342
+ }
4343
+ catch (err) {
4344
+ if (err instanceof GithubPrNotFoundError)
4345
+ return c.json({ error: err.message }, 404);
4346
+ throw err;
4347
+ }
4348
+ });
4349
+ // The full comment thread for one issue/PR (#499). Additive sibling of /api/github — lazy
4350
+ // (fetched only while a detail view is open), zod-validated params, 400 on garbage, and the
4351
+ // same in-payload availability degrade (gh missing / offline / 404 all render as a hint).
4352
+ const commentsParams = z.object({
4353
+ kind: z.enum(['issue', 'pr']),
4354
+ number: z.coerce.number().int().positive(),
2739
4355
  });
2740
- // ---- repo view -----------------------------------------------------------
2741
- api.get('/repo', async (c) => {
4356
+ const mergeBodySchema = z.object({
4357
+ method: z.enum(['merge', 'squash', 'rebase']),
4358
+ expectedHeadSha: z.string().regex(/^[0-9a-f]{40}$/),
4359
+ overrideRules: z.boolean().optional().default(false),
4360
+ }).strict();
4361
+ // ---- chained family: repo / git (project-scoped) ----
4362
+ const repoRoutes = new Hono()
4363
+ .get('/repo', async (c) => {
2742
4364
  const { root: repoRoot } = c.get('project');
2743
4365
  const info = await getRepoInfo(repoRoot);
2744
4366
  if (!info)
@@ -2762,64 +4384,115 @@ export function createApp(deps) {
2762
4384
  branches,
2763
4385
  baseBranch: config.baseBranch ?? null,
2764
4386
  });
4387
+ })
4388
+ .get('/repo/diff', async (c) => {
4389
+ const { root: repoRoot } = c.get('project');
4390
+ const info = await getRepoInfo(repoRoot);
4391
+ if (!info)
4392
+ return c.text('not a git repository');
4393
+ return c.text(await getDiff(info.root));
4394
+ })
4395
+ // One commit's message + stat + patch — the Repo view expands it inline.
4396
+ // `?structured=1` is the ADDITIVE sibling (R5 Step 1.7): the new repo view's commit-diff
4397
+ // shape `{sha, subject, author, when, files, stat}` with 409 + reason on failure. The
4398
+ // legacy text answer below is a protected surface (BACKWARD_COMPATIBILITY.md §2) — its
4399
+ // shape, including the in-band failure sentences, stays exactly as it was.
4400
+ //
4401
+ // `Accept: application/json` reaches the same structured answer without the flag, and
4402
+ // `Accept: text/plain` asks for the blob; the flag still wins whenever it is present, and a
4403
+ // request with no opinion still gets the blob. See `negotiate`.
4404
+ .get('/repo/commit/:sha', queryZodValidator(z.object({ structured: queryValue })), async (c) => {
4405
+ const { root: repoRoot } = c.get('project');
4406
+ c.header('vary', 'Accept');
4407
+ const { structured } = c.req.valid('query');
4408
+ const wantsJson = structured !== undefined
4409
+ ? structured === '1'
4410
+ : negotiate(c.req.header('accept'), COMMIT_FORMATS) === 'application/json';
4411
+ const info = await getRepoInfo(repoRoot);
4412
+ if (wantsJson) {
4413
+ if (!info)
4414
+ return c.json({ error: 'not a git repository' }, 409);
4415
+ const result = await collectCommitChanges(info.root, c.req.param('sha'));
4416
+ if (!result.ok)
4417
+ return c.json({ error: result.error }, 409);
4418
+ return c.json(result.commit);
4419
+ }
4420
+ if (!info)
4421
+ return c.text('not a git repository');
4422
+ try {
4423
+ return c.text(await getCommit(info.root, c.req.param('sha')));
4424
+ }
4425
+ catch (err) {
4426
+ return c.text(`(git show failed: ${err instanceof Error ? err.message : String(err)})`);
4427
+ }
4428
+ })
4429
+ // Structured sibling of the text-blob /api/repo/diff above (protected
4430
+ // surface, untouched): the same {files, stat} shape the session /changes
4431
+ // route serves, here for the MAIN working tree's uncommitted changes vs
4432
+ // HEAD (redesign R5 Step 1.3 — §"Git/session API additions").
4433
+ .get('/repo/changes', async (c) => {
4434
+ const { root: repoRoot } = c.get('project');
4435
+ const info = await getRepoInfo(repoRoot);
4436
+ if (!info)
4437
+ return c.json({ error: 'not a git repository' }, 409);
4438
+ // The user's REAL working tree — never stage into their index (a GET must not write).
4439
+ const result = await collectChanges(info.root, 'HEAD', {
4440
+ intentToAdd: false,
4441
+ });
4442
+ if (!result.ok)
4443
+ return c.json({ error: result.error }, 409);
4444
+ return c.json(result.changes);
4445
+ })
4446
+ .post('/repo/branch', jsonZodValidator(() => repoBranchSchema), async (c) => {
4447
+ const { root: repoRoot } = c.get('project');
4448
+ const info = await getRepoInfo(repoRoot);
4449
+ if (!info)
4450
+ return c.json({ error: 'not a git repository' }, 409);
4451
+ const parsed = { data: c.req.valid('json') };
4452
+ const result = await createOrSwitchBranch(info.root, parsed.data.name, parsed.data.from);
4453
+ if (!result.ok)
4454
+ return c.json({ error: result.error }, 409);
4455
+ return c.json({ branch: result.branch, created: result.created });
2765
4456
  });
2766
4457
  // The Settings → Agents knobs in one read (R6 Step 1.5) — an ADDITIVE
2767
4458
  // sibling of PUT /api/config below; /api/health keeps its protected shape.
2768
- const configAnswer = (config) => ({
2769
- baseBranch: config.baseBranch ?? null,
2770
- defaultRunner: config.defaultRunner,
2771
- systemPrompt: config.systemPrompt ?? null,
2772
- defaultModels: config.defaultModels ?? {},
2773
- maxParallel: config.maxParallel,
2774
- memoryLimitMb: config.memoryLimitMb ?? null,
2775
- // Count-based worktree retention (#483): keep the last N finished worktrees
2776
- // on disk. 0 = unlimited. Always materialized (schema default 10).
2777
- worktreeRetention: config.worktreeRetention,
2778
- // Live title updates (task auto-naming spec): tri-state — null means "no
2779
- // config key, the CEZ_TITLE_UPDATES env default (ON) decides".
2780
- liveTitleUpdates: config.liveTitleUpdates ?? null,
2781
- // Optional review gate (#489): tri-state — null means "no config key, the
2782
- // CEZ_REVIEW_GATE env default (OFF) decides".
2783
- reviewGate: config.reviewGate ?? null,
2784
- });
2785
- api.get('/config', async (c) => c.json(configAnswer(await loadConfig(c.get('project').root))));
2786
- // Set/clear the agents' config knobs (Settings → Agents; the Repo tab's
2787
- // base-branch picker). Merges into the RAW config.json so user keys
2788
- // (skillsRepos…) survive and schema defaults are never materialized into
2789
- // the file. All fields optional + additive: `null` (and `''` for the
2790
- // R6 keys) clears a knob back to its default.
2791
- const modelPresetSchema = z.string().trim().max(200).nullable().optional();
2792
- const setConfigSchema = z.object({
2793
- baseBranch: z.string().trim().min(1).max(200).nullable().optional(),
2794
- defaultRunner: z.enum(['claude', 'codex', 'opencode']).optional(),
2795
- systemPrompt: z.string().trim().max(20_000, 'systemPrompt must be at most 20000 characters').nullable().optional(),
2796
- defaultModels: z
2797
- .object({
2798
- claude: modelPresetSchema,
2799
- codex: modelPresetSchema,
2800
- opencode: modelPresetSchema,
2801
- })
2802
- .optional(),
2803
- // Concurrency + memory guard (Settings → Resources). maxParallel clamps to
2804
- // the schema's 1–16; memoryLimitMb null/0 clears the ceiling.
2805
- maxParallel: z.number().int().min(1).max(16).optional(),
2806
- memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
2807
- // Worktree retention count (Settings → Resources, #483). 0 = unlimited;
2808
- // null clears the key back to the schema default (10). Unlike memoryLimitMb,
2809
- // 0 is a meaningful value (unlimited), so it is stored, not treated as clear.
2810
- worktreeRetention: z.number().int().min(0).max(1000).nullable().optional(),
2811
- // Live title updates toggle (Settings → Agents): null clears the key back
2812
- // to the env-default behavior.
2813
- liveTitleUpdates: z.boolean().nullable().optional(),
2814
- // Optional review gate toggle (Settings → Agents, #489): null clears the key
2815
- // back to the env-default behavior (OFF).
2816
- reviewGate: z.boolean().nullable().optional(),
2817
- });
2818
- api.put('/config', async (c) => {
4459
+ const configAnswer = async (repoRoot, config) => {
4460
+ const nativeModels = await readAgentModelDefaults(repoRoot);
4461
+ const modelsLocked = agentModelsLocked(repoRoot);
4462
+ return {
4463
+ baseBranch: config.baseBranch ?? null,
4464
+ defaultRunner: config.defaultRunner,
4465
+ systemPrompt: config.systemPrompt ?? null,
4466
+ // Native defaults seed each runner independently. A Cezar preset remains
4467
+ // selectable unless the operator opts into the fixed-model policy.
4468
+ defaultModels: modelsLocked
4469
+ ? nativeModels
4470
+ : { ...nativeModels, ...(config.defaultModels ?? {}) },
4471
+ modelsLocked,
4472
+ maxParallel: config.maxParallel,
4473
+ memoryLimitMb: config.memoryLimitMb ?? null,
4474
+ // Count-based worktree retention (#483): keep the last N finished worktrees
4475
+ // on disk. 0 = unlimited. Always materialized (schema default 10).
4476
+ worktreeRetention: config.worktreeRetention,
4477
+ // Live title updates (task auto-naming spec): tri-state — null means "no
4478
+ // config key, the CEZ_TITLE_UPDATES env default (ON) decides".
4479
+ liveTitleUpdates: config.liveTitleUpdates ?? null,
4480
+ // Optional review gate (#489): tri-state — null means "no config key, the
4481
+ // CEZ_REVIEW_GATE env default (OFF) decides".
4482
+ reviewGate: config.reviewGate ?? null,
4483
+ };
4484
+ };
4485
+ // ---- chained family: per-repo config (project-scoped) ----
4486
+ const configRoutes = new Hono()
4487
+ .get('/config', async (c) => {
4488
+ const repoRoot = c.get('project').root;
4489
+ return c.json(await configAnswer(repoRoot, await loadConfig(repoRoot)));
4490
+ })
4491
+ .put('/config', jsonZodValidator(() => setConfigSchema), async (c) => {
2819
4492
  const { root: repoRoot, dataDir } = c.get('project');
2820
- const parsed = setConfigSchema.safeParse(await c.req.json().catch(() => null));
2821
- if (!parsed.success) {
2822
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
4493
+ const parsed = { data: c.req.valid('json') };
4494
+ if (agentModelsLocked(repoRoot) && parsed.data.defaultModels !== undefined) {
4495
+ return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
2823
4496
  }
2824
4497
  const configPath = join(dataDir, 'config.json');
2825
4498
  let raw = {};
@@ -2905,16 +4578,56 @@ export function createApp(deps) {
2905
4578
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2906
4579
  }
2907
4580
  // Pre-R6 answer shape ({baseBranch, defaultRunner}) + additive R6 fields.
2908
- return c.json(configAnswer(await loadConfig(repoRoot)));
4581
+ return c.json(await configAnswer(repoRoot, await loadConfig(repoRoot)));
4582
+ });
4583
+ // Set/clear the agents' config knobs (Settings → Agents; the Repo tab's
4584
+ // base-branch picker). Merges into the RAW config.json so user keys
4585
+ // (skillsRepos…) survive and schema defaults are never materialized into
4586
+ // the file. All fields optional + additive: `null` (and `''` for the
4587
+ // R6 keys) clears a knob back to its default.
4588
+ const modelPresetSchema = z.string().trim().max(200).nullable().optional();
4589
+ const setConfigSchema = z.object({
4590
+ baseBranch: z.string().trim().min(1).max(200).nullable().optional(),
4591
+ defaultRunner: z.enum(RUNNER_IDS).optional(),
4592
+ systemPrompt: z.string().trim().max(20_000, 'must be at most 20000 characters').nullable().optional(),
4593
+ defaultModels: z
4594
+ .object({
4595
+ claude: modelPresetSchema,
4596
+ codex: modelPresetSchema,
4597
+ opencode: modelPresetSchema,
4598
+ pi: modelPresetSchema,
4599
+ })
4600
+ .optional(),
4601
+ // Concurrency + memory guard (Settings → Resources). maxParallel clamps to
4602
+ // the schema's 1–16; memoryLimitMb null/0 clears the ceiling.
4603
+ maxParallel: z.number().int().min(1).max(16).optional(),
4604
+ memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
4605
+ // Worktree retention count (Settings → Resources, #483). 0 = unlimited;
4606
+ // null clears the key back to the schema default (10). Unlike memoryLimitMb,
4607
+ // 0 is a meaningful value (unlimited), so it is stored, not treated as clear.
4608
+ worktreeRetention: z.number().int().min(0).max(1000).nullable().optional(),
4609
+ // Live title updates toggle (Settings → Agents): null clears the key back
4610
+ // to the env-default behavior.
4611
+ liveTitleUpdates: z.boolean().nullable().optional(),
4612
+ // Optional review gate toggle (Settings → Agents, #489): null clears the key
4613
+ // back to the env-default behavior (OFF).
4614
+ reviewGate: z.boolean().nullable().optional(),
4615
+ });
4616
+ const setAgentConfigSchema = z.object({
4617
+ content: z.string().max(2_000_000),
4618
+ version: z.string().nullable(),
2909
4619
  });
4620
+ // ---- chained family: agent-config (project-scoped) -----------------------
2910
4621
  // Agent config is project-scoped (spec #404, adapted to the multi-project
2911
4622
  // route seam from #521): handlers resolve the selected repo through the
2912
- // same ProjectContext as every other mirrored route.
2913
- api.get('/agent-config', async (c) => {
4623
+ // same ProjectContext as every other mirrored route. Chained (not statements)
4624
+ // so `AppType` carries it — see `healthRoutes`.
4625
+ const agentConfigRoutes = new Hono()
4626
+ .get('/agent-config', async (c) => {
2914
4627
  const editable = capabilities().localHandoff;
2915
4628
  return c.json(await listAgentConfig(c.get('project').root, process.env, editable));
2916
- });
2917
- api.get('/agent-config/:id', async (c) => {
4629
+ })
4630
+ .get('/agent-config/:id', async (c) => {
2918
4631
  const id = c.req.param('id');
2919
4632
  const def = findConfigFile(id);
2920
4633
  if (!def)
@@ -2930,12 +4643,8 @@ export function createApp(deps) {
2930
4643
  if ('error' in read)
2931
4644
  return c.json({ error: read.error }, 500);
2932
4645
  return c.json(read);
2933
- });
2934
- const setAgentConfigSchema = z.object({
2935
- content: z.string().max(2_000_000),
2936
- version: z.string().nullable(),
2937
- });
2938
- api.put('/agent-config/:id', async (c) => {
4646
+ })
4647
+ .put('/agent-config/:id', jsonZodValidator(setAgentConfigSchema), async (c) => {
2939
4648
  // Config files may define hooks and MCP commands, so writes remain a
2940
4649
  // local-machine capability and are re-gated on every request.
2941
4650
  if (!capabilities().localHandoff) {
@@ -2943,10 +4652,7 @@ export function createApp(deps) {
2943
4652
  error: 'editing agent config is disabled in hosted mode (CEZ_REMOTE) — edit it from the machine that owns the checkout',
2944
4653
  }, 409);
2945
4654
  }
2946
- const parsed = setAgentConfigSchema.safeParse(await c.req.json().catch(() => null));
2947
- if (!parsed.success) {
2948
- return c.json({ error: parsed.error.issues.map((issue) => issue.message).join('; ') }, 400);
2949
- }
4655
+ const parsed = { data: c.req.valid('json') };
2950
4656
  const out = await writeConfigFile(c.req.param('id'), parsed.data.content, parsed.data.version, c.get('project').root);
2951
4657
  if (out === null)
2952
4658
  return c.json({ error: 'unknown config file' }, 404);
@@ -2954,55 +4660,6 @@ export function createApp(deps) {
2954
4660
  return c.json({ error: out.error }, out.status);
2955
4661
  return c.json(out.read);
2956
4662
  });
2957
- api.get('/repo/diff', async (c) => {
2958
- const { root: repoRoot } = c.get('project');
2959
- const info = await getRepoInfo(repoRoot);
2960
- if (!info)
2961
- return c.text('not a git repository');
2962
- return c.text(await getDiff(info.root));
2963
- });
2964
- // One commit's message + stat + patch — the Repo view expands it inline.
2965
- // `?structured=1` is the ADDITIVE sibling (R5 Step 1.7): the new repo view's commit-diff
2966
- // shape `{sha, subject, author, when, files, stat}` with 409 + reason on failure. The
2967
- // legacy text answer below is a protected surface (BACKWARD_COMPATIBILITY.md §2) — its
2968
- // shape, including the in-band failure sentences, stays exactly as it was.
2969
- api.get('/repo/commit/:sha', async (c) => {
2970
- const { root: repoRoot } = c.get('project');
2971
- const info = await getRepoInfo(repoRoot);
2972
- if (c.req.query('structured') === '1') {
2973
- if (!info)
2974
- return c.json({ error: 'not a git repository' }, 409);
2975
- const result = await collectCommitChanges(info.root, c.req.param('sha'));
2976
- if (!result.ok)
2977
- return c.json({ error: result.error }, 409);
2978
- return c.json(result.commit);
2979
- }
2980
- if (!info)
2981
- return c.text('not a git repository');
2982
- try {
2983
- return c.text(await getCommit(info.root, c.req.param('sha')));
2984
- }
2985
- catch (err) {
2986
- return c.text(`(git show failed: ${err instanceof Error ? err.message : String(err)})`);
2987
- }
2988
- });
2989
- // Structured sibling of the text-blob /api/repo/diff above (protected
2990
- // surface, untouched): the same {files, stat} shape the session /changes
2991
- // route serves, here for the MAIN working tree's uncommitted changes vs
2992
- // HEAD (redesign R5 Step 1.3 — §"Git/session API additions").
2993
- api.get('/repo/changes', async (c) => {
2994
- const { root: repoRoot } = c.get('project');
2995
- const info = await getRepoInfo(repoRoot);
2996
- if (!info)
2997
- return c.json({ error: 'not a git repository' }, 409);
2998
- // The user's REAL working tree — never stage into their index (a GET must not write).
2999
- const result = await collectChanges(info.root, 'HEAD', {
3000
- intentToAdd: false,
3001
- });
3002
- if (!result.ok)
3003
- return c.json({ error: result.error }, 409);
3004
- return c.json(result.changes);
3005
- });
3006
4663
  // Repo view branch actions: switch to an existing branch, or create one
3007
4664
  // (from `from` or HEAD) and switch. Predictable git failures — invalid
3008
4665
  // name, unknown `from`, dirty-tree checkout conflict — are 409 + reason.
@@ -3010,28 +4667,165 @@ export function createApp(deps) {
3010
4667
  name: z.string().trim().min(1).max(200),
3011
4668
  from: z.string().trim().min(1).max(200).optional(),
3012
4669
  });
3013
- api.post('/repo/branch', async (c) => {
3014
- const { root: repoRoot } = c.get('project');
3015
- const info = await getRepoInfo(repoRoot);
3016
- if (!info)
3017
- return c.json({ error: 'not a git repository' }, 409);
3018
- const parsed = repoBranchSchema.safeParse(await c.req.json().catch(() => null));
3019
- if (!parsed.success) {
3020
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
4670
+ // ---- assemble the chained families --------------------------------------
4671
+ // Every chained family is registered ONCE and mounted into the versioned table. There is no
4672
+ // second, unversioned spelling: `/api/*` was removed once the whole API was reachable under
4673
+ // `/api/v1` (BACKWARD_COMPATIBILITY.md §2). One surface means one thing to keep working, and
4674
+ // it is the one the typed client describes.
4675
+ //
4676
+ // MOUNT ORDER IS REGISTRATION ORDER. Hono matches in the order routes were added, so each
4677
+ // family keeps its internal order and the families keep theirs relative to each other.
4678
+ //
4679
+ // Written as ONE chained expression because that is the only shape Hono can infer route types
4680
+ // from — it is what puts these routes in `AppType`, and so in the typed client.
4681
+ const v1 = new Hono()
4682
+ .use('*', resolveProjectScope)
4683
+ .route('/', launchKeyRoutes)
4684
+ .route('/', skillsRoutes)
4685
+ .route('/', uiStateRoutes)
4686
+ .route('/', workflowsRoutes)
4687
+ .route('/', planRoutes)
4688
+ .route('/', automationsRoutes)
4689
+ .route('/', runsRoutes)
4690
+ .route('/', groupsRoutes)
4691
+ .route('/', openTargetsRoutes)
4692
+ .route('/', worktreesRoutes)
4693
+ .route('/', todosRoutes)
4694
+ .route('/', sseRoutes)
4695
+ .route('/', githubRoutes)
4696
+ .route('/', repoRoutes)
4697
+ .route('/', configRoutes)
4698
+ .route('/', agentConfigRoutes);
4699
+ // ---- chained family: the cross-project run index (workspace-level) -------
4700
+ /**
4701
+ * How many runs each project may contribute, newest first. The index is a FINDER, not a
4702
+ * listing: past the newest couple of hundred per project you are looking for something the
4703
+ * project's own Tasks table answers better, and every extra row is a DOM node the palette's
4704
+ * filter walks on each keystroke. `truncated` names the projects this bit, so a consumer never
4705
+ * has to pretend the list is complete.
4706
+ */
4707
+ const RUNS_INDEX_PER_PROJECT = 200;
4708
+ /** `RunRecord` → the wire row. Optional keys are spread CONDITIONALLY: writing
4709
+ * `titleSummary: run.titleSummary` types a key as always-present that `JSON.stringify` then
4710
+ * drops when it is undefined, which is exactly the drift the parity guard fails on. */
4711
+ const runIndexEntry = (projectId, run) => {
4712
+ const usage = currentUsage(run.id);
4713
+ return {
4714
+ projectId,
4715
+ id: run.id,
4716
+ title: run.title,
4717
+ ...(run.titleSummary !== undefined ? { titleSummary: run.titleSummary } : {}),
4718
+ ...(run.titleOrigin !== undefined ? { titleOrigin: run.titleOrigin } : {}),
4719
+ status: run.status,
4720
+ ...(run.activity !== undefined ? { activity: run.activity } : {}),
4721
+ createdAt: run.createdAt,
4722
+ ...(run.finishedAt !== undefined ? { finishedAt: run.finishedAt } : {}),
4723
+ ...(run.seenAt !== undefined ? { seenAt: run.seenAt } : {}),
4724
+ archived: run.archived,
4725
+ ...(run.autoResumeAt !== undefined ? { autoResumeAt: run.autoResumeAt } : {}),
4726
+ workflow: run.workflow,
4727
+ ...(run.branch !== undefined ? { branch: run.branch } : {}),
4728
+ ...(run.startedAt !== undefined ? { startedAt: run.startedAt } : {}),
4729
+ // The tracker-reference inputs, verbatim — the cockpit's `taskReference()` owns the rule
4730
+ // that picks between them (see the schema's note).
4731
+ ...(run.pullRequestUrl !== undefined ? { pullRequestUrl: run.pullRequestUrl } : {}),
4732
+ ...(run.referencedPullRequestUrl !== undefined
4733
+ ? { referencedPullRequestUrl: run.referencedPullRequestUrl }
4734
+ : {}),
4735
+ ...(run.prNumber !== undefined ? { prNumber: run.prNumber } : {}),
4736
+ ...(run.issueNumber !== undefined ? { issueNumber: run.issueNumber } : {}),
4737
+ ...(run.referencedIssueUrl !== undefined ? { referencedIssueUrl: run.referencedIssueUrl } : {}),
4738
+ ...(run.markerRefs !== undefined ? { markerRefs: run.markerRefs } : {}),
4739
+ ...(run.costUsd !== undefined ? { costUsd: run.costUsd } : {}),
4740
+ ...(run.peakRssBytes !== undefined ? { peakRssBytes: run.peakRssBytes } : {}),
4741
+ ...(run.peakProcCount !== undefined ? { peakProcCount: run.peakProcCount } : {}),
4742
+ // The live sample, on the same terms as `GET /runs`: process-wide sampler, so a
4743
+ // workspace-level answer can carry it for every project's runs at once.
4744
+ ...(usage ? { usage } : {}),
4745
+ };
4746
+ };
4747
+ /**
4748
+ * `GET /workspace/runs-index` — every registered project's recent tasks in one slim answer, so
4749
+ * ⌘K can find a task without knowing which project it lives in.
4750
+ *
4751
+ * Workspace-level and single-mount for the obvious reason: a project-scoped spelling of "all
4752
+ * projects" is a contradiction. The per-project source is chosen the same way the automation
4753
+ * coordinator's boot fan-out chooses it (`bootContext` for the boot project, `contexts.peek`
4754
+ * for anything this process already owns, disk otherwise) — and never `contexts.context()`,
4755
+ * which would build a context, prune worktrees and `recover()` running agents. Typing in a
4756
+ * search box must not resume work; see `runs/run-index.ts`.
4757
+ */
4758
+ const runsIndexRoutes = new Hono()
4759
+ .get('/workspace/runs-index', async (c) => {
4760
+ let projects = [];
4761
+ try {
4762
+ const selector = capabilities().singleProject
4763
+ ? { projectId: await resolveBootProject() }
4764
+ : undefined;
4765
+ projects = await listProjects(selector);
3021
4766
  }
3022
- const result = await createOrSwitchBranch(info.root, parsed.data.name, parsed.data.from);
3023
- if (!result.ok)
3024
- return c.json({ error: result.error }, 409);
3025
- return c.json({ branch: result.branch, created: result.created });
4767
+ catch {
4768
+ // unreadable workspace — an empty index, never a 500. The palette degrades to the
4769
+ // active project's own run list, which it holds either way.
4770
+ }
4771
+ const bootId = await resolveBootProject(projects);
4772
+ const runs = [];
4773
+ const truncated = [];
4774
+ for (const project of projects) {
4775
+ // No folder, no runs to read. `not-git` still has an `.ai/cezar` worth indexing.
4776
+ if (project.status === 'missing')
4777
+ continue;
4778
+ const owned = project.id === bootId ? bootContext : contexts.peek(project.id);
4779
+ // `listRuns()` already sorts newest-first; the disk reader returns file order, so both
4780
+ // paths get sorted below rather than trusting either.
4781
+ //
4782
+ // Archived runs are INCLUDED. The active project's rows reach the palette through
4783
+ // `GET /runs`, which has always carried them, and excluding them here would mean a task
4784
+ // is findable while you stand in its project and vanishes the moment you leave — the
4785
+ // exact asymmetry a cross-project finder exists to remove.
4786
+ const recent = (owned ? owned.store.listRuns() : readRunIndexFromDisk(join(project.root, '.ai/cezar'))).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
4787
+ if (recent.length > RUNS_INDEX_PER_PROJECT)
4788
+ truncated.push(project.id);
4789
+ for (const run of recent.slice(0, RUNS_INDEX_PER_PROJECT)) {
4790
+ runs.push(runIndexEntry(project.id, run));
4791
+ }
4792
+ }
4793
+ runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
4794
+ const body = {
4795
+ runs,
4796
+ perProjectLimit: RUNS_INDEX_PER_PROJECT,
4797
+ truncated,
4798
+ };
4799
+ return c.json(body);
3026
4800
  });
3027
- // ---- mount the mirrored table (multi-project spec, step 2.2) -------------
3028
- // Scoped first, then the legacy aliases. The paths are disjoint (no legacy
3029
- // route starts with `/p/`), so order between the two mounts never decides a
3030
- // match — but the catch-all below must still come last. `route()` re-registers
3031
- // the sub-app's routes under each prefix, handlers shared, internal order
3032
- // (e.g. `/runs/archive-finished` before `/runs/:id/archive`) preserved.
3033
- app.route(SCOPED_PREFIX, api);
3034
- app.route('/api', api);
4801
+ // Workspace-level families answer for the whole workspace, so they are single-mount: never a
4802
+ // project-scoped spelling, which would be a second surface to protect with no consumer.
4803
+ const workspaceV1 = new Hono()
4804
+ .route('/', healthRoutes)
4805
+ .route('/', modelsRoutes)
4806
+ .route('/', providersRoutes)
4807
+ .route('/', projectsRoutes)
4808
+ .route('/', agentProfilesRoutes)
4809
+ .route('/', skillsUpdateRoutes)
4810
+ .route('/', workspaceConfigRoutes)
4811
+ .route('/', fsBrowseRoutes)
4812
+ .route('/', automationChecksRoutes)
4813
+ .route('/', runsIndexRoutes)
4814
+ .route('/', workspaceEventsRoutes);
4815
+ // ---- mount ---------------------------------------------------------------
4816
+ // Scoped first, then the unscoped alias bound to the boot project. The paths are disjoint (no
4817
+ // route starts with `/p/`), so order between the two never decides a match — but the SPA
4818
+ // catch-all below must still come last. `route()` re-registers the sub-app's routes under
4819
+ // each prefix, handlers shared, internal order preserved.
4820
+ //
4821
+ // Workspace families mount LAST and that is load-bearing: mounting the project table also
4822
+ // mounts its `use('*')` scope resolver over the whole prefix, and Hono runs matched middleware
4823
+ // in registration order. `/health` in particular answers for the workspace, has no project to
4824
+ // resolve, and is the CORS-open discovery route — it must not sit behind the resolver.
4825
+ const routed = app
4826
+ .route(V1_SCOPED_PREFIX, v1)
4827
+ .route(V1_PREFIX, v1)
4828
+ .route(V1_PREFIX, workspaceV1);
3035
4829
  // ---- SPA catch-all -------------------------------------------------------
3036
4830
  // Last, so every route above still wins. Any other GET gets the cockpit shell:
3037
4831
  // react-router owns the route map, including the 404, so `/tasks/:id/changes`
@@ -3040,14 +4834,38 @@ export function createApp(deps) {
3040
4834
  // an unknown API path must never answer with HTML.
3041
4835
  // Without a web/dist build this serves the built-in build-hint page (dev-only
3042
4836
  // state — the published tarball ships web/dist), never a 404.
3043
- app.get('*', (c) => serveShell(c) ?? c.notFound());
3044
- return app;
4837
+ routed.get('*', (c) => serveShell(c) ?? c.notFound());
4838
+ return routed;
3045
4839
  }
3046
4840
  export function startServer(deps, port) {
4841
+ const workspaceEvents = deps.workspaceEvents ?? new WorkspaceEventBus();
4842
+ const skillsUpdate = deps.skillsUpdate ?? new SkillsUpdateService({ invalidateCatalog: refreshTeamSkills });
3047
4843
  // The subscription hub rides the same HTTP server (one port, zero config):
3048
4844
  // createApp registers the topics, the `upgrade` hook below owns the socket.
3049
4845
  const socketHub = deps.socketHub ?? createSocketHub();
3050
- const app = createApp({ ...deps, socketHub });
4846
+ const automationCoordinator = new AutomationCoordinator({ listProjects });
4847
+ const bootProjectId = deps.bootProjectId ?? 'default';
4848
+ const bootAutomationStore = automationCoordinator.store(bootProjectId, deps.repoRoot);
4849
+ const sharedContexts = deps.contexts ?? new ProjectContexts({
4850
+ listProjects,
4851
+ semaphore: deps.semaphore,
4852
+ automationStore: (projectId, root) => automationCoordinator.store(projectId, root),
4853
+ });
4854
+ // #801: GitHub automations are opt-in. Off, the flag must remove the BEHAVIOR and not merely
4855
+ // the UI — no scheduler, no GitHub polling, no launched runs — so every entry point into the
4856
+ // workspace scheduler below is gated on it. Read per call rather than captured, for the same
4857
+ // reason `capabilities()` is inside `createApp`: tests flip the variable between apps.
4858
+ const automationsEnabled = () => resolveCapabilities(process.env, deps.bindHost).automations;
4859
+ let rescheduleAutomations = () => { };
4860
+ const app = createApp({
4861
+ ...deps,
4862
+ contexts: sharedContexts,
4863
+ automationStore: bootAutomationStore,
4864
+ workspaceEvents,
4865
+ skillsUpdate,
4866
+ socketHub,
4867
+ automationsChanged: () => rescheduleAutomations(),
4868
+ });
3051
4869
  // SECURITY: default to loopback. This server executes agents locally and its endpoints are
3052
4870
  // same-origin-trusted (only /api/health is CORS-open); binding to a non-loopback host would
3053
4871
  // expose an agent-executing box to the network. `bindHost` exists only for a deliberate
@@ -3058,13 +4876,101 @@ export function startServer(deps, port) {
3058
4876
  port,
3059
4877
  hostname: deps.bindHost ?? '127.0.0.1',
3060
4878
  });
4879
+ const coordinator = new SkillsUpdateCoordinator(skillsUpdate, async () => effectiveSkillsAutoUpdate(await loadWorkspaceConfig()));
4880
+ const automationProjects = new Map();
4881
+ const automationScheduler = new WorkspaceAutomationScheduler({
4882
+ coordinator: automationCoordinator,
4883
+ handle: (projectId, store) => {
4884
+ const project = automationProjects.get(projectId);
4885
+ if (!project)
4886
+ return undefined;
4887
+ return {
4888
+ projectId,
4889
+ owner: project.owner,
4890
+ repo: project.repo,
4891
+ store,
4892
+ poller: new GithubPoller(),
4893
+ onChange: (automationId, revision) => workspaceEvents.emit('automation-change', { project: projectId, automationId, revision }),
4894
+ launch: async (definition, candidate, receiptId) => {
4895
+ const bootId = deps.bootProjectId ?? 'default';
4896
+ const context = projectId === bootId
4897
+ ? { root: deps.repoRoot, manager: deps.manager, store: deps.store }
4898
+ : await sharedContexts.context(projectId);
4899
+ return launchAutomationRun({
4900
+ root: context.root,
4901
+ manager: context.manager,
4902
+ store: context.store,
4903
+ definition,
4904
+ candidate,
4905
+ receiptId,
4906
+ });
4907
+ },
4908
+ };
4909
+ },
4910
+ });
4911
+ // The scheduler is inert until `start()` anyway (it constructs `stopped`), but the gate is
4912
+ // stated here rather than inherited from that detail: a definition saved while the flag is off
4913
+ // must not even ask the coordinator to refresh.
4914
+ rescheduleAutomations = () => {
4915
+ if (!automationsEnabled())
4916
+ return;
4917
+ void automationScheduler.reschedule();
4918
+ };
4919
+ const unsubscribe = workspaceEvents.on((event, data) => {
4920
+ if (event === 'project-added') {
4921
+ const project = data.project;
4922
+ if (project && typeof project.id === 'string' && typeof project.root === 'string' && project.status !== 'missing') {
4923
+ coordinator.add(project.id, project.root);
4924
+ void getRepoInfo(project.root).then((info) => {
4925
+ const parsed = parseRemote(info?.remote ?? '');
4926
+ if (parsed?.host === 'github.com')
4927
+ automationProjects.set(project.id, { root: project.root, owner: parsed.owner, repo: parsed.repo });
4928
+ return rescheduleAutomations();
4929
+ });
4930
+ }
4931
+ }
4932
+ else if (event === 'project-removed') {
4933
+ const id = data.id;
4934
+ if (typeof id === 'string')
4935
+ coordinator.remove(id);
4936
+ if (typeof id === 'string') {
4937
+ automationCoordinator.remove(id);
4938
+ automationProjects.delete(id);
4939
+ rescheduleAutomations();
4940
+ }
4941
+ }
4942
+ });
4943
+ server.once('listening', () => {
4944
+ void listProjects().then((projects) => {
4945
+ const all = projects.some((project) => project.root === deps.repoRoot)
4946
+ ? projects : [{ id: deps.bootProjectId ?? 'default', root: deps.repoRoot, status: 'ok' }, ...projects];
4947
+ coordinator.start(all);
4948
+ // #801: with automations off there is nothing to warm — no remote to resolve, no receipts
4949
+ // to reconcile, and above all no scheduler to start. The skills-update coordinator above is
4950
+ // a separate feature and starts either way.
4951
+ if (!automationsEnabled())
4952
+ return;
4953
+ void Promise.all(all.map(async (project) => {
4954
+ const parsed = parseRemote((await getRepoInfo(project.root))?.remote ?? '');
4955
+ if (parsed?.host === 'github.com')
4956
+ automationProjects.set(project.id, { root: project.root, owner: parsed.owner, repo: parsed.repo });
4957
+ const automationStore = automationCoordinator.store(project.id, project.root);
4958
+ const runStore = project.id === (deps.bootProjectId ?? 'default')
4959
+ ? deps.store
4960
+ : sharedContexts.peek(project.id)?.store;
4961
+ if (automationStore && runStore)
4962
+ reconcileAutomationReceipts(automationStore, runStore);
4963
+ })).then(() => automationScheduler.start()).catch(() => undefined);
4964
+ }).catch(() => undefined);
4965
+ });
4966
+ server.once('close', () => { unsubscribe(); coordinator.stop(); automationScheduler.stop(); });
3061
4967
  socketHub.attach(server, (req) => verifyWsUpgrade(req, deps.bindHost));
3062
4968
  return server;
3063
4969
  }
3064
4970
  /**
3065
4971
  * The WebSocket twin of the `/api/*` request-origin guard (#426), applied
3066
- * before the `/api/ws` handshake. WebSocket is NOT subject to CORS — any web
3067
- * page may open `ws://127.0.0.1:<port>/api/ws` and, unlike a forced HTTP GET,
4972
+ * before the `/api/v1/ws` handshake. WebSocket is NOT subject to CORS — any web
4973
+ * page may open `ws://127.0.0.1:<port>/api/v1/ws` and, unlike a forced HTTP GET,
3068
4974
  * would get to READ what comes back — so this guard is load-bearing:
3069
4975
  *
3070
4976
  * 1. Host allowlist (local mode): a non-loopback Host is a DNS-rebound
@@ -3213,6 +5119,8 @@ export function resumeCommand(runner, sessionId) {
3213
5119
  return `codex resume ${sessionId}`;
3214
5120
  case 'opencode':
3215
5121
  return `opencode --session ${sessionId}`;
5122
+ case 'pi':
5123
+ return `pi --session ${sessionId}`;
3216
5124
  default:
3217
5125
  return `claude --resume ${sessionId}`;
3218
5126
  }