@open-mercato/cezar 0.9.1 → 0.9.2-pr773.1068

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 (358) hide show
  1. package/README.md +29 -11
  2. package/dist/agent-config/account-identity.d.ts +44 -0
  3. package/dist/agent-config/account-identity.js +128 -0
  4. package/dist/agent-config/account-identity.js.map +1 -0
  5. package/dist/agent-config/catalog.d.ts +9 -1
  6. package/dist/agent-config/catalog.js +18 -0
  7. package/dist/agent-config/catalog.js.map +1 -1
  8. package/dist/agent-config/files.d.ts +1 -1
  9. package/dist/agent-config/model-settings/claude.d.ts +2 -0
  10. package/dist/agent-config/model-settings/claude.js +11 -0
  11. package/dist/agent-config/model-settings/claude.js.map +1 -0
  12. package/dist/agent-config/model-settings/codex.d.ts +2 -0
  13. package/dist/agent-config/model-settings/codex.js +16 -0
  14. package/dist/agent-config/model-settings/codex.js.map +1 -0
  15. package/dist/agent-config/model-settings/opencode.d.ts +2 -0
  16. package/dist/agent-config/model-settings/opencode.js +8 -0
  17. package/dist/agent-config/model-settings/opencode.js.map +1 -0
  18. package/dist/agent-config/model-settings/shared.d.ts +9 -0
  19. package/dist/agent-config/model-settings/shared.js +93 -0
  20. package/dist/agent-config/model-settings/shared.js.map +1 -0
  21. package/dist/agent-config/model-settings/types.d.ts +9 -0
  22. package/dist/agent-config/model-settings/types.js +2 -0
  23. package/dist/agent-config/model-settings/types.js.map +1 -0
  24. package/dist/agent-config/models.d.ts +9 -0
  25. package/dist/agent-config/models.js +30 -0
  26. package/dist/agent-config/models.js.map +1 -0
  27. package/dist/agent-config/service.d.ts +1 -1
  28. package/dist/agent-config/service.js +4 -4
  29. package/dist/agent-config/service.js.map +1 -1
  30. package/dist/agent-config/validate.d.ts +1 -1
  31. package/dist/automations/coordinator.d.ts +26 -0
  32. package/dist/automations/coordinator.js +66 -0
  33. package/dist/automations/coordinator.js.map +1 -0
  34. package/dist/automations/github-poller.d.ts +74 -0
  35. package/dist/automations/github-poller.js +234 -0
  36. package/dist/automations/github-poller.js.map +1 -0
  37. package/dist/automations/scheduler.d.ts +42 -0
  38. package/dist/automations/scheduler.js +192 -0
  39. package/dist/automations/scheduler.js.map +1 -0
  40. package/dist/automations/store.d.ts +65 -0
  41. package/dist/automations/store.js +298 -0
  42. package/dist/automations/store.js.map +1 -0
  43. package/dist/automations/task-template.d.ts +18 -0
  44. package/dist/automations/task-template.js +95 -0
  45. package/dist/automations/task-template.js.map +1 -0
  46. package/dist/automations/types.d.ts +251 -0
  47. package/dist/automations/types.js +158 -0
  48. package/dist/automations/types.js.map +1 -0
  49. package/dist/config.d.ts +17 -112
  50. package/dist/config.js +45 -4
  51. package/dist/config.js.map +1 -1
  52. package/dist/contract/agent-config.d.ts +151 -0
  53. package/dist/contract/agent-profiles.d.ts +332 -0
  54. package/dist/contract/automations.d.ts +919 -0
  55. package/dist/contract/events.d.ts +45 -0
  56. package/dist/contract/github.d.ts +467 -0
  57. package/dist/contract/health.d.ts +96 -0
  58. package/dist/contract/index.d.ts +16 -0
  59. package/dist/contract/index.js +1640 -0
  60. package/dist/contract/projects.d.ts +185 -0
  61. package/dist/contract/repo.d.ts +234 -0
  62. package/dist/contract/runs.d.ts +1347 -0
  63. package/dist/contract/skills.d.ts +229 -0
  64. package/dist/contract/workflows.d.ts +212 -0
  65. package/dist/contract/workspace.d.ts +472 -0
  66. package/dist/core/agent-env.d.ts +1 -1
  67. package/dist/core/agent-model-policy.d.ts +10 -0
  68. package/dist/core/agent-model-policy.js +36 -0
  69. package/dist/core/agent-model-policy.js.map +1 -0
  70. package/dist/core/agent-profiles.d.ts +59 -0
  71. package/dist/core/agent-profiles.js +83 -0
  72. package/dist/core/agent-profiles.js.map +1 -0
  73. package/dist/core/agent-runner.d.ts +12 -1
  74. package/dist/core/agent-runner.js +13 -0
  75. package/dist/core/agent-runner.js.map +1 -1
  76. package/dist/core/ask.d.ts +45 -124
  77. package/dist/core/ask.js +103 -13
  78. package/dist/core/ask.js.map +1 -1
  79. package/dist/core/backend-detect.js +7 -2
  80. package/dist/core/backend-detect.js.map +1 -1
  81. package/dist/core/claude-cli-runner.d.ts +2 -2
  82. package/dist/core/claude-cli-runner.js +52 -9
  83. package/dist/core/claude-cli-runner.js.map +1 -1
  84. package/dist/core/claude-ui-mapper.d.ts +1 -1
  85. package/dist/core/codex-app-server-runner.d.ts +1 -1
  86. package/dist/core/codex-app-server-runner.js +60 -3
  87. package/dist/core/codex-app-server-runner.js.map +1 -1
  88. package/dist/core/codex-app-server-transport.d.ts +6 -1
  89. package/dist/core/codex-app-server-transport.js +12 -3
  90. package/dist/core/codex-app-server-transport.js.map +1 -1
  91. package/dist/core/codex-model-catalog.d.ts +1 -1
  92. package/dist/core/codex-ui-mapper.d.ts +5 -1
  93. package/dist/core/codex-ui-mapper.js +74 -17
  94. package/dist/core/codex-ui-mapper.js.map +1 -1
  95. package/dist/core/model-identity.d.ts +13 -6
  96. package/dist/core/model-identity.js +32 -17
  97. package/dist/core/model-identity.js.map +1 -1
  98. package/dist/core/model-presets.d.ts +2 -2
  99. package/dist/core/model-presets.js +1 -1
  100. package/dist/core/opencode-server-runner.d.ts +2 -2
  101. package/dist/core/opencode-ui-mapper.d.ts +4 -1
  102. package/dist/core/opencode-ui-mapper.js +45 -7
  103. package/dist/core/opencode-ui-mapper.js.map +1 -1
  104. package/dist/core/provider-auth.d.ts +154 -0
  105. package/dist/core/provider-auth.js +488 -0
  106. package/dist/core/provider-auth.js.map +1 -0
  107. package/dist/core/provider-availability.d.ts +3 -0
  108. package/dist/core/provider-availability.js +14 -0
  109. package/dist/core/provider-availability.js.map +1 -0
  110. package/dist/core/runner-factory.d.ts +1 -1
  111. package/dist/core/runner-model-catalog.d.ts +1 -1
  112. package/dist/core/shell-env.d.ts +28 -0
  113. package/dist/core/shell-env.js +56 -0
  114. package/dist/core/shell-env.js.map +1 -0
  115. package/dist/core/tool-display.d.ts +1 -1
  116. package/dist/core/ui-events.d.ts +1 -1
  117. package/dist/core/ui-events.js +1 -1
  118. package/dist/core/usage-limit.d.ts +42 -0
  119. package/dist/core/usage-limit.js +209 -0
  120. package/dist/core/usage-limit.js.map +1 -0
  121. package/dist/git-diff-base.d.ts +60 -0
  122. package/dist/git-diff-base.js +54 -0
  123. package/dist/git-diff-base.js.map +1 -0
  124. package/dist/git-worktree.d.ts +37 -6
  125. package/dist/git-worktree.js +39 -9
  126. package/dist/git-worktree.js.map +1 -1
  127. package/dist/handoff.d.ts +2 -2
  128. package/dist/handoff.js +1 -1
  129. package/dist/index.js +65 -8
  130. package/dist/index.js.map +1 -1
  131. package/dist/paths.d.ts +69 -6
  132. package/dist/paths.js +86 -9
  133. package/dist/paths.js.map +1 -1
  134. package/dist/planner.d.ts +2 -2
  135. package/dist/planner.js +7 -1
  136. package/dist/planner.js.map +1 -1
  137. package/dist/release/manifests.d.ts +81 -0
  138. package/dist/release/manifests.js +74 -0
  139. package/dist/release/manifests.js.map +1 -0
  140. package/dist/release/snapshot.d.ts +9 -23
  141. package/dist/release/snapshot.js +9 -22
  142. package/dist/release/snapshot.js.map +1 -1
  143. package/dist/release/stable.d.ts +15 -29
  144. package/dist/release/stable.js +14 -27
  145. package/dist/release/stable.js.map +1 -1
  146. package/dist/runs/auto-name.d.ts +1 -1
  147. package/dist/runs/auto-name.js +6 -1
  148. package/dist/runs/auto-name.js.map +1 -1
  149. package/dist/runs/retention.d.ts +1 -1
  150. package/dist/runs/run-index.d.ts +19 -0
  151. package/dist/runs/run-index.js +43 -0
  152. package/dist/runs/run-index.js.map +1 -0
  153. package/dist/runs/store.d.ts +169 -343
  154. package/dist/runs/store.js +223 -34
  155. package/dist/runs/store.js.map +1 -1
  156. package/dist/runs/ui-event-sink.d.ts +1 -1
  157. package/dist/server/app-type.d.ts +21 -0
  158. package/dist/server/app-type.js +2 -0
  159. package/dist/server/app-type.js.map +1 -0
  160. package/dist/server/capabilities.d.ts +6 -5
  161. package/dist/server/capabilities.js +11 -0
  162. package/dist/server/capabilities.js.map +1 -1
  163. package/dist/server/forge/github.d.ts +58 -151
  164. package/dist/server/forge/github.js +530 -6
  165. package/dist/server/forge/github.js.map +1 -1
  166. package/dist/server/forge/index.d.ts +10 -3
  167. package/dist/server/forge/index.js +14 -1
  168. package/dist/server/forge/index.js.map +1 -1
  169. package/dist/server/forge/types.d.ts +86 -1
  170. package/dist/server/git-changes.d.ts +8 -8
  171. package/dist/server/git-changes.js +14 -17
  172. package/dist/server/git-changes.js.map +1 -1
  173. package/dist/server/git.d.ts +2 -0
  174. package/dist/server/git.js +9 -0
  175. package/dist/server/git.js.map +1 -1
  176. package/dist/server/github.d.ts +3 -3
  177. package/dist/server/github.js +1 -1
  178. package/dist/server/github.js.map +1 -1
  179. package/dist/server/open-in-terminal.d.ts +8 -1
  180. package/dist/server/open-in-terminal.js +16 -8
  181. package/dist/server/open-in-terminal.js.map +1 -1
  182. package/dist/server/pr.d.ts +2 -2
  183. package/dist/server/project-context.d.ts +20 -3
  184. package/dist/server/project-context.js +30 -1
  185. package/dist/server/project-context.js.map +1 -1
  186. package/dist/server/provider-action-gate.d.ts +8 -0
  187. package/dist/server/provider-action-gate.js +54 -0
  188. package/dist/server/provider-action-gate.js.map +1 -0
  189. package/dist/server/provider-auth-runtime.d.ts +21 -0
  190. package/dist/server/provider-auth-runtime.js +66 -0
  191. package/dist/server/provider-auth-runtime.js.map +1 -0
  192. package/dist/server/server.d.ts +12078 -47
  193. package/dist/server/server.js +2700 -910
  194. package/dist/server/server.js.map +1 -1
  195. package/dist/server/validators.d.ts +97 -0
  196. package/dist/server/validators.js +86 -0
  197. package/dist/server/validators.js.map +1 -0
  198. package/dist/server/ws.d.ts +2 -2
  199. package/dist/server/ws.js +2 -2
  200. package/dist/server/ws.js.map +1 -1
  201. package/dist/server-install/engine.d.ts +1 -1
  202. package/dist/server-install/platforms/macosx-ngrok.d.ts +1 -1
  203. package/dist/server-install/platforms/ubuntu-vps.d.ts +16 -1
  204. package/dist/server-install/platforms/ubuntu-vps.js +83 -1
  205. package/dist/server-install/platforms/ubuntu-vps.js.map +1 -1
  206. package/dist/server-install/state.d.ts +1 -1
  207. package/dist/server-install/steps.d.ts +2 -2
  208. package/dist/server-install/strategies.d.ts +1 -1
  209. package/dist/server-install/types.d.ts +38 -424
  210. package/dist/server-install/ui.d.ts +1 -1
  211. package/dist/skills-banner.d.ts +7 -6
  212. package/dist/skills-banner.js +7 -6
  213. package/dist/skills-banner.js.map +1 -1
  214. package/dist/skills-remote.d.ts +2 -2
  215. package/dist/skills-update.d.ts +88 -0
  216. package/dist/skills-update.js +392 -0
  217. package/dist/skills-update.js.map +1 -0
  218. package/dist/skills.d.ts +2 -0
  219. package/dist/skills.js +7 -2
  220. package/dist/skills.js.map +1 -1
  221. package/dist/todos.d.ts +2 -28
  222. package/dist/todos.js +1 -1
  223. package/dist/ui-state.d.ts +16 -2
  224. package/dist/ui-state.js +14 -1
  225. package/dist/ui-state.js.map +1 -1
  226. package/dist/workflows/load.d.ts +1 -1
  227. package/dist/workflows/run.d.ts +238 -12
  228. package/dist/workflows/run.js +1103 -115
  229. package/dist/workflows/run.js.map +1 -1
  230. package/dist/workflows/types.d.ts +59 -224
  231. package/dist/workflows/types.js +14 -0
  232. package/dist/workflows/types.js.map +1 -1
  233. package/dist/workspace/agent-accounts.d.ts +149 -0
  234. package/dist/workspace/agent-accounts.js +303 -0
  235. package/dist/workspace/agent-accounts.js.map +1 -0
  236. package/dist/workspace/agent-profiles.d.ts +78 -0
  237. package/dist/workspace/agent-profiles.js +115 -0
  238. package/dist/workspace/agent-profiles.js.map +1 -0
  239. package/dist/workspace/config.d.ts +82 -206
  240. package/dist/workspace/config.js +183 -14
  241. package/dist/workspace/config.js.map +1 -1
  242. package/dist/workspace/migrations.d.ts +1 -1
  243. package/dist/workspace/migrations.js +19 -4
  244. package/dist/workspace/migrations.js.map +1 -1
  245. package/dist/workspace/projects.d.ts +8 -2
  246. package/dist/workspace/projects.js +9 -3
  247. package/dist/workspace/projects.js.map +1 -1
  248. package/dist/workspace/semaphore.d.ts +45 -0
  249. package/dist/workspace/semaphore.js +45 -1
  250. package/dist/workspace/semaphore.js.map +1 -1
  251. package/dist/workspace/ui-state.d.ts +13 -3
  252. package/dist/workspace/ui-state.js +15 -5
  253. package/dist/workspace/ui-state.js.map +1 -1
  254. package/package.json +17 -39
  255. package/scripts/inline-contract.mjs +112 -0
  256. package/scripts/mock-claude.mjs +59 -1
  257. package/scripts/sync-readme.mjs +20 -0
  258. package/web/dist/assets/alert-dialog-DgGXiX0-.js +1 -0
  259. package/web/dist/assets/arrow-down-CVWn39DB.js +1 -0
  260. package/web/dist/assets/arrow-left-CytehkvJ.js +1 -0
  261. package/web/dist/assets/bundle-mjs-BT31bpU6.js +1 -0
  262. package/web/dist/assets/centered-state-BWu9EZeH.js +43 -0
  263. package/web/dist/assets/chevron-right-Bn2cprQo.js +1 -0
  264. package/web/dist/assets/{chunk-BO2N2NFS-DtrdTCWa.js → chunk-BO2N2NFS-DE6qKn3r.js} +8 -8
  265. package/web/dist/assets/circle-check-C9dg0Lwg.js +1 -0
  266. package/web/dist/assets/circle-x-HJMlr00g.js +1 -0
  267. package/web/dist/assets/collapsible-CW__roLv.js +1 -0
  268. package/web/dist/assets/commit-list-D-ZJT7xL.js +1 -0
  269. package/web/dist/assets/compare-variants-B_P9w6Pa.js +1 -0
  270. package/web/dist/assets/{core-DePtBHcl.js → core-BCsw8oQw.js} +1 -1
  271. package/web/dist/assets/diff-CutJnvjv.js +3 -0
  272. package/web/dist/assets/diff-stat-DFXP90Vc.js +1 -0
  273. package/web/dist/assets/{diff-view-DCeSegKk.js → diff-view-BWuBSVFT.js} +4 -4
  274. package/web/dist/assets/dropdown-menu-D2A2pGjJ.js +1 -0
  275. package/web/dist/assets/editable-title-C6a64Vwp.js +1 -0
  276. package/web/dist/assets/{ellipsis-vertical-CNnSLn5a.js → ellipsis-vertical-vx7lL0mm.js} +1 -1
  277. package/web/dist/assets/{file-D4GBh1ao.js → file-DAIQGWQp.js} +1 -1
  278. package/web/dist/assets/folder-BvhNOUoH.js +1 -0
  279. package/web/dist/assets/{git-pull-request-DnR8Xh4_.js → git-pull-request-CymF90c4.js} +1 -1
  280. package/web/dist/assets/git-toolbar-Db-_fahh.js +1 -0
  281. package/web/dist/assets/github-C3d-_ewX.js +1 -0
  282. package/web/dist/assets/highlighted-body-OFNGDK62-BL-MfFgl.js +1 -0
  283. package/web/dist/assets/highlighter-CdHqIwFr.js +3 -0
  284. package/web/dist/assets/image-preview-DsqvjJhH.js +1 -0
  285. package/web/dist/assets/index-cLI08SdE.css +2 -0
  286. package/web/dist/assets/index-e2WSiiVD.js +6 -0
  287. package/web/dist/assets/lib-BQXq3kEf.js +1 -0
  288. package/web/dist/assets/lib-BxQXEXDF.js +1 -0
  289. package/web/dist/assets/markdown-v5x5LE3s.js +2 -0
  290. package/web/dist/assets/mermaid-GHXKKRXX-C4jxqUUb.js +1 -0
  291. package/web/dist/assets/new-task-form-DAqGlabw.js +1 -0
  292. package/web/dist/assets/pill-AwEgoqpU.js +1 -0
  293. package/web/dist/assets/project-router-CLFUa8om.js +1 -0
  294. package/web/dist/assets/prompt-templates-DhV3gn9A.js +15 -0
  295. package/web/dist/assets/react-runtime-CCIEwYL0.js +9 -0
  296. package/web/dist/assets/{refresh-cw-BT8E96Tm.js → refresh-cw-D3EzHdHY.js} +1 -1
  297. package/web/dist/assets/repo-git-BidLXJPd.js +1 -0
  298. package/web/dist/assets/rolldown-runtime-QTnfLwEv.js +1 -0
  299. package/web/dist/assets/run-diff-D7nsnTW9.js +3 -0
  300. package/web/dist/assets/run-header-CTsQn0Xg.js +1 -0
  301. package/web/dist/assets/{search-x-BDjaMrzG.js → search-x-B5IWjGTy.js} +1 -1
  302. package/web/dist/assets/skill-empty-hint-Cc3J-Vw9.js +1 -0
  303. package/web/dist/assets/skills-BiWfHmCR.js +1 -0
  304. package/web/dist/assets/skills-eu1MYWhp.js +1 -0
  305. package/web/dist/assets/sparkles-CfUE1-C7.js +1 -0
  306. package/web/dist/assets/{square-terminal-Ckjjn0vb.js → square-terminal-D2wOSwur.js} +1 -1
  307. package/web/dist/assets/tab-link-gfQ-aL-o.js +1 -0
  308. package/web/dist/assets/task-changes-5CfU8f_H.js +1 -0
  309. package/web/dist/assets/task-commits-Dav8UN6F.js +1 -0
  310. package/web/dist/assets/task-files-BxmqQ-gr.js +2 -0
  311. package/web/dist/assets/task-thread-DPZQ_MH3.js +9 -0
  312. package/web/dist/assets/textarea-DsfeVQQ3.js +1 -0
  313. package/web/dist/assets/thread-loading-u9UysgWm.js +1 -0
  314. package/web/dist/assets/{trash-2-DAyZ0VSi.js → trash-2-DgRtM_8B.js} +1 -1
  315. package/web/dist/assets/{triangle-alert-Uy8rokUh.js → triangle-alert-rNmHpkue.js} +1 -1
  316. package/web/dist/assets/{upload-CDdyPPo_.js → upload-3tpzQ7VY.js} +1 -1
  317. package/web/dist/assets/use-desktop-D5epLJpa.js +1 -0
  318. package/web/dist/assets/use-submit-shortcut-CNJod3_0.js +1 -0
  319. package/web/dist/assets/utils-CNU1Tppw.js +64 -0
  320. package/web/dist/assets/workflows-DK43e7Ep.js +11 -0
  321. package/web/dist/assets/zoomable-image-DJH8H5T5.js +1 -0
  322. package/web/dist/index.html +33 -13
  323. package/scripts/dev.mjs +0 -74
  324. package/scripts/release-snapshot.mjs +0 -130
  325. package/scripts/release.mjs +0 -119
  326. package/web/dist/assets/arrow-left-CnSdp92h.js +0 -1
  327. package/web/dist/assets/bundle-mjs-BegPhL5c.js +0 -1
  328. package/web/dist/assets/centered-state-DOsTcJZF.js +0 -43
  329. package/web/dist/assets/commit-list-BrxBEzRK.js +0 -1
  330. package/web/dist/assets/compare-variants-Bv2c9ORF.js +0 -1
  331. package/web/dist/assets/dist-B6XQOhgM.js +0 -1
  332. package/web/dist/assets/git-toolbar-pIC1ubPM.js +0 -1
  333. package/web/dist/assets/github-DTTSG0ht.js +0 -1
  334. package/web/dist/assets/highlighted-body-OFNGDK62-BuFJwunK.js +0 -1
  335. package/web/dist/assets/image-preview-DEhW0TFz.js +0 -1
  336. package/web/dist/assets/index-Cijmlz7q.js +0 -30
  337. package/web/dist/assets/index-DzroxqBG.css +0 -2
  338. package/web/dist/assets/lib-DPEZDBUN.js +0 -1
  339. package/web/dist/assets/lib-uHpeUPzK.js +0 -1
  340. package/web/dist/assets/mermaid-GHXKKRXX-CN2zQue0.js +0 -1
  341. package/web/dist/assets/open-mercato-toBr6SOa.svg +0 -11
  342. package/web/dist/assets/project-router-BMx4e8m-.js +0 -1
  343. package/web/dist/assets/repo-git-BusYfa3t.js +0 -1
  344. package/web/dist/assets/run-diff-wOJ3odA-.js +0 -3
  345. package/web/dist/assets/run-header-Cg5incil.js +0 -1
  346. package/web/dist/assets/skill-empty-hint-DTUSp6Vl.js +0 -1
  347. package/web/dist/assets/skills-PpP2owbM.js +0 -1
  348. package/web/dist/assets/tab-link-F3uQrdm2.js +0 -1
  349. package/web/dist/assets/task-changes-iD9kS79I.js +0 -1
  350. package/web/dist/assets/task-commits-COkLaUri.js +0 -1
  351. package/web/dist/assets/task-files-Ckttz7XH.js +0 -2
  352. package/web/dist/assets/task-thread-CQD91VPt.js +0 -9
  353. package/web/dist/assets/textarea-Bj_LCreg.js +0 -1
  354. package/web/dist/assets/use-desktop-DUh0STzq.js +0 -3
  355. package/web/dist/assets/utils-B8s5qIcK.js +0 -1
  356. package/web/dist/assets/workflows-BFk8C0co.js +0 -11
  357. package/web/dist/assets/zoomable-image-PEDOqyLQ.js +0 -1
  358. /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,19 +13,25 @@ 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';
11
19
  import { detectEnvironment } from '../core/backend-detect.js';
20
+ import { AGENT_MODELS_LOCKED_ERROR, agentModelsLocked } from '../core/agent-model-policy.js';
12
21
  import { discoverCodexModels } from '../core/codex-model-catalog.js';
22
+ import { PROVIDER_IDS, ProviderAuthService, providerAuthChecksDisabled, } from '../core/provider-auth.js';
23
+ import { applyProviderEnablement } from '../core/provider-availability.js';
13
24
  import { RunnerModelCatalog } from '../core/runner-model-catalog.js';
14
25
  import { currentUsage, onUsage } from '../core/process-usage.js';
15
26
  import { WORKFLOWS_DIR, loadWorkflows } from '../workflows/load.js';
16
27
  import { QUICK_TASK_WORKFLOW, normalizeWorkflowDoc, skillStackOf, skillsToSteps, stepsIssue, workflowFileSchema, workflowStepSchema, } from '../workflows/types.js';
17
28
  import { planChain, slugify } from '../planner.js';
18
29
  import { discoverSkills } from '../skills.js';
30
+ import { SkillsUpdateConflictError, SkillsUpdateCoordinator, SkillsUpdateService } from '../skills-update.js';
19
31
  import { getTeamSkillsCached, refreshTeamSkills, waitForTeamSkills } from '../skills-remote.js';
20
32
  import { appendHandoffHeartbeat, handoffProgressExcerpt, readHandoff } from '../handoff.js';
21
33
  import { markStarted, onTodosChanged, readTodos, removeTodo, todoTaskText } from '../todos.js';
34
+ import { readRunIndexFromDisk } from '../runs/run-index.js';
22
35
  import { isV2WireEventType } from '../runs/ui-event-sink.js';
23
36
  import { removeWorktree, worktreeDiff, worktreeDiffStat, worktreeSizeBytes } from '../git-worktree.js';
24
37
  import { isReclaimable, reclaimWorktrees } from '../runs/retention.js';
@@ -26,47 +39,157 @@ import { getBranches, getCommit, getDiff, getLog, getRepoInfo, getStatus } from
26
39
  import { collectChanges, collectCommitChanges, collectRunCommits, commitAll, createOrSwitchBranch, imageMimeType, isOsOpenableImage, pushCurrentBranch, readWorktreePath, } from './git-changes.js';
27
40
  import { gatedSkillsRepos, loadConfig, resolveWorktreeRetention } from '../config.js';
28
41
  import { findConfigFile } from '../agent-config/catalog.js';
29
- import { readConfigFile, writeConfigFile } from '../agent-config/files.js';
42
+ import { readConfigFile, statConfigPath, writeConfigFile } from '../agent-config/files.js';
43
+ import { readAgentModelDefaults } from '../agent-config/models.js';
30
44
  import { listAgentConfig } from '../agent-config/service.js';
31
- import { PROJECT_ID_RE, defaultWorkspaceConfig, loadWorkspaceConfig, mergeWriteWorkspaceConfig, } from '../workspace/config.js';
45
+ import { listConfigFiles } from '../agent-config/catalog.js';
46
+ import { readAccountIdentity } from '../agent-config/account-identity.js';
47
+ import { PROJECT_ID_RE, defaultWorkspaceConfig, effectiveSkillsAutoUpdate, loadWorkspaceConfig, mergeWriteWorkspaceConfig, effectiveComposerDefault, } from '../workspace/config.js';
48
+ import { CONTROL_CHARS_RE, DEFAULT_AGENT_ACCOUNT_ID, defaultAgentAccountStore, isAbsoluteConfigDir, loadAgentAccounts, mergeWriteAgentAccounts, } from '../workspace/agent-accounts.js';
49
+ import { defaultAgentProfile, listAgentProfiles, profileDirState, resolveProfileEnvForRoot, resolveStoredProfile, sameProfileDir, } from '../workspace/agent-profiles.js';
50
+ import { PROFILE_CAPABLE_PROVIDERS, profileEnv, supportsProfiles } from '../core/agent-profiles.js';
51
+ import { withEnvPrefix } from '../core/shell-env.js';
32
52
  import { allocateProjectSlug, listProjects, probeProjectStatus, registerProject, removeProject, shouldRegisterProject, } from '../workspace/projects.js';
33
53
  import { mergeWriteWorkspaceUiState, readWorkspaceUiState } from '../workspace/ui-state.js';
34
54
  import { checkoutRepo } from './checkout.js';
35
55
  import { ProjectContextError, ProjectContexts } from './project-context.js';
36
56
  import { reviewGateEnabled } from '../runs/review-gate.js';
37
57
  import { readUiState, uiStatePath } from '../ui-state.js';
38
- import { expandTilde } from '../paths.js';
58
+ import { agentHomePaths, expandTilde } from '../paths.js';
39
59
  import { isLoopbackHostHeader, normalizeHostname, resolveCapabilities } from './capabilities.js';
40
60
  import { createSocketHub } from './ws.js';
41
61
  import { browseDirectory, isInsideBrowseRoot, isLexicallyInsideBrowseRoot, resolveBrowseRoot } from './fs-browse.js';
42
- import { resolveForge } from './forge/index.js';
43
- import { fetchGithub, fetchGithubComments } from './github.js';
62
+ import { parseRemote, resolveForge } from './forge/index.js';
63
+ import { fetchGithub, fetchGithubChecks, fetchGithubComments, fetchGithubPrDiff, GithubPrNotFoundError, GH_CHECKS_MAX } from './github.js';
44
64
  import { ensureLaunchKey } from './launch-key.js';
45
65
  import { openInTerminal } from './open-in-terminal.js';
46
66
  import { agentCliRunner, detectOpenTargets, openFileInDefaultApp, openInApp } from './open-in-app.js';
47
67
  import { createDraftPr } from './pr.js';
68
+ import { ProviderRuntimeAuthObserver } from './provider-auth-runtime.js';
69
+ import { providerForActiveRun, providerForExistingRun, providersRequiredByWorkflow, unavailableProviderMessage, } from './provider-action-gate.js';
48
70
  import { ASSET_CACHE_CONTROL, BUILD_HINT_HTML, assetContentType, isSafeAssetFilename, resolveGetRequest, } from './static-ui.js';
49
71
  /** `projectId` gate at the route boundary (spec "Project identity"): the slug
50
72
  * shape or the reserved `default` alias — validated BEFORE touching any map
51
73
  * or path. (`default` matches the slug regex too; the literal keeps the
52
74
  * contract explicit.) */
53
75
  const projectIdSchema = z.union([z.literal('default'), z.string().regex(PROJECT_ID_RE)]);
54
- const SCOPED_PREFIX = '/api/p/:projectId';
76
+ const providerConnectSchema = z.object({
77
+ provider: z.enum(PROVIDER_IDS),
78
+ /** Which agent account to sign in (spec 2026-07-29-agent-profiles). Absent = the discovered
79
+ * default, which is what every pre-profiles client sends. Without this, "Connect" on a second
80
+ * Claude account would open a login for the FIRST one and report success. */
81
+ profileId: z.string().max(64).optional(),
82
+ }).strict();
83
+ /** Agent-account bodies (spec 2026-07-29-agent-profiles). Bounds mirror `agentProfileSchema` in
84
+ * src/workspace/config.ts exactly, so a value these accept can never be degraded away by the
85
+ * next load's `.catch`. The id is allocated server-side and is never a request field. */
86
+ const createAgentProfileSchema = z.object({
87
+ provider: z.enum(PROVIDER_IDS),
88
+ label: z.string().trim().max(200).optional(),
89
+ configDir: z.string().trim().min(1).max(4096),
90
+ }).strict();
91
+ /** `POST …/agent-profiles/:id/open` — a catalog id (or `folder`) plus an optional open target. */
92
+ const openAgentAccountFileSchema = z.object({
93
+ file: z.string().min(1).max(200),
94
+ target: z.string().min(1).max(64).optional(),
95
+ }).strict();
96
+ const updateAgentProfileSchema = z.object({
97
+ label: z.string().trim().max(200).optional(),
98
+ configDir: z.string().trim().min(1).max(4096).optional(),
99
+ }).strict().refine((value) => value.label !== undefined || value.configDir !== undefined, 'send label or configDir');
100
+ /** `PUT …/agent-profiles/selection` — which account a project uses for one provider. `null`
101
+ * clears it back to the discovered account. */
102
+ const selectAgentProfileSchema = z.object({
103
+ /** `null` targets the machine-wide default rather than one repo. */
104
+ projectId: z.string().min(1).max(64).nullable(),
105
+ provider: z.enum(PROVIDER_IDS),
106
+ profileId: z.string().max(64).nullable(),
107
+ }).strict();
108
+ /** The hosted-mode refusal, worded like the agent-config one it mirrors. */
109
+ const hostedProfileRefusal = {
110
+ error: 'agent accounts are managed from the machine that owns the checkout (this cockpit runs in hosted mode)',
111
+ };
112
+ /**
113
+ * Allocate a profile id from the label (or, with no label, the folder name).
114
+ *
115
+ * Reuses the project allocator's shape, and deliberately its reserved set too: `default` is the
116
+ * discovered profile here exactly as it is the boot alias there, so a folder called `default/`
117
+ * becomes `default-2` and can never shadow it.
118
+ */
119
+ function allocateAgentProfileId(source, taken) {
120
+ // `allocateProjectSlug` already basenames its argument and enforces the shared
121
+ // `^[a-z0-9][a-z0-9-]{0,63}$` shape, so `~/.claude-klaudiusz` slugs to `claude-klaudiusz`.
122
+ return allocateProjectSlug(source, taken);
123
+ }
124
+ const providerParamSchema = z.enum(PROVIDER_IDS);
125
+ const providerEnabledSchema = z.object({ enabled: z.boolean() }).strict();
126
+ const providerRetrySchema = z.object({
127
+ authFailureId: z.string().min(1).max(128),
128
+ }).strict();
129
+ const automationEditableSchema = z
130
+ .object({
131
+ name: z.string().trim().min(1).max(200),
132
+ description: z.string().max(2_000).optional(),
133
+ enabled: z.boolean().optional(),
134
+ events: z.array(automationEventSchema).min(1).max(4),
135
+ intervalSeconds: z.number().int().min(60).max(86_400),
136
+ filters: automationFiltersSchema,
137
+ task: automationTaskSchema,
138
+ })
139
+ .strict();
140
+ const automationCreateSchema = automationEditableSchema.extend({ enable: z.boolean().optional() });
141
+ const automationUpdateSchema = automationEditableSchema.extend({ expectedRevision: z.number().int().positive() });
142
+ const automationCheckRequestSchema = z.object({ mode: z.enum(['preview', 'execute']) }).strict();
143
+ const automationLogQuerySchema = z.object({
144
+ automationId: z.string().optional(),
145
+ result: automationLogResultSchema.optional(),
146
+ event: automationEventSchema.optional(),
147
+ since: z.string().datetime().optional(),
148
+ cursor: z.coerce.number().int().positive().optional(),
149
+ // Optional, not `.default(100)`: `AutomationStore.logs` already clamps `limit ?? 100` into
150
+ // 1..100, so the default here was a second copy of it — and a defaulted key is REQUIRED in the
151
+ // request type `queryZodValidator` publishes (see validators.ts on why the request side falls
152
+ // back to the schema's output), which would have made every caller send a `?limit=` this route
153
+ // never needed.
154
+ limit: z.coerce.number().int().min(1).max(100).optional(),
155
+ });
156
+ function editableAutomation(definition) {
157
+ return {
158
+ name: definition.name,
159
+ description: definition.description,
160
+ enabled: definition.enabled,
161
+ events: definition.events,
162
+ intervalSeconds: definition.intervalSeconds,
163
+ filters: definition.filters,
164
+ task: definition.task,
165
+ };
166
+ }
167
+ /**
168
+ * The public API surface (spec 2026-07-23-independent-server-web-packages).
169
+ *
170
+ * Every route lives under this one prefix. The unversioned `/api/*` spelling the cockpit used
171
+ * to speak was removed once the whole API was reachable here — carrying two spellings meant two
172
+ * surfaces to keep working, and only one of them could be the typed contract. Bumping to `v2`
173
+ * means mounting a second table beside this one, not editing route paths.
174
+ */
175
+ const V1_PREFIX = '/api/v1';
176
+ /** Project scoping inside the versioned surface. The version is the OUTER dimension, so a
177
+ * consumer picks its API version once and then addresses projects inside it. */
178
+ const V1_SCOPED_PREFIX = `${V1_PREFIX}/p/:projectId`;
55
179
  /**
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>`.
180
+ * The project-scoped route table of a `createApp()` app, derived from its actual registrations
181
+ * (so it can never drift from the code): every method+path mounted under
182
+ * `/api/v1/p/:projectId/…`, minus the scope-resolver middleware (method ALL), deduped. The
183
+ * alias-parity suite iterates this to assert `/api/v1/<path>` ≡ `/api/v1/p/<boot>/<path>` ≡
184
+ * `/api/v1/p/default/<path>`.
62
185
  */
63
186
  export function projectRouteManifest(app) {
64
187
  const seen = new Set();
65
188
  const manifest = [];
66
189
  for (const route of app.routes) {
67
- if (route.method === 'ALL' || !route.path.startsWith(`${SCOPED_PREFIX}/`))
190
+ if (route.method === 'ALL' || !route.path.startsWith(`${V1_SCOPED_PREFIX}/`))
68
191
  continue;
69
- const path = route.path.slice(SCOPED_PREFIX.length);
192
+ const path = route.path.slice(V1_SCOPED_PREFIX.length);
70
193
  const key = `${route.method} ${path}`;
71
194
  if (seen.has(key))
72
195
  continue;
@@ -81,10 +204,11 @@ const FOLLOWUPS_OFF = 'the follow-up inbox is disabled — set CEZ_FOLLOWUPS=1 t
81
204
  * The in-process bus for workspace-level SSE events. The registry-mutating
82
205
  * routes (`POST /api/projects` — step 4.2, emits `project-added` for a
83
206
  * 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.
207
+ * checkout flow (step 4.3) call `emit()`; runtime provider auth observation
208
+ * emits host-wide `provider-status`; every open `/api/workspace/events` stream
209
+ * relays the event verbatim under its name. Injectable via
210
+ * `ServerDeps.workspaceEvents` so tests (and any out-of-createApp emitter) can
211
+ * drive the stream.
88
212
  */
89
213
  export class WorkspaceEventBus {
90
214
  listeners = new Set();
@@ -120,15 +244,19 @@ const startRunSchema = z
120
244
  // other prompt fields (`systemPrompt` 20k, message `text` 100k) so an
121
245
  // unbounded body can't be piped into a spawned process (#429). 100k chars
122
246
  // (~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'),
247
+ task: z.string().min(1).max(100_000, 'must be at most 100000 characters'),
124
248
  model: z.string().optional(),
125
249
  // Agent backend for this task (falls back to config `defaultRunner`).
126
250
  runner: z.enum(['claude', 'codex', 'opencode']).optional(),
251
+ // Agent account for this task (spec 2026-07-29-agent-profiles). Falls back to the project's
252
+ // own selection, then the discovered default. Bounded like a profile id in the workspace
253
+ // schema, so a value this route accepts can never be degraded away by the next load.
254
+ agentProfile: z.string().max(64).optional(),
127
255
  // Parallel variants (spec 010): ×2/×3 runs the task as 2–3 competing
128
256
  // agents in separate worktrees; the user compares diffs and picks one.
129
257
  variants: z.number().int().min(1).max(3).optional(),
130
258
  // Composer worktree opt-out (#worktree-toggle): false runs in the repo
131
- // working tree (read-only skills). Ignored when variants > 1.
259
+ // working tree. Ignored when variants > 1.
132
260
  worktree: z.boolean().optional(),
133
261
  // Autonomous mode (#autonomous): the run never parks at `waiting` — it
134
262
  // auto-continues until the agent signals done. No "needs you" is raised.
@@ -145,7 +273,7 @@ const startRunSchema = z
145
273
  systemPrompt: z
146
274
  .string()
147
275
  .trim()
148
- .max(20_000, 'systemPrompt must be at most 20000 characters')
276
+ .max(20_000, 'must be at most 20000 characters')
149
277
  .optional()
150
278
  .transform((s) => (s ? s : undefined)),
151
279
  // Screenshots pasted into the new-task form — same shape and limits as a
@@ -163,7 +291,7 @@ const startRunSchema = z
163
291
  // started — the same bookkeeping POST /api/todos/:id/start does, so the
164
292
  // audit trail survives the composer detour. Bounded like every other
165
293
  // 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(),
294
+ todoId: z.string().min(1).max(200, 'must be at most 200 characters').optional(),
167
295
  })
168
296
  .refine((b) => Boolean(b.workflow) !== Boolean(b.steps), {
169
297
  message: 'provide either "workflow" or "steps", not both',
@@ -173,7 +301,7 @@ const pickSchema = z.object({
173
301
  });
174
302
  const planSchema = z.object({
175
303
  // 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'),
304
+ task: z.string().trim().min(1).max(100_000, 'must be at most 100000 characters'),
177
305
  });
178
306
  // A saved workflow carries full `steps` OR the builder's `skills` stack
179
307
  // (spec 012). `overwrite: true` is the builder's Save on an existing file —
@@ -183,7 +311,7 @@ const saveWorkflowSchema = z
183
311
  name: z.string().trim().min(1).max(80),
184
312
  // Written into a YAML file on disk (#429) — a workflow description is a
185
313
  // 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(),
314
+ description: z.string().max(2_000, 'must be at most 2000 characters').optional(),
187
315
  steps: z.array(workflowStepSchema).min(1).max(8).optional(),
188
316
  skills: z.array(z.string().trim().min(1)).min(1).max(8).optional(),
189
317
  overwrite: z.boolean().optional(),
@@ -206,14 +334,32 @@ const SKILL_USAGE_MAX_ENTRIES = 200;
206
334
  // generous for GUI prefs; over-limit is a 400, never a silent strip. Shared by
207
335
  // BOTH ui-state routes (per-repo and workspace) via `parseUiStateBody`.
208
336
  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). */
337
+ /** Settings → Appearance (redesign R6): accent + density + reading width. ONE
338
+ * schema for both ui-state files — per-repo (the legacy home, kept so an older
339
+ * cezar in the same repo still honours it) and workspace
340
+ * (`~/.cezar/ui-state.json`, its post-migration home — multi-project spec,
341
+ * Data Model).
342
+ *
343
+ * Every key is `.optional()` so an older ui-state.json parses unchanged, but
344
+ * each one must be listed HERE: the enclosing `workspaceUiStateSchema` is
345
+ * `.passthrough()` at the top level only, so an unlisted key inside
346
+ * `appearance` is stripped by zod and then wiped from the file by the shallow
347
+ * merge-on-write. The cockpit adopts the PUT response as authoritative, so a
348
+ * stripped key does not merely fail to persist — it visibly reverts the
349
+ * control the user just touched. Adding an appearance preference means adding
350
+ * it here in the same change. */
213
351
  const appearanceSchema = z.object({
214
352
  accent: z.enum(['lime', 'violet']).optional(),
215
353
  density: z.enum(['comfortable', 'compact', 'ultra']).optional(),
354
+ width: z.enum(['narrow', 'wide']).optional(),
216
355
  });
356
+ const providerAuthDismissalsSchema = z
357
+ .object({
358
+ claude: z.string().min(1).max(128).optional(),
359
+ codex: z.string().min(1).max(128).optional(),
360
+ opencode: z.string().min(1).max(128).optional(),
361
+ })
362
+ .strict();
217
363
  /** Global GUI state (`~/.cezar/ui-state.json`, step 2.7) — the workspace twin
218
364
  * of `uiStateSchema` below, sharing its `.passthrough()` + key-cap + shallow
219
365
  * merge-on-write semantics via `parseUiStateBody`. Known keys are the
@@ -223,6 +369,16 @@ const workspaceUiStateSchema = z
223
369
  .object({
224
370
  appearance: appearanceSchema.optional(),
225
371
  notifications: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
372
+ dismissedProviderAuthFailures: providerAuthDismissalsSchema.optional(),
373
+ lastLocation: z
374
+ .object({
375
+ projectId: z.string().min(1).max(64),
376
+ pathname: z.string().min(1).max(2048).startsWith('/p/'),
377
+ search: z.string().max(4096).startsWith('?').optional(),
378
+ hash: z.string().max(2048).startsWith('#').optional(),
379
+ })
380
+ .strict()
381
+ .optional(),
226
382
  // Sidebar per-project collapse map, keyed by project id (slug ≤ 64 chars).
227
383
  // Entry-capped like `skillUsage`: the map is written straight to a file the
228
384
  // cockpit GETs on every load, so it must stay bounded on every axis.
@@ -330,7 +486,7 @@ const patchRunSchema = z.object({
330
486
  });
331
487
  // Session commit (redesign R5 — §"Git/session API additions").
332
488
  const gitCommitSchema = z.object({
333
- message: z.string().trim().min(1, 'commit message must not be empty').max(5_000),
489
+ message: z.string().trim().min(1, 'must not be empty').max(5_000),
334
490
  });
335
491
  // "Open in…" (#open-in / #365): `target` selects the app; `path` (optional, worktree-relative)
336
492
  // narrows the target's own worktree/repo-root default to one file — used by the diff pane's
@@ -387,7 +543,7 @@ function foldedLength(task, stack) {
387
543
  // follow-up composer is a full composer, so a screenshot pasted into it must reach the reopened
388
544
  // session rather than being silently dropped.
389
545
  const continueSchema = z.object({
390
- text: z.string().max(100_000, 'text must be at most 100000 characters').optional(),
546
+ text: z.string().max(100_000, 'must be at most 100000 characters').optional(),
391
547
  images: z.array(imageInputSchema).max(4).optional(),
392
548
  runner: z.enum(['claude', 'codex', 'opencode']).optional(),
393
549
  model: z.string().max(200).optional(),
@@ -406,7 +562,7 @@ const startTodoSchema = z
406
562
  prompt: z
407
563
  .string()
408
564
  .trim()
409
- .max(20_000, 'prompt must be at most 20000 characters')
565
+ .max(20_000, 'must be at most 20000 characters')
410
566
  .optional()
411
567
  .transform((s) => (s ? s : undefined)),
412
568
  })
@@ -432,21 +588,100 @@ function stripHostPort(host) {
432
588
  return bracketed[1];
433
589
  return host.replace(/:\d+$/, '');
434
590
  }
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})` };
591
+ /** The shared write-side half of BOTH ui-state routes (per-repo `/api/v1/ui-state`
592
+ * and workspace `/api/v1/workspace/ui-state`) — the factored split the
593
+ * multi-project spec calls for instead of a copy: the route's own schema, plus a
594
+ * cap on the top-level key count so a `.passthrough()` schema can't accumulate an
595
+ * unbounded key set (#429). The cap rides as a refinement so the whole thing is one
596
+ * schema and can go through `jsonBody` like every other mutating route. The
597
+ * merge-on-write stays with each route (they write different files) but is shallow
598
+ * in both. */
599
+ function capUiStateKeys(data, ctx) {
600
+ if (Object.keys(data).length > UI_STATE_MAX_KEYS) {
601
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `ui-state has too many keys (max ${UI_STATE_MAX_KEYS})` });
602
+ }
603
+ }
604
+ // Derived once per route instead of by a generic `wrap(schema)` helper called at the route: a
605
+ // generic wrapper leaves the schema type unresolved where `jsonBody` needs it, and Hono answers
606
+ // that by dropping the whole PUT from the route schema rather than erroring — both ui-state PUTs
607
+ // silently vanished from `AppType`. Concrete consts keep them visible to `hc`.
608
+ const workspaceUiStateBody = workspaceUiStateSchema.superRefine(capUiStateKeys);
609
+ const uiStateBody = uiStateSchema.superRefine(capUiStateKeys);
610
+ /**
611
+ * Query schemas for the READ routes — deliberately permissive.
612
+ *
613
+ * These exist to make a query key VISIBLE to the route type (`hc` refuses a `query` argument for
614
+ * a key no validator declares), NOT to narrow what the route accepts. Every one of these handlers
615
+ * compares `=== '1'` and treats everything else as false, so `?refresh=0` is a successful request
616
+ * today; a literal schema would silently turn it into a 400. The comparison stays in the handler
617
+ * and the validator stays out of its way.
618
+ *
619
+ * The one route that really is strict on the wire — `GET /github/prs/:number/changes`, which 400s
620
+ * on `?refresh=true` — keeps its own `z.enum(['1'])` schema next to the route.
621
+ */
622
+ /**
623
+ * One query value, matching what `c.req.query('k')` did before these routes were validated.
624
+ *
625
+ * Hono's query validator hands a REPEATED key as an array (`?wait=1&wait=1` → `['1','1']`), where
626
+ * `c.req.query()` silently took the first. A plain `z.string()` therefore turns a request that
627
+ * used to answer 200 into a 400 — a wire change no caller asked for. Collapsing to the first
628
+ * value keeps the old behaviour, and the client still sees a plain `key?: string`.
629
+ */
630
+ const queryValue = z.union([z.string(), z.array(z.string()).transform((v) => v[0])]).optional();
631
+ const refreshQuery = z.object({ refresh: queryValue });
632
+ const waitQuery = z.object({ wait: queryValue });
633
+ function parseAccept(header) {
634
+ const ranges = [];
635
+ for (const part of header.split(',')) {
636
+ const [range = '', ...params] = part.split(';');
637
+ const [type = '', subtype = ''] = range.trim().toLowerCase().split('/');
638
+ if (type === '' || subtype === '')
639
+ continue;
640
+ const weight = params.map((p) => p.trim().toLowerCase()).find((p) => p.startsWith('q='));
641
+ const parsed = weight === undefined ? 1 : Number.parseFloat(weight.slice(2));
642
+ const q = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 0), 1) : 0;
643
+ if (q > 0)
644
+ ranges.push({ type, subtype, q });
645
+ }
646
+ return ranges;
647
+ }
648
+ /**
649
+ * The best of `offers` for this `Accept`, or `null` for "no preference expressed" — which is what
650
+ * an absent header, a `*<slash>*`-only header and a header naming nothing on offer all mean, and
651
+ * what leaves the caller on its route's default.
652
+ *
653
+ * `offers` is the server's own preference order: an offer may itself be a wildcard (`image/*`,
654
+ * since the concrete image type is not known until the file is read), and equal q-values go to the
655
+ * EARLIER offer, so each route lists its default representation first.
656
+ */
657
+ function negotiate(accept, offers) {
658
+ if (accept === undefined)
659
+ return null;
660
+ const ranges = parseAccept(accept);
661
+ let best = null;
662
+ for (const offer of offers) {
663
+ const [type = '', subtype = ''] = offer.split('/');
664
+ let q = 0;
665
+ for (const range of ranges) {
666
+ if (range.type === '*' && range.subtype === '*')
667
+ continue; // "anything" is not a preference
668
+ const typeMatches = range.type === type || range.type === '*' || type === '*';
669
+ const subtypeMatches = range.subtype === subtype || range.subtype === '*' || subtype === '*';
670
+ if (typeMatches && subtypeMatches && range.q > q)
671
+ q = range.q;
672
+ }
673
+ if (q > 0 && (best === null || q > best.q))
674
+ best = { offer, q };
447
675
  }
448
- return { data: parsed.data };
676
+ return best?.offer ?? null;
449
677
  }
678
+ /** What `GET /repo/commit/:sha` offers, DEFAULT FIRST: the legacy `text/plain` blob is what a
679
+ * request with no opinion has always received (§2), so it also wins an Accept tie. */
680
+ const COMMIT_FORMATS = ['text/plain', 'application/json'];
681
+ /** What `GET /runs/:id/files` offers, DEFAULT FIRST. `image/*` rather than a concrete type: which
682
+ * image type the bytes are is only known once the path resolves, and the raw branch refuses
683
+ * everything that is not an image anyway. */
684
+ const FILE_FORMATS = ['application/json', 'image/*'];
450
685
  /** Workspace-root writability probe (multi-project spec, "API Contracts"):
451
686
  * optional `mkdir -p`, `access W_OK`, then a real create/delete round-trip — W_OK alone
452
687
  * can lie (e.g. a read-only mount still reports writable permission bits).
@@ -469,6 +704,10 @@ async function probeWritableDir(dir, create) {
469
704
  return err instanceof Error ? err.message : String(err);
470
705
  }
471
706
  }
707
+ // The return type is INFERRED on purpose: it is the chained app type built at the bottom of
708
+ // this function, and `AppType` (src/server/app-type.ts) is `ReturnType<typeof createApp>`.
709
+ // Annotating it `Hono` here would erase every route from the type and leave the typed client
710
+ // with nothing to offer. See the `routed` assembly at the end of the function.
472
711
  export function createApp(deps) {
473
712
  const { version, update, bindHost, bootProjectId } = deps;
474
713
  // Boot singletons keep DELIBERATELY distinct names (`boot*`): every
@@ -481,6 +720,54 @@ export function createApp(deps) {
481
720
  const modelCatalog = deps.modelCatalog ?? new RunnerModelCatalog({
482
721
  adapters: { codex: { discover: () => discoverCodexModels({ cwd: bootRoot }) } },
483
722
  });
723
+ const providerAuth = deps.providerAuth ?? new ProviderAuthService();
724
+ const workspaceConfig = deps.workspaceConfig ?? {
725
+ load: loadWorkspaceConfig,
726
+ mergeWrite: mergeWriteWorkspaceConfig,
727
+ };
728
+ const providerStatus = async (options) => {
729
+ if (providerAuthChecksDisabled()) {
730
+ return applyProviderEnablement(await providerAuth.status(options?.refresh ? { refresh: true } : undefined), []);
731
+ }
732
+ const [discovered, workspace] = await Promise.all([
733
+ providerAuth.status(options?.refresh ? { refresh: true } : undefined),
734
+ workspaceConfig.load(),
735
+ ]);
736
+ return applyProviderEnablement(discovered, workspace.disabledProviders);
737
+ };
738
+ /**
739
+ * The gate: why a run cannot start against `required`, or null when it can.
740
+ *
741
+ * VERIFY BEFORE YOU REFUSE. Auth state is served stale-while-revalidate (see
742
+ * `ProviderAuthService.status`), so a cached "disconnected" may predate a login cezar could not
743
+ * observe — someone running `claude auth login` in a terminal. Refusing on that would lock a user
744
+ * out of their own cockpit with no way back but waiting. So a believed-unavailable provider is
745
+ * re-probed, and only a refusal that survives the fresh answer is returned.
746
+ *
747
+ * The cost lands where it belongs: the common path (connected, warm) pays nothing at all, and the
748
+ * probe is only spawned when cezar is about to say no — a rare, interactive moment. This is also
749
+ * what lets the cache hold a negative for a minute instead of five seconds, which is what made
750
+ * every reader of `GET /providers/status` periodically pay for a CLI spawn.
751
+ *
752
+ * A runtime auth latch is deliberately NOT escaped by this: `withRuntimeFailures` keeps forcing
753
+ * the row disconnected until the user acknowledges that exact incident, so the re-probe cannot
754
+ * talk cezar out of a rejection it actually observed.
755
+ */
756
+ const providerActionError = async (required) => {
757
+ const known = await providerStatus();
758
+ const message = unavailableProviderMessage(required, known);
759
+ if (message === null)
760
+ return null;
761
+ // A DISABLED provider is a settings fact, not a probe result — re-probing it learns nothing and
762
+ // would spawn a CLI to re-read something the user typed.
763
+ const disabled = required.some((provider) => known.providers.find((row) => row.provider === provider)?.enabled === false);
764
+ if (disabled)
765
+ return message;
766
+ return unavailableProviderMessage(required, await providerStatus({ refresh: true }));
767
+ };
768
+ const openTerminal = deps.openTerminal ?? openInTerminal;
769
+ const openFile = deps.openFile ?? openFileInDefaultApp;
770
+ const skillsUpdate = deps.skillsUpdate ?? new SkillsUpdateService();
484
771
  // ---- workspace boot-project identity (multi-project spec) ----------------
485
772
  // The boot flow (`initWorkspace` in src/index.ts) registers the boot repo
486
773
  // and plumbs its registry id in via `deps.bootProjectId`. Legacy callers and
@@ -551,6 +838,7 @@ export function createApp(deps) {
551
838
  dataDir: bootDataDir,
552
839
  store: deps.store,
553
840
  manager: deps.manager,
841
+ automationStore: deps.automationStore ?? AutomationStore.open(bootDataDir),
554
842
  launchKey: ensureLaunchKey(bootDataDir), // bookmarklet auto-start secret (spec 011)
555
843
  };
556
844
  // Non-boot projects build lazily on first scoped request; their managers
@@ -567,6 +855,25 @@ export function createApp(deps) {
567
855
  // Workspace-level SSE bus (step 2.8) — the registry mutators and the
568
856
  // checkout flow (Phase 4) emit here; /api/workspace/events relays.
569
857
  const workspaceEvents = deps.workspaceEvents ?? new WorkspaceEventBus();
858
+ const emitAutomationChange = (project, automationId, revision, deleted = false) => workspaceEvents.emit('automation-change', {
859
+ project: project.id,
860
+ automationId,
861
+ revision,
862
+ ...(deleted ? { deleted: true } : {}),
863
+ });
864
+ const automationsChanged = () => deps.automationsChanged?.();
865
+ const providerRuntimeAuth = deps.providerRuntimeAuth
866
+ ?? new ProviderRuntimeAuthObserver(providerAuth, (status) => {
867
+ workspaceEvents.emit('provider-status', status);
868
+ });
869
+ providerRuntimeAuth.watch(bootContext.store);
870
+ for (const id of contexts.ids()) {
871
+ const ctx = contexts.peek(id);
872
+ if (ctx)
873
+ providerRuntimeAuth.watch(ctx.store);
874
+ }
875
+ contexts.onStoreCreated((store) => providerRuntimeAuth.watch(store));
876
+ contexts.onContextBuilt((ctx) => providerRuntimeAuth.watch(ctx.store));
570
877
  const app = new Hono();
571
878
  // Reject oversized request bodies before they reach any handler (#429). GETs
572
879
  // and SSE carry no body, so this only ever gates the mutating routes.
@@ -577,7 +884,7 @@ export function createApp(deps) {
577
884
  // own: any page the user visits can still POST to us (CSRF), and DNS
578
885
  // rebinding can point a foreign domain at loopback and read our responses.
579
886
  // Two zero-config checks close both holes on every /api route EXCEPT
580
- // /api/health (the intentional cross-origin discovery endpoint, spec 011 —
887
+ // /api/v1/health (the intentional cross-origin discovery endpoint, spec 011 —
581
888
  // it exposes nothing sensitive, see #431):
582
889
  // 1. Host allowlist (loopback deployments only) — a request whose Host is
583
890
  // not a loopback name did not really originate from this machine. A
@@ -600,7 +907,7 @@ export function createApp(deps) {
600
907
  // Scope note: check 2 covers writes only. A cross-origin GET from any site
601
908
  // still reaches the read routes — but its Host is ours, so it is a *forced
602
909
  // 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
910
+ // response body (we send CORS headers on /api/v1/health alone), and no GET
604
911
  // handler mutates state. Rebinding, which WOULD make those reads legible, is
605
912
  // what check 1 stops.
606
913
  const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
@@ -615,9 +922,12 @@ export function createApp(deps) {
615
922
  error: 'forbidden: unexpected Host header — this request did not originate from this machine (see #426)',
616
923
  }, 403);
617
924
  }
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')
925
+ // /api/v1/health stays CORS-open for cross-origin discovery, but its Host is
926
+ // still checked above: cross-origin is legitimate, DNS rebinding is not. The
927
+ // path is spelled through V1_PREFIX rather than inline — an unversioned
928
+ // literal here silently stopped matching when the API moved and left health
929
+ // relying on the mutating-methods gate below to let its GETs through.
930
+ if (c.req.path === `${V1_PREFIX}/health`)
621
931
  return next();
622
932
  if (MUTATING_METHODS.has(c.req.method)) {
623
933
  const origin = c.req.header('origin');
@@ -661,15 +971,16 @@ export function createApp(deps) {
661
971
  });
662
972
  // The mirrored project-route table (spec "API Contracts → Project-scoped").
663
973
  // 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
974
+ // twice — under `/api/v1/p/:projectId` (scoped) and under `/api/v1` (bound to
975
+ // the boot project) — so both spellings share one handler and can never
976
+ // drift. The resolver middleware binds `c.get('project')`:
977
+ // no `projectId` param (unscoped mount) → the boot context, byte-identical to
668
978
  // the pre-workspace closures; `default` or the boot project's own id → the
669
979
  // boot context too; anything else → the lazy context map, with
670
980
  // `ProjectContextError` mapped to 404 (unknown) / 409 (missing root).
671
- const api = new Hono();
672
- api.use('*', async (c, next) => {
981
+ // Named rather than inlined because both mounts share it — one function, so
982
+ // the two spellings can never disagree about what `default` means.
983
+ const resolveProjectScope = async (c, next) => {
673
984
  const raw = c.req.param('projectId');
674
985
  if (raw === undefined) {
675
986
  c.set('project', bootContext);
@@ -694,15 +1005,23 @@ export function createApp(deps) {
694
1005
  throw err;
695
1006
  }
696
1007
  return next();
697
- });
1008
+ };
698
1009
  // ---- static GUI ----------------------------------------------------------
699
1010
  const webDir = resolveWebDir();
700
1011
  const distDir = join(webDir, 'dist');
701
1012
  const HTML_TYPE = 'text/html; charset=utf-8';
702
- const staticFile = (name, type) => () => {
1013
+ const staticFile = (name, type) => (c) => {
703
1014
  // 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 } });
1015
+ //
1016
+ // Served out of the Vite build: the file is a `public/` asset of the web package, which the
1017
+ // build copies verbatim into `web/dist`. One home, one URL — the same bytes this route
1018
+ // hands out are what the bundle's own `<img src="/open-mercato.svg">` asks for.
1019
+ // Without a build there is nothing to serve, which is a 404 rather than a crash (the shell
1020
+ // route answers the same dev-only state with its build hint).
1021
+ const path = join(distDir, name);
1022
+ if (!existsSync(path))
1023
+ return c.json({ error: 'not found' }, 404);
1024
+ return new Response(readFileSync(path), { headers: { 'content-type': type } });
706
1025
  };
707
1026
  let hintLogged = false;
708
1027
  const serveShell = (c) => {
@@ -749,14 +1068,14 @@ export function createApp(deps) {
749
1068
  },
750
1069
  });
751
1070
  });
752
- // The favicon web/app/index.html points at (`../open-mercato.svg`).
1071
+ // The favicon packages/web/index.html points at (`/open-mercato.svg`).
753
1072
  app.get('/open-mercato.svg', staticFile('open-mercato.svg', 'image/svg+xml'));
754
1073
  // ---- meta ----------------------------------------------------------------
755
1074
  // CORS — deliberately for /api/health ONLY (spec 011): the bookmarklets
756
1075
  // fetch it cross-origin from github.com to discover which local ports run a
757
1076
  // cockpit and which repo each serves. Health exposes no secrets beyond the
758
1077
  // repo path/remote; every other endpoint stays same-origin.
759
- app.use('/api/health', async (c, next) => {
1078
+ const healthCors = async (c, next) => {
760
1079
  c.header('access-control-allow-origin', '*');
761
1080
  if (c.req.method === 'OPTIONS') {
762
1081
  // Preflight (e.g. Chrome Private Network Access) — allow the plain GET.
@@ -765,10 +1084,16 @@ export function createApp(deps) {
765
1084
  return c.body(null, 204);
766
1085
  }
767
1086
  await next();
768
- });
1087
+ };
1088
+ app.use(`${V1_PREFIX}/health`, healthCors);
769
1089
  // One builder for both transports: `GET /api/health` (the authoritative,
770
- // CORS-open discovery endpoint) and the `health` topic on `/api/ws` below
1090
+ // CORS-open discovery endpoint) and the `health` topic on `/api/v1/ws` below
771
1091
  // push the byte-identical shape, so the two can never drift.
1092
+ // Deliberately UNANNOTATED: this literal is the source of the `/health` shape. Annotating it
1093
+ // with the api-client's `HealthResponse` made the contract circular — the DTO was declared by
1094
+ // hand, the handler was checked against it, and `AppType` then reported the hand-written type
1095
+ // back as if the server had proven it. Inferring here means the route says what it actually
1096
+ // sends, which is what lets the DTO be derived instead of maintained.
772
1097
  const healthSnapshot = async () => {
773
1098
  const [checks, repo, config, workspace] = await Promise.all([
774
1099
  detectEnvironment(),
@@ -782,7 +1107,11 @@ export function createApp(deps) {
782
1107
  const caps = capabilities();
783
1108
  return {
784
1109
  version,
785
- latestVersion: update?.latest,
1110
+ // Spread rather than `latestVersion: update?.latest`: an `undefined` VALUE is dropped by
1111
+ // JSON.stringify, so the key is absent on the wire — but writing it unconditionally types
1112
+ // the key as always-present, which is a shape no client ever receives. The contract schema
1113
+ // says `.optional()`, and contract-parity.test.ts holds the two together.
1114
+ ...(update?.latest !== undefined ? { latestVersion: update.latest } : {}),
786
1115
  // Health is CORS-open and, in hosted mode, reachable off the loopback —
787
1116
  // so any site/host that reads it would learn the developer's absolute
788
1117
  // checkout path and username (#431). Local mode keeps the full path (the
@@ -874,7 +1203,13 @@ export function createApp(deps) {
874
1203
  void refreshHealth(); // stale: refresh, don't wait on it
875
1204
  return healthCache.payload;
876
1205
  };
877
- app.get('/api/health', async (c) => c.json(await readHealth()));
1206
+ // ---- chained family: health (workspace-level) ----------------------------
1207
+ // Written as ONE chained expression rather than a loose `app.get(...)`
1208
+ // statement because Hono accumulates its route types through the chain: a
1209
+ // statement's return value is discarded, so `typeof app` would record nothing
1210
+ // and `hc<AppType>` would have no endpoint to offer. `createApp` mounts this
1211
+ // under both `/api` (the frozen legacy spelling) and `/api/v1`.
1212
+ const healthRoutes = new Hono().get('/health', async (c) => c.json(await readHealth()));
878
1213
  // The push twin of the poll it replaced (#369): while at least one cockpit
879
1214
  // holds the `health` topic the server re-reads the snapshot on the old 5 s
880
1215
  // cadence and broadcasts ONLY when it changed — a `git checkout` in a
@@ -906,14 +1241,676 @@ export function createApp(deps) {
906
1241
  // `GET /api/health` reads a warm value instead of the cold ~1 s compute.
907
1242
  if (deps.socketHub)
908
1243
  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);
1244
+ /**
1245
+ * Warm the whole of cezar's agent knowledge — the three discovered defaults AND every extra
1246
+ * account — so no reader ever pays the first shell-out.
1247
+ *
1248
+ * Which login each agent is signed into is operating knowledge, not a settings-page detail: the
1249
+ * composer, the action gate, the accounts pane and every run resolution ask for it. So the server
1250
+ * learns it once, at boot, and keeps it (see the asymmetric cache lifetime in
1251
+ * `core/provider-auth.ts`); on-demand refresh rides on `?refresh=1` and on the explicit
1252
+ * invalidations — connect, repoint, remove, runtime rejection.
1253
+ *
1254
+ * Extra accounts are warmed ONE AT A TIME, after the defaults. Each is a CLI spawn, and a machine
1255
+ * with several accounts would otherwise fan out a spawn storm at exactly the moment the browser is
1256
+ * fetching the bundle; nothing is waiting on this, so sequential costs nothing that matters.
1257
+ *
1258
+ * Hosted mode warms only the defaults: the agent-profiles family is refused there, so there are
1259
+ * no accounts to learn about.
1260
+ */
1261
+ const warmAgentKnowledge = async () => {
1262
+ await providerAuth.status().catch(() => { });
1263
+ if (!capabilities().localHandoff)
1264
+ return;
1265
+ const store = await loadAgentAccounts().catch(() => defaultAgentAccountStore());
1266
+ for (const account of listAgentProfiles(store, PROVIDER_IDS)) {
1267
+ if (account.isDefault)
1268
+ continue; // covered by `status()` above
1269
+ await providerAuth
1270
+ .profileStatus(account.provider, { id: account.id, configDir: account.path })
1271
+ .catch(() => { });
1272
+ }
1273
+ };
1274
+ // Same gate as `refreshHealth`, same reason (startServer injects the hub; a bare app in tests does
1275
+ // not, so tests never spawn probes here). Fire-and-forget: a probe that fails leaves that row
1276
+ // cold, which is exactly the state every reader already handles.
1277
+ if (deps.socketHub)
1278
+ void warmAgentKnowledge();
1279
+ // ---- chained family: host model catalog (workspace-level) ----
1280
+ const modelsRoutes = new Hono()
1281
+ .get('/models', queryZodValidator(z.object({ runner: z.union([z.string(), z.array(z.string()).transform((v) => v[0])]).pipe(z.literal('codex')) }), { message: 'runner must be codex' }), async (c) => {
1282
+ const query = { data: c.req.valid('query') };
915
1283
  return c.json(await modelCatalog.get(query.data.runner));
916
1284
  });
1285
+ /**
1286
+ * Resolve `profileId` (absent = the discovered default) into a concrete account for `provider`.
1287
+ *
1288
+ * A dangling id is an ERROR here rather than the silent fall-back to the default that run
1289
+ * resolution performs. The difference is who is asking: a run is replaying a stored reference
1290
+ * and the default is the only safe answer it can act on, whereas a route is answering a user
1291
+ * who just named an account — telling them "unknown account" is honest, and quietly connecting
1292
+ * or reporting on a different one is not.
1293
+ */
1294
+ const resolveWorkspaceProfile = async (provider, profileId) => {
1295
+ if (profileId === undefined || profileId === DEFAULT_AGENT_ACCOUNT_ID) {
1296
+ return { profile: defaultAgentProfile(provider) };
1297
+ }
1298
+ let accounts;
1299
+ try {
1300
+ accounts = (await loadAgentAccounts()).accounts;
1301
+ }
1302
+ catch {
1303
+ return { error: `unknown ${provider} account: ${profileId}` };
1304
+ }
1305
+ const stored = accounts.find((a) => a.id === profileId && a.provider === provider);
1306
+ if (!stored)
1307
+ return { error: `unknown ${provider} account: ${profileId}` };
1308
+ return { profile: resolveStoredProfile(stored) };
1309
+ };
1310
+ /**
1311
+ * The environment a terminal handoff must carry so the CLI lands on the right account.
1312
+ *
1313
+ * `profileId` is the one RECORDED on the step that owns the session, never the project's
1314
+ * current selection: `claude --resume <id>` reads `<configDir>/sessions`, so a handoff for a
1315
+ * run started on the work account and resumed after the project was switched back would find
1316
+ * nothing and silently open a fresh conversation.
1317
+ */
1318
+ const handoffEnv = async (provider, profileId) => {
1319
+ const resolved = await resolveWorkspaceProfile(provider, profileId);
1320
+ if ('error' in resolved) {
1321
+ return { error: `this session belongs to an account that no longer exists (${profileId})` };
1322
+ }
1323
+ const { profile } = resolved;
1324
+ return { env: profile.isDefault ? {} : profileEnv(provider, profile.path) };
1325
+ };
1326
+ /** The copy-paste fallback shown when no terminal could be opened — same account, spelled for
1327
+ * this platform's shell. `null` when the dir cannot be embedded safely, which is a refusal. */
1328
+ const handoffFallbackCommand = (cwd, command, env) => {
1329
+ const prefixed = withEnvPrefix(command, env, process.platform);
1330
+ return prefixed === null ? null : `cd '${cwd}' && ${prefixed}`;
1331
+ };
1332
+ /** A registered project's realpath'd root, or null when the id is unknown. */
1333
+ const projectRootFor = async (projectId) => {
1334
+ try {
1335
+ return (await loadWorkspaceConfig()).projects.find((p) => p.id === projectId)?.root ?? null;
1336
+ }
1337
+ catch {
1338
+ return null;
1339
+ }
1340
+ };
1341
+ // ---- chained family: agent providers (workspace-level) ----
1342
+ const providersRoutes = new Hono()
1343
+ .get('/providers/status', queryZodValidator(z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') }), { message: 'refresh must be 1 when provided' }), async (c) => {
1344
+ const query = { data: c.req.valid('query') };
1345
+ return c.json(await providerStatus({ refresh: query.data.refresh === '1' }));
1346
+ })
1347
+ .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) => {
1348
+ const provider = { data: c.req.valid('param').provider };
1349
+ const body = { data: c.req.valid('json') };
1350
+ let workspace;
1351
+ try {
1352
+ workspace = await workspaceConfig.mergeWrite((config) => {
1353
+ const disabled = new Set(config.disabledProviders);
1354
+ if (body.data.enabled)
1355
+ disabled.delete(provider.data);
1356
+ else
1357
+ disabled.add(provider.data);
1358
+ config.disabledProviders = PROVIDER_IDS.filter((id) => disabled.has(id));
1359
+ });
1360
+ }
1361
+ catch {
1362
+ return c.json({ error: 'Provider preference could not be saved.' }, 500);
1363
+ }
1364
+ const result = applyProviderEnablement(await providerAuth.status(), workspace.disabledProviders);
1365
+ const row = result.providers.find(({ provider: id }) => id === provider.data);
1366
+ if (row)
1367
+ workspaceEvents.emit('provider-status', row);
1368
+ return c.json(result);
1369
+ })
1370
+ .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) => {
1371
+ const provider = { data: c.req.valid('param').provider };
1372
+ const body = { data: c.req.valid('json') };
1373
+ if (!providerAuth.clearRuntimeAuthFailure(provider.data, body.data.authFailureId)) {
1374
+ return c.json({ error: 'Authentication incident changed. Refresh and try again.' }, 409);
1375
+ }
1376
+ const result = await providerStatus({ refresh: true });
1377
+ const row = result.providers.find(({ provider: id }) => id === provider.data);
1378
+ if (row)
1379
+ workspaceEvents.emit('provider-status', row);
1380
+ return c.json(result);
1381
+ })
1382
+ .post('/providers/connect', jsonZodValidator(providerConnectSchema, { message: 'provider must be claude, codex, or opencode' }), async (c) => {
1383
+ const body = { data: c.req.valid('json') };
1384
+ const provider = body.data.provider;
1385
+ // A NAMED account is refused in hosted mode before anything is resolved, exactly like every
1386
+ // sibling route in the agent-profiles family. Checking later would already have read
1387
+ // `~/.cezar/agent-accounts.json`, built a command carrying the account's absolute path (which
1388
+ // both the success body and the hosted 409 echo), and — for a stored account — spawned a
1389
+ // probe. It would also answer `unknown account: <id>` for a wrong id, which is an enumeration
1390
+ // oracle for the very ids the hosted listing withholds. The bare-provider spelling keeps its
1391
+ // existing behaviour: it names no host path and is how the Providers card has always worked.
1392
+ if (body.data.profileId !== undefined
1393
+ && body.data.profileId !== DEFAULT_AGENT_ACCOUNT_ID
1394
+ && !capabilities().localHandoff) {
1395
+ return c.json(hostedProfileRefusal, 409);
1396
+ }
1397
+ // Resolve the account BEFORE anything else: both the command and the status probe below
1398
+ // must describe the same one, or the pane reports on the personal login while the terminal
1399
+ // signs into the work login.
1400
+ const resolved = await resolveWorkspaceProfile(provider, body.data.profileId);
1401
+ if ('error' in resolved)
1402
+ return c.json({ error: resolved.error }, 400);
1403
+ const { profile } = resolved;
1404
+ const command = providerAuth.loginCommand(provider, profile.isDefault ? null : profile.path);
1405
+ // Fail closed: a config dir that cannot be embedded safely in this platform's shell has no
1406
+ // safe degradation. Running the bare command would sign the user into a DIFFERENT account
1407
+ // than the one they clicked, and nothing in the terminal would say so.
1408
+ if (command === null) {
1409
+ return c.json({ error: `This account's folder cannot be used in a terminal command: ${profile.configDir}` }, 409);
1410
+ }
1411
+ // BOTH branches must mean "is this account signed in NOW". The default branch refreshes; the
1412
+ // account branch has to evict first, because `profileStatus` serves the per-account cache and
1413
+ // a connected answer there stands for CONNECTED_TTL_MS. Without this, Connect after a
1414
+ // `claude /logout` answers "already connected", opens nothing, and the user is stuck — and it
1415
+ // would contradict this module's own invariant that opening a login is one of the things
1416
+ // cezar CAN observe and therefore invalidates explicitly rather than waiting out a window.
1417
+ if (!profile.isDefault)
1418
+ providerAuth.forgetProfileStatus(provider, profile.id);
1419
+ const row = profile.isDefault
1420
+ ? (await providerAuth.status({ refresh: true })).providers.find((candidate) => candidate.provider === provider)
1421
+ : await providerAuth.profileStatus(provider, { id: profile.id, configDir: profile.path });
1422
+ if (!row) {
1423
+ return c.json({ error: 'Authentication could not be verified. Try again.' }, 500);
1424
+ }
1425
+ if (row.status === 'connected') {
1426
+ return c.json({ opened: false, connected: true, command });
1427
+ }
1428
+ if (row.status === 'not-installed') {
1429
+ return c.json({ error: row.hint ?? providerAuth.installHint(provider), command }, 409);
1430
+ }
1431
+ if (row.status === 'unknown') {
1432
+ return c.json({ error: row.hint ?? 'Authentication could not be verified. Try again.', command }, 409);
1433
+ }
1434
+ if (!capabilities().localHandoff) {
1435
+ return c.json({ error: 'Run this command on the machine hosting cezar.', command }, 409);
1436
+ }
1437
+ let opened = false;
1438
+ try {
1439
+ // No `env` argument: `loginCommand` already rendered the account's config dir INTO the
1440
+ // command, because this string is also the copy-paste fallback the pane shows. Passing
1441
+ // it again here would set the variable twice.
1442
+ opened = await openTerminal(bootRoot, command);
1443
+ }
1444
+ catch {
1445
+ // Terminal handoff is best-effort; the exact command remains the safe fallback.
1446
+ }
1447
+ if (!opened) {
1448
+ return c.json({ error: 'No terminal emulator could be opened. Run this command manually.', command }, 409);
1449
+ }
1450
+ return c.json({ opened: true, command });
1451
+ });
1452
+ // ---- chained family: agent profiles / accounts (workspace-level) ----------
1453
+ // Extra config dirs for a SECOND login of the same agent CLI (spec
1454
+ // 2026-07-29-agent-profiles). Workspace-level and therefore SINGLE-MOUNT: an account belongs to
1455
+ // the person and the machine, never to a repo, and a project-scoped spelling would be a second
1456
+ // surface to protect with no consumer. Which account a project uses is a field on
1457
+ // `PATCH /api/v1/projects/:projectId` instead.
1458
+ //
1459
+ // Writing is a LOCAL-MACHINE capability, exactly like `PUT /api/v1/agent-config/:id`: a profile
1460
+ // points an agent at a directory on the host, and the listing echoes absolute paths carrying
1461
+ // the username — the same disclosure `/api/v1/health` trims in hosted mode (#431).
1462
+ /**
1463
+ * This agent's own USER-scope config files, resolved inside ONE account's folder.
1464
+ *
1465
+ * Straight from the catalog — the single home of config-file vendor knowledge — with an
1466
+ * `AgentHomePaths` whose slot for this provider is the account's dir. That is what makes a second
1467
+ * login's `settings.json` the file you open rather than the default account's, and it keeps the
1468
+ * ids opaque and stable so the open route below never takes a path from the client.
1469
+ */
1470
+ const accountFiles = async (profile) => {
1471
+ const home = {
1472
+ ...agentHomePaths(),
1473
+ ...(profile.provider === 'claude' ? { claude: profile.path } : {}),
1474
+ ...(profile.provider === 'codex' ? { codex: profile.path } : {}),
1475
+ ...(profile.provider === 'opencode' ? { opencodeConfig: profile.path } : {}),
1476
+ };
1477
+ const defs = listConfigFiles().filter((def) => def.scope === 'user' && def.runners.includes(profile.provider));
1478
+ return Promise.all(defs.map(async (def) => {
1479
+ const path = def.resolve(bootRoot, home);
1480
+ return { id: def.id, label: basename(path), path, exists: (await statConfigPath(path)).exists };
1481
+ }));
1482
+ };
1483
+ /** Build the wire row for one resolved profile: its dir state plus whatever auth is cached. */
1484
+ const agentProfileBody = async (profile) => ({
1485
+ id: profile.id,
1486
+ provider: profile.provider,
1487
+ label: profile.label,
1488
+ configDir: profile.configDir,
1489
+ path: profile.path,
1490
+ ...(await profileDirState(profile.provider, profile.path)),
1491
+ isDefault: profile.isDefault,
1492
+ // CACHED auth only — this listing must never pay a CLI spawn.
1493
+ //
1494
+ // Probing here cost a shell-out per provider PLUS one per extra account: ~0.7s with no extra
1495
+ // accounts and over 2s with a few, on every cold load, for a route whose real job (what
1496
+ // accounts exist) is a JSON read and a handful of stats. `GET /api/v1/health` already
1497
+ // established the rule — it serves whatever the cache holds and never pays a `gh` shell-out.
1498
+ // An absent `status` means "not determined yet", which the cockpit renders as Checking… and
1499
+ // then fills in from the per-account status route below.
1500
+ //
1501
+ // SPREAD, not `status: maybeUndefined`: hono would type the key as always-present while
1502
+ // `JSON.stringify` drops it, which is exactly the drift `contract-parity` catches (AGENTS.md
1503
+ // names this as one of the two recurring mismatches).
1504
+ ...(() => {
1505
+ const cached = profile.isDefault
1506
+ ? providerAuth.peekStatus()?.providers.find((row) => row.provider === profile.provider)
1507
+ : providerAuth.peekProfileStatus(profile.provider, profile.id);
1508
+ return cached ? { status: cached } : {};
1509
+ })(),
1510
+ files: await accountFiles(profile),
1511
+ });
1512
+ /**
1513
+ * Resolve `:id` to an account for the per-account reads below. Unlike `resolveWorkspaceProfile`
1514
+ * this accepts the reserved `default` for a NAMED provider, which these routes cannot infer — so
1515
+ * the id may be `default:<provider>` as well as a stored account id.
1516
+ */
1517
+ const accountById = async (id) => {
1518
+ const [head, tail] = id.split(':');
1519
+ if (head === DEFAULT_AGENT_ACCOUNT_ID) {
1520
+ const provider = PROVIDER_IDS.find((p) => p === tail);
1521
+ return provider ? defaultAgentProfile(provider) : null;
1522
+ }
1523
+ try {
1524
+ const stored = (await loadAgentAccounts()).accounts.find((a) => a.id === id);
1525
+ return stored ? resolveStoredProfile(stored) : null;
1526
+ }
1527
+ catch {
1528
+ return null;
1529
+ }
1530
+ };
1531
+ /** Validate a client-supplied config dir. Returns the error text, or null when it is usable. */
1532
+ const checkProfileDir = (configDir) => {
1533
+ if (CONTROL_CHARS_RE.test(configDir))
1534
+ return 'folder must not contain control characters';
1535
+ const expanded = expandTilde(configDir);
1536
+ // Absolute after expansion: a relative dir would resolve against whatever cwd the agent
1537
+ // happens to be spawned in, which for a task is a throwaway worktree. Through
1538
+ // `isAbsoluteConfigDir`, never a leading-`/` test — see its note: a string test refuses every
1539
+ // real Windows path, and this is the only gate the Add-account dialog has.
1540
+ if (!isAbsoluteConfigDir(expanded))
1541
+ return `folder must be an absolute path: ${configDir}`;
1542
+ return null;
1543
+ };
1544
+ /** Refuse a dir that is already some other account's (or the default's), compared through
1545
+ * `realpath` — two spellings of one directory would be two accounts silently sharing one
1546
+ * session store, and "which one am I logged into?" would stop having an answer. */
1547
+ const conflictingProfile = async (profiles, provider, path, exceptId) => {
1548
+ if (await sameProfileDir(path, defaultAgentProfile(provider).path)) {
1549
+ return 'that is already this agent\'s default folder';
1550
+ }
1551
+ for (const candidate of profiles) {
1552
+ if (candidate.provider !== provider || candidate.id === exceptId)
1553
+ continue;
1554
+ if (await sameProfileDir(path, expandTilde(candidate.configDir))) {
1555
+ return `that folder is already used by "${candidate.label || candidate.id}"`;
1556
+ }
1557
+ }
1558
+ return null;
1559
+ };
1560
+ const agentProfilesRoutes = new Hono()
1561
+ .get('/workspace/agent-profiles', async (c) => {
1562
+ const editable = capabilities().localHandoff;
1563
+ // Hosted mode withholds the listing entirely rather than serving it read-only: the paths
1564
+ // are the host disclosure, so an empty list is the only honest hosted answer.
1565
+ //
1566
+ // ONE body object, never a hosted `return` and a local `return`: two returns let hono
1567
+ // narrow `editable` to the literal `false`/`true` of each branch, and the contract's
1568
+ // honest `z.boolean()` then reads as wider than the route. Same shape as
1569
+ // `listAgentConfig`, which carries the same flag for the same reason.
1570
+ let store = defaultAgentAccountStore();
1571
+ if (editable) {
1572
+ try {
1573
+ store = await loadAgentAccounts();
1574
+ }
1575
+ catch {
1576
+ // an unreadable home degrades to "no extra accounts", never a failed request
1577
+ }
1578
+ }
1579
+ const profiles = editable
1580
+ ? await Promise.all(listAgentProfiles(store, PROVIDER_IDS).map(agentProfileBody))
1581
+ : [];
1582
+ return c.json({
1583
+ editable,
1584
+ profiles,
1585
+ profileCapableProviders: [...PROFILE_CAPABLE_PROVIDERS],
1586
+ // Which account each project uses, keyed by repo root. Served here rather than on the
1587
+ // project registry because it lives in the same file as the accounts it names.
1588
+ selections: editable ? store.selections : {},
1589
+ /** The machine-wide fallback, for repos that have chosen nothing. Withheld in hosted mode
1590
+ * on the same terms as the rest of this family. */
1591
+ defaults: editable ? store.defaults : {},
1592
+ });
1593
+ })
1594
+ .post('/workspace/agent-profiles', jsonZodValidator(() => createAgentProfileSchema), async (c) => {
1595
+ if (!capabilities().localHandoff)
1596
+ return c.json(hostedProfileRefusal, 409);
1597
+ const { provider, configDir, label } = c.req.valid('json');
1598
+ if (!supportsProfiles(provider)) {
1599
+ return c.json({ error: `${provider} cannot carry more than one account` }, 400);
1600
+ }
1601
+ const dirError = checkProfileDir(configDir);
1602
+ if (dirError)
1603
+ return c.json({ error: dirError }, 400);
1604
+ // Read-first, exactly like `POST /projects`: the duplicate check needs `realpath`, and the
1605
+ // merge-write mutator is deliberately SYNCHRONOUS so the read→rename window stays as small
1606
+ // as it is for every other writer of this file. Two processes adding the same folder in
1607
+ // that window would both win — a cosmetic duplicate, not a correctness problem, since the
1608
+ // schema's id dedupe is what keeps resolution deterministic.
1609
+ let existing = [];
1610
+ try {
1611
+ existing = (await loadAgentAccounts()).accounts;
1612
+ }
1613
+ catch {
1614
+ // unreadable store — the merge-write below reports the real failure
1615
+ }
1616
+ const conflict = await conflictingProfile(existing, provider, expandTilde(configDir));
1617
+ if (conflict !== null)
1618
+ return c.json({ error: conflict }, 409);
1619
+ let created;
1620
+ try {
1621
+ await mergeWriteAgentAccounts((store) => {
1622
+ const id = allocateAgentProfileId(label ?? configDir, store.accounts.map((a) => a.id));
1623
+ created = {
1624
+ id,
1625
+ provider,
1626
+ configDir,
1627
+ label: label?.trim() || id,
1628
+ addedAt: new Date().toISOString(),
1629
+ };
1630
+ store.accounts.push(created);
1631
+ });
1632
+ }
1633
+ catch (err) {
1634
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1635
+ }
1636
+ if (!created)
1637
+ return c.json({ error: 'account could not be saved' }, 500);
1638
+ // A brand-new account is the one thing the boot warm could not have known about, so learn it
1639
+ // now rather than only when something asks. Still off the response: the row is returned with
1640
+ // `status` absent (the listing's rule), the pane shows Checking…, and its follow-up request
1641
+ // joins this same in-flight probe.
1642
+ const account = resolveStoredProfile(created);
1643
+ const created201 = await agentProfileBody(account);
1644
+ void providerAuth
1645
+ .profileStatus(account.provider, { id: account.id, configDir: account.path })
1646
+ .catch(() => { });
1647
+ return c.json({ profile: created201 }, 201);
1648
+ })
1649
+ .patch('/workspace/agent-profiles/:id', paramZodValidator(z.object({ id: z.string() })), jsonZodValidator(() => updateAgentProfileSchema), async (c) => {
1650
+ if (!capabilities().localHandoff)
1651
+ return c.json(hostedProfileRefusal, 409);
1652
+ const id = c.req.param('id');
1653
+ const { label, configDir } = c.req.valid('json');
1654
+ if (configDir !== undefined) {
1655
+ const dirError = checkProfileDir(configDir);
1656
+ if (dirError)
1657
+ return c.json({ error: dirError }, 400);
1658
+ }
1659
+ // Read-first (see POST above, and `PATCH /projects`): a well-formed but unknown id must
1660
+ // 404 WITHOUT rewriting the config, and the duplicate check needs async `realpath`.
1661
+ let existing = [];
1662
+ try {
1663
+ existing = (await loadAgentAccounts()).accounts;
1664
+ }
1665
+ catch {
1666
+ // unreadable store — treated as unknown, like DELETE and PATCH /projects
1667
+ }
1668
+ const current = existing.find((a) => a.id === id);
1669
+ if (!current)
1670
+ return c.json({ error: `unknown account: ${id}` }, 404);
1671
+ if (configDir !== undefined) {
1672
+ const conflict = await conflictingProfile(existing, current.provider, expandTilde(configDir), id);
1673
+ if (conflict !== null)
1674
+ return c.json({ error: conflict }, 409);
1675
+ }
1676
+ let updated;
1677
+ try {
1678
+ await mergeWriteAgentAccounts((store) => {
1679
+ const entry = store.accounts.find((a) => a.id === id);
1680
+ if (!entry)
1681
+ return; // lost a race with a concurrent delete — answered below
1682
+ // Mutated in place so `.passthrough()` keys on the row survive.
1683
+ if (label !== undefined)
1684
+ entry.label = label.trim() || entry.id;
1685
+ if (configDir !== undefined)
1686
+ entry.configDir = configDir;
1687
+ updated = entry;
1688
+ });
1689
+ }
1690
+ catch (err) {
1691
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1692
+ }
1693
+ if (!updated)
1694
+ return c.json({ error: `unknown account: ${id}` }, 404);
1695
+ // The dir may have moved under a cached probe — drop THIS account's answer so the response
1696
+ // reports the new folder's state rather than the old one's, and leave every other account's
1697
+ // warm answer alone (it is still true). Then re-learn it in the background, so the server's
1698
+ // knowledge is complete again whether or not a cockpit is open to ask; the pane's own
1699
+ // request for this row joins the same in-flight probe rather than spawning a second.
1700
+ const repointed = resolveStoredProfile(updated);
1701
+ providerAuth.forgetProfileStatus(repointed.provider, repointed.id);
1702
+ const body = await agentProfileBody(repointed);
1703
+ void providerAuth
1704
+ .profileStatus(repointed.provider, { id: repointed.id, configDir: repointed.path })
1705
+ .catch(() => { });
1706
+ return c.json({ profile: body });
1707
+ })
1708
+ /**
1709
+ * One account's authentication state, probed for real.
1710
+ *
1711
+ * Separate from the listing because a probe shells out to an agent CLI: keeping it here lets
1712
+ * the pane paint immediately and fill each row in as its answer lands, instead of every cold
1713
+ * load blocking on N spawns. `refresh=1` drops this account's cached answer and re-probes, for
1714
+ * the "Check again" affordance — the cache itself holds a connected answer for minutes and an
1715
+ * unsettled one for a minute (`cacheTtlFor` in `core/provider-auth.ts`).
1716
+ */
1717
+ .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) => {
1718
+ if (!capabilities().localHandoff)
1719
+ return c.json(hostedProfileRefusal, 409);
1720
+ const account = await accountById(c.req.param('id'));
1721
+ if (!account)
1722
+ return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
1723
+ const refresh = c.req.valid('query').refresh === '1';
1724
+ // The discovered account's row is the one `GET /api/v1/providers/status` owns, so it comes
1725
+ // from there — enablement included, which a bare probe does not know about.
1726
+ if (account.isDefault) {
1727
+ const all = await providerStatus(refresh ? { refresh: true } : undefined);
1728
+ const row = all.providers.find((candidate) => candidate.provider === account.provider);
1729
+ return c.json({ status: row ?? { provider: account.provider, status: 'unknown' } });
1730
+ }
1731
+ // "Check again" re-probes THIS account only — the other accounts' warm answers are not
1732
+ // invalidated by asking about this one.
1733
+ if (refresh)
1734
+ providerAuth.forgetProfileStatus(account.provider, account.id);
1735
+ return c.json({
1736
+ status: await providerAuth.profileStatus(account.provider, {
1737
+ id: account.id,
1738
+ configDir: account.path,
1739
+ }),
1740
+ });
1741
+ })
1742
+ /**
1743
+ * Who this account is signed in AS — the pane's "Show details".
1744
+ *
1745
+ * A separate, on-demand route rather than a field on the listing, and that is what makes
1746
+ * "hidden by default" real: an email carried by the listing would already be in the response,
1747
+ * the query cache and devtools, whatever the UI chose to render. `provider-auth.ts` keeps
1748
+ * identity out of ITS boundary on purpose; this is the deliberate, local-only, opt-in
1749
+ * exception — not a widening of that rule. Never logged, never persisted.
1750
+ */
1751
+ .get('/workspace/agent-profiles/:id/details', paramZodValidator(z.object({ id: z.string() })), async (c) => {
1752
+ if (!capabilities().localHandoff)
1753
+ return c.json(hostedProfileRefusal, 409);
1754
+ const account = await accountById(c.req.param('id'));
1755
+ if (!account)
1756
+ return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
1757
+ return c.json(await readAccountIdentity(account.provider, account.path));
1758
+ })
1759
+ /**
1760
+ * Open one of this account's own config files — or its folder — in a local app.
1761
+ *
1762
+ * `file` is a catalog ID from the account's own `files`, never a path: the client cannot name a
1763
+ * location, so there is no traversal surface here at all (the same rule
1764
+ * `/api/v1/agent-config/:id` follows). `folder` is the one extra keyword, and it resolves to
1765
+ * the account's dir rather than anything the caller spelled.
1766
+ */
1767
+ .post('/workspace/agent-profiles/:id/open', paramZodValidator(z.object({ id: z.string() })), jsonZodValidator(() => openAgentAccountFileSchema), async (c) => {
1768
+ if (!capabilities().localHandoff)
1769
+ return c.json(hostedProfileRefusal, 409);
1770
+ const account = await accountById(c.req.param('id'));
1771
+ if (!account)
1772
+ return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
1773
+ const { file, target } = c.req.valid('json');
1774
+ let path;
1775
+ if (file === 'folder') {
1776
+ path = account.path;
1777
+ }
1778
+ else {
1779
+ const match = (await accountFiles(account)).find((f) => f.id === file);
1780
+ if (!match)
1781
+ return c.json({ error: `unknown file: ${file}` }, 404);
1782
+ path = match.path;
1783
+ }
1784
+ // A file the agent has not written yet has nothing to open; say so rather than hand the OS
1785
+ // a missing path and report success.
1786
+ if (!(await statConfigPath(path)).exists && file !== 'folder') {
1787
+ return c.json({ error: `this account has no ${basename(path)} yet` }, 409);
1788
+ }
1789
+ // `target` names a detected app; absent means the OS default handler. Either way the PATH
1790
+ // came from the catalog, so an editor is only ever pointed inside this account's folder.
1791
+ //
1792
+ // Which targets APPLY is checked here rather than left to the UI, because two of them are
1793
+ // actively wrong rather than merely useless: `terminal` runs `cd <path>`, which fails on a
1794
+ // file, and a `cli:<runner>` handoff would start an agent session inside the config folder.
1795
+ // A route is a surface of its own; it refuses what it cannot do correctly.
1796
+ if (target !== undefined) {
1797
+ if (agentCliRunner(target) !== null) {
1798
+ return c.json({ error: 'agent CLIs open a task worktree, not a config folder' }, 400);
1799
+ }
1800
+ if (target === 'terminal' && file !== 'folder') {
1801
+ return c.json({ error: 'a terminal opens a folder, not a file' }, 400);
1802
+ }
1803
+ if (!detectOpenTargets().some((candidate) => candidate.id === target)) {
1804
+ return c.json({ error: `no such app on this machine: ${target}` }, 400);
1805
+ }
1806
+ }
1807
+ const opened = target === undefined
1808
+ ? await openFile(path)
1809
+ : await openInApp(target, path);
1810
+ if (!opened)
1811
+ return c.json({ error: `could not open ${basename(path)}`, path }, 409);
1812
+ return c.json({ opened: true, path });
1813
+ })
1814
+ // Which account a PROJECT uses. On the accounts family rather than `PATCH /api/v1/projects`
1815
+ // because the selection is stored beside the accounts it names — one file, one atomic write,
1816
+ // and nothing about it can be dropped by a cezar version that never heard of accounts.
1817
+ .put('/workspace/agent-profiles/selection', jsonZodValidator(() => selectAgentProfileSchema), async (c) => {
1818
+ if (!capabilities().localHandoff)
1819
+ return c.json(hostedProfileRefusal, 409);
1820
+ const { projectId, provider, profileId } = c.req.valid('json');
1821
+ // `null` writes the MACHINE-WIDE default instead of one repo's selection: the account any
1822
+ // repo that has chosen nothing uses, so a second login is set up once rather than per
1823
+ // checkout. No project to resolve, and therefore no 404 path.
1824
+ let root = null;
1825
+ if (projectId !== null) {
1826
+ const resolvedId = projectId === 'default' ? await resolveBootProject() : projectId;
1827
+ // Keyed by repo ROOT, so the selection survives the registry being rebuilt and needs no
1828
+ // cross-reference into config.json. An unknown project is a 404 rather than an orphan
1829
+ // entry nobody will ever read.
1830
+ root = await projectRootFor(resolvedId);
1831
+ if (root === null)
1832
+ return c.json({ error: `unknown project: ${projectId}` }, 404);
1833
+ }
1834
+ // A user naming an account that does not exist gets told so — the opposite of how RUN
1835
+ // resolution treats a dangling stored id, and deliberately: a run has no better answer
1836
+ // than the default, a person does.
1837
+ if (profileId !== null && profileId !== DEFAULT_AGENT_ACCOUNT_ID) {
1838
+ const account = await resolveWorkspaceProfile(provider, profileId);
1839
+ if ('error' in account)
1840
+ return c.json({ error: account.error }, 400);
1841
+ }
1842
+ let store;
1843
+ try {
1844
+ store = await mergeWriteAgentAccounts((current) => {
1845
+ // One rule, two targets: the machine default is the same per-provider shape as a repo's
1846
+ // selection, so it clears the same way rather than growing its own spelling.
1847
+ const selection = root === null ? current.defaults : current.selections[root] ?? {};
1848
+ // `null` and the reserved `default` both mean "back to the discovered account", which
1849
+ // is stored as ABSENCE — the default id is never written to the file.
1850
+ if (profileId === null || profileId === DEFAULT_AGENT_ACCOUNT_ID)
1851
+ delete selection[provider];
1852
+ else
1853
+ selection[provider] = profileId;
1854
+ // The machine default keeps an emptied object where a repo selection is deleted, and
1855
+ // that asymmetry is the schema's, not an oversight: `defaults` is one FIXED field with
1856
+ // `.default(() => ({}))`, so `mergeWriteAgentAccounts` re-materializes it on the next
1857
+ // write no matter what this one omits (verified). `selections` is a growing MAP, where
1858
+ // an emptied entry is a row per repo ever touched that says nothing. The rule stated
1859
+ // for `agentDefaults.models` — "absence is the same answer" — applies there because
1860
+ // that key is `.optional()`, so deleting it actually sticks.
1861
+ if (root === null)
1862
+ current.defaults = selection;
1863
+ else if (Object.keys(selection).length === 0)
1864
+ delete current.selections[root];
1865
+ else
1866
+ current.selections[root] = selection;
1867
+ });
1868
+ }
1869
+ catch (err) {
1870
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1871
+ }
1872
+ return c.json({ selections: store.selections, defaults: store.defaults });
1873
+ })
1874
+ .delete('/workspace/agent-profiles/:id', paramZodValidator(z.object({ id: z.string() })), async (c) => {
1875
+ if (!capabilities().localHandoff)
1876
+ return c.json(hostedProfileRefusal, 409);
1877
+ const id = c.req.param('id');
1878
+ let removed = false;
1879
+ // Captured inside the mutator, because after the write there is nothing left to ask which
1880
+ // provider this account belonged to — and the eviction below is keyed by it.
1881
+ let removedProvider;
1882
+ try {
1883
+ await mergeWriteAgentAccounts((store) => {
1884
+ const before = store.accounts.length;
1885
+ removedProvider = store.accounts.find((a) => a.id === id)?.provider;
1886
+ store.accounts = store.accounts.filter((a) => a.id !== id);
1887
+ removed = store.accounts.length < before;
1888
+ if (!removed)
1889
+ return;
1890
+ // Scrub every reference IN THE SAME MUTATOR — the reason selections share this file.
1891
+ // A two-call delete-then-scrub can be observed mid-way by another cezar process on
1892
+ // this machine, which would then resolve a dangling id; harmless today (it degrades
1893
+ // to the default) but only by luck.
1894
+ for (const [root, selection] of Object.entries(store.selections)) {
1895
+ for (const key of Object.keys(selection)) {
1896
+ if (selection[key] === id)
1897
+ delete selection[key];
1898
+ }
1899
+ if (Object.keys(selection).length === 0)
1900
+ delete store.selections[root];
1901
+ }
1902
+ });
1903
+ }
1904
+ catch (err) {
1905
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1906
+ }
1907
+ if (!removed)
1908
+ return c.json({ error: `unknown account: ${id}` }, 404);
1909
+ // Only this account's answer: it is about to stop existing, and holding it would let a
1910
+ // re-added account with the same id read the deleted one's state.
1911
+ providerAuth.forgetProfileStatus(removedProvider, id);
1912
+ return c.json({ removed: true, id });
1913
+ });
917
1914
  // ---- workspace projects (multi-project spec) -----------------------------
918
1915
  // The registered-project list for the cockpit sidebar. Same-origin (unlike
919
1916
  // health), so absolute `root`s are fine here. `listProjects()` TTL-caches
@@ -939,7 +1936,9 @@ export function createApp(deps) {
939
1936
  return defaultWorkspaceConfig().browseRoot;
940
1937
  }
941
1938
  };
942
- app.get('/api/projects', async (c) => {
1939
+ // ---- chained family: project registry (workspace-level) ----
1940
+ const projectsRoutes = new Hono()
1941
+ .get('/projects', async (c) => {
943
1942
  let projects = [];
944
1943
  let projectsDir = defaultWorkspaceConfig().projectsDir;
945
1944
  try {
@@ -958,38 +1957,196 @@ export function createApp(deps) {
958
1957
  projectsDir,
959
1958
  };
960
1959
  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) => {
1960
+ })
1961
+ .post('/projects', jsonZodValidator(() => registerProjectSchema, { message: 'root must be a non-empty path' }), async (c) => {
1962
+ const parsed = { data: c.req.valid('json') };
1963
+ const registered = await registerFolder(parsed.data.root, 'local');
1964
+ if (registered.status !== 200)
1965
+ return c.json(registered.body, registered.status);
1966
+ return c.json(registered.body, 200);
1967
+ })
1968
+ .delete('/projects/:projectId', async (c) => {
976
1969
  if (capabilities().singleProject) {
977
- return { status: 409, body: singleProjectRefusal('adding projects') };
1970
+ return c.json(singleProjectRefusal('removing projects'), 409);
978
1971
  }
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
- };
1972
+ const raw = c.req.param('projectId');
1973
+ // Same gate the scoped-route resolver applies, and the same 404 wording —
1974
+ // a malformed id is an unknown project, not a validation essay.
1975
+ if (!projectIdSchema.safeParse(raw).success) {
1976
+ return c.json({ error: `unknown project: ${raw}` }, 404);
988
1977
  }
989
- // Hosted mode: the same root the picker is narrowed to, re-checked — see
990
- // `isInsideBrowseRoot`. Local mode deliberately has NO containment: a
991
- // project under `/srv/code` is a normal local setup and `cezar serve`
992
- // registers it today.
1978
+ const bootId = await resolveBootProject();
1979
+ // `default` is the boot alias everywhere else in the API; honour it here
1980
+ // too rather than 404ing a spelling the cockpit is allowed to use.
1981
+ const id = raw === 'default' ? bootId : raw;
1982
+ let entry;
1983
+ try {
1984
+ entry = (await loadWorkspaceConfig()).projects.find((p) => p.id === id);
1985
+ }
1986
+ catch {
1987
+ // unreadable workspace — there is nothing to remove, and saying so is
1988
+ // more useful than a 500 the user cannot act on
1989
+ }
1990
+ if (!entry)
1991
+ return c.json({ error: `unknown project: ${id}` }, 404);
1992
+ // The boot project is refused, not removed: `cezar serve` re-registers the
1993
+ // repo it was started in on every boot, so "removing" it would undo itself
1994
+ // at the next restart while breaking this session's sidebar in the
1995
+ // meantime. The pane disables the button and says the same thing.
1996
+ if (id === bootId) {
1997
+ return c.json({
1998
+ error: `cezar is serving ${entry.name} right now — it re-registers itself at every start, so it cannot be removed from here`,
1999
+ }, 409);
2000
+ }
2001
+ const active = activeRunCount(id);
2002
+ if (active > 0) {
2003
+ return c.json({
2004
+ error: `${entry.name} has ${active} running task${active === 1 ? '' : 's'} — cancel or finish ${active === 1 ? 'it' : 'them'} before removing the project`,
2005
+ runningTasks: active,
2006
+ }, 409);
2007
+ }
2008
+ let removed;
2009
+ try {
2010
+ removed = await removeProject(id);
2011
+ }
2012
+ catch (err) {
2013
+ // e.g. a read-only home — nothing was persisted (atomic tmp+rename).
2014
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2015
+ }
2016
+ // Lost a race with another writer (or another cezar process): the entry is
2017
+ // gone, which is what the caller wanted, but say it honestly.
2018
+ if (!removed)
2019
+ return c.json({ error: `unknown project: ${id}` }, 404);
2020
+ // In-process handles for a project no route can reach any more: store
2021
+ // closed (index flushed), manager's timers and usage subscription dropped.
2022
+ contexts.dispose(id);
2023
+ workspaceEvents.emit('project-removed', { id });
2024
+ const body = { removed: true, id };
2025
+ return c.json(body);
2026
+ })
2027
+ .patch('/projects/:projectId', jsonZodValidator(() => updateProjectSchema), async (c) => {
2028
+ if (capabilities().singleProject) {
2029
+ return c.json(singleProjectRefusal('editing projects'), 409);
2030
+ }
2031
+ const raw = c.req.param('projectId');
2032
+ // Same gate + 404 wording as DELETE: a malformed id is an unknown project.
2033
+ if (!projectIdSchema.safeParse(raw).success) {
2034
+ return c.json({ error: `unknown project: ${raw}` }, 404);
2035
+ }
2036
+ const parsed = { data: c.req.valid('json') };
2037
+ // `default` is the boot alias the cockpit is allowed to use everywhere else.
2038
+ const id = raw === 'default' ? await resolveBootProject() : raw;
2039
+ const { maxParallel } = parsed.data;
2040
+ // Read-first (mirroring DELETE, server.ts:1252-1258): a well-formed but
2041
+ // unknown id must 404 WITHOUT rewriting the config — otherwise it would both
2042
+ // do a needless full-config tmp+rename and, on a read-only home, surface the
2043
+ // write failure as a 500 where the honest answer is 404.
2044
+ let known = false;
2045
+ try {
2046
+ known = (await loadWorkspaceConfig()).projects.some((p) => p.id === id);
2047
+ }
2048
+ catch {
2049
+ // unreadable workspace — treat as unknown; the read-only case answers 404,
2050
+ // not a 500 the caller cannot act on (same reasoning as DELETE).
2051
+ }
2052
+ if (!known)
2053
+ return c.json({ error: `unknown project: ${id}` }, 404);
2054
+ let updated;
2055
+ try {
2056
+ await mergeWriteWorkspaceConfig((config) => {
2057
+ const entry = config.projects.find((p) => p.id === id);
2058
+ if (!entry)
2059
+ return; // lost a race with a concurrent remove — answered below
2060
+ // null clears the override; a number sets it. Mutated in place so
2061
+ // `.passthrough()` keys on the entry survive.
2062
+ if (maxParallel === null)
2063
+ delete entry.maxParallel;
2064
+ else
2065
+ entry.maxParallel = maxParallel;
2066
+ updated = entry;
2067
+ });
2068
+ }
2069
+ catch (err) {
2070
+ // e.g. a read-only home — nothing was persisted (atomic tmp+rename).
2071
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2072
+ }
2073
+ // Raced with a concurrent removal between the read and the write.
2074
+ if (!updated)
2075
+ return c.json({ error: `unknown project: ${id}` }, 404);
2076
+ // The new ceiling takes effect WITHOUT a restart: refresh the shared
2077
+ // semaphore's snapshot and pump every manager — the same live-apply hook
2078
+ // `PUT /api/workspace/config` fires for a workspace-cap change.
2079
+ await deps.semaphore?.refresh();
2080
+ const body = {
2081
+ project: { ...updated, ...(await probeProjectStatus(updated.root)) },
2082
+ };
2083
+ return c.json(body);
2084
+ })
2085
+ .post('/projects/checkout', jsonZodValidator(() => checkoutSchema, { message: 'url must be a GitHub repository' }), async (c) => {
2086
+ if (capabilities().singleProject) {
2087
+ return c.json(singleProjectRefusal('adding projects'), 409);
2088
+ }
2089
+ const parsed = { data: c.req.valid('json') };
2090
+ const { url, name, checkoutId } = parsed.data;
2091
+ const result = await checkoutRepo({
2092
+ url,
2093
+ name,
2094
+ checkoutId,
2095
+ projectsDir: expandTilde(await workspaceProjectsDir()),
2096
+ onProgress: (event) => workspaceEvents.emit('checkout-progress', event),
2097
+ // A closed dialog / navigated-away tab aborts the request; the clone is
2098
+ // killed and its partial directory removed rather than left running.
2099
+ signal: c.req.raw.signal,
2100
+ ...(deps.cloneRunner ? { run: deps.cloneRunner } : {}),
2101
+ });
2102
+ if (!result.ok) {
2103
+ // `reason` rides along on the 503 (`gh` unavailable) — the spec's
2104
+ // `{ error, reason }` degradation, mirroring the GitHub pane.
2105
+ return c.json('reason' in result ? { error: result.error, reason: result.reason } : { error: result.error }, result.status);
2106
+ }
2107
+ const registered = await registerFolder(result.target, 'checkout');
2108
+ if (registered.status !== 200) {
2109
+ // The clone SUCCEEDED and its files are legitimately the user's, so this
2110
+ // path deliberately does NOT clean up — an unregisterable checkout is a
2111
+ // registry problem, not a reason to delete a repo we just fetched. Say
2112
+ // where it is so they can register it by hand.
2113
+ const { body } = registered;
2114
+ const error = 'error' in body && body.error ? body.error : 'could not register the checkout';
2115
+ return c.json({ error: `${error} (the clone is at ${result.target})` }, registered.status);
2116
+ }
2117
+ return c.json(registered.body, 200);
2118
+ });
2119
+ // Register an existing folder (multi-project spec, "Add project" — the
2120
+ // folder-browser dialog's commit step, step 4.2). Workspace-level like its
2121
+ // GET twin. Everything here is a guard; the registry write itself is one
2122
+ // idempotent `registerProject` call.
2123
+ const registerProjectSchema = z.object({
2124
+ root: z.string().trim().min(1).max(4096),
2125
+ });
2126
+ /**
2127
+ * The register-a-folder half of `POST /api/projects`, factored out so the
2128
+ * checkout route (step 4.3) commits its fresh clone through the SAME guards
2129
+ * and the same `project-added` emission rather than a second copy of them.
2130
+ * Returns the status + body for the caller to answer with.
2131
+ */
2132
+ const registerFolder = async (spelled, source) => {
2133
+ if (capabilities().singleProject) {
2134
+ return { status: 409, body: singleProjectRefusal('adding projects') };
2135
+ }
2136
+ // `~` is expanded for the same reason `/api/fs/browse` expands it: the
2137
+ // dialog hands back absolute paths, but a hand-written body (curl, a
2138
+ // future CLI) spells home the way a shell does.
2139
+ const requested = expandTilde(spelled);
2140
+ if (!requested.startsWith('/')) {
2141
+ return {
2142
+ status: 400,
2143
+ body: { error: `not a folder: ${spelled} is not an absolute path` },
2144
+ };
2145
+ }
2146
+ // Hosted mode: the same root the picker is narrowed to, re-checked — see
2147
+ // `isInsideBrowseRoot`. Local mode deliberately has NO containment: a
2148
+ // project under `/srv/code` is a normal local setup and `cezar serve`
2149
+ // registers it today.
993
2150
  //
994
2151
  // Containment is asked in two halves, around the stat, and the split is
995
2152
  // deliberate.
@@ -1079,13 +2236,6 @@ export function createApp(deps) {
1079
2236
  workspaceEvents.emit('project-added', { project });
1080
2237
  return { status: 200, body: { project } };
1081
2238
  };
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
2239
  // Deregister a project (multi-project spec, step 4.4 — Settings → Projects,
1090
2240
  // the per-row "Remove"). READ THIS BEFORE TOUCHING THE HANDLER: the ONLY
1091
2241
  // durable effect allowed here is dropping one entry from
@@ -1120,136 +2270,74 @@ export function createApp(deps) {
1120
2270
  return 0;
1121
2271
  return ctx.store.listRuns().filter((run) => ACTIVE_RUN_STATUSES.has(run.status)).length;
1122
2272
  };
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
- }
2273
+ // Workspace-level by design: update state spans project and global installs,
2274
+ // but the selected registered project supplies the safe, server-owned cwd.
2275
+ const skillsUpdateInputSchema = z.object({ projectId: projectIdSchema }).strict();
2276
+ const resolveSkillsUpdateRoot = async (raw) => {
2277
+ if (!projectIdSchema.safeParse(raw).success)
2278
+ return { status: 404, error: `unknown project: ${raw}` };
1133
2279
  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);
1166
- }
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;
1224
- 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);
1241
- }
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);
2280
+ if (raw === 'default' || raw === bootId)
2281
+ return { root: bootRoot };
2282
+ const project = (await loadWorkspaceConfig()).projects.find((entry) => entry.id === raw);
2283
+ if (!project)
2284
+ return { status: 404, error: `unknown project: ${raw}` };
2285
+ if ((await probeProjectStatus(project.root)).status === 'missing') {
2286
+ return { status: 409, error: `project folder not found: ${raw}` };
2287
+ }
2288
+ return { root: project.root };
2289
+ };
2290
+ const skillsUpdateResponse = async (state) => {
2291
+ const config = await loadWorkspaceConfig();
2292
+ return { ...state, autoUpdateEnabled: effectiveSkillsAutoUpdate(config), inherited: config.skillsAutoUpdate === undefined };
2293
+ };
2294
+ // ---- chained family: skills updates (workspace-level) ----
2295
+ const skillsUpdateRoutes = new Hono()
2296
+ .get('/workspace/skills-update', queryZodValidator(skillsUpdateInputSchema, { message: 'projectId is required' }), async (c) => {
2297
+ const parsed = { data: c.req.valid('query') };
2298
+ const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
2299
+ if ('error' in resolved)
2300
+ return c.json({ error: resolved.error }, resolved.status);
2301
+ const state = skillsUpdate.snapshot(resolved.root);
2302
+ void skillsUpdate.check(resolved.root).catch(() => { });
2303
+ return c.json(await skillsUpdateResponse(state));
2304
+ })
2305
+ .post('/workspace/skills-update/check', jsonZodValidator(skillsUpdateInputSchema, { message: 'body must contain only projectId' }), async (c) => {
2306
+ const parsed = { data: c.req.valid('json') };
2307
+ const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
2308
+ if ('error' in resolved)
2309
+ return c.json({ error: resolved.error }, resolved.status);
2310
+ return c.json(await skillsUpdateResponse(await skillsUpdate.check(resolved.root, true)));
2311
+ })
2312
+ .post('/workspace/skills-update/apply', jsonZodValidator(skillsUpdateInputSchema, { message: 'body must contain only projectId' }), async (c) => {
2313
+ const parsed = { data: c.req.valid('json') };
2314
+ const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
2315
+ if ('error' in resolved)
2316
+ return c.json({ error: resolved.error }, resolved.status);
2317
+ try {
2318
+ return c.json(await skillsUpdateResponse(await skillsUpdate.update(resolved.root, true)));
2319
+ }
2320
+ catch (error) {
2321
+ if (error instanceof SkillsUpdateConflictError) {
2322
+ return c.json({ error: 'another skills update operation is running', state: await skillsUpdateResponse(skillsUpdate.snapshot(resolved.root)) }, 409);
2323
+ }
2324
+ throw error;
2325
+ }
2326
+ });
2327
+ // Edit one field of an existing registry entry (spec
2328
+ // 2026-07-22-per-project-concurrency): the per-project concurrency ceiling.
2329
+ // A PATCH (not PUT) because it touches a single field, and a distinct route
2330
+ // from POST (register-a-folder) to keep register vs. edit semantics clear.
2331
+ // `maxParallel: null` clears the override back to "inherit the workspace
2332
+ // cap". Bounds mirror `workspaceProjectSchema` (config.ts) exactly, so a
2333
+ // value this route accepts can never be degraded away by the next load's
2334
+ // `.catch`.
2335
+ //
2336
+ // Deliberately NOT the home of the agent-account selection: that lives in
2337
+ // `~/.cezar/agent-accounts.json` beside the accounts it names, so a cezar version that has
2338
+ // never heard of accounts cannot drop it (see workspace/agent-accounts.ts).
2339
+ const updateProjectSchema = z.object({
2340
+ maxParallel: z.number().int().min(1).max(16).nullable(),
1253
2341
  });
1254
2342
  // ---- GUI clone (multi-project spec, step 4.3) ----------------------------
1255
2343
  // "Add project → Clone from GitHub": clone into the checkout root, then
@@ -1267,41 +2355,6 @@ export function createApp(deps) {
1267
2355
  name: z.string().trim().max(128).optional(),
1268
2356
  checkoutId: z.string().trim().max(128).optional(),
1269
2357
  });
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
2358
  // ---- workspace settings (multi-project spec, step 2.7) -------------------
1306
2359
  // WORKSPACE-level routes: single-mount (never mirrored under /api/p/),
1307
2360
  // same-origin. The config routes carry the settings UI's slice of
@@ -1311,33 +2364,40 @@ export function createApp(deps) {
1311
2364
  const workspaceConfigBody = (config) => ({
1312
2365
  browseRoot: config.browseRoot,
1313
2366
  projectsDir: config.projectsDir,
2367
+ skillsAutoUpdate: config.skillsAutoUpdate ?? null,
2368
+ effectiveSkillsAutoUpdate: effectiveSkillsAutoUpdate(config),
2369
+ composerDefaults: {
2370
+ autonomous: config.composerDefaults.autonomous ?? null,
2371
+ worktree: config.composerDefaults.worktree ?? null,
2372
+ inheritedAutonomous: process.env.CEZ_AUTONOMOUS_DEFAULT === '0'
2373
+ ? false
2374
+ : process.env.CEZ_AUTONOMOUS_DEFAULT === '1'
2375
+ ? true
2376
+ : 'source-dependent',
2377
+ inheritedWorktree: effectiveComposerDefault(undefined, process.env.CEZ_WORKTREE_DEFAULT, true),
2378
+ },
1314
2379
  resources: {
1315
2380
  maxParallel: config.resources.maxParallel,
2381
+ maxMonitoringSessions: config.resources.maxMonitoringSessions,
2382
+ monitoringWakeIntervalMinutes: config.resources.monitoringWakeIntervalMinutes,
2383
+ autoResumeOnUsageLimit: config.resources.autoResumeOnUsageLimit,
1316
2384
  memoryLimitMb: config.resources.memoryLimitMb,
1317
2385
  worktreeRetentionDefault: config.resources.worktreeRetentionDefault,
1318
2386
  },
2387
+ // SPREAD, never `runner: maybeUndefined`: hono would type the key as always-present while
2388
+ // `JSON.stringify` drops it, which is the exact drift `contract-parity` catches. And absent has
2389
+ // to keep meaning "no opinion" here, or the fallback collapses into "always claude".
2390
+ agentDefaults: {
2391
+ ...(config.agentDefaults.runner !== undefined ? { runner: config.agentDefaults.runner } : {}),
2392
+ ...(config.agentDefaults.models !== undefined ? { models: config.agentDefaults.models } : {}),
2393
+ },
1319
2394
  });
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;
2395
+ // ---- chained family: workspace settings + GUI prefs (workspace-level) ----
2396
+ const workspaceConfigRoutes = new Hono()
2397
+ .get('/workspace/config', async (c) => c.json(workspaceConfigBody(await loadWorkspaceConfig())))
2398
+ .put('/workspace/config', jsonZodValidator(() => workspaceConfigUpdateSchema), async (c) => {
2399
+ const parsed = { data: c.req.valid('json') };
2400
+ const { browseRoot, projectsDir, skillsAutoUpdate, composerDefaults, resources, agentDefaults } = parsed.data;
1341
2401
  for (const [configuredRoot, create] of [
1342
2402
  [browseRoot, false],
1343
2403
  [projectsDir, true],
@@ -1376,13 +2436,57 @@ export function createApp(deps) {
1376
2436
  config.browseRoot = browseRoot;
1377
2437
  if (projectsDir !== undefined)
1378
2438
  config.projectsDir = projectsDir;
2439
+ if (skillsAutoUpdate === null)
2440
+ delete config.skillsAutoUpdate;
2441
+ else if (skillsAutoUpdate !== undefined)
2442
+ config.skillsAutoUpdate = skillsAutoUpdate;
2443
+ if (composerDefaults?.autonomous === null)
2444
+ delete config.composerDefaults.autonomous;
2445
+ else if (composerDefaults?.autonomous !== undefined) {
2446
+ config.composerDefaults.autonomous = composerDefaults.autonomous;
2447
+ }
2448
+ if (composerDefaults?.worktree === null)
2449
+ delete config.composerDefaults.worktree;
2450
+ else if (composerDefaults?.worktree !== undefined) {
2451
+ config.composerDefaults.worktree = composerDefaults.worktree;
2452
+ }
1379
2453
  if (resources?.maxParallel !== undefined)
1380
2454
  config.resources.maxParallel = resources.maxParallel;
2455
+ if (resources?.maxMonitoringSessions !== undefined) {
2456
+ config.resources.maxMonitoringSessions = resources.maxMonitoringSessions;
2457
+ }
2458
+ if (resources?.monitoringWakeIntervalMinutes !== undefined) {
2459
+ config.resources.monitoringWakeIntervalMinutes = resources.monitoringWakeIntervalMinutes;
2460
+ }
2461
+ if (resources?.autoResumeOnUsageLimit !== undefined) {
2462
+ config.resources.autoResumeOnUsageLimit = resources.autoResumeOnUsageLimit;
2463
+ }
1381
2464
  if (resources?.memoryLimitMb !== undefined)
1382
2465
  config.resources.memoryLimitMb = resources.memoryLimitMb;
1383
2466
  if (resources?.worktreeRetentionDefault !== undefined) {
1384
2467
  config.resources.worktreeRetentionDefault = resources.worktreeRetentionDefault;
1385
2468
  }
2469
+ // `null` CLEARS back to "no opinion" — a partial patch cannot say that by omission,
2470
+ // and leaving a stale runner behind would keep overriding repos that never chose.
2471
+ if (agentDefaults?.runner === null)
2472
+ delete config.agentDefaults.runner;
2473
+ else if (agentDefaults?.runner !== undefined)
2474
+ config.agentDefaults.runner = agentDefaults.runner;
2475
+ for (const runner of PROVIDER_IDS) {
2476
+ const model = agentDefaults?.models?.[runner];
2477
+ if (model === undefined)
2478
+ continue;
2479
+ const models = config.agentDefaults.models ?? {};
2480
+ if (model === null)
2481
+ delete models[runner];
2482
+ else
2483
+ models[runner] = model;
2484
+ // An empty object would persist a key that says nothing; absence is the same answer.
2485
+ if (Object.keys(models).length === 0)
2486
+ delete config.agentDefaults.models;
2487
+ else
2488
+ config.agentDefaults.models = models;
2489
+ }
1386
2490
  });
1387
2491
  }
1388
2492
  catch (err) {
@@ -1394,15 +2498,19 @@ export function createApp(deps) {
1394
2498
  if (resources !== undefined)
1395
2499
  await deps.semaphore?.refresh();
1396
2500
  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);
2501
+ })
2502
+ // Global GUI state (`~/.cezar/ui-state.json`) — same parse/key-cap/shallow-
2503
+ // merge semantics as the per-repo /api/v1/ui-state route below (the shared half
2504
+ // is `uiStateBodySchema`), but backed by the workspace file.
2505
+ .get('/workspace/ui-state', async (c) => c.json(await readWorkspaceUiState()))
2506
+ // The tighter body cap rides on `use` rather than inline on the route: `bodyLimit` is typed
2507
+ // as a bare MiddlewareHandler, and passing one to `.put()` collapses the route's schema, so
2508
+ // the PUT went missing from `AppType` and `hc` could not see its body at all. `use` runs at
2509
+ // the same point (before the handler, so the cap still precedes any read) and leaves the
2510
+ // chain's type accumulation alone. Method-agnostic here, which the GET does not mind.
2511
+ .use('/workspace/ui-state', bodyLimit({ maxSize: UI_STATE_BODY_LIMIT }))
2512
+ .put('/workspace/ui-state', jsonZodValidator(workspaceUiStateBody), async (c) => {
2513
+ const parsed = { data: c.req.valid('json') };
1406
2514
  try {
1407
2515
  return c.json(await mergeWriteWorkspaceUiState((state) => ({
1408
2516
  ...state,
@@ -1414,67 +2522,118 @@ export function createApp(deps) {
1414
2522
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1415
2523
  }
1416
2524
  });
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) => {
2525
+ // Partial updates only — absent keys stay untouched. Bounds mirror the
2526
+ // workspace schema (src/workspace/config.ts, step 1.2) exactly, so a value
2527
+ // this route accepts can never be degraded away by the next load's `.catch`.
2528
+ const workspaceConfigUpdateSchema = z.object({
2529
+ browseRoot: z.string().trim().min(1).max(4096).optional(),
2530
+ projectsDir: z.string().trim().min(1).max(4096).optional(),
2531
+ skillsAutoUpdate: z.boolean().nullable().optional(),
2532
+ composerDefaults: z
2533
+ .object({
2534
+ autonomous: z.boolean().nullable().optional(),
2535
+ worktree: z.boolean().nullable().optional(),
2536
+ })
2537
+ .optional(),
2538
+ resources: z
2539
+ .object({
2540
+ maxParallel: z.number().int().min(1).max(16).optional(),
2541
+ maxMonitoringSessions: z.number().int().min(0).max(16).optional(),
2542
+ monitoringWakeIntervalMinutes: z.number().int().min(1).max(60).nullable().optional(),
2543
+ autoResumeOnUsageLimit: z.boolean().optional(),
2544
+ memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
2545
+ worktreeRetentionDefault: z.number().int().min(0).max(1000).optional(),
2546
+ })
2547
+ .optional(),
2548
+ // Bounds mirror `src/workspace/config.ts`, so a value this accepts is never degraded away by
2549
+ // the next load's `.catch`. `null` clears a key back to "no opinion".
2550
+ agentDefaults: z
2551
+ .object({
2552
+ runner: z.enum(PROVIDER_IDS).nullable().optional(),
2553
+ models: z
2554
+ .object({
2555
+ claude: z.string().trim().min(1).max(200).nullable().optional(),
2556
+ codex: z.string().trim().min(1).max(200).nullable().optional(),
2557
+ opencode: z.string().trim().min(1).max(200).nullable().optional(),
2558
+ })
2559
+ .optional(),
2560
+ })
2561
+ .optional(),
2562
+ });
2563
+ // ---- chained family: filesystem browse (workspace-level) ----
2564
+ const fsBrowseRoutes = new Hono()
2565
+ .get('/fs/browse', queryZodValidator(z.object({ path: queryValue, showHidden: queryValue })), async (c) => {
1424
2566
  if (capabilities().singleProject) {
1425
2567
  return c.json(singleProjectRefusal('folder browsing'), 409);
1426
2568
  }
2569
+ const query = c.req.valid('query');
1427
2570
  const root = resolveBrowseRoot(await workspaceBrowseRoot());
1428
2571
  const result = await browseDirectory({
1429
2572
  root,
1430
- path: c.req.query('path'),
1431
- showHidden: c.req.query('showHidden') === '1',
2573
+ path: query.path,
2574
+ showHidden: query.showHidden === '1',
1432
2575
  });
1433
2576
  if (!result.ok)
1434
2577
  return c.json({ error: result.error }, result.status);
1435
2578
  return c.json(result.body);
1436
2579
  });
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) => {
2580
+ // ---- chained family: launch-key (project-scoped) ----
2581
+ const launchKeyRoutes = new Hono()
2582
+ .get('/launch-key', (c) => c.json({ key: c.get('project').launchKey }));
2583
+ // ---- chained family: skills (project-scoped) ----
2584
+ const skillsRoutes = new Hono()
2585
+ .get('/skills', queryZodValidator(waitQuery), async (c) => {
1441
2586
  const repoRoot = c.get('project').root;
1442
2587
  // The default read stays fast and starts the team load in the background.
1443
2588
  // The cockpit follows it with `wait=1`, off the render path, so a cold
1444
2589
  // cache converges without polling or a manual reload (spec 005 / #555).
1445
- if (c.req.query('wait') === '1')
2590
+ if (c.req.valid('query').wait === '1')
1446
2591
  await waitForTeamSkills(repoRoot);
1447
2592
  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) => {
2593
+ })
2594
+ // The opt-in catalog for the "Import skills" panel: every skill a default
2595
+ // (vendor) repo offers — `open-mercato/skills` — regardless of import state,
2596
+ // so the panel can present them all with a per-skill toggle. Empty once a repo
2597
+ // configures its own `skillsRepos` (nothing is gated then). `wait=1` lets the
2598
+ // panel wait out a cold team-skill cache, same as `GET /skills` (spec 005).
2599
+ .get('/skills/importable', queryZodValidator(waitQuery), async (c) => {
1455
2600
  const repoRoot = c.get('project').root;
1456
2601
  const gated = await gatedSkillsRepos(repoRoot);
1457
2602
  if (gated.size === 0)
1458
2603
  return c.json([]);
1459
- if (c.req.query('wait') === '1')
2604
+ if (c.req.valid('query').wait === '1')
1460
2605
  await waitForTeamSkills(repoRoot);
1461
2606
  const importable = getTeamSkillsCached(repoRoot)
1462
2607
  .filter((skill) => skill.team && gated.has(skill.team.repo))
1463
- .map((skill) => ({ name: skill.name, description: skill.description }));
2608
+ // Spread `description` rather than writing it unconditionally: an undefined VALUE is
2609
+ // dropped by JSON.stringify, so the key is absent on the wire, and writing it always
2610
+ // typed the route as sending a key it does not. contract/skills.ts says `.optional()`,
2611
+ // which is what the client actually receives.
2612
+ .map((skill) => ({
2613
+ name: skill.name,
2614
+ ...(skill.description !== undefined ? { description: skill.description } : {}),
2615
+ }));
1464
2616
  return c.json(importable);
2617
+ })
2618
+ // Refresh team skills (spec 005): clone/fetch the configured skills repos,
2619
+ // then return the merged catalog. Degrades quietly — offline just means the
2620
+ // team entries stay as they were (or absent).
2621
+ .post('/skills/refresh', async (c) => {
2622
+ const { root: repoRoot } = c.get('project');
2623
+ await refreshTeamSkills(repoRoot);
2624
+ return c.json(await discoverSkills(repoRoot));
1465
2625
  });
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) => {
2626
+ // ---- chained family: GUI prefs / ui-state (project-scoped) ----
2627
+ const uiStateRoutes = new Hono()
2628
+ .get('/ui-state', async (c) => c.json(await readUiState(c.get('project').root)))
2629
+ // On `use`, not inline on the route — see the workspace ui-state PUT above.
2630
+ .use('/ui-state', bodyLimit({ maxSize: UI_STATE_BODY_LIMIT }))
2631
+ .put('/ui-state', jsonZodValidator(uiStateBody), async (c) => {
1471
2632
  const { root: repoRoot, dataDir } = c.get('project');
1472
2633
  // `.passthrough()` keeps unknown prefs (BACKWARD_COMPATIBILITY §3), but a
1473
2634
  // 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);
2635
+ // schema+cap half of both ui-state routes lives in `uiStateBody`.
2636
+ const parsed = { data: c.req.valid('json') };
1478
2637
  const merged = { ...(await readUiState(repoRoot)), ...parsed.data };
1479
2638
  try {
1480
2639
  await mkdir(dataDir, { recursive: true });
@@ -1485,24 +2644,18 @@ export function createApp(deps) {
1485
2644
  }
1486
2645
  return c.json(merged);
1487
2646
  });
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) => {
2647
+ // ---- chained family: workflows (project-scoped) --------------------------
2648
+ // One chained expression, mounted by `createApp` into BOTH the legacy `api`
2649
+ // table and the versioned `v1` one — see `healthRoutes` for why the chain
2650
+ // shape (not the statement shape) is what carries the types.
2651
+ const workflowsRoutes = new Hono()
2652
+ .get('/workflows', async (c) => c.json(await loadWorkflows(c.get('project').root)))
2653
+ // Save an approved plan as a reusable chain (spec 008): YAML in
2654
+ // `.ai/cezar/workflows/<slug>.yaml` — from then on it's in the dropdown
2655
+ // like any other workflow.
2656
+ .post('/workflows', jsonZodValidator(saveWorkflowSchema), async (c) => {
1501
2657
  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
- }
2658
+ const parsed = { data: c.req.valid('json') };
1506
2659
  const steps = parsed.data.steps ?? skillsToSteps(parsed.data.skills ?? []);
1507
2660
  const issue = stepsIssue(steps);
1508
2661
  if (issue)
@@ -1534,10 +2687,10 @@ export function createApp(deps) {
1534
2687
  return c.json({ error: message }, 500);
1535
2688
  }
1536
2689
  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) => {
2690
+ })
2691
+ // Delete a saved workflow (spec 012 follow-up): file workflows only —
2692
+ // built-ins have no file and always come back.
2693
+ .delete('/workflows/:name', async (c) => {
1541
2694
  const { root: repoRoot } = c.get('project');
1542
2695
  const name = c.req.param('name');
1543
2696
  const { workflows } = await loadWorkflows(repoRoot);
@@ -1559,15 +2712,12 @@ export function createApp(deps) {
1559
2712
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1560
2713
  }
1561
2714
  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
- }
2715
+ })
2716
+ // Import support for the builder (spec 012): parse + validate a pasted
2717
+ // workflow YAML (either form) and hand back the normalized definition. The
2718
+ // server owns YAML parsing — the GUI stays dependency-free.
2719
+ .post('/workflows/parse', jsonZodValidator(parseWorkflowSchema), async (c) => {
2720
+ const parsed = { data: c.req.valid('json') };
1571
2721
  let raw;
1572
2722
  try {
1573
2723
  raw = parseYaml(parsed.data.yaml);
@@ -1586,17 +2736,250 @@ export function createApp(deps) {
1586
2736
  return c.json({ error: issue }, 400);
1587
2737
  return c.json(normalized);
1588
2738
  });
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) => {
2739
+ // ---- chained family: plan (project-scoped) ----
2740
+ const planRoutes = new Hono()
2741
+ .post('/plan', jsonZodValidator(planSchema), async (c) => {
1593
2742
  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
- }
2743
+ const parsed = { data: c.req.valid('json') };
2744
+ const blocked = await providerActionError([(await loadConfig(repoRoot)).defaultRunner]);
2745
+ if (blocked)
2746
+ return c.json({ error: blocked }, 409);
1598
2747
  return c.json(await planChain(repoRoot, parsed.data.task));
1599
2748
  });
2749
+ const manualChecks = new Map();
2750
+ // ---- chained family: GitHub automations (project-scoped) ----
2751
+ // Every handler below reads `c.get('project')` — the definitions, their runtime state and the
2752
+ // execution log are per-project files — so the family is project-scoped and mounted with the
2753
+ // rest of the mirrored table. The one exception is the manual-check read, which touches no
2754
+ // project at all; it is its own workspace-level family below.
2755
+ const automationsRoutes = new Hono()
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
+ .get('/automation-checks/:checkId', (c) => {
2980
+ const check = manualChecks.get(c.req.param('checkId'));
2981
+ return check ? c.json(check) : c.json({ error: 'not found' }, 404);
2982
+ });
1600
2983
  // ---- runs ----------------------------------------------------------------
1601
2984
  // Additive `usage` field (#348): the latest CPU/RSS/proc-count sample of the
1602
2985
  // run's live process tree — absent for finished runs and when `ps` yields
@@ -1625,27 +3008,49 @@ export function createApp(deps) {
1625
3008
  console.warn(`[cezar] could not mark inbox entry ${todoId} started: ${String(err)}`);
1626
3009
  }
1627
3010
  };
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) => {
3011
+ // ---- chained family: runs lifecycle + artifacts (project-scoped) ----
3012
+ const runsRoutes = new Hono()
3013
+ .get('/runs', (c) => c.json(c.get('project').store.listRuns().map(withUsage)))
3014
+ // Registered before the `/:id/...` routes so "archive-finished" and "read-all"
3015
+ // never match as a run id.
3016
+ .post('/runs/archive-finished', (c) => c.json({ archived: c.get('project').store.archiveFinished() }))
3017
+ // The read-receipt sweep (#unread-done-items) — the mark-read twin of the archive
3018
+ // sweep above, and under the same registration-order guard.
3019
+ .post('/runs/read-all', (c) => c.json({ read: c.get('project').store.markAllRead() }))
3020
+ .post('/runs/:id/archive', jsonZodValidator(archiveSchema, { absent: ({}) }), async (c) => {
1633
3021
  const { store } = c.get('project');
1634
3022
  const id = c.req.param('id');
1635
3023
  // An empty/absent body archives (the common case); a malformed body degrades
1636
3024
  // 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
- }
3025
+ // Archiving also retires any pending usage-limit resume, but that rule belongs to
3026
+ // `setArchived` itself — the bulk sweep must obey it too (spec
3027
+ // 2026-08-03-auto-resume-after-usage-limit).
3028
+ const parsed = { data: c.req.valid('json') };
1641
3029
  const run = store.setArchived(id, parsed.data.archived !== false);
1642
3030
  return run ? c.json(run) : c.json({ error: 'not found' }, 404);
1643
- });
1644
- api.post('/runs', async (c) => {
3031
+ })
3032
+ // The per-task off switch for that resume (the workspace setting is Settings → Resources).
3033
+ // Idempotent: a run with nothing pending answers 200 too, because "this task will not
3034
+ // resume itself" is equally true either way.
3035
+ .delete('/runs/:id/auto-resume', (c) => {
3036
+ const { store, manager } = c.get('project');
3037
+ const id = c.req.param('id');
3038
+ if (!store.getRun(id))
3039
+ return c.json({ error: 'not found' }, 404);
3040
+ manager.cancelAutoResume(id);
3041
+ return c.json({ cancelled: true });
3042
+ })
3043
+ .post('/runs/:id/read', (c) => {
3044
+ // No body: opening a thread marks it read, full stop. Stamps `seenAt = now` and
3045
+ // returns the updated record (which also rides the `run` SSE via `touch`).
3046
+ const run = c.get('project').store.setRead(c.req.param('id'));
3047
+ return run ? c.json(run) : c.json({ error: 'not found' }, 404);
3048
+ })
3049
+ .post('/runs', jsonZodValidator(startRunSchema), async (c) => {
1645
3050
  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);
3051
+ const parsed = { data: c.req.valid('json') };
3052
+ if (agentModelsLocked(repoRoot) && parsed.data.model?.trim()) {
3053
+ return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
1649
3054
  }
1650
3055
  let workflow;
1651
3056
  if (parsed.data.steps) {
@@ -1665,6 +3070,19 @@ export function createApp(deps) {
1665
3070
  if (!workflow)
1666
3071
  return c.json({ error: `unknown workflow: ${parsed.data.workflow}` }, 404);
1667
3072
  }
3073
+ const fallback = parsed.data.runner ?? (await loadConfig(repoRoot)).defaultRunner;
3074
+ const blocked = await providerActionError(providersRequiredByWorkflow(workflow, fallback));
3075
+ if (blocked)
3076
+ return c.json({ error: blocked }, 409);
3077
+ // A composer override names an account the user just picked, so a stale id (deleted since
3078
+ // the page loaded) is answered honestly instead of quietly running on the default — the
3079
+ // opposite of how RESOLUTION treats a dangling reference, and deliberately so: a run
3080
+ // replaying a stored id has no better answer than the default, a user does.
3081
+ if (parsed.data.agentProfile !== undefined) {
3082
+ const account = await resolveWorkspaceProfile(fallback, parsed.data.agentProfile);
3083
+ if ('error' in account)
3084
+ return c.json({ error: account.error }, 400);
3085
+ }
1668
3086
  const images = parsed.data.images?.map((img) => ({
1669
3087
  type: 'image',
1670
3088
  source: { type: 'base64', media_type: img.mediaType, data: img.data },
@@ -1672,147 +3090,59 @@ export function createApp(deps) {
1672
3090
  const input = {
1673
3091
  task: parsed.data.task,
1674
3092
  model: parsed.data.model,
1675
- runner: parsed.data.runner,
1676
- images,
1677
- systemPrompt: parsed.data.systemPrompt,
1678
- worktree: parsed.data.worktree,
1679
- autonomous: parsed.data.autonomous,
1680
- // Opt-in inbox (#471): the capability is the ceiling, so a client asking
1681
- // 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' });
3093
+ runner: parsed.data.runner,
3094
+ agentProfile: parsed.data.agentProfile,
3095
+ images,
3096
+ systemPrompt: parsed.data.systemPrompt,
3097
+ worktree: parsed.data.worktree,
3098
+ autonomous: parsed.data.autonomous,
3099
+ // Opt-in inbox (#471): the capability is the ceiling, so a client asking
3100
+ // for follow-ups on a server that has them off gets a plain `false`
3101
+ // rather than an error — the run is still perfectly valid without them.
3102
+ // One decision here feeds the run record, the system prompt and
3103
+ // CEZ_TODOS_FILE alike (RunManager.agentEnv).
3104
+ generateFollowups: capabilities().followups ? parsed.data.generateFollowups : false,
3105
+ };
3106
+ const variants = parsed.data.variants ?? 1;
3107
+ if (variants > 1) {
3108
+ // Variants live in worktrees — without git there's nothing to isolate
3109
+ // them with, so this degrades to a clear 400 instead of stepping on
3110
+ // one shared working tree.
3111
+ const repo = await getRepoInfo(repoRoot);
3112
+ if (!repo) {
3113
+ return c.json({
3114
+ error: 'parallel variants need a git repository (each variant runs in its own worktree) — run ×1 here, or start cezar inside a git repo',
3115
+ }, 400);
1772
3116
  }
3117
+ const runs = manager.startVariants(workflow, input, variants);
3118
+ // The entry points at the first variant — the thread the composer navigates to.
3119
+ const first = runs[0];
3120
+ if (parsed.data.todoId && first)
3121
+ await noteTodoStarted(dataDir, parsed.data.todoId, first.id);
3122
+ return c.json({ runs }, 201);
1773
3123
  }
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
- });
1791
- }
1792
- return c.json({
1793
- winner: store.getRun(winner.id),
1794
- });
1795
- });
1796
- api.get('/runs/:id', (c) => {
3124
+ const run = manager.startRun(workflow, input);
3125
+ if (parsed.data.todoId)
3126
+ await noteTodoStarted(dataDir, parsed.data.todoId, run.id);
3127
+ return c.json(run, 201);
3128
+ })
3129
+ .get('/runs/:id', (c) => {
1797
3130
  const { store } = c.get('project');
1798
3131
  const run = store.getRun(c.req.param('id'));
1799
3132
  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) => {
3133
+ })
3134
+ // Editable titles (#389). The UI displays `titleSummary ?? title`, so a
3135
+ // user edit sets BOTH: `title` (the record's own name — the raw task stops
3136
+ // being it the moment the user renames the run) and `titleSummary` (what
3137
+ // actually displays). The auto-summarizer only ever fills an *unset*
3138
+ // titleSummary (RunManager.recordTurnEnd), so an edit wins over any past or
3139
+ // future auto-summary. Answers the updated record.
3140
+ .patch('/runs/:id', jsonZodValidator(patchRunSchema), async (c) => {
1808
3141
  const { store, manager } = c.get('project');
1809
3142
  const id = c.req.param('id');
1810
3143
  if (!store.getRun(id))
1811
3144
  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
- }
3145
+ const parsed = { data: c.req.valid('json') };
1816
3146
  // The prompt is editable only while the run is still queued (#472). Checked
1817
3147
  // BEFORE the title write so a rejected PATCH is a no-op rather than a partial
1818
3148
  // one. `title` itself keeps working on any status — no regression to #389.
@@ -1837,25 +3167,33 @@ export function createApp(deps) {
1837
3167
  });
1838
3168
  }
1839
3169
  return c.json(store.getRun(id));
1840
- });
1841
- api.post('/runs/:id/cancel', (c) => {
3170
+ })
3171
+ .post('/runs/:id/cancel', (c) => {
1842
3172
  const { store, manager } = c.get('project');
1843
3173
  const id = c.req.param('id');
1844
3174
  if (!store.getRun(id))
1845
3175
  return c.json({ error: 'not found' }, 404);
1846
3176
  const cancelled = manager.cancel(id);
1847
3177
  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) => {
3178
+ })
3179
+ // Live-session participation (spec 002): deliver a user message (text +
3180
+ // pasted screenshots) into the run's open claude session.
3181
+ .post('/runs/:id/messages', jsonZodValidator(messageSchema), async (c) => {
1852
3182
  const { store, manager } = c.get('project');
1853
3183
  const id = c.req.param('id');
1854
- if (!store.getRun(id))
3184
+ const run = store.getRun(id);
3185
+ if (!run)
1855
3186
  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);
3187
+ const parsed = { data: c.req.valid('json') };
3188
+ // Stacking onto a queued prompt mutates an existing task and invokes no provider.
3189
+ // Provider availability still gates live delivery after the record leaves `queued`, but
3190
+ // must not strand prompt authoring just because an unrelated fallback provider is
3191
+ // disconnected (provider-auth spec: disabling never blocks existing-task mutations).
3192
+ // In the dequeue race, the ladder below safely turns this into a starting-state buffer.
3193
+ if (run.status !== 'queued') {
3194
+ const blocked = await providerActionError([providerForActiveRun(run)]);
3195
+ if (blocked)
3196
+ return c.json({ error: blocked }, 409);
1859
3197
  }
1860
3198
  const content = [
1861
3199
  ...parsed.data.images.map((img) => ({
@@ -1871,14 +3209,14 @@ export function createApp(deps) {
1871
3209
  // starting up → buffered · anything else → 409, exactly as before
1872
3210
  if (manager.sendMessage(id, content))
1873
3211
  return c.json({ delivered: true });
1874
- const run = store.getRun(id);
1875
- const stack = run?.queuedMessages ?? [];
3212
+ const currentRun = store.getRun(id);
3213
+ const stack = currentRun?.queuedMessages ?? [];
1876
3214
  // Bounds apply only to a message that is actually about to be stacked. Without this
1877
3215
  // gate an over-long message posted to a *finished* run would answer `400 prompt too
1878
3216
  // long` when the truthful answer is `409 session closed`. The status read is safe
1879
3217
  // here because it only decides whether to reject EARLY — `enqueueMessage` still
1880
3218
  // re-checks against the engine's own queue before writing anything.
1881
- if (run?.status === 'queued') {
3219
+ if (currentRun?.status === 'queued') {
1882
3220
  if (stack.length >= MAX_QUEUED_MESSAGES) {
1883
3221
  return c.json({ error: `too many queued messages — ${MAX_QUEUED_MESSAGES} message limit` }, 400);
1884
3222
  }
@@ -1886,7 +3224,7 @@ export function createApp(deps) {
1886
3224
  if (stackedImages + parsed.data.images.length > MAX_QUEUED_IMAGES) {
1887
3225
  return c.json({ error: `too many queued images — ${MAX_QUEUED_IMAGES} image limit across the stack` }, 400);
1888
3226
  }
1889
- const prospective = foldedLength(run.task, [...stack, { text: parsed.data.text }]);
3227
+ const prospective = foldedLength(currentRun.task, [...stack, { text: parsed.data.text }]);
1890
3228
  if (prospective > MAX_FOLDED_TASK_CHARS) {
1891
3229
  return c.json({
1892
3230
  error: `prompt too long — ${MAX_FOLDED_TASK_CHARS} character limit across the task and its queued messages (would be ${prospective})`,
@@ -1899,19 +3237,16 @@ export function createApp(deps) {
1899
3237
  if (manager.deferMessage(id, content))
1900
3238
  return c.json({ deferred: true });
1901
3239
  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) => {
3240
+ })
3241
+ // Edit / remove a stacked message (#472). Registered before any conflicting
3242
+ // `/:id` route so `queued-messages` never matches as a run id.
3243
+ .patch('/runs/:id/queued-messages/:msgId', jsonZodValidator(queuedMessagePatchSchema), async (c) => {
1906
3244
  const { store, manager } = c.get('project');
1907
3245
  const id = c.req.param('id');
1908
3246
  const run = store.getRun(id);
1909
3247
  if (!run)
1910
3248
  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
- }
3249
+ const parsed = { data: c.req.valid('json') };
1915
3250
  const msgId = c.req.param('msgId');
1916
3251
  const stack = run.queuedMessages ?? [];
1917
3252
  const existing = stack.find((m) => m.id === msgId);
@@ -1944,8 +3279,8 @@ export function createApp(deps) {
1944
3279
  if (!message)
1945
3280
  return c.json({ error: 'run already started' }, 409);
1946
3281
  return c.json({ message });
1947
- });
1948
- api.delete('/runs/:id/queued-messages/:msgId', (c) => {
3282
+ })
3283
+ .delete('/runs/:id/queued-messages/:msgId', (c) => {
1949
3284
  const { store, manager } = c.get('project');
1950
3285
  const id = c.req.param('id');
1951
3286
  const run = store.getRun(id);
@@ -1958,9 +3293,9 @@ export function createApp(deps) {
1958
3293
  if (!manager.removeQueuedMessage(id, msgId))
1959
3294
  return c.json({ error: 'run already started' }, 409);
1960
3295
  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) => {
3296
+ })
3297
+ // "Finish": gracefully close a waiting session — the run completes as done.
3298
+ .post('/runs/:id/finish', (c) => {
1964
3299
  const { store, manager } = c.get('project');
1965
3300
  const id = c.req.param('id');
1966
3301
  if (!store.getRun(id))
@@ -1969,19 +3304,23 @@ export function createApp(deps) {
1969
3304
  if (!finished)
1970
3305
  return c.json({ error: 'no open session' }, 409);
1971
3306
  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');
3307
+ })
3308
+ // "Continue" (spec 003): reopen a finished run's session in-process.
3309
+ .post('/runs/:id/continue', jsonZodValidator(continueSchema, { absent: ({}) }), async (c) => {
3310
+ const { root: repoRoot, store, manager } = c.get('project');
1976
3311
  const id = c.req.param('id');
1977
- if (!store.getRun(id))
3312
+ const run = store.getRun(id);
3313
+ if (!run)
1978
3314
  return c.json({ error: 'not found' }, 404);
1979
3315
  // Bounded resume text (#429); an empty/absent body still just re-runs on the
1980
3316
  // 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);
3317
+ const parsed = { data: c.req.valid('json') };
3318
+ if (agentModelsLocked(repoRoot) && parsed.data.model?.trim()) {
3319
+ return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
1984
3320
  }
3321
+ const blocked = await providerActionError([providerForExistingRun(run, parsed.data.runner)]);
3322
+ if (blocked)
3323
+ return c.json({ error: blocked }, 409);
1985
3324
  const result = manager.continueRun(id, {
1986
3325
  text: parsed.data.text,
1987
3326
  images: parsed.data.images?.map((img) => ({
@@ -1994,10 +3333,10 @@ export function createApp(deps) {
1994
3333
  if (!result.ok)
1995
3334
  return c.json({ error: result.error }, 409);
1996
3335
  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) => {
3336
+ })
3337
+ // "Open in terminal" (spec 003): hand the session off to a real terminal —
3338
+ // in the task's worktree when it still exists (spec 006).
3339
+ .post('/runs/:id/open-in-cli', async (c) => {
2001
3340
  const { root: repoRoot, store } = c.get('project');
2002
3341
  const id = c.req.param('id');
2003
3342
  const run = store.getRun(id);
@@ -2010,28 +3349,36 @@ export function createApp(deps) {
2010
3349
  error: 'local handoff is disabled — this cockpit runs in hosted mode (CEZ_REMOTE); resume the session from a machine that has the checkout',
2011
3350
  }, 409);
2012
3351
  }
2013
- const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
3352
+ const sessionStep = [...run.steps].reverse().find((s) => s.sessionId);
3353
+ const sessionId = sessionStep?.sessionId;
2014
3354
  if (!sessionId)
2015
3355
  return c.json({ error: 'no agent session to resume' }, 409);
3356
+ const blocked = await providerActionError([providerForExistingRun(run)]);
3357
+ if (blocked)
3358
+ return c.json({ error: blocked }, 409);
2016
3359
  const cwd = run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : repoRoot;
2017
3360
  const command = resumeCommand(run.runner, sessionId);
2018
3361
  // Fails closed on an id we do not recognise — see resumeCommand (#431).
2019
3362
  if (!command)
2020
3363
  return c.json({ error: 'the recorded session id has an unexpected shape' }, 409);
2021
- const opened = await openInTerminal(cwd, command);
3364
+ // The account that OWNS this session, not the project's current one (spec 2026-07-29).
3365
+ const account = await handoffEnv(run.runner ?? 'claude', sessionStep?.profileId);
3366
+ if ('error' in account)
3367
+ return c.json({ error: account.error }, 409);
3368
+ const fallback = handoffFallbackCommand(cwd, command, account.env);
3369
+ // Fail closed for the same reason as the session id: a terminal opened without the
3370
+ // account's config dir resumes nothing and says nothing about why.
3371
+ if (fallback === null) {
3372
+ return c.json({ error: 'this account\'s folder cannot be used in a terminal command' }, 409);
3373
+ }
3374
+ const opened = await openInTerminal(cwd, command, account.env);
2022
3375
  if (!opened) {
2023
- return c.json({
2024
- error: 'no terminal emulator found',
2025
- command: `cd '${cwd}' && ${command}`,
2026
- }, 409);
3376
+ return c.json({ error: 'no terminal emulator found', command: fallback }, 409);
2027
3377
  }
2028
3378
  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) => {
3379
+ })
3380
+ // Open a run's worktree (or the repo root) in the chosen local app.
3381
+ .post('/runs/:id/open-in', jsonZodValidator(openInSchema), async (c) => {
2035
3382
  const { root: repoRoot, store } = c.get('project');
2036
3383
  const id = c.req.param('id');
2037
3384
  const run = store.getRun(id);
@@ -2044,10 +3391,7 @@ export function createApp(deps) {
2044
3391
  }
2045
3392
  // Follows the safeParse convention (#429); the downstream allowlist match is the real
2046
3393
  // 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
- }
3394
+ const parsedBody = { data: c.req.valid('json') };
2051
3395
  const { target, path: relPath } = parsedBody.data;
2052
3396
  const dir = run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : repoRoot;
2053
3397
  // Diff pane "open in OS default app" (#365): one worktree file, opened with the platform's
@@ -2105,18 +3449,32 @@ export function createApp(deps) {
2105
3449
  // and what the client's cliTargetResumes now labels. Resume-after-finish is untouched.
2106
3450
  const cliRunner = agentCliRunner(target);
2107
3451
  if (cliRunner) {
3452
+ const blocked = await providerActionError([cliRunner]);
3453
+ if (blocked)
3454
+ return c.json({ error: blocked }, 409);
2108
3455
  const engineOwnsSession = run.status === 'running' || run.status === 'queued' || run.status === 'waiting';
2109
- const sessionId = engineOwnsSession ? undefined : [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
3456
+ const sessionStep = engineOwnsSession ? undefined : [...run.steps].reverse().find((s) => s.sessionId);
3457
+ const sessionId = sessionStep?.sessionId;
2110
3458
  // An id resumeCommand refuses (#431) degrades to a fresh CLI in the worktree,
2111
3459
  // exactly like a run that never recorded a session.
2112
3460
  const resume = sessionId && cliRunner === (run.runner ?? 'claude') ? resumeCommand(cliRunner, sessionId) : null;
2113
3461
  const command = resume ?? cliRunner;
2114
- const opened = await openInTerminal(dir, command);
3462
+ // BOTH branches carry the account (spec 2026-07-29-agent-profiles): a resume needs the
3463
+ // config dir that holds its session, and a FRESH CLI in this worktree should still open
3464
+ // on the account the project works under — otherwise "Open in → Claude CLI" quietly
3465
+ // hands the user a different subscription than every task in the same project uses.
3466
+ const account = resume
3467
+ ? await handoffEnv(cliRunner, sessionStep?.profileId)
3468
+ : { env: (await resolveProfileEnvForRoot(repoRoot, cliRunner)).env };
3469
+ if ('error' in account)
3470
+ return c.json({ error: account.error }, 409);
3471
+ const fallback = handoffFallbackCommand(dir, command, account.env);
3472
+ if (fallback === null) {
3473
+ return c.json({ error: 'this account\'s folder cannot be used in a terminal command' }, 409);
3474
+ }
3475
+ const opened = await openInTerminal(dir, command, account.env);
2115
3476
  if (!opened) {
2116
- return c.json({
2117
- error: 'no terminal emulator found',
2118
- command: `cd '${dir}' && ${command}`,
2119
- }, 409);
3477
+ return c.json({ error: 'no terminal emulator found', command: fallback }, 409);
2120
3478
  }
2121
3479
  return c.json({ opened: true, path: dir, command });
2122
3480
  }
@@ -2124,10 +3482,10 @@ export function createApp(deps) {
2124
3482
  if (!opened)
2125
3483
  return c.json({ error: `could not open ${target}`, path: dir }, 409);
2126
3484
  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) => {
3485
+ })
3486
+ // Handoff journal (spec 007): the per-task handoff.md as markdown. 404 only
3487
+ // when the task is unknown; a task without a (yet) seeded file returns ''.
3488
+ .get('/runs/:id/handoff', (c) => {
2131
3489
  const { dataDir, store } = c.get('project');
2132
3490
  const run = store.getRun(c.req.param('id'));
2133
3491
  if (!run)
@@ -2135,16 +3493,8 @@ export function createApp(deps) {
2135
3493
  return c.text(readHandoff(dataDir, run.id), 200, {
2136
3494
  'content-type': 'text/markdown; charset=utf-8',
2137
3495
  });
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) => {
3496
+ })
3497
+ .get('/runs/:id/images/:file', (c) => {
2148
3498
  const { dataDir, store } = c.get('project');
2149
3499
  const run = store.getRun(c.req.param('id'));
2150
3500
  if (!run)
@@ -2160,9 +3510,9 @@ export function createApp(deps) {
2160
3510
  'cache-control': 'private, max-age=31536000, immutable',
2161
3511
  },
2162
3512
  });
2163
- });
2164
- // Task diff (spec 006): what this run changed — its worktree vs its base.
2165
- api.get('/runs/:id/diff', async (c) => {
3513
+ })
3514
+ // Task diff (spec 006): what this run changed — its worktree vs its base.
3515
+ .get('/runs/:id/diff', async (c) => {
2166
3516
  const { store } = c.get('project');
2167
3517
  const run = store.getRun(c.req.param('id'));
2168
3518
  if (!run)
@@ -2171,94 +3521,111 @@ export function createApp(deps) {
2171
3521
  return c.text('(no worktree — this task ran directly in the repo working tree)');
2172
3522
  }
2173
3523
  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');
3524
+ })
3525
+ .get('/runs/:id/changes', async (c) => {
3526
+ const { root: repoRoot, store } = c.get('project');
2183
3527
  const run = store.getRun(c.req.param('id'));
2184
3528
  if (!run)
2185
3529
  return c.json({ error: 'not found' }, 404);
2186
- const worktree = worktreeOf(run);
2187
- if (!worktree)
3530
+ const workingDirectory = workingDirectoryOf(run, repoRoot);
3531
+ if (!workingDirectory)
2188
3532
  return c.json({ error: NO_WORKTREE }, 409);
2189
- const result = await collectChanges(worktree, run.baseBranch ?? 'HEAD', { taskBranch: run.branch });
3533
+ const result = await collectChanges(workingDirectory, run.baseBranch ?? 'HEAD', {
3534
+ taskBranch: run.branch,
3535
+ // A read-only GET against the user's real checkout must never modify its index.
3536
+ intentToAdd: run.worktreePath ? undefined : false,
3537
+ });
2190
3538
  if (!result.ok)
2191
3539
  return c.json({ error: result.error }, 409);
2192
3540
  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');
3541
+ })
3542
+ // The run's own commits (<base>..HEAD on the worktree branch) — the Commits tab.
3543
+ .get('/runs/:id/commits', async (c) => {
3544
+ const { root: repoRoot, store } = c.get('project');
2197
3545
  const run = store.getRun(c.req.param('id'));
2198
3546
  if (!run)
2199
3547
  return c.json({ error: 'not found' }, 404);
2200
- const worktree = worktreeOf(run);
2201
- if (!worktree)
3548
+ const workingDirectory = workingDirectoryOf(run, repoRoot);
3549
+ if (!workingDirectory)
2202
3550
  return c.json({ error: NO_WORKTREE }, 409);
2203
- const result = await collectRunCommits(worktree, run.baseBranch ?? 'HEAD');
3551
+ const result = await collectRunCommits(workingDirectory, run.baseBranch ?? 'HEAD');
2204
3552
  if (!result.ok)
2205
3553
  return c.json({ error: result.error }, 409);
2206
3554
  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');
3555
+ })
3556
+ // One of the run's commits, structured like the Changes tab (reuses collectCommitChanges).
3557
+ .get('/runs/:id/commit/:sha', async (c) => {
3558
+ const { root: repoRoot, store } = c.get('project');
2211
3559
  const run = store.getRun(c.req.param('id'));
2212
3560
  if (!run)
2213
3561
  return c.json({ error: 'not found' }, 404);
2214
- const worktree = worktreeOf(run);
2215
- if (!worktree)
3562
+ const workingDirectory = workingDirectoryOf(run, repoRoot);
3563
+ if (!workingDirectory)
2216
3564
  return c.json({ error: NO_WORKTREE }, 409);
2217
- const result = await collectCommitChanges(worktree, c.req.param('sha'));
3565
+ const result = await collectCommitChanges(workingDirectory, c.req.param('sha'));
2218
3566
  if (!result.ok)
2219
3567
  return c.json({ error: result.error }, 409);
2220
3568
  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');
3569
+ })
3570
+ // Files tab: directory listing (path omitted or a dir) or file content
3571
+ // (size-capped, binary flagged). Traversal-safe — readWorktreePath rejects
3572
+ // anything escaping the worktree. `raw=1` (R5 Step 1.6) serves the BYTES of
3573
+ // image files only, for the preview's inline <img> — never HTML/JS/etc., so
3574
+ // no worktree file can become a same-origin document, and never past the
3575
+ // size cap. The no-script CSP neutralizes SVG opened as a top-level URL.
3576
+ //
3577
+ // An `Accept` that asks for images reaches the same raw branch without the flag — which is
3578
+ // what an `<img>` sends — while the flag still wins whenever it is present and `*<slash>*`
3579
+ // (every `fetch`) still gets the JSON listing. See `negotiate`.
3580
+ .get('/runs/:id/files', queryZodValidator(z.object({ path: queryValue, raw: queryValue })), async (c) => {
3581
+ const { root: repoRoot, store } = c.get('project');
3582
+ const query = c.req.valid('query');
3583
+ c.header('vary', 'Accept');
3584
+ const wantsRaw = query.raw !== undefined
3585
+ ? query.raw === '1'
3586
+ : negotiate(c.req.header('accept'), FILE_FORMATS) === 'image/*';
2230
3587
  const run = store.getRun(c.req.param('id'));
2231
3588
  if (!run)
2232
3589
  return c.json({ error: 'not found' }, 404);
2233
- const worktree = worktreeOf(run);
2234
- if (!worktree)
3590
+ const workingDirectory = workingDirectoryOf(run, repoRoot);
3591
+ if (!workingDirectory)
2235
3592
  return c.json({ error: NO_WORKTREE }, 409);
2236
- const result = await readWorktreePath(worktree, c.req.query('path') ?? '');
3593
+ const result = await readWorktreePath(workingDirectory, query.path ?? '');
2237
3594
  if (result.kind === 'invalid' || result.kind === 'missing') {
2238
3595
  return c.json({ error: result.error }, 409);
2239
3596
  }
2240
3597
  if (result.kind === 'dir') {
2241
3598
  return c.json({
3599
+ // `as const` or the literal widens to `string` during Hono's route-type inference,
3600
+ // which erases the discriminant a consumer narrows on — `entry.type === 'dir'` then
3601
+ // leaves `never` and every field access on it fails. The wire was always 'dir'.
2242
3602
  type: 'dir',
2243
3603
  path: result.path,
2244
3604
  entries: result.entries,
2245
3605
  });
2246
3606
  }
2247
- if (c.req.query('raw') === '1') {
3607
+ if (wantsRaw) {
2248
3608
  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);
3609
+ if (mime === null || result.tooLarge) {
3610
+ // `?raw=1` ASKED for bytes, so it hears why it cannot have them — that 409 and its
3611
+ // wording are the protected surface (§2). An `Accept` is only a preference, so a
3612
+ // resource with no image representation falls THROUGH to the JSON answer below rather
3613
+ // than turning a browser's navigation to a text file into an error.
3614
+ if (query.raw !== undefined) {
3615
+ const error = mime === null
3616
+ ? `raw serving is limited to images: ${result.path}`
3617
+ : `file too large to serve raw (${result.size} bytes): ${result.path}`;
3618
+ return c.json({ error }, 409);
3619
+ }
3620
+ }
3621
+ else {
3622
+ const bytes = await readFile(join(workingDirectory, result.path));
3623
+ return c.body(new Uint8Array(bytes).buffer, 200, {
3624
+ 'content-type': mime,
3625
+ 'x-content-type-options': 'nosniff',
3626
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
3627
+ });
2255
3628
  }
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
3629
  }
2263
3630
  return c.json({
2264
3631
  type: 'file',
@@ -2268,8 +3635,8 @@ export function createApp(deps) {
2268
3635
  tooLarge: result.tooLarge,
2269
3636
  ...(result.content !== undefined ? { content: result.content } : {}),
2270
3637
  });
2271
- });
2272
- api.post('/runs/:id/git/commit', async (c) => {
3638
+ })
3639
+ .post('/runs/:id/git/commit', jsonZodValidator(gitCommitSchema), async (c) => {
2273
3640
  const { store } = c.get('project');
2274
3641
  const run = store.getRun(c.req.param('id'));
2275
3642
  if (!run)
@@ -2277,16 +3644,13 @@ export function createApp(deps) {
2277
3644
  const worktree = worktreeOf(run);
2278
3645
  if (!worktree)
2279
3646
  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
- }
3647
+ const parsed = { data: c.req.valid('json') };
2284
3648
  const result = await commitAll(worktree, parsed.data.message);
2285
3649
  if (!result.ok)
2286
3650
  return c.json({ error: result.error }, 409);
2287
3651
  return c.json({ committed: true, sha: result.sha });
2288
- });
2289
- api.post('/runs/:id/git/push', async (c) => {
3652
+ })
3653
+ .post('/runs/:id/git/push', async (c) => {
2290
3654
  const { store } = c.get('project');
2291
3655
  const run = store.getRun(c.req.param('id'));
2292
3656
  if (!run)
@@ -2303,12 +3667,12 @@ export function createApp(deps) {
2303
3667
  remote: result.remote,
2304
3668
  upstreamSet: result.upstreamSet,
2305
3669
  });
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) => {
3670
+ })
3671
+ // Draft PR from the review gate (spec 009): final autosave → push →
3672
+ // `gh pr create --draft`; on success the run completes as done with the PR
3673
+ // badge. Failures come back as 409 with a `manual` merge command the GUI
3674
+ // shows next to the toast. CEZ_DRY_RUN=1 fakes the URL (no push, no gh).
3675
+ .post('/runs/:id/pr', async (c) => {
2312
3676
  const { root: repoRoot, dataDir, store, manager } = c.get('project');
2313
3677
  const id = c.req.param('id');
2314
3678
  const run = store.getRun(id);
@@ -2339,10 +3703,10 @@ export function createApp(deps) {
2339
3703
  message: `draft PR created: ${outcome.url}${outcome.dryRun ? ' (dry run — no real PR)' : ''}`,
2340
3704
  });
2341
3705
  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) => {
3706
+ })
3707
+ // Archived tasks keep their worktree for inspection; this is the explicit
3708
+ // "🧹 Remove worktree" cleanup (spec 006).
3709
+ .post('/runs/:id/remove-worktree', async (c) => {
2346
3710
  const { root: repoRoot, store, manager } = c.get('project');
2347
3711
  const id = c.req.param('id');
2348
3712
  const run = store.getRun(id);
@@ -2354,8 +3718,8 @@ export function createApp(deps) {
2354
3718
  await removeWorktree(repoRoot, run.worktreePath, run.branch);
2355
3719
  store.updateRun(id, { worktreePath: undefined, branch: undefined });
2356
3720
  return c.json({ removed: true });
2357
- });
2358
- api.delete('/runs/:id', async (c) => {
3721
+ })
3722
+ .delete('/runs/:id', async (c) => {
2359
3723
  const { root: repoRoot, store, manager } = c.get('project');
2360
3724
  const id = c.req.param('id');
2361
3725
  if (manager.isActive(id))
@@ -2368,11 +3732,116 @@ export function createApp(deps) {
2368
3732
  await removeWorktree(repoRoot, run.worktreePath, run.branch);
2369
3733
  return store.deleteRun(id) ? c.json({ deleted: true }) : c.json({ error: 'not found' }, 404);
2370
3734
  });
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) => {
3735
+ // ---- parallel variants (spec 010) -----------------------------------------
3736
+ const groupRuns = (store, groupId) => store
3737
+ .listRuns()
3738
+ .filter((r) => r.groupId === groupId)
3739
+ .sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
3740
+ // ---- chained family: variant groups (project-scoped) ----
3741
+ const groupsRoutes = new Hono()
3742
+ .get('/groups/:groupId', async (c) => {
3743
+ const { dataDir, store } = c.get('project');
3744
+ const runs = groupRuns(store, c.req.param('groupId'));
3745
+ if (runs.length === 0)
3746
+ return c.json({ error: 'not found' }, 404);
3747
+ const detailed = await Promise.all(runs.map(async (r) => ({
3748
+ id: r.id,
3749
+ variant: r.variant ?? '?',
3750
+ title: r.title,
3751
+ status: r.status,
3752
+ archived: r.archived,
3753
+ tokensUsed: r.tokensUsed,
3754
+ ...(r.inputTokens !== undefined ? { inputTokens: r.inputTokens } : {}),
3755
+ ...(r.outputTokens !== undefined ? { outputTokens: r.outputTokens } : {}),
3756
+ ...(r.costUsd !== undefined ? { costUsd: r.costUsd } : {}),
3757
+ diffStat: r.worktreePath && existsSync(r.worktreePath)
3758
+ ? await worktreeDiffStat(r.worktreePath, r.baseBranch ?? 'HEAD')
3759
+ : '',
3760
+ handoffExcerpt: handoffProgressExcerpt(readHandoff(dataDir, r.id)),
3761
+ })));
3762
+ return c.json({
3763
+ groupId: c.req.param('groupId'),
3764
+ runs: detailed,
3765
+ });
3766
+ })
3767
+ // "Pick this one": the winner rests at `review` (spec 009 takes it from
3768
+ // there — send back / draft PR / finish); the losers are cancelled if
3769
+ // alive, archived, and their worktrees + branches removed.
3770
+ .post('/groups/:groupId/pick', jsonZodValidator(pickSchema), async (c) => {
3771
+ const { root: repoRoot, dataDir, store, manager } = c.get('project');
3772
+ const runs = groupRuns(store, c.req.param('groupId'));
3773
+ if (runs.length === 0)
3774
+ return c.json({ error: 'not found' }, 404);
3775
+ const parsed = { data: c.req.valid('json') };
3776
+ const winner = runs.find((r) => r.id === parsed.data.runId);
3777
+ if (!winner)
3778
+ return c.json({ error: 'runId is not part of this group' }, 404);
3779
+ if (manager.isActive(winner.id)) {
3780
+ return c.json({ error: 'this variant is still active — wait for it to finish first' }, 409);
3781
+ }
3782
+ // Winner: a non-review terminal state with a non-empty diff flips to
3783
+ // `review` (the settleSuccess rule) — but only when the review gate applies
3784
+ // (#489): it is enabled (`reviewGateEnabled`, default off) AND the winner is
3785
+ // not autonomous. An autonomous / gate-off winner keeps its `done` state with
3786
+ // the diff left in the worktree; an empty diff (or no worktree) stays too.
3787
+ if (winner.status !== 'review' &&
3788
+ winner.worktreePath &&
3789
+ existsSync(winner.worktreePath) &&
3790
+ winner.autonomous !== true &&
3791
+ reviewGateEnabled(await loadConfig(repoRoot))) {
3792
+ const diff = await worktreeDiff(winner.worktreePath, winner.baseBranch ?? 'HEAD');
3793
+ if (diff.trim().length > 0 && !diff.startsWith('(diff failed')) {
3794
+ store.updateRun(winner.id, { status: 'review' });
3795
+ }
3796
+ }
3797
+ const losers = runs.filter((r) => r.id !== winner.id);
3798
+ store.appendEvent(winner.id, {
3799
+ type: 'lifecycle',
3800
+ message: `picked from ${runs.length} variants — ${losers.length} other variant(s) archived`,
3801
+ });
3802
+ appendHandoffHeartbeat(dataDir, winner.id, `picked from ${runs.length} variants`);
3803
+ for (const loser of losers) {
3804
+ if (manager.isActive(loser.id))
3805
+ manager.cancel(loser.id);
3806
+ if (loser.worktreePath)
3807
+ await removeWorktree(repoRoot, loser.worktreePath, loser.branch);
3808
+ store.updateRun(loser.id, { worktreePath: undefined, branch: undefined });
3809
+ store.setArchived(loser.id, true);
3810
+ store.appendEvent(loser.id, {
3811
+ type: 'lifecycle',
3812
+ message: `variant ${winner.variant ?? '?'} was picked — this variant is archived, its worktree removed`,
3813
+ });
3814
+ }
3815
+ // Spread: `getRun` may answer undefined, and an undefined VALUE is dropped by
3816
+ // JSON.stringify — so writing the key unconditionally typed the route as sending a key it
3817
+ // does not. contract/workflows.ts says `.optional()`, which is what a client receives.
3818
+ const picked = store.getRun(winner.id);
3819
+ return c.json({ ...(picked !== undefined ? { winner: picked } : {}) });
3820
+ });
3821
+ // ---- chained family: open-targets (project-scoped) ----
3822
+ const openTargetsRoutes = new Hono()
3823
+ .get('/open-targets', (c) => c.json({ targets: capabilities().localHandoff ? detectOpenTargets() : [] }));
3824
+ // Agent screenshots — image blocks the run manager persisted out of tool
3825
+ // results (persistImage). `basename` pins reads inside the run's own dir.
3826
+ const IMAGE_TYPES = {
3827
+ png: 'image/png',
3828
+ jpg: 'image/jpeg',
3829
+ webp: 'image/webp',
3830
+ gif: 'image/gif',
3831
+ };
3832
+ // ---- session git view (redesign R5 Step 1.2 — §"Git/session API additions").
3833
+ // Structured sibling of the text-blob /diff above (which stays untouched —
3834
+ // protected surface). Isolated runs read their worktree; worktree-off runs
3835
+ // read the repo checkout they executed in. Every predictable git failure
3836
+ // degrades to 409 + human-readable reason, 404 only for unknown ids.
3837
+ const worktreeOf = (run) => run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : null;
3838
+ const workingDirectoryOf = (run, repoRoot) => run.worktree === false
3839
+ ? repoRoot
3840
+ : worktreeOf(run);
3841
+ const NO_WORKTREE = 'no worktree — this task ran directly in the repo working tree';
3842
+ // ---- chained family: worktrees (project-scoped) ----
3843
+ const worktreesRoutes = new Hono()
3844
+ .get('/worktrees', async (c) => {
2376
3845
  const { root: repoRoot, store } = c.get('project');
2377
3846
  // The keep-limit the panel reports is the one the enforcer will actually
2378
3847
  // apply — inherited from the workspace default when this repo sets none.
@@ -2393,60 +3862,68 @@ export function createApp(deps) {
2393
3862
  ? null
2394
3863
  : worktrees.reduce((sum, w) => sum + (w.sizeBytes ?? 0), 0);
2395
3864
  return c.json({ worktrees, totalBytes, keep });
2396
- });
2397
- const reclaimBodySchema = z.object({}).passthrough();
2398
- api.post('/worktrees/reclaim', async (c) => {
3865
+ })
3866
+ .post('/worktrees/reclaim', jsonZodValidator(() => reclaimBodySchema, { absent: ({}), message: 'invalid body' }), async (c) => {
2399
3867
  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);
3868
+ // The body is validated (an empty or `{}` one is accepted) but carries nothing this
3869
+ // handler reads; retention is best-effort, so 200 always.
2404
3870
  const reclaimed = await reclaimWorktrees(repoRoot, store, await resolveWorktreeRetention(repoRoot));
2405
3871
  return c.json({ reclaimed });
2406
3872
  });
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) => {
3873
+ const reclaimBodySchema = z.object({}).passthrough();
3874
+ /**
3875
+ * "The inbox is on and this entry exists" — the 409/404 half of `POST /todos/:id/start`, lifted
3876
+ * out of the handler and in FRONT of the body validator.
3877
+ *
3878
+ * That position is the whole point. The route's contract is that an unknown id 404s before the
3879
+ * body is looked at, and Hono only records a body in the route type when it is validated as
3880
+ * MIDDLEWARE — which necessarily runs before the handler. Registering this guard first satisfies
3881
+ * both: the documented status order is unchanged, and `startTodoSchema` becomes visible to
3882
+ * `AppType` (and so to `hc`) instead of being parsed invisibly inside the handler.
3883
+ *
3884
+ * Deliberately NOT annotated with a return type: the inferred one carries the two typed
3885
+ * responses, which is what keeps the 409 and 404 branches in the route's schema for the client.
3886
+ */
3887
+ const todoMustExist = async (c, next) => {
3888
+ if (!capabilities().followups)
3889
+ return c.json({ error: FOLLOWUPS_OFF }, 409);
3890
+ const todo = (await readTodos(c.get('project').dataDir)).find((t) => t.id === c.req.param('id'));
3891
+ if (!todo)
3892
+ return c.json({ error: 'not found' }, 404);
3893
+ c.set('todo', todo);
3894
+ await next();
3895
+ };
3896
+ // ---- chained family: follow-up inbox / todos (project-scoped) ----
3897
+ const todosRoutes = new Hono()
3898
+ .get('/todos', async (c) => c.json(capabilities().followups ? await readTodos(c.get('project').dataDir) : []))
3899
+ // Check off = delete the entry.
3900
+ .delete('/todos/:id', async (c) => {
2416
3901
  const { dataDir } = c.get('project');
2417
3902
  if (!capabilities().followups)
2418
3903
  return c.json({ error: FOLLOWUPS_OFF }, 409);
2419
3904
  const removed = await removeTodo(dataDir, c.req.param('id'));
2420
3905
  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) => {
2425
- const { root: repoRoot, dataDir, manager } = c.get('project');
2426
- if (!capabilities().followups)
2427
- return c.json({ error: FOLLOWUPS_OFF }, 409);
3906
+ })
3907
+ // "▶ Run": turn an inbox entry into a task — a one-off single-step workflow
3908
+ // around the suggested skill when it exists, plain quick-task otherwise.
3909
+ //
3910
+ // TWO middlewares, and their ORDER is the contract. This route's documented status order
3911
+ // (pinned by todos-start.test.ts) is that a disabled inbox 409s and an unknown id 404s BEFORE
3912
+ // the body is looked at — which is why the body used to be parsed inline, invisible to `hc`.
3913
+ // Hono runs route middleware in registration order, so `todoMustExist` FIRST keeps that order
3914
+ // exactly while `jsonZodValidator` second is what records the body in the route type.
3915
+ //
3916
+ // The two no-body cases the old inline parse distinguished are carried by the validator's
3917
+ // `absent`/`malformed` options: no body at all is `undefined` (the pre-#401 bodyless POST,
3918
+ // which the optional schema accepts → 201), a truncated payload is `null` (which it rejects
3919
+ // → 400, rather than passing as "no body" and silently starting a run).
3920
+ .post('/todos/:id/start', todoMustExist, jsonZodValidator(startTodoSchema, { absent: undefined, malformed: null }), async (c) => {
3921
+ const { root: repoRoot, dataDir, manager } = c.get('project');
2428
3922
  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);
3923
+ const todo = c.get('todo');
3924
+ const parsed = { data: c.req.valid('json') };
3925
+ if (agentModelsLocked(repoRoot) && parsed.data?.model?.trim()) {
3926
+ return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
2450
3927
  }
2451
3928
  if (todo.startedTaskId)
2452
3929
  return c.json({ error: 'already started' }, 409);
@@ -2476,6 +3953,10 @@ export function createApp(deps) {
2476
3953
  const { workflows } = await loadWorkflows(repoRoot);
2477
3954
  workflow = workflows.find((w) => w.name === 'quick-task') ?? QUICK_TASK_WORKFLOW;
2478
3955
  }
3956
+ const fallback = parsed.data?.runner ?? (await loadConfig(repoRoot)).defaultRunner;
3957
+ const blocked = await providerActionError(providersRequiredByWorkflow(workflow, fallback));
3958
+ if (blocked)
3959
+ return c.json({ error: blocked }, 409);
2479
3960
  const run = manager.startRun(workflow, {
2480
3961
  task,
2481
3962
  runner: parsed.data?.runner,
@@ -2484,10 +3965,9 @@ export function createApp(deps) {
2484
3965
  await markStarted(dataDir, id, run.id);
2485
3966
  return c.json({ run }, 201);
2486
3967
  });
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) => {
3968
+ // ---- chained family: SSE streams (project-scoped) ----
3969
+ const sseRoutes = new Hono()
3970
+ .get('/runs/:id/events', (c) => {
2491
3971
  const { store } = c.get('project');
2492
3972
  const id = c.req.param('id');
2493
3973
  if (!store.getRun(id))
@@ -2543,13 +4023,13 @@ export function createApp(deps) {
2543
4023
  await stream.sleep(15_000);
2544
4024
  }
2545
4025
  });
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) => {
4026
+ })
4027
+ // Global SSE: run-summary updates for the list view + inbox changes.
4028
+ // Scoped `/p/:projectId/events` carries that project's stream in today's
4029
+ // shape; the legacy unprefixed alias stays bound to the boot project ONLY
4030
+ // (spec "Legacy aliases" — widening it would be a silent behavioral break;
4031
+ // the all-project stream arrives as `/api/workspace/events` in step 2.8).
4032
+ .get('/events', (c) => {
2553
4033
  const { dataDir, store } = c.get('project');
2554
4034
  return streamSSENoBuffer(c, async (stream) => {
2555
4035
  const onRun = (run) => void stream.writeSSE({ event: 'run', data: JSON.stringify(run) });
@@ -2595,22 +4075,9 @@ export function createApp(deps) {
2595
4075
  }
2596
4076
  });
2597
4077
  });
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) => {
4078
+ // ---- chained family: workspace SSE stream (workspace-level) ----
4079
+ const workspaceEventsRoutes = new Hono()
4080
+ .get('/workspace/events', (c) => {
2614
4081
  return streamSSENoBuffer(c, async (stream) => {
2615
4082
  // One detach bundle per attached project — the id guard makes a double
2616
4083
  // attach (connect-time snapshot vs. the built hook) impossible.
@@ -2682,11 +4149,12 @@ export function createApp(deps) {
2682
4149
  }
2683
4150
  });
2684
4151
  // 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.
4152
+ // checkout-progress plus host-wide unstamped provider-status) — relayed
4153
+ // verbatim under their own names. A removal also drops the project's
4154
+ // attach entry: the id guard in `attach` would otherwise pin the
4155
+ // DISPOSED context forever, so a project removed and re-added on the
4156
+ // same slug would rebuild a fresh context whose events never reach this
4157
+ // already-open stream.
2690
4158
  const offWorkspace = workspaceEvents.on((event, data) => {
2691
4159
  if (event === 'project-removed') {
2692
4160
  const removed = data.id;
@@ -2711,23 +4179,25 @@ export function createApp(deps) {
2711
4179
  }
2712
4180
  });
2713
4181
  });
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) => {
4182
+ // ---- chained family: GitHub (project-scoped) ----
4183
+ // These sit ABOVE the routes rather than with the family's other schemas below it, because a
4184
+ // validator argument is evaluated when the route is REGISTERED — a schema declared further down
4185
+ // would be in its temporal dead zone. (The schemas below are all read inside a handler, or
4186
+ // passed as a thunk, which defers them past that point.)
4187
+ const mergeNumberParams = z.object({ number: z.coerce.number().int().positive() });
4188
+ const prChangesParams = z.object({ number: z.coerce.number().int().positive().safe() });
4189
+ const prChangesQuery = z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') });
4190
+ const githubRoutes = new Hono()
4191
+ .get('/github',
4192
+ // `limit` stays a bare string: the handler's `Number.parseInt`/`Number.isFinite` fallback to
4193
+ // 30 already accepts `?limit=banana`, and a numeric schema would 400 it instead.
4194
+ queryZodValidator(z.object({ limit: queryValue, refresh: queryValue })), async (c) => {
2719
4195
  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) => {
4196
+ const query = c.req.valid('query');
4197
+ const limit = Number.parseInt(query.limit ?? '', 10);
4198
+ return c.json(await fetchGithub(repoRoot, query.refresh === '1', Number.isFinite(limit) ? limit : 30));
4199
+ })
4200
+ .get('/github/comments/:kind/:number', queryZodValidator(refreshQuery), async (c) => {
2731
4201
  const { root: repoRoot } = c.get('project');
2732
4202
  const parsed = commentsParams.safeParse({
2733
4203
  kind: c.req.param('kind'),
@@ -2735,10 +4205,86 @@ export function createApp(deps) {
2735
4205
  });
2736
4206
  if (!parsed.success)
2737
4207
  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'));
4208
+ return c.json(await fetchGithubComments(repoRoot, parsed.data.kind, parsed.data.number, c.req.valid('query').refresh === '1'));
4209
+ })
4210
+ // Lazy checks glyphs for on-screen PR rows (#664). Additive sibling of /api/github — the list
4211
+ // call dropped `statusCheckRollup` (the dominant cost), so the glyph is hydrated here per
4212
+ // visible row. `prs` is a comma-separated list of positive integers, capped at GH_CHECKS_MAX;
4213
+ // anything malformed is a 400. Same in-payload availability degrade as the list (never a 5xx).
4214
+ // `prs` is the one genuinely REQUIRED query key on this server, so it is the one validated
4215
+ // strictly — `.min(1)` because `?prs=` answered `missing prs query` before it answered
4216
+ // `invalid prs query`, and both spellings must keep their own words.
4217
+ .get('/github/checks', queryZodValidator(z.object({ prs: z.string().min(1) }), { message: 'missing prs query' }), async (c) => {
4218
+ const { root: repoRoot } = c.get('project');
4219
+ const raw = c.req.valid('query').prs;
4220
+ const parts = raw.split(',').map((p) => p.trim()).filter(Boolean);
4221
+ if (parts.length === 0 || parts.length > GH_CHECKS_MAX)
4222
+ return c.json({ error: 'invalid prs query' }, 400);
4223
+ const numbers = [];
4224
+ for (const part of parts) {
4225
+ const n = Number(part);
4226
+ if (!Number.isInteger(n) || n <= 0 || String(n) !== part)
4227
+ return c.json({ error: 'invalid prs query' }, 400);
4228
+ numbers.push(n);
4229
+ }
4230
+ return c.json(await fetchGithubChecks(repoRoot, numbers));
4231
+ })
4232
+ .get('/github/prs/:number/merge-state', paramZodValidator(mergeNumberParams, { message: 'invalid pull request number' }), queryZodValidator(refreshQuery), async (c) => {
4233
+ const { root: repoRoot } = c.get('project');
4234
+ const parsed = { data: c.req.valid('param') };
4235
+ const forge = resolveForge(await getRepoInfo(repoRoot));
4236
+ if (!forge?.prMergeState)
4237
+ return c.json({ available: false, reason: 'GitHub merge state is unavailable' });
4238
+ return c.json(await forge.prMergeState(parsed.data.number, { refresh: c.req.valid('query').refresh === '1' }));
4239
+ })
4240
+ .post('/github/prs/:number/merge', paramZodValidator(mergeNumberParams, { message: 'invalid pull request number' }), jsonZodValidator(() => mergeBodySchema, { message: 'invalid merge request' }), async (c) => {
4241
+ const { root: repoRoot } = c.get('project');
4242
+ const parsedNumber = { data: c.req.valid('param') };
4243
+ const body = { data: c.req.valid('json') };
4244
+ const forge = resolveForge(await getRepoInfo(repoRoot));
4245
+ if (!forge?.mergePR)
4246
+ return c.json({ error: 'GitHub merge is unavailable' }, 409);
4247
+ const result = await forge.mergePR(parsedNumber.data.number, body.data);
4248
+ if (result.merged)
4249
+ return c.json(result);
4250
+ return c.json({
4251
+ error: result.error,
4252
+ ...(result.code ? { code: result.code } : {}),
4253
+ ...(result.current ? { current: result.current } : {}),
4254
+ }, result.status);
4255
+ })
4256
+ .get('/github/prs/:number/changes',
4257
+ // Split out of one `safeParse` over both inputs, because a path param and the query string
4258
+ // are separate validation targets to Hono and only a split makes each visible to the route
4259
+ // type. Both keep the single 400 sentence the combined parse answered. `refresh` stays
4260
+ // STRICT here (`?refresh=true` is a 400 today, unlike everywhere else on this server).
4261
+ paramZodValidator(prChangesParams, { message: 'invalid pull request number or refresh flag' }), queryZodValidator(prChangesQuery, { message: 'invalid pull request number or refresh flag' }), async (c) => {
4262
+ const { root: repoRoot } = c.get('project');
4263
+ const parsed = { data: c.req.valid('param') };
4264
+ try {
4265
+ return c.json(await fetchGithubPrDiff(repoRoot, parsed.data.number, c.req.valid('query').refresh === '1'));
4266
+ }
4267
+ catch (err) {
4268
+ if (err instanceof GithubPrNotFoundError)
4269
+ return c.json({ error: err.message }, 404);
4270
+ throw err;
4271
+ }
4272
+ });
4273
+ // The full comment thread for one issue/PR (#499). Additive sibling of /api/github — lazy
4274
+ // (fetched only while a detail view is open), zod-validated params, 400 on garbage, and the
4275
+ // same in-payload availability degrade (gh missing / offline / 404 all render as a hint).
4276
+ const commentsParams = z.object({
4277
+ kind: z.enum(['issue', 'pr']),
4278
+ number: z.coerce.number().int().positive(),
2739
4279
  });
2740
- // ---- repo view -----------------------------------------------------------
2741
- api.get('/repo', async (c) => {
4280
+ const mergeBodySchema = z.object({
4281
+ method: z.enum(['merge', 'squash', 'rebase']),
4282
+ expectedHeadSha: z.string().regex(/^[0-9a-f]{40}$/),
4283
+ overrideRules: z.boolean().optional().default(false),
4284
+ }).strict();
4285
+ // ---- chained family: repo / git (project-scoped) ----
4286
+ const repoRoutes = new Hono()
4287
+ .get('/repo', async (c) => {
2742
4288
  const { root: repoRoot } = c.get('project');
2743
4289
  const info = await getRepoInfo(repoRoot);
2744
4290
  if (!info)
@@ -2762,64 +4308,115 @@ export function createApp(deps) {
2762
4308
  branches,
2763
4309
  baseBranch: config.baseBranch ?? null,
2764
4310
  });
4311
+ })
4312
+ .get('/repo/diff', async (c) => {
4313
+ const { root: repoRoot } = c.get('project');
4314
+ const info = await getRepoInfo(repoRoot);
4315
+ if (!info)
4316
+ return c.text('not a git repository');
4317
+ return c.text(await getDiff(info.root));
4318
+ })
4319
+ // One commit's message + stat + patch — the Repo view expands it inline.
4320
+ // `?structured=1` is the ADDITIVE sibling (R5 Step 1.7): the new repo view's commit-diff
4321
+ // shape `{sha, subject, author, when, files, stat}` with 409 + reason on failure. The
4322
+ // legacy text answer below is a protected surface (BACKWARD_COMPATIBILITY.md §2) — its
4323
+ // shape, including the in-band failure sentences, stays exactly as it was.
4324
+ //
4325
+ // `Accept: application/json` reaches the same structured answer without the flag, and
4326
+ // `Accept: text/plain` asks for the blob; the flag still wins whenever it is present, and a
4327
+ // request with no opinion still gets the blob. See `negotiate`.
4328
+ .get('/repo/commit/:sha', queryZodValidator(z.object({ structured: queryValue })), async (c) => {
4329
+ const { root: repoRoot } = c.get('project');
4330
+ c.header('vary', 'Accept');
4331
+ const { structured } = c.req.valid('query');
4332
+ const wantsJson = structured !== undefined
4333
+ ? structured === '1'
4334
+ : negotiate(c.req.header('accept'), COMMIT_FORMATS) === 'application/json';
4335
+ const info = await getRepoInfo(repoRoot);
4336
+ if (wantsJson) {
4337
+ if (!info)
4338
+ return c.json({ error: 'not a git repository' }, 409);
4339
+ const result = await collectCommitChanges(info.root, c.req.param('sha'));
4340
+ if (!result.ok)
4341
+ return c.json({ error: result.error }, 409);
4342
+ return c.json(result.commit);
4343
+ }
4344
+ if (!info)
4345
+ return c.text('not a git repository');
4346
+ try {
4347
+ return c.text(await getCommit(info.root, c.req.param('sha')));
4348
+ }
4349
+ catch (err) {
4350
+ return c.text(`(git show failed: ${err instanceof Error ? err.message : String(err)})`);
4351
+ }
4352
+ })
4353
+ // Structured sibling of the text-blob /api/repo/diff above (protected
4354
+ // surface, untouched): the same {files, stat} shape the session /changes
4355
+ // route serves, here for the MAIN working tree's uncommitted changes vs
4356
+ // HEAD (redesign R5 Step 1.3 — §"Git/session API additions").
4357
+ .get('/repo/changes', async (c) => {
4358
+ const { root: repoRoot } = c.get('project');
4359
+ const info = await getRepoInfo(repoRoot);
4360
+ if (!info)
4361
+ return c.json({ error: 'not a git repository' }, 409);
4362
+ // The user's REAL working tree — never stage into their index (a GET must not write).
4363
+ const result = await collectChanges(info.root, 'HEAD', {
4364
+ intentToAdd: false,
4365
+ });
4366
+ if (!result.ok)
4367
+ return c.json({ error: result.error }, 409);
4368
+ return c.json(result.changes);
4369
+ })
4370
+ .post('/repo/branch', jsonZodValidator(() => repoBranchSchema), async (c) => {
4371
+ const { root: repoRoot } = c.get('project');
4372
+ const info = await getRepoInfo(repoRoot);
4373
+ if (!info)
4374
+ return c.json({ error: 'not a git repository' }, 409);
4375
+ const parsed = { data: c.req.valid('json') };
4376
+ const result = await createOrSwitchBranch(info.root, parsed.data.name, parsed.data.from);
4377
+ if (!result.ok)
4378
+ return c.json({ error: result.error }, 409);
4379
+ return c.json({ branch: result.branch, created: result.created });
2765
4380
  });
2766
4381
  // The Settings → Agents knobs in one read (R6 Step 1.5) — an ADDITIVE
2767
4382
  // 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) => {
4383
+ const configAnswer = async (repoRoot, config) => {
4384
+ const nativeModels = await readAgentModelDefaults(repoRoot);
4385
+ const modelsLocked = agentModelsLocked(repoRoot);
4386
+ return {
4387
+ baseBranch: config.baseBranch ?? null,
4388
+ defaultRunner: config.defaultRunner,
4389
+ systemPrompt: config.systemPrompt ?? null,
4390
+ // Native defaults seed each runner independently. A Cezar preset remains
4391
+ // selectable unless the operator opts into the fixed-model policy.
4392
+ defaultModels: modelsLocked
4393
+ ? nativeModels
4394
+ : { ...nativeModels, ...(config.defaultModels ?? {}) },
4395
+ modelsLocked,
4396
+ maxParallel: config.maxParallel,
4397
+ memoryLimitMb: config.memoryLimitMb ?? null,
4398
+ // Count-based worktree retention (#483): keep the last N finished worktrees
4399
+ // on disk. 0 = unlimited. Always materialized (schema default 10).
4400
+ worktreeRetention: config.worktreeRetention,
4401
+ // Live title updates (task auto-naming spec): tri-state — null means "no
4402
+ // config key, the CEZ_TITLE_UPDATES env default (ON) decides".
4403
+ liveTitleUpdates: config.liveTitleUpdates ?? null,
4404
+ // Optional review gate (#489): tri-state — null means "no config key, the
4405
+ // CEZ_REVIEW_GATE env default (OFF) decides".
4406
+ reviewGate: config.reviewGate ?? null,
4407
+ };
4408
+ };
4409
+ // ---- chained family: per-repo config (project-scoped) ----
4410
+ const configRoutes = new Hono()
4411
+ .get('/config', async (c) => {
4412
+ const repoRoot = c.get('project').root;
4413
+ return c.json(await configAnswer(repoRoot, await loadConfig(repoRoot)));
4414
+ })
4415
+ .put('/config', jsonZodValidator(() => setConfigSchema), async (c) => {
2819
4416
  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);
4417
+ const parsed = { data: c.req.valid('json') };
4418
+ if (agentModelsLocked(repoRoot) && parsed.data.defaultModels !== undefined) {
4419
+ return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
2823
4420
  }
2824
4421
  const configPath = join(dataDir, 'config.json');
2825
4422
  let raw = {};
@@ -2905,16 +4502,55 @@ export function createApp(deps) {
2905
4502
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2906
4503
  }
2907
4504
  // Pre-R6 answer shape ({baseBranch, defaultRunner}) + additive R6 fields.
2908
- return c.json(configAnswer(await loadConfig(repoRoot)));
4505
+ return c.json(await configAnswer(repoRoot, await loadConfig(repoRoot)));
4506
+ });
4507
+ // Set/clear the agents' config knobs (Settings → Agents; the Repo tab's
4508
+ // base-branch picker). Merges into the RAW config.json so user keys
4509
+ // (skillsRepos…) survive and schema defaults are never materialized into
4510
+ // the file. All fields optional + additive: `null` (and `''` for the
4511
+ // R6 keys) clears a knob back to its default.
4512
+ const modelPresetSchema = z.string().trim().max(200).nullable().optional();
4513
+ const setConfigSchema = z.object({
4514
+ baseBranch: z.string().trim().min(1).max(200).nullable().optional(),
4515
+ defaultRunner: z.enum(['claude', 'codex', 'opencode']).optional(),
4516
+ systemPrompt: z.string().trim().max(20_000, 'must be at most 20000 characters').nullable().optional(),
4517
+ defaultModels: z
4518
+ .object({
4519
+ claude: modelPresetSchema,
4520
+ codex: modelPresetSchema,
4521
+ opencode: modelPresetSchema,
4522
+ })
4523
+ .optional(),
4524
+ // Concurrency + memory guard (Settings → Resources). maxParallel clamps to
4525
+ // the schema's 1–16; memoryLimitMb null/0 clears the ceiling.
4526
+ maxParallel: z.number().int().min(1).max(16).optional(),
4527
+ memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
4528
+ // Worktree retention count (Settings → Resources, #483). 0 = unlimited;
4529
+ // null clears the key back to the schema default (10). Unlike memoryLimitMb,
4530
+ // 0 is a meaningful value (unlimited), so it is stored, not treated as clear.
4531
+ worktreeRetention: z.number().int().min(0).max(1000).nullable().optional(),
4532
+ // Live title updates toggle (Settings → Agents): null clears the key back
4533
+ // to the env-default behavior.
4534
+ liveTitleUpdates: z.boolean().nullable().optional(),
4535
+ // Optional review gate toggle (Settings → Agents, #489): null clears the key
4536
+ // back to the env-default behavior (OFF).
4537
+ reviewGate: z.boolean().nullable().optional(),
4538
+ });
4539
+ const setAgentConfigSchema = z.object({
4540
+ content: z.string().max(2_000_000),
4541
+ version: z.string().nullable(),
2909
4542
  });
4543
+ // ---- chained family: agent-config (project-scoped) -----------------------
2910
4544
  // Agent config is project-scoped (spec #404, adapted to the multi-project
2911
4545
  // 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) => {
4546
+ // same ProjectContext as every other mirrored route. Chained (not statements)
4547
+ // so `AppType` carries it — see `healthRoutes`.
4548
+ const agentConfigRoutes = new Hono()
4549
+ .get('/agent-config', async (c) => {
2914
4550
  const editable = capabilities().localHandoff;
2915
4551
  return c.json(await listAgentConfig(c.get('project').root, process.env, editable));
2916
- });
2917
- api.get('/agent-config/:id', async (c) => {
4552
+ })
4553
+ .get('/agent-config/:id', async (c) => {
2918
4554
  const id = c.req.param('id');
2919
4555
  const def = findConfigFile(id);
2920
4556
  if (!def)
@@ -2930,12 +4566,8 @@ export function createApp(deps) {
2930
4566
  if ('error' in read)
2931
4567
  return c.json({ error: read.error }, 500);
2932
4568
  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) => {
4569
+ })
4570
+ .put('/agent-config/:id', jsonZodValidator(setAgentConfigSchema), async (c) => {
2939
4571
  // Config files may define hooks and MCP commands, so writes remain a
2940
4572
  // local-machine capability and are re-gated on every request.
2941
4573
  if (!capabilities().localHandoff) {
@@ -2943,10 +4575,7 @@ export function createApp(deps) {
2943
4575
  error: 'editing agent config is disabled in hosted mode (CEZ_REMOTE) — edit it from the machine that owns the checkout',
2944
4576
  }, 409);
2945
4577
  }
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
- }
4578
+ const parsed = { data: c.req.valid('json') };
2950
4579
  const out = await writeConfigFile(c.req.param('id'), parsed.data.content, parsed.data.version, c.get('project').root);
2951
4580
  if (out === null)
2952
4581
  return c.json({ error: 'unknown config file' }, 404);
@@ -2954,55 +4583,6 @@ export function createApp(deps) {
2954
4583
  return c.json({ error: out.error }, out.status);
2955
4584
  return c.json(out.read);
2956
4585
  });
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
4586
  // Repo view branch actions: switch to an existing branch, or create one
3007
4587
  // (from `from` or HEAD) and switch. Predictable git failures — invalid
3008
4588
  // name, unknown `from`, dirty-tree checkout conflict — are 409 + reason.
@@ -3010,28 +4590,143 @@ export function createApp(deps) {
3010
4590
  name: z.string().trim().min(1).max(200),
3011
4591
  from: z.string().trim().min(1).max(200).optional(),
3012
4592
  });
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);
4593
+ // ---- assemble the chained families --------------------------------------
4594
+ // Every chained family is registered ONCE and mounted into the versioned table. There is no
4595
+ // second, unversioned spelling: `/api/*` was removed once the whole API was reachable under
4596
+ // `/api/v1` (BACKWARD_COMPATIBILITY.md §2). One surface means one thing to keep working, and
4597
+ // it is the one the typed client describes.
4598
+ //
4599
+ // MOUNT ORDER IS REGISTRATION ORDER. Hono matches in the order routes were added, so each
4600
+ // family keeps its internal order and the families keep theirs relative to each other.
4601
+ //
4602
+ // Written as ONE chained expression because that is the only shape Hono can infer route types
4603
+ // from — it is what puts these routes in `AppType`, and so in the typed client.
4604
+ const v1 = new Hono()
4605
+ .use('*', resolveProjectScope)
4606
+ .route('/', launchKeyRoutes)
4607
+ .route('/', skillsRoutes)
4608
+ .route('/', uiStateRoutes)
4609
+ .route('/', workflowsRoutes)
4610
+ .route('/', planRoutes)
4611
+ .route('/', automationsRoutes)
4612
+ .route('/', runsRoutes)
4613
+ .route('/', groupsRoutes)
4614
+ .route('/', openTargetsRoutes)
4615
+ .route('/', worktreesRoutes)
4616
+ .route('/', todosRoutes)
4617
+ .route('/', sseRoutes)
4618
+ .route('/', githubRoutes)
4619
+ .route('/', repoRoutes)
4620
+ .route('/', configRoutes)
4621
+ .route('/', agentConfigRoutes);
4622
+ // ---- chained family: the cross-project run index (workspace-level) -------
4623
+ /**
4624
+ * How many runs each project may contribute, newest first. The index is a FINDER, not a
4625
+ * listing: past the newest couple of hundred per project you are looking for something the
4626
+ * project's own Tasks table answers better, and every extra row is a DOM node the palette's
4627
+ * filter walks on each keystroke. `truncated` names the projects this bit, so a consumer never
4628
+ * has to pretend the list is complete.
4629
+ */
4630
+ const RUNS_INDEX_PER_PROJECT = 200;
4631
+ /** `RunRecord` → the wire row. Optional keys are spread CONDITIONALLY: writing
4632
+ * `titleSummary: run.titleSummary` types a key as always-present that `JSON.stringify` then
4633
+ * drops when it is undefined, which is exactly the drift the parity guard fails on. */
4634
+ const runIndexEntry = (projectId, run) => ({
4635
+ projectId,
4636
+ id: run.id,
4637
+ title: run.title,
4638
+ ...(run.titleSummary !== undefined ? { titleSummary: run.titleSummary } : {}),
4639
+ ...(run.titleOrigin !== undefined ? { titleOrigin: run.titleOrigin } : {}),
4640
+ status: run.status,
4641
+ ...(run.activity !== undefined ? { activity: run.activity } : {}),
4642
+ createdAt: run.createdAt,
4643
+ ...(run.finishedAt !== undefined ? { finishedAt: run.finishedAt } : {}),
4644
+ ...(run.seenAt !== undefined ? { seenAt: run.seenAt } : {}),
4645
+ archived: run.archived,
4646
+ ...(run.autoResumeAt !== undefined ? { autoResumeAt: run.autoResumeAt } : {}),
4647
+ });
4648
+ /**
4649
+ * `GET /workspace/runs-index` — every registered project's recent tasks in one slim answer, so
4650
+ * ⌘K can find a task without knowing which project it lives in.
4651
+ *
4652
+ * Workspace-level and single-mount for the obvious reason: a project-scoped spelling of "all
4653
+ * projects" is a contradiction. The per-project source is chosen the same way the automation
4654
+ * coordinator's boot fan-out chooses it (`bootContext` for the boot project, `contexts.peek`
4655
+ * for anything this process already owns, disk otherwise) — and never `contexts.context()`,
4656
+ * which would build a context, prune worktrees and `recover()` running agents. Typing in a
4657
+ * search box must not resume work; see `runs/run-index.ts`.
4658
+ */
4659
+ const runsIndexRoutes = new Hono()
4660
+ .get('/workspace/runs-index', async (c) => {
4661
+ let projects = [];
4662
+ try {
4663
+ const selector = capabilities().singleProject
4664
+ ? { projectId: await resolveBootProject() }
4665
+ : undefined;
4666
+ projects = await listProjects(selector);
3021
4667
  }
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 });
4668
+ catch {
4669
+ // unreadable workspace — an empty index, never a 500. The palette degrades to the
4670
+ // active project's own run list, which it holds either way.
4671
+ }
4672
+ const bootId = await resolveBootProject(projects);
4673
+ const runs = [];
4674
+ const truncated = [];
4675
+ for (const project of projects) {
4676
+ // No folder, no runs to read. `not-git` still has an `.ai/cezar` worth indexing.
4677
+ if (project.status === 'missing')
4678
+ continue;
4679
+ const owned = project.id === bootId ? bootContext : contexts.peek(project.id);
4680
+ // `listRuns()` already sorts newest-first; the disk reader returns file order, so both
4681
+ // paths get sorted below rather than trusting either.
4682
+ //
4683
+ // Archived runs are INCLUDED. The active project's rows reach the palette through
4684
+ // `GET /runs`, which has always carried them, and excluding them here would mean a task
4685
+ // is findable while you stand in its project and vanishes the moment you leave — the
4686
+ // exact asymmetry a cross-project finder exists to remove.
4687
+ const recent = (owned ? owned.store.listRuns() : readRunIndexFromDisk(join(project.root, '.ai/cezar'))).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
4688
+ if (recent.length > RUNS_INDEX_PER_PROJECT)
4689
+ truncated.push(project.id);
4690
+ for (const run of recent.slice(0, RUNS_INDEX_PER_PROJECT)) {
4691
+ runs.push(runIndexEntry(project.id, run));
4692
+ }
4693
+ }
4694
+ runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
4695
+ const body = {
4696
+ runs,
4697
+ perProjectLimit: RUNS_INDEX_PER_PROJECT,
4698
+ truncated,
4699
+ };
4700
+ return c.json(body);
3026
4701
  });
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);
4702
+ // Workspace-level families answer for the whole workspace, so they are single-mount: never a
4703
+ // project-scoped spelling, which would be a second surface to protect with no consumer.
4704
+ const workspaceV1 = new Hono()
4705
+ .route('/', healthRoutes)
4706
+ .route('/', modelsRoutes)
4707
+ .route('/', providersRoutes)
4708
+ .route('/', projectsRoutes)
4709
+ .route('/', agentProfilesRoutes)
4710
+ .route('/', skillsUpdateRoutes)
4711
+ .route('/', workspaceConfigRoutes)
4712
+ .route('/', fsBrowseRoutes)
4713
+ .route('/', automationChecksRoutes)
4714
+ .route('/', runsIndexRoutes)
4715
+ .route('/', workspaceEventsRoutes);
4716
+ // ---- mount ---------------------------------------------------------------
4717
+ // Scoped first, then the unscoped alias bound to the boot project. The paths are disjoint (no
4718
+ // route starts with `/p/`), so order between the two never decides a match — but the SPA
4719
+ // catch-all below must still come last. `route()` re-registers the sub-app's routes under
4720
+ // each prefix, handlers shared, internal order preserved.
4721
+ //
4722
+ // Workspace families mount LAST and that is load-bearing: mounting the project table also
4723
+ // mounts its `use('*')` scope resolver over the whole prefix, and Hono runs matched middleware
4724
+ // in registration order. `/health` in particular answers for the workspace, has no project to
4725
+ // resolve, and is the CORS-open discovery route — it must not sit behind the resolver.
4726
+ const routed = app
4727
+ .route(V1_SCOPED_PREFIX, v1)
4728
+ .route(V1_PREFIX, v1)
4729
+ .route(V1_PREFIX, workspaceV1);
3035
4730
  // ---- SPA catch-all -------------------------------------------------------
3036
4731
  // Last, so every route above still wins. Any other GET gets the cockpit shell:
3037
4732
  // react-router owns the route map, including the 404, so `/tasks/:id/changes`
@@ -3040,14 +4735,33 @@ export function createApp(deps) {
3040
4735
  // an unknown API path must never answer with HTML.
3041
4736
  // Without a web/dist build this serves the built-in build-hint page (dev-only
3042
4737
  // state — the published tarball ships web/dist), never a 404.
3043
- app.get('*', (c) => serveShell(c) ?? c.notFound());
3044
- return app;
4738
+ routed.get('*', (c) => serveShell(c) ?? c.notFound());
4739
+ return routed;
3045
4740
  }
3046
4741
  export function startServer(deps, port) {
4742
+ const workspaceEvents = deps.workspaceEvents ?? new WorkspaceEventBus();
4743
+ const skillsUpdate = deps.skillsUpdate ?? new SkillsUpdateService({ invalidateCatalog: refreshTeamSkills });
3047
4744
  // The subscription hub rides the same HTTP server (one port, zero config):
3048
4745
  // createApp registers the topics, the `upgrade` hook below owns the socket.
3049
4746
  const socketHub = deps.socketHub ?? createSocketHub();
3050
- const app = createApp({ ...deps, socketHub });
4747
+ const automationCoordinator = new AutomationCoordinator({ listProjects });
4748
+ const bootProjectId = deps.bootProjectId ?? 'default';
4749
+ const bootAutomationStore = automationCoordinator.store(bootProjectId, deps.repoRoot);
4750
+ const sharedContexts = deps.contexts ?? new ProjectContexts({
4751
+ listProjects,
4752
+ semaphore: deps.semaphore,
4753
+ automationStore: (projectId, root) => automationCoordinator.store(projectId, root),
4754
+ });
4755
+ let rescheduleAutomations = () => { };
4756
+ const app = createApp({
4757
+ ...deps,
4758
+ contexts: sharedContexts,
4759
+ automationStore: bootAutomationStore,
4760
+ workspaceEvents,
4761
+ skillsUpdate,
4762
+ socketHub,
4763
+ automationsChanged: () => rescheduleAutomations(),
4764
+ });
3051
4765
  // SECURITY: default to loopback. This server executes agents locally and its endpoints are
3052
4766
  // same-origin-trusted (only /api/health is CORS-open); binding to a non-loopback host would
3053
4767
  // expose an agent-executing box to the network. `bindHost` exists only for a deliberate
@@ -3058,13 +4772,89 @@ export function startServer(deps, port) {
3058
4772
  port,
3059
4773
  hostname: deps.bindHost ?? '127.0.0.1',
3060
4774
  });
4775
+ const coordinator = new SkillsUpdateCoordinator(skillsUpdate, async () => effectiveSkillsAutoUpdate(await loadWorkspaceConfig()));
4776
+ const automationProjects = new Map();
4777
+ const automationScheduler = new WorkspaceAutomationScheduler({
4778
+ coordinator: automationCoordinator,
4779
+ handle: (projectId, store) => {
4780
+ const project = automationProjects.get(projectId);
4781
+ if (!project)
4782
+ return undefined;
4783
+ return {
4784
+ projectId,
4785
+ owner: project.owner,
4786
+ repo: project.repo,
4787
+ store,
4788
+ poller: new GithubPoller(),
4789
+ onChange: (automationId, revision) => workspaceEvents.emit('automation-change', { project: projectId, automationId, revision }),
4790
+ launch: async (definition, candidate, receiptId) => {
4791
+ const bootId = deps.bootProjectId ?? 'default';
4792
+ const context = projectId === bootId
4793
+ ? { root: deps.repoRoot, manager: deps.manager, store: deps.store }
4794
+ : await sharedContexts.context(projectId);
4795
+ return launchAutomationRun({
4796
+ root: context.root,
4797
+ manager: context.manager,
4798
+ store: context.store,
4799
+ definition,
4800
+ candidate,
4801
+ receiptId,
4802
+ });
4803
+ },
4804
+ };
4805
+ },
4806
+ });
4807
+ rescheduleAutomations = () => { void automationScheduler.reschedule(); };
4808
+ const unsubscribe = workspaceEvents.on((event, data) => {
4809
+ if (event === 'project-added') {
4810
+ const project = data.project;
4811
+ if (project && typeof project.id === 'string' && typeof project.root === 'string' && project.status !== 'missing') {
4812
+ coordinator.add(project.id, project.root);
4813
+ void getRepoInfo(project.root).then((info) => {
4814
+ const parsed = parseRemote(info?.remote ?? '');
4815
+ if (parsed?.host === 'github.com')
4816
+ automationProjects.set(project.id, { root: project.root, owner: parsed.owner, repo: parsed.repo });
4817
+ return automationScheduler.reschedule();
4818
+ });
4819
+ }
4820
+ }
4821
+ else if (event === 'project-removed') {
4822
+ const id = data.id;
4823
+ if (typeof id === 'string')
4824
+ coordinator.remove(id);
4825
+ if (typeof id === 'string') {
4826
+ automationCoordinator.remove(id);
4827
+ automationProjects.delete(id);
4828
+ void automationScheduler.reschedule();
4829
+ }
4830
+ }
4831
+ });
4832
+ server.once('listening', () => {
4833
+ void listProjects().then((projects) => {
4834
+ const all = projects.some((project) => project.root === deps.repoRoot)
4835
+ ? projects : [{ id: deps.bootProjectId ?? 'default', root: deps.repoRoot, status: 'ok' }, ...projects];
4836
+ coordinator.start(all);
4837
+ void Promise.all(all.map(async (project) => {
4838
+ const parsed = parseRemote((await getRepoInfo(project.root))?.remote ?? '');
4839
+ if (parsed?.host === 'github.com')
4840
+ automationProjects.set(project.id, { root: project.root, owner: parsed.owner, repo: parsed.repo });
4841
+ const automationStore = automationCoordinator.store(project.id, project.root);
4842
+ const runStore = project.id === (deps.bootProjectId ?? 'default')
4843
+ ? deps.store
4844
+ : sharedContexts.peek(project.id)?.store;
4845
+ if (automationStore && runStore)
4846
+ reconcileAutomationReceipts(automationStore, runStore);
4847
+ })).then(() => automationScheduler.start()).catch(() => undefined);
4848
+ }).catch(() => undefined);
4849
+ });
4850
+ server.once('close', () => { unsubscribe(); coordinator.stop(); automationScheduler.stop(); });
3061
4851
  socketHub.attach(server, (req) => verifyWsUpgrade(req, deps.bindHost));
3062
4852
  return server;
3063
4853
  }
3064
4854
  /**
3065
4855
  * 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,
4856
+ * before the `/api/v1/ws` handshake. WebSocket is NOT subject to CORS — any web
4857
+ * page may open `ws://127.0.0.1:<port>/api/v1/ws` and, unlike a forced HTTP GET,
3068
4858
  * would get to READ what comes back — so this guard is load-bearing:
3069
4859
  *
3070
4860
  * 1. Host allowlist (local mode): a non-loopback Host is a DNS-rebound