@open-mercato/cezar 0.9.1 → 0.9.2

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 (300) hide show
  1. package/README.md +19 -11
  2. package/dist/agent-config/catalog.d.ts +1 -1
  3. package/dist/agent-config/files.d.ts +1 -1
  4. package/dist/agent-config/service.d.ts +1 -1
  5. package/dist/agent-config/validate.d.ts +1 -1
  6. package/dist/automations/coordinator.d.ts +26 -0
  7. package/dist/automations/coordinator.js +66 -0
  8. package/dist/automations/coordinator.js.map +1 -0
  9. package/dist/automations/github-poller.d.ts +74 -0
  10. package/dist/automations/github-poller.js +234 -0
  11. package/dist/automations/github-poller.js.map +1 -0
  12. package/dist/automations/scheduler.d.ts +42 -0
  13. package/dist/automations/scheduler.js +192 -0
  14. package/dist/automations/scheduler.js.map +1 -0
  15. package/dist/automations/store.d.ts +65 -0
  16. package/dist/automations/store.js +298 -0
  17. package/dist/automations/store.js.map +1 -0
  18. package/dist/automations/task-template.d.ts +18 -0
  19. package/dist/automations/task-template.js +95 -0
  20. package/dist/automations/task-template.js.map +1 -0
  21. package/dist/automations/types.d.ts +251 -0
  22. package/dist/automations/types.js +158 -0
  23. package/dist/automations/types.js.map +1 -0
  24. package/dist/config.d.ts +9 -111
  25. package/dist/contract/agent-config.d.ts +151 -0
  26. package/dist/contract/automations.d.ts +911 -0
  27. package/dist/contract/events.d.ts +45 -0
  28. package/dist/contract/github.d.ts +467 -0
  29. package/dist/contract/health.d.ts +96 -0
  30. package/dist/contract/index.d.ts +15 -0
  31. package/dist/contract/index.js +1421 -0
  32. package/dist/contract/projects.d.ts +182 -0
  33. package/dist/contract/repo.d.ts +234 -0
  34. package/dist/contract/runs.d.ts +1226 -0
  35. package/dist/contract/skills.d.ts +225 -0
  36. package/dist/contract/workflows.d.ts +212 -0
  37. package/dist/contract/workspace.d.ts +442 -0
  38. package/dist/core/agent-env.d.ts +1 -1
  39. package/dist/core/agent-runner.d.ts +12 -1
  40. package/dist/core/agent-runner.js +13 -0
  41. package/dist/core/agent-runner.js.map +1 -1
  42. package/dist/core/ask.d.ts +45 -124
  43. package/dist/core/ask.js +103 -13
  44. package/dist/core/ask.js.map +1 -1
  45. package/dist/core/claude-cli-runner.d.ts +2 -2
  46. package/dist/core/claude-cli-runner.js +52 -9
  47. package/dist/core/claude-cli-runner.js.map +1 -1
  48. package/dist/core/claude-ui-mapper.d.ts +1 -1
  49. package/dist/core/codex-app-server-runner.d.ts +1 -1
  50. package/dist/core/codex-app-server-runner.js +55 -3
  51. package/dist/core/codex-app-server-runner.js.map +1 -1
  52. package/dist/core/codex-app-server-transport.d.ts +6 -1
  53. package/dist/core/codex-app-server-transport.js +12 -3
  54. package/dist/core/codex-app-server-transport.js.map +1 -1
  55. package/dist/core/codex-model-catalog.d.ts +1 -1
  56. package/dist/core/codex-ui-mapper.d.ts +5 -1
  57. package/dist/core/codex-ui-mapper.js +74 -17
  58. package/dist/core/codex-ui-mapper.js.map +1 -1
  59. package/dist/core/model-identity.d.ts +1 -1
  60. package/dist/core/model-presets.d.ts +2 -2
  61. package/dist/core/model-presets.js +1 -1
  62. package/dist/core/opencode-server-runner.d.ts +2 -2
  63. package/dist/core/opencode-ui-mapper.d.ts +4 -1
  64. package/dist/core/opencode-ui-mapper.js +45 -7
  65. package/dist/core/opencode-ui-mapper.js.map +1 -1
  66. package/dist/core/provider-auth.d.ts +62 -0
  67. package/dist/core/provider-auth.js +290 -0
  68. package/dist/core/provider-auth.js.map +1 -0
  69. package/dist/core/provider-availability.d.ts +3 -0
  70. package/dist/core/provider-availability.js +13 -0
  71. package/dist/core/provider-availability.js.map +1 -0
  72. package/dist/core/runner-factory.d.ts +1 -1
  73. package/dist/core/runner-model-catalog.d.ts +1 -1
  74. package/dist/core/tool-display.d.ts +1 -1
  75. package/dist/core/ui-events.d.ts +1 -1
  76. package/dist/core/ui-events.js +1 -1
  77. package/dist/git-diff-base.d.ts +60 -0
  78. package/dist/git-diff-base.js +54 -0
  79. package/dist/git-diff-base.js.map +1 -0
  80. package/dist/git-worktree.d.ts +37 -6
  81. package/dist/git-worktree.js +39 -9
  82. package/dist/git-worktree.js.map +1 -1
  83. package/dist/handoff.d.ts +2 -2
  84. package/dist/handoff.js +1 -1
  85. package/dist/index.js +65 -8
  86. package/dist/index.js.map +1 -1
  87. package/dist/paths.d.ts +16 -1
  88. package/dist/paths.js +24 -1
  89. package/dist/paths.js.map +1 -1
  90. package/dist/planner.d.ts +2 -2
  91. package/dist/planner.js +1 -1
  92. package/dist/planner.js.map +1 -1
  93. package/dist/release/manifests.d.ts +81 -0
  94. package/dist/release/manifests.js +74 -0
  95. package/dist/release/manifests.js.map +1 -0
  96. package/dist/release/snapshot.d.ts +9 -23
  97. package/dist/release/snapshot.js +9 -22
  98. package/dist/release/snapshot.js.map +1 -1
  99. package/dist/release/stable.d.ts +15 -29
  100. package/dist/release/stable.js +14 -27
  101. package/dist/release/stable.js.map +1 -1
  102. package/dist/runs/auto-name.d.ts +1 -1
  103. package/dist/runs/retention.d.ts +1 -1
  104. package/dist/runs/store.d.ts +142 -336
  105. package/dist/runs/store.js +136 -9
  106. package/dist/runs/store.js.map +1 -1
  107. package/dist/runs/ui-event-sink.d.ts +1 -1
  108. package/dist/server/app-type.d.ts +21 -0
  109. package/dist/server/app-type.js +2 -0
  110. package/dist/server/app-type.js.map +1 -0
  111. package/dist/server/capabilities.d.ts +6 -5
  112. package/dist/server/capabilities.js +11 -0
  113. package/dist/server/capabilities.js.map +1 -1
  114. package/dist/server/forge/github.d.ts +58 -151
  115. package/dist/server/forge/github.js +530 -6
  116. package/dist/server/forge/github.js.map +1 -1
  117. package/dist/server/forge/index.d.ts +10 -3
  118. package/dist/server/forge/index.js +14 -1
  119. package/dist/server/forge/index.js.map +1 -1
  120. package/dist/server/forge/types.d.ts +86 -1
  121. package/dist/server/git-changes.d.ts +8 -8
  122. package/dist/server/git-changes.js +14 -17
  123. package/dist/server/git-changes.js.map +1 -1
  124. package/dist/server/git.d.ts +2 -0
  125. package/dist/server/git.js +9 -0
  126. package/dist/server/git.js.map +1 -1
  127. package/dist/server/github.d.ts +3 -3
  128. package/dist/server/github.js +1 -1
  129. package/dist/server/github.js.map +1 -1
  130. package/dist/server/pr.d.ts +2 -2
  131. package/dist/server/project-context.d.ts +20 -3
  132. package/dist/server/project-context.js +30 -1
  133. package/dist/server/project-context.js.map +1 -1
  134. package/dist/server/provider-action-gate.d.ts +8 -0
  135. package/dist/server/provider-action-gate.js +54 -0
  136. package/dist/server/provider-action-gate.js.map +1 -0
  137. package/dist/server/provider-auth-runtime.d.ts +21 -0
  138. package/dist/server/provider-auth-runtime.js +66 -0
  139. package/dist/server/provider-auth-runtime.js.map +1 -0
  140. package/dist/server/server.d.ts +11142 -47
  141. package/dist/server/server.js +1714 -806
  142. package/dist/server/server.js.map +1 -1
  143. package/dist/server/validators.d.ts +97 -0
  144. package/dist/server/validators.js +86 -0
  145. package/dist/server/validators.js.map +1 -0
  146. package/dist/server/ws.d.ts +2 -2
  147. package/dist/server/ws.js +2 -2
  148. package/dist/server/ws.js.map +1 -1
  149. package/dist/server-install/engine.d.ts +1 -1
  150. package/dist/server-install/platforms/macosx-ngrok.d.ts +1 -1
  151. package/dist/server-install/platforms/ubuntu-vps.d.ts +16 -1
  152. package/dist/server-install/platforms/ubuntu-vps.js +83 -1
  153. package/dist/server-install/platforms/ubuntu-vps.js.map +1 -1
  154. package/dist/server-install/state.d.ts +1 -1
  155. package/dist/server-install/steps.d.ts +2 -2
  156. package/dist/server-install/strategies.d.ts +1 -1
  157. package/dist/server-install/types.d.ts +38 -424
  158. package/dist/server-install/ui.d.ts +1 -1
  159. package/dist/skills-banner.d.ts +7 -6
  160. package/dist/skills-banner.js +7 -6
  161. package/dist/skills-banner.js.map +1 -1
  162. package/dist/skills-remote.d.ts +2 -2
  163. package/dist/skills-update.d.ts +88 -0
  164. package/dist/skills-update.js +392 -0
  165. package/dist/skills-update.js.map +1 -0
  166. package/dist/skills.d.ts +2 -0
  167. package/dist/skills.js +3 -2
  168. package/dist/skills.js.map +1 -1
  169. package/dist/todos.d.ts +2 -28
  170. package/dist/todos.js +1 -1
  171. package/dist/ui-state.d.ts +16 -2
  172. package/dist/ui-state.js +14 -1
  173. package/dist/ui-state.js.map +1 -1
  174. package/dist/workflows/load.d.ts +1 -1
  175. package/dist/workflows/run.d.ts +53 -12
  176. package/dist/workflows/run.js +436 -64
  177. package/dist/workflows/run.js.map +1 -1
  178. package/dist/workflows/types.d.ts +59 -224
  179. package/dist/workflows/types.js +14 -0
  180. package/dist/workflows/types.js.map +1 -1
  181. package/dist/workspace/config.d.ts +68 -206
  182. package/dist/workspace/config.js +145 -14
  183. package/dist/workspace/config.js.map +1 -1
  184. package/dist/workspace/migrations.d.ts +1 -1
  185. package/dist/workspace/migrations.js +19 -4
  186. package/dist/workspace/migrations.js.map +1 -1
  187. package/dist/workspace/projects.d.ts +8 -2
  188. package/dist/workspace/projects.js +9 -3
  189. package/dist/workspace/projects.js.map +1 -1
  190. package/dist/workspace/semaphore.d.ts +6 -0
  191. package/dist/workspace/semaphore.js +14 -1
  192. package/dist/workspace/semaphore.js.map +1 -1
  193. package/dist/workspace/ui-state.d.ts +13 -3
  194. package/dist/workspace/ui-state.js +15 -5
  195. package/dist/workspace/ui-state.js.map +1 -1
  196. package/package.json +17 -39
  197. package/scripts/inline-contract.mjs +112 -0
  198. package/scripts/mock-claude.mjs +39 -1
  199. package/scripts/sync-readme.mjs +20 -0
  200. package/web/dist/assets/alert-dialog-ghffK6g9.js +1 -0
  201. package/web/dist/assets/arrow-down-Cz8qb71e.js +1 -0
  202. package/web/dist/assets/arrow-left-wcYKQXI_.js +1 -0
  203. package/web/dist/assets/bundle-mjs-BT31bpU6.js +1 -0
  204. package/web/dist/assets/centered-state-ZtnFHA-n.js +43 -0
  205. package/web/dist/assets/chevron-right-G4caF2QV.js +1 -0
  206. package/web/dist/assets/{chunk-BO2N2NFS-DtrdTCWa.js → chunk-BO2N2NFS-DE6qKn3r.js} +8 -8
  207. package/web/dist/assets/circle-check--KvBxXWp.js +1 -0
  208. package/web/dist/assets/circle-x-C3EFZ3xf.js +1 -0
  209. package/web/dist/assets/collapsible-B0JVzd0B.js +1 -0
  210. package/web/dist/assets/commit-list-wm6DPHNs.js +1 -0
  211. package/web/dist/assets/compare-variants-DbdP4Y5T.js +1 -0
  212. package/web/dist/assets/{core-DePtBHcl.js → core-BCsw8oQw.js} +1 -1
  213. package/web/dist/assets/diff-Bizc96qe.js +3 -0
  214. package/web/dist/assets/diff-stat-Dyfg3ltF.js +1 -0
  215. package/web/dist/assets/{diff-view-DCeSegKk.js → diff-view-CG8ek6tC.js} +4 -4
  216. package/web/dist/assets/dropdown-menu-C1Dlf35P.js +1 -0
  217. package/web/dist/assets/editable-title-5rC_QRBZ.js +1 -0
  218. package/web/dist/assets/{ellipsis-vertical-CNnSLn5a.js → ellipsis-vertical-DpGTRqC2.js} +1 -1
  219. package/web/dist/assets/{file-D4GBh1ao.js → file-DlUGpUlb.js} +1 -1
  220. package/web/dist/assets/folder-BAY6Vddy.js +1 -0
  221. package/web/dist/assets/{git-pull-request-DnR8Xh4_.js → git-pull-request-agOG0iGM.js} +1 -1
  222. package/web/dist/assets/git-toolbar-DGiNMmtY.js +1 -0
  223. package/web/dist/assets/github-Vp7lxYGQ.js +1 -0
  224. package/web/dist/assets/highlighted-body-OFNGDK62-BL-MfFgl.js +1 -0
  225. package/web/dist/assets/highlighter-CdHqIwFr.js +3 -0
  226. package/web/dist/assets/image-preview-BWGk5fPX.js +1 -0
  227. package/web/dist/assets/index-DSxyJpuK.css +2 -0
  228. package/web/dist/assets/index-DiawI6RX.js +6 -0
  229. package/web/dist/assets/lib-BQXq3kEf.js +1 -0
  230. package/web/dist/assets/lib-BxQXEXDF.js +1 -0
  231. package/web/dist/assets/markdown-BGBOGCA_.js +2 -0
  232. package/web/dist/assets/mermaid-GHXKKRXX-C4jxqUUb.js +1 -0
  233. package/web/dist/assets/new-task-form-BpAw_Dks.js +1 -0
  234. package/web/dist/assets/pill-Dvs-P0xy.js +1 -0
  235. package/web/dist/assets/project-router-Cx8NPoqn.js +1 -0
  236. package/web/dist/assets/prompt-templates-CSBYBR1I.js +15 -0
  237. package/web/dist/assets/react-runtime-CCIEwYL0.js +9 -0
  238. package/web/dist/assets/{refresh-cw-BT8E96Tm.js → refresh-cw-BoGdAykg.js} +1 -1
  239. package/web/dist/assets/repo-git-PmX-BNMo.js +1 -0
  240. package/web/dist/assets/rolldown-runtime-QTnfLwEv.js +1 -0
  241. package/web/dist/assets/run-diff-EcOkf3ti.js +3 -0
  242. package/web/dist/assets/run-header-BGiv6_qX.js +1 -0
  243. package/web/dist/assets/{search-x-BDjaMrzG.js → search-x-X3EpVhgs.js} +1 -1
  244. package/web/dist/assets/skill-empty-hint-CZLALx6l.js +1 -0
  245. package/web/dist/assets/skills-BiWfHmCR.js +1 -0
  246. package/web/dist/assets/skills-CC91ouGH.js +1 -0
  247. package/web/dist/assets/sparkles-Dt4q7pbG.js +1 -0
  248. package/web/dist/assets/{square-terminal-Ckjjn0vb.js → square-terminal-hwBL8XsC.js} +1 -1
  249. package/web/dist/assets/tab-link-DIlR5T07.js +1 -0
  250. package/web/dist/assets/task-changes-XkHDxzQA.js +1 -0
  251. package/web/dist/assets/task-commits-BTTA5k5T.js +1 -0
  252. package/web/dist/assets/task-files-Da8_4C9g.js +2 -0
  253. package/web/dist/assets/task-thread-DoNXUsHr.js +9 -0
  254. package/web/dist/assets/textarea-CUdokhkv.js +1 -0
  255. package/web/dist/assets/thread-loading-B8q8ukXJ.js +1 -0
  256. package/web/dist/assets/{trash-2-DAyZ0VSi.js → trash-2-DhVIdWv8.js} +1 -1
  257. package/web/dist/assets/{triangle-alert-Uy8rokUh.js → triangle-alert-XYT0DGCI.js} +1 -1
  258. package/web/dist/assets/{upload-CDdyPPo_.js → upload-BKjN59-1.js} +1 -1
  259. package/web/dist/assets/use-desktop-Bf5LS_P4.js +1 -0
  260. package/web/dist/assets/use-submit-shortcut-CzVyoAMd.js +1 -0
  261. package/web/dist/assets/utils-DsLuRXwV.js +64 -0
  262. package/web/dist/assets/workflows-o2QU_pvU.js +11 -0
  263. package/web/dist/assets/zoomable-image-7WyUiaVD.js +1 -0
  264. package/web/dist/index.html +32 -13
  265. package/scripts/dev.mjs +0 -74
  266. package/scripts/release-snapshot.mjs +0 -130
  267. package/scripts/release.mjs +0 -119
  268. package/web/dist/assets/arrow-left-CnSdp92h.js +0 -1
  269. package/web/dist/assets/bundle-mjs-BegPhL5c.js +0 -1
  270. package/web/dist/assets/centered-state-DOsTcJZF.js +0 -43
  271. package/web/dist/assets/commit-list-BrxBEzRK.js +0 -1
  272. package/web/dist/assets/compare-variants-Bv2c9ORF.js +0 -1
  273. package/web/dist/assets/dist-B6XQOhgM.js +0 -1
  274. package/web/dist/assets/git-toolbar-pIC1ubPM.js +0 -1
  275. package/web/dist/assets/github-DTTSG0ht.js +0 -1
  276. package/web/dist/assets/highlighted-body-OFNGDK62-BuFJwunK.js +0 -1
  277. package/web/dist/assets/image-preview-DEhW0TFz.js +0 -1
  278. package/web/dist/assets/index-Cijmlz7q.js +0 -30
  279. package/web/dist/assets/index-DzroxqBG.css +0 -2
  280. package/web/dist/assets/lib-DPEZDBUN.js +0 -1
  281. package/web/dist/assets/lib-uHpeUPzK.js +0 -1
  282. package/web/dist/assets/mermaid-GHXKKRXX-CN2zQue0.js +0 -1
  283. package/web/dist/assets/open-mercato-toBr6SOa.svg +0 -11
  284. package/web/dist/assets/project-router-BMx4e8m-.js +0 -1
  285. package/web/dist/assets/repo-git-BusYfa3t.js +0 -1
  286. package/web/dist/assets/run-diff-wOJ3odA-.js +0 -3
  287. package/web/dist/assets/run-header-Cg5incil.js +0 -1
  288. package/web/dist/assets/skill-empty-hint-DTUSp6Vl.js +0 -1
  289. package/web/dist/assets/skills-PpP2owbM.js +0 -1
  290. package/web/dist/assets/tab-link-F3uQrdm2.js +0 -1
  291. package/web/dist/assets/task-changes-iD9kS79I.js +0 -1
  292. package/web/dist/assets/task-commits-COkLaUri.js +0 -1
  293. package/web/dist/assets/task-files-Ckttz7XH.js +0 -2
  294. package/web/dist/assets/task-thread-CQD91VPt.js +0 -9
  295. package/web/dist/assets/textarea-Bj_LCreg.js +0 -1
  296. package/web/dist/assets/use-desktop-DUh0STzq.js +0 -3
  297. package/web/dist/assets/utils-B8s5qIcK.js +0 -1
  298. package/web/dist/assets/workflows-BFk8C0co.js +0 -11
  299. package/web/dist/assets/zoomable-image-PEDOqyLQ.js +0 -1
  300. /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,16 +13,20 @@ 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';
12
20
  import { discoverCodexModels } from '../core/codex-model-catalog.js';
21
+ import { PROVIDER_IDS, ProviderAuthService, } from '../core/provider-auth.js';
22
+ import { applyProviderEnablement } from '../core/provider-availability.js';
13
23
  import { RunnerModelCatalog } from '../core/runner-model-catalog.js';
14
24
  import { currentUsage, onUsage } from '../core/process-usage.js';
15
25
  import { WORKFLOWS_DIR, loadWorkflows } from '../workflows/load.js';
16
26
  import { QUICK_TASK_WORKFLOW, normalizeWorkflowDoc, skillStackOf, skillsToSteps, stepsIssue, workflowFileSchema, workflowStepSchema, } from '../workflows/types.js';
17
27
  import { planChain, slugify } from '../planner.js';
18
28
  import { discoverSkills } from '../skills.js';
29
+ import { SkillsUpdateConflictError, SkillsUpdateCoordinator, SkillsUpdateService } from '../skills-update.js';
19
30
  import { getTeamSkillsCached, refreshTeamSkills, waitForTeamSkills } from '../skills-remote.js';
20
31
  import { appendHandoffHeartbeat, handoffProgressExcerpt, readHandoff } from '../handoff.js';
21
32
  import { markStarted, onTodosChanged, readTodos, removeTodo, todoTaskText } from '../todos.js';
@@ -28,7 +39,7 @@ import { gatedSkillsRepos, loadConfig, resolveWorktreeRetention } from '../confi
28
39
  import { findConfigFile } from '../agent-config/catalog.js';
29
40
  import { readConfigFile, writeConfigFile } from '../agent-config/files.js';
30
41
  import { listAgentConfig } from '../agent-config/service.js';
31
- import { PROJECT_ID_RE, defaultWorkspaceConfig, loadWorkspaceConfig, mergeWriteWorkspaceConfig, } from '../workspace/config.js';
42
+ import { PROJECT_ID_RE, defaultWorkspaceConfig, effectiveSkillsAutoUpdate, loadWorkspaceConfig, mergeWriteWorkspaceConfig, effectiveComposerDefault, } from '../workspace/config.js';
32
43
  import { allocateProjectSlug, listProjects, probeProjectStatus, registerProject, removeProject, shouldRegisterProject, } from '../workspace/projects.js';
33
44
  import { mergeWriteWorkspaceUiState, readWorkspaceUiState } from '../workspace/ui-state.js';
34
45
  import { checkoutRepo } from './checkout.js';
@@ -39,34 +50,92 @@ import { expandTilde } from '../paths.js';
39
50
  import { isLoopbackHostHeader, normalizeHostname, resolveCapabilities } from './capabilities.js';
40
51
  import { createSocketHub } from './ws.js';
41
52
  import { browseDirectory, isInsideBrowseRoot, isLexicallyInsideBrowseRoot, resolveBrowseRoot } from './fs-browse.js';
42
- import { resolveForge } from './forge/index.js';
43
- import { fetchGithub, fetchGithubComments } from './github.js';
53
+ import { parseRemote, resolveForge } from './forge/index.js';
54
+ import { fetchGithub, fetchGithubChecks, fetchGithubComments, fetchGithubPrDiff, GithubPrNotFoundError, GH_CHECKS_MAX } from './github.js';
44
55
  import { ensureLaunchKey } from './launch-key.js';
45
56
  import { openInTerminal } from './open-in-terminal.js';
46
57
  import { agentCliRunner, detectOpenTargets, openFileInDefaultApp, openInApp } from './open-in-app.js';
47
58
  import { createDraftPr } from './pr.js';
59
+ import { ProviderRuntimeAuthObserver } from './provider-auth-runtime.js';
60
+ import { providerForActiveRun, providerForExistingRun, providersRequiredByWorkflow, unavailableProviderMessage, } from './provider-action-gate.js';
48
61
  import { ASSET_CACHE_CONTROL, BUILD_HINT_HTML, assetContentType, isSafeAssetFilename, resolveGetRequest, } from './static-ui.js';
49
62
  /** `projectId` gate at the route boundary (spec "Project identity"): the slug
50
63
  * shape or the reserved `default` alias — validated BEFORE touching any map
51
64
  * or path. (`default` matches the slug regex too; the literal keeps the
52
65
  * contract explicit.) */
53
66
  const projectIdSchema = z.union([z.literal('default'), z.string().regex(PROJECT_ID_RE)]);
54
- const SCOPED_PREFIX = '/api/p/:projectId';
67
+ const providerConnectSchema = z.object({
68
+ provider: z.enum(PROVIDER_IDS),
69
+ }).strict();
70
+ const providerParamSchema = z.enum(PROVIDER_IDS);
71
+ const providerEnabledSchema = z.object({ enabled: z.boolean() }).strict();
72
+ const providerRetrySchema = z.object({
73
+ authFailureId: z.string().min(1).max(128),
74
+ }).strict();
75
+ const automationEditableSchema = z
76
+ .object({
77
+ name: z.string().trim().min(1).max(200),
78
+ description: z.string().max(2_000).optional(),
79
+ enabled: z.boolean().optional(),
80
+ events: z.array(automationEventSchema).min(1).max(4),
81
+ intervalSeconds: z.number().int().min(60).max(86_400),
82
+ filters: automationFiltersSchema,
83
+ task: automationTaskSchema,
84
+ })
85
+ .strict();
86
+ const automationCreateSchema = automationEditableSchema.extend({ enable: z.boolean().optional() });
87
+ const automationUpdateSchema = automationEditableSchema.extend({ expectedRevision: z.number().int().positive() });
88
+ const automationCheckRequestSchema = z.object({ mode: z.enum(['preview', 'execute']) }).strict();
89
+ const automationLogQuerySchema = z.object({
90
+ automationId: z.string().optional(),
91
+ result: automationLogResultSchema.optional(),
92
+ event: automationEventSchema.optional(),
93
+ since: z.string().datetime().optional(),
94
+ cursor: z.coerce.number().int().positive().optional(),
95
+ // Optional, not `.default(100)`: `AutomationStore.logs` already clamps `limit ?? 100` into
96
+ // 1..100, so the default here was a second copy of it — and a defaulted key is REQUIRED in the
97
+ // request type `queryZodValidator` publishes (see validators.ts on why the request side falls
98
+ // back to the schema's output), which would have made every caller send a `?limit=` this route
99
+ // never needed.
100
+ limit: z.coerce.number().int().min(1).max(100).optional(),
101
+ });
102
+ function editableAutomation(definition) {
103
+ return {
104
+ name: definition.name,
105
+ description: definition.description,
106
+ enabled: definition.enabled,
107
+ events: definition.events,
108
+ intervalSeconds: definition.intervalSeconds,
109
+ filters: definition.filters,
110
+ task: definition.task,
111
+ };
112
+ }
113
+ /**
114
+ * The public API surface (spec 2026-07-23-independent-server-web-packages).
115
+ *
116
+ * Every route lives under this one prefix. The unversioned `/api/*` spelling the cockpit used
117
+ * to speak was removed once the whole API was reachable here — carrying two spellings meant two
118
+ * surfaces to keep working, and only one of them could be the typed contract. Bumping to `v2`
119
+ * means mounting a second table beside this one, not editing route paths.
120
+ */
121
+ const V1_PREFIX = '/api/v1';
122
+ /** Project scoping inside the versioned surface. The version is the OUTER dimension, so a
123
+ * consumer picks its API version once and then addresses projects inside it. */
124
+ const V1_SCOPED_PREFIX = `${V1_PREFIX}/p/:projectId`;
55
125
  /**
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>`.
126
+ * The project-scoped route table of a `createApp()` app, derived from its actual registrations
127
+ * (so it can never drift from the code): every method+path mounted under
128
+ * `/api/v1/p/:projectId/…`, minus the scope-resolver middleware (method ALL), deduped. The
129
+ * alias-parity suite iterates this to assert `/api/v1/<path>` ≡ `/api/v1/p/<boot>/<path>` ≡
130
+ * `/api/v1/p/default/<path>`.
62
131
  */
63
132
  export function projectRouteManifest(app) {
64
133
  const seen = new Set();
65
134
  const manifest = [];
66
135
  for (const route of app.routes) {
67
- if (route.method === 'ALL' || !route.path.startsWith(`${SCOPED_PREFIX}/`))
136
+ if (route.method === 'ALL' || !route.path.startsWith(`${V1_SCOPED_PREFIX}/`))
68
137
  continue;
69
- const path = route.path.slice(SCOPED_PREFIX.length);
138
+ const path = route.path.slice(V1_SCOPED_PREFIX.length);
70
139
  const key = `${route.method} ${path}`;
71
140
  if (seen.has(key))
72
141
  continue;
@@ -81,10 +150,11 @@ const FOLLOWUPS_OFF = 'the follow-up inbox is disabled — set CEZ_FOLLOWUPS=1 t
81
150
  * The in-process bus for workspace-level SSE events. The registry-mutating
82
151
  * routes (`POST /api/projects` — step 4.2, emits `project-added` for a
83
152
  * 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.
153
+ * checkout flow (step 4.3) call `emit()`; runtime provider auth observation
154
+ * emits host-wide `provider-status`; every open `/api/workspace/events` stream
155
+ * relays the event verbatim under its name. Injectable via
156
+ * `ServerDeps.workspaceEvents` so tests (and any out-of-createApp emitter) can
157
+ * drive the stream.
88
158
  */
89
159
  export class WorkspaceEventBus {
90
160
  listeners = new Set();
@@ -120,7 +190,7 @@ const startRunSchema = z
120
190
  // other prompt fields (`systemPrompt` 20k, message `text` 100k) so an
121
191
  // unbounded body can't be piped into a spawned process (#429). 100k chars
122
192
  // (~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'),
193
+ task: z.string().min(1).max(100_000, 'must be at most 100000 characters'),
124
194
  model: z.string().optional(),
125
195
  // Agent backend for this task (falls back to config `defaultRunner`).
126
196
  runner: z.enum(['claude', 'codex', 'opencode']).optional(),
@@ -128,7 +198,7 @@ const startRunSchema = z
128
198
  // agents in separate worktrees; the user compares diffs and picks one.
129
199
  variants: z.number().int().min(1).max(3).optional(),
130
200
  // Composer worktree opt-out (#worktree-toggle): false runs in the repo
131
- // working tree (read-only skills). Ignored when variants > 1.
201
+ // working tree. Ignored when variants > 1.
132
202
  worktree: z.boolean().optional(),
133
203
  // Autonomous mode (#autonomous): the run never parks at `waiting` — it
134
204
  // auto-continues until the agent signals done. No "needs you" is raised.
@@ -145,7 +215,7 @@ const startRunSchema = z
145
215
  systemPrompt: z
146
216
  .string()
147
217
  .trim()
148
- .max(20_000, 'systemPrompt must be at most 20000 characters')
218
+ .max(20_000, 'must be at most 20000 characters')
149
219
  .optional()
150
220
  .transform((s) => (s ? s : undefined)),
151
221
  // Screenshots pasted into the new-task form — same shape and limits as a
@@ -163,7 +233,7 @@ const startRunSchema = z
163
233
  // started — the same bookkeeping POST /api/todos/:id/start does, so the
164
234
  // audit trail survives the composer detour. Bounded like every other
165
235
  // 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(),
236
+ todoId: z.string().min(1).max(200, 'must be at most 200 characters').optional(),
167
237
  })
168
238
  .refine((b) => Boolean(b.workflow) !== Boolean(b.steps), {
169
239
  message: 'provide either "workflow" or "steps", not both',
@@ -173,7 +243,7 @@ const pickSchema = z.object({
173
243
  });
174
244
  const planSchema = z.object({
175
245
  // 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'),
246
+ task: z.string().trim().min(1).max(100_000, 'must be at most 100000 characters'),
177
247
  });
178
248
  // A saved workflow carries full `steps` OR the builder's `skills` stack
179
249
  // (spec 012). `overwrite: true` is the builder's Save on an existing file —
@@ -183,7 +253,7 @@ const saveWorkflowSchema = z
183
253
  name: z.string().trim().min(1).max(80),
184
254
  // Written into a YAML file on disk (#429) — a workflow description is a
185
255
  // 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(),
256
+ description: z.string().max(2_000, 'must be at most 2000 characters').optional(),
187
257
  steps: z.array(workflowStepSchema).min(1).max(8).optional(),
188
258
  skills: z.array(z.string().trim().min(1)).min(1).max(8).optional(),
189
259
  overwrite: z.boolean().optional(),
@@ -206,14 +276,32 @@ const SKILL_USAGE_MAX_ENTRIES = 200;
206
276
  // generous for GUI prefs; over-limit is a 400, never a silent strip. Shared by
207
277
  // BOTH ui-state routes (per-repo and workspace) via `parseUiStateBody`.
208
278
  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). */
279
+ /** Settings → Appearance (redesign R6): accent + density + reading width. ONE
280
+ * schema for both ui-state files — per-repo (the legacy home, kept so an older
281
+ * cezar in the same repo still honours it) and workspace
282
+ * (`~/.cezar/ui-state.json`, its post-migration home — multi-project spec,
283
+ * Data Model).
284
+ *
285
+ * Every key is `.optional()` so an older ui-state.json parses unchanged, but
286
+ * each one must be listed HERE: the enclosing `workspaceUiStateSchema` is
287
+ * `.passthrough()` at the top level only, so an unlisted key inside
288
+ * `appearance` is stripped by zod and then wiped from the file by the shallow
289
+ * merge-on-write. The cockpit adopts the PUT response as authoritative, so a
290
+ * stripped key does not merely fail to persist — it visibly reverts the
291
+ * control the user just touched. Adding an appearance preference means adding
292
+ * it here in the same change. */
213
293
  const appearanceSchema = z.object({
214
294
  accent: z.enum(['lime', 'violet']).optional(),
215
295
  density: z.enum(['comfortable', 'compact', 'ultra']).optional(),
296
+ width: z.enum(['narrow', 'wide']).optional(),
216
297
  });
298
+ const providerAuthDismissalsSchema = z
299
+ .object({
300
+ claude: z.string().min(1).max(128).optional(),
301
+ codex: z.string().min(1).max(128).optional(),
302
+ opencode: z.string().min(1).max(128).optional(),
303
+ })
304
+ .strict();
217
305
  /** Global GUI state (`~/.cezar/ui-state.json`, step 2.7) — the workspace twin
218
306
  * of `uiStateSchema` below, sharing its `.passthrough()` + key-cap + shallow
219
307
  * merge-on-write semantics via `parseUiStateBody`. Known keys are the
@@ -223,6 +311,16 @@ const workspaceUiStateSchema = z
223
311
  .object({
224
312
  appearance: appearanceSchema.optional(),
225
313
  notifications: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
314
+ dismissedProviderAuthFailures: providerAuthDismissalsSchema.optional(),
315
+ lastLocation: z
316
+ .object({
317
+ projectId: z.string().min(1).max(64),
318
+ pathname: z.string().min(1).max(2048).startsWith('/p/'),
319
+ search: z.string().max(4096).startsWith('?').optional(),
320
+ hash: z.string().max(2048).startsWith('#').optional(),
321
+ })
322
+ .strict()
323
+ .optional(),
226
324
  // Sidebar per-project collapse map, keyed by project id (slug ≤ 64 chars).
227
325
  // Entry-capped like `skillUsage`: the map is written straight to a file the
228
326
  // cockpit GETs on every load, so it must stay bounded on every axis.
@@ -330,7 +428,7 @@ const patchRunSchema = z.object({
330
428
  });
331
429
  // Session commit (redesign R5 — §"Git/session API additions").
332
430
  const gitCommitSchema = z.object({
333
- message: z.string().trim().min(1, 'commit message must not be empty').max(5_000),
431
+ message: z.string().trim().min(1, 'must not be empty').max(5_000),
334
432
  });
335
433
  // "Open in…" (#open-in / #365): `target` selects the app; `path` (optional, worktree-relative)
336
434
  // narrows the target's own worktree/repo-root default to one file — used by the diff pane's
@@ -387,7 +485,7 @@ function foldedLength(task, stack) {
387
485
  // follow-up composer is a full composer, so a screenshot pasted into it must reach the reopened
388
486
  // session rather than being silently dropped.
389
487
  const continueSchema = z.object({
390
- text: z.string().max(100_000, 'text must be at most 100000 characters').optional(),
488
+ text: z.string().max(100_000, 'must be at most 100000 characters').optional(),
391
489
  images: z.array(imageInputSchema).max(4).optional(),
392
490
  runner: z.enum(['claude', 'codex', 'opencode']).optional(),
393
491
  model: z.string().max(200).optional(),
@@ -406,7 +504,7 @@ const startTodoSchema = z
406
504
  prompt: z
407
505
  .string()
408
506
  .trim()
409
- .max(20_000, 'prompt must be at most 20000 characters')
507
+ .max(20_000, 'must be at most 20000 characters')
410
508
  .optional()
411
509
  .transform((s) => (s ? s : undefined)),
412
510
  })
@@ -432,21 +530,100 @@ function stripHostPort(host) {
432
530
  return bracketed[1];
433
531
  return host.replace(/:\d+$/, '');
434
532
  }
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})` };
533
+ /** The shared write-side half of BOTH ui-state routes (per-repo `/api/v1/ui-state`
534
+ * and workspace `/api/v1/workspace/ui-state`) — the factored split the
535
+ * multi-project spec calls for instead of a copy: the route's own schema, plus a
536
+ * cap on the top-level key count so a `.passthrough()` schema can't accumulate an
537
+ * unbounded key set (#429). The cap rides as a refinement so the whole thing is one
538
+ * schema and can go through `jsonBody` like every other mutating route. The
539
+ * merge-on-write stays with each route (they write different files) but is shallow
540
+ * in both. */
541
+ function capUiStateKeys(data, ctx) {
542
+ if (Object.keys(data).length > UI_STATE_MAX_KEYS) {
543
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `ui-state has too many keys (max ${UI_STATE_MAX_KEYS})` });
544
+ }
545
+ }
546
+ // Derived once per route instead of by a generic `wrap(schema)` helper called at the route: a
547
+ // generic wrapper leaves the schema type unresolved where `jsonBody` needs it, and Hono answers
548
+ // that by dropping the whole PUT from the route schema rather than erroring — both ui-state PUTs
549
+ // silently vanished from `AppType`. Concrete consts keep them visible to `hc`.
550
+ const workspaceUiStateBody = workspaceUiStateSchema.superRefine(capUiStateKeys);
551
+ const uiStateBody = uiStateSchema.superRefine(capUiStateKeys);
552
+ /**
553
+ * Query schemas for the READ routes — deliberately permissive.
554
+ *
555
+ * These exist to make a query key VISIBLE to the route type (`hc` refuses a `query` argument for
556
+ * a key no validator declares), NOT to narrow what the route accepts. Every one of these handlers
557
+ * compares `=== '1'` and treats everything else as false, so `?refresh=0` is a successful request
558
+ * today; a literal schema would silently turn it into a 400. The comparison stays in the handler
559
+ * and the validator stays out of its way.
560
+ *
561
+ * The one route that really is strict on the wire — `GET /github/prs/:number/changes`, which 400s
562
+ * on `?refresh=true` — keeps its own `z.enum(['1'])` schema next to the route.
563
+ */
564
+ /**
565
+ * One query value, matching what `c.req.query('k')` did before these routes were validated.
566
+ *
567
+ * Hono's query validator hands a REPEATED key as an array (`?wait=1&wait=1` → `['1','1']`), where
568
+ * `c.req.query()` silently took the first. A plain `z.string()` therefore turns a request that
569
+ * used to answer 200 into a 400 — a wire change no caller asked for. Collapsing to the first
570
+ * value keeps the old behaviour, and the client still sees a plain `key?: string`.
571
+ */
572
+ const queryValue = z.union([z.string(), z.array(z.string()).transform((v) => v[0])]).optional();
573
+ const refreshQuery = z.object({ refresh: queryValue });
574
+ const waitQuery = z.object({ wait: queryValue });
575
+ function parseAccept(header) {
576
+ const ranges = [];
577
+ for (const part of header.split(',')) {
578
+ const [range = '', ...params] = part.split(';');
579
+ const [type = '', subtype = ''] = range.trim().toLowerCase().split('/');
580
+ if (type === '' || subtype === '')
581
+ continue;
582
+ const weight = params.map((p) => p.trim().toLowerCase()).find((p) => p.startsWith('q='));
583
+ const parsed = weight === undefined ? 1 : Number.parseFloat(weight.slice(2));
584
+ const q = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 0), 1) : 0;
585
+ if (q > 0)
586
+ ranges.push({ type, subtype, q });
447
587
  }
448
- return { data: parsed.data };
588
+ return ranges;
449
589
  }
590
+ /**
591
+ * The best of `offers` for this `Accept`, or `null` for "no preference expressed" — which is what
592
+ * an absent header, a `*<slash>*`-only header and a header naming nothing on offer all mean, and
593
+ * what leaves the caller on its route's default.
594
+ *
595
+ * `offers` is the server's own preference order: an offer may itself be a wildcard (`image/*`,
596
+ * since the concrete image type is not known until the file is read), and equal q-values go to the
597
+ * EARLIER offer, so each route lists its default representation first.
598
+ */
599
+ function negotiate(accept, offers) {
600
+ if (accept === undefined)
601
+ return null;
602
+ const ranges = parseAccept(accept);
603
+ let best = null;
604
+ for (const offer of offers) {
605
+ const [type = '', subtype = ''] = offer.split('/');
606
+ let q = 0;
607
+ for (const range of ranges) {
608
+ if (range.type === '*' && range.subtype === '*')
609
+ continue; // "anything" is not a preference
610
+ const typeMatches = range.type === type || range.type === '*' || type === '*';
611
+ const subtypeMatches = range.subtype === subtype || range.subtype === '*' || subtype === '*';
612
+ if (typeMatches && subtypeMatches && range.q > q)
613
+ q = range.q;
614
+ }
615
+ if (q > 0 && (best === null || q > best.q))
616
+ best = { offer, q };
617
+ }
618
+ return best?.offer ?? null;
619
+ }
620
+ /** What `GET /repo/commit/:sha` offers, DEFAULT FIRST: the legacy `text/plain` blob is what a
621
+ * request with no opinion has always received (§2), so it also wins an Accept tie. */
622
+ const COMMIT_FORMATS = ['text/plain', 'application/json'];
623
+ /** What `GET /runs/:id/files` offers, DEFAULT FIRST. `image/*` rather than a concrete type: which
624
+ * image type the bytes are is only known once the path resolves, and the raw branch refuses
625
+ * everything that is not an image anyway. */
626
+ const FILE_FORMATS = ['application/json', 'image/*'];
450
627
  /** Workspace-root writability probe (multi-project spec, "API Contracts"):
451
628
  * optional `mkdir -p`, `access W_OK`, then a real create/delete round-trip — W_OK alone
452
629
  * can lie (e.g. a read-only mount still reports writable permission bits).
@@ -469,6 +646,10 @@ async function probeWritableDir(dir, create) {
469
646
  return err instanceof Error ? err.message : String(err);
470
647
  }
471
648
  }
649
+ // The return type is INFERRED on purpose: it is the chained app type built at the bottom of
650
+ // this function, and `AppType` (src/server/app-type.ts) is `ReturnType<typeof createApp>`.
651
+ // Annotating it `Hono` here would erase every route from the type and leave the typed client
652
+ // with nothing to offer. See the `routed` assembly at the end of the function.
472
653
  export function createApp(deps) {
473
654
  const { version, update, bindHost, bootProjectId } = deps;
474
655
  // Boot singletons keep DELIBERATELY distinct names (`boot*`): every
@@ -481,6 +662,21 @@ export function createApp(deps) {
481
662
  const modelCatalog = deps.modelCatalog ?? new RunnerModelCatalog({
482
663
  adapters: { codex: { discover: () => discoverCodexModels({ cwd: bootRoot }) } },
483
664
  });
665
+ const providerAuth = deps.providerAuth ?? new ProviderAuthService();
666
+ const workspaceConfig = deps.workspaceConfig ?? {
667
+ load: loadWorkspaceConfig,
668
+ mergeWrite: mergeWriteWorkspaceConfig,
669
+ };
670
+ const providerStatus = async (options) => {
671
+ const [discovered, workspace] = await Promise.all([
672
+ providerAuth.status(options?.refresh ? { refresh: true } : undefined),
673
+ workspaceConfig.load(),
674
+ ]);
675
+ return applyProviderEnablement(discovered, workspace.disabledProviders);
676
+ };
677
+ const providerActionError = async (required) => unavailableProviderMessage(required, await providerStatus());
678
+ const openTerminal = deps.openTerminal ?? openInTerminal;
679
+ const skillsUpdate = deps.skillsUpdate ?? new SkillsUpdateService();
484
680
  // ---- workspace boot-project identity (multi-project spec) ----------------
485
681
  // The boot flow (`initWorkspace` in src/index.ts) registers the boot repo
486
682
  // and plumbs its registry id in via `deps.bootProjectId`. Legacy callers and
@@ -551,6 +747,7 @@ export function createApp(deps) {
551
747
  dataDir: bootDataDir,
552
748
  store: deps.store,
553
749
  manager: deps.manager,
750
+ automationStore: deps.automationStore ?? AutomationStore.open(bootDataDir),
554
751
  launchKey: ensureLaunchKey(bootDataDir), // bookmarklet auto-start secret (spec 011)
555
752
  };
556
753
  // Non-boot projects build lazily on first scoped request; their managers
@@ -567,6 +764,25 @@ export function createApp(deps) {
567
764
  // Workspace-level SSE bus (step 2.8) — the registry mutators and the
568
765
  // checkout flow (Phase 4) emit here; /api/workspace/events relays.
569
766
  const workspaceEvents = deps.workspaceEvents ?? new WorkspaceEventBus();
767
+ const emitAutomationChange = (project, automationId, revision, deleted = false) => workspaceEvents.emit('automation-change', {
768
+ project: project.id,
769
+ automationId,
770
+ revision,
771
+ ...(deleted ? { deleted: true } : {}),
772
+ });
773
+ const automationsChanged = () => deps.automationsChanged?.();
774
+ const providerRuntimeAuth = deps.providerRuntimeAuth
775
+ ?? new ProviderRuntimeAuthObserver(providerAuth, (status) => {
776
+ workspaceEvents.emit('provider-status', status);
777
+ });
778
+ providerRuntimeAuth.watch(bootContext.store);
779
+ for (const id of contexts.ids()) {
780
+ const ctx = contexts.peek(id);
781
+ if (ctx)
782
+ providerRuntimeAuth.watch(ctx.store);
783
+ }
784
+ contexts.onStoreCreated((store) => providerRuntimeAuth.watch(store));
785
+ contexts.onContextBuilt((ctx) => providerRuntimeAuth.watch(ctx.store));
570
786
  const app = new Hono();
571
787
  // Reject oversized request bodies before they reach any handler (#429). GETs
572
788
  // and SSE carry no body, so this only ever gates the mutating routes.
@@ -577,7 +793,7 @@ export function createApp(deps) {
577
793
  // own: any page the user visits can still POST to us (CSRF), and DNS
578
794
  // rebinding can point a foreign domain at loopback and read our responses.
579
795
  // Two zero-config checks close both holes on every /api route EXCEPT
580
- // /api/health (the intentional cross-origin discovery endpoint, spec 011 —
796
+ // /api/v1/health (the intentional cross-origin discovery endpoint, spec 011 —
581
797
  // it exposes nothing sensitive, see #431):
582
798
  // 1. Host allowlist (loopback deployments only) — a request whose Host is
583
799
  // not a loopback name did not really originate from this machine. A
@@ -600,7 +816,7 @@ export function createApp(deps) {
600
816
  // Scope note: check 2 covers writes only. A cross-origin GET from any site
601
817
  // still reaches the read routes — but its Host is ours, so it is a *forced
602
818
  // 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
819
+ // response body (we send CORS headers on /api/v1/health alone), and no GET
604
820
  // handler mutates state. Rebinding, which WOULD make those reads legible, is
605
821
  // what check 1 stops.
606
822
  const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
@@ -615,9 +831,12 @@ export function createApp(deps) {
615
831
  error: 'forbidden: unexpected Host header — this request did not originate from this machine (see #426)',
616
832
  }, 403);
617
833
  }
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')
834
+ // /api/v1/health stays CORS-open for cross-origin discovery, but its Host is
835
+ // still checked above: cross-origin is legitimate, DNS rebinding is not. The
836
+ // path is spelled through V1_PREFIX rather than inline — an unversioned
837
+ // literal here silently stopped matching when the API moved and left health
838
+ // relying on the mutating-methods gate below to let its GETs through.
839
+ if (c.req.path === `${V1_PREFIX}/health`)
621
840
  return next();
622
841
  if (MUTATING_METHODS.has(c.req.method)) {
623
842
  const origin = c.req.header('origin');
@@ -661,15 +880,16 @@ export function createApp(deps) {
661
880
  });
662
881
  // The mirrored project-route table (spec "API Contracts → Project-scoped").
663
882
  // 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
883
+ // twice — under `/api/v1/p/:projectId` (scoped) and under `/api/v1` (bound to
884
+ // the boot project) — so both spellings share one handler and can never
885
+ // drift. The resolver middleware binds `c.get('project')`:
886
+ // no `projectId` param (unscoped mount) → the boot context, byte-identical to
668
887
  // the pre-workspace closures; `default` or the boot project's own id → the
669
888
  // boot context too; anything else → the lazy context map, with
670
889
  // `ProjectContextError` mapped to 404 (unknown) / 409 (missing root).
671
- const api = new Hono();
672
- api.use('*', async (c, next) => {
890
+ // Named rather than inlined because both mounts share it — one function, so
891
+ // the two spellings can never disagree about what `default` means.
892
+ const resolveProjectScope = async (c, next) => {
673
893
  const raw = c.req.param('projectId');
674
894
  if (raw === undefined) {
675
895
  c.set('project', bootContext);
@@ -694,15 +914,23 @@ export function createApp(deps) {
694
914
  throw err;
695
915
  }
696
916
  return next();
697
- });
917
+ };
698
918
  // ---- static GUI ----------------------------------------------------------
699
919
  const webDir = resolveWebDir();
700
920
  const distDir = join(webDir, 'dist');
701
921
  const HTML_TYPE = 'text/html; charset=utf-8';
702
- const staticFile = (name, type) => () => {
922
+ const staticFile = (name, type) => (c) => {
703
923
  // 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 } });
924
+ //
925
+ // Served out of the Vite build: the file is a `public/` asset of the web package, which the
926
+ // build copies verbatim into `web/dist`. One home, one URL — the same bytes this route
927
+ // hands out are what the bundle's own `<img src="/open-mercato.svg">` asks for.
928
+ // Without a build there is nothing to serve, which is a 404 rather than a crash (the shell
929
+ // route answers the same dev-only state with its build hint).
930
+ const path = join(distDir, name);
931
+ if (!existsSync(path))
932
+ return c.json({ error: 'not found' }, 404);
933
+ return new Response(readFileSync(path), { headers: { 'content-type': type } });
706
934
  };
707
935
  let hintLogged = false;
708
936
  const serveShell = (c) => {
@@ -749,14 +977,14 @@ export function createApp(deps) {
749
977
  },
750
978
  });
751
979
  });
752
- // The favicon web/app/index.html points at (`../open-mercato.svg`).
980
+ // The favicon packages/web/index.html points at (`/open-mercato.svg`).
753
981
  app.get('/open-mercato.svg', staticFile('open-mercato.svg', 'image/svg+xml'));
754
982
  // ---- meta ----------------------------------------------------------------
755
983
  // CORS — deliberately for /api/health ONLY (spec 011): the bookmarklets
756
984
  // fetch it cross-origin from github.com to discover which local ports run a
757
985
  // cockpit and which repo each serves. Health exposes no secrets beyond the
758
986
  // repo path/remote; every other endpoint stays same-origin.
759
- app.use('/api/health', async (c, next) => {
987
+ const healthCors = async (c, next) => {
760
988
  c.header('access-control-allow-origin', '*');
761
989
  if (c.req.method === 'OPTIONS') {
762
990
  // Preflight (e.g. Chrome Private Network Access) — allow the plain GET.
@@ -765,10 +993,16 @@ export function createApp(deps) {
765
993
  return c.body(null, 204);
766
994
  }
767
995
  await next();
768
- });
996
+ };
997
+ app.use(`${V1_PREFIX}/health`, healthCors);
769
998
  // One builder for both transports: `GET /api/health` (the authoritative,
770
- // CORS-open discovery endpoint) and the `health` topic on `/api/ws` below
999
+ // CORS-open discovery endpoint) and the `health` topic on `/api/v1/ws` below
771
1000
  // push the byte-identical shape, so the two can never drift.
1001
+ // Deliberately UNANNOTATED: this literal is the source of the `/health` shape. Annotating it
1002
+ // with the api-client's `HealthResponse` made the contract circular — the DTO was declared by
1003
+ // hand, the handler was checked against it, and `AppType` then reported the hand-written type
1004
+ // back as if the server had proven it. Inferring here means the route says what it actually
1005
+ // sends, which is what lets the DTO be derived instead of maintained.
772
1006
  const healthSnapshot = async () => {
773
1007
  const [checks, repo, config, workspace] = await Promise.all([
774
1008
  detectEnvironment(),
@@ -782,7 +1016,11 @@ export function createApp(deps) {
782
1016
  const caps = capabilities();
783
1017
  return {
784
1018
  version,
785
- latestVersion: update?.latest,
1019
+ // Spread rather than `latestVersion: update?.latest`: an `undefined` VALUE is dropped by
1020
+ // JSON.stringify, so the key is absent on the wire — but writing it unconditionally types
1021
+ // the key as always-present, which is a shape no client ever receives. The contract schema
1022
+ // says `.optional()`, and contract-parity.test.ts holds the two together.
1023
+ ...(update?.latest !== undefined ? { latestVersion: update.latest } : {}),
786
1024
  // Health is CORS-open and, in hosted mode, reachable off the loopback —
787
1025
  // so any site/host that reads it would learn the developer's absolute
788
1026
  // checkout path and username (#431). Local mode keeps the full path (the
@@ -874,7 +1112,13 @@ export function createApp(deps) {
874
1112
  void refreshHealth(); // stale: refresh, don't wait on it
875
1113
  return healthCache.payload;
876
1114
  };
877
- app.get('/api/health', async (c) => c.json(await readHealth()));
1115
+ // ---- chained family: health (workspace-level) ----------------------------
1116
+ // Written as ONE chained expression rather than a loose `app.get(...)`
1117
+ // statement because Hono accumulates its route types through the chain: a
1118
+ // statement's return value is discarded, so `typeof app` would record nothing
1119
+ // and `hc<AppType>` would have no endpoint to offer. `createApp` mounts this
1120
+ // under both `/api` (the frozen legacy spelling) and `/api/v1`.
1121
+ const healthRoutes = new Hono().get('/health', async (c) => c.json(await readHealth()));
878
1122
  // The push twin of the poll it replaced (#369): while at least one cockpit
879
1123
  // holds the `health` topic the server re-reads the snapshot on the old 5 s
880
1124
  // cadence and broadcasts ONLY when it changed — a `git checkout` in a
@@ -906,14 +1150,85 @@ export function createApp(deps) {
906
1150
  // `GET /api/health` reads a warm value instead of the cold ~1 s compute.
907
1151
  if (deps.socketHub)
908
1152
  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);
1153
+ // ---- chained family: host model catalog (workspace-level) ----
1154
+ const modelsRoutes = new Hono()
1155
+ .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) => {
1156
+ const query = { data: c.req.valid('query') };
915
1157
  return c.json(await modelCatalog.get(query.data.runner));
916
1158
  });
1159
+ // ---- chained family: agent providers (workspace-level) ----
1160
+ const providersRoutes = new Hono()
1161
+ .get('/providers/status', queryZodValidator(z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') }), { message: 'refresh must be 1 when provided' }), async (c) => {
1162
+ const query = { data: c.req.valid('query') };
1163
+ return c.json(await providerStatus({ refresh: query.data.refresh === '1' }));
1164
+ })
1165
+ .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) => {
1166
+ const provider = { data: c.req.valid('param').provider };
1167
+ const body = { data: c.req.valid('json') };
1168
+ let workspace;
1169
+ try {
1170
+ workspace = await workspaceConfig.mergeWrite((config) => {
1171
+ const disabled = new Set(config.disabledProviders);
1172
+ if (body.data.enabled)
1173
+ disabled.delete(provider.data);
1174
+ else
1175
+ disabled.add(provider.data);
1176
+ config.disabledProviders = PROVIDER_IDS.filter((id) => disabled.has(id));
1177
+ });
1178
+ }
1179
+ catch {
1180
+ return c.json({ error: 'Provider preference could not be saved.' }, 500);
1181
+ }
1182
+ const result = applyProviderEnablement(await providerAuth.status(), workspace.disabledProviders);
1183
+ const row = result.providers.find(({ provider: id }) => id === provider.data);
1184
+ if (row)
1185
+ workspaceEvents.emit('provider-status', row);
1186
+ return c.json(result);
1187
+ })
1188
+ .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) => {
1189
+ const provider = { data: c.req.valid('param').provider };
1190
+ const body = { data: c.req.valid('json') };
1191
+ if (!providerAuth.clearRuntimeAuthFailure(provider.data, body.data.authFailureId)) {
1192
+ return c.json({ error: 'Authentication incident changed. Refresh and try again.' }, 409);
1193
+ }
1194
+ const result = await providerStatus({ refresh: true });
1195
+ const row = result.providers.find(({ provider: id }) => id === provider.data);
1196
+ if (row)
1197
+ workspaceEvents.emit('provider-status', row);
1198
+ return c.json(result);
1199
+ })
1200
+ .post('/providers/connect', jsonZodValidator(providerConnectSchema, { message: 'provider must be claude, codex, or opencode' }), async (c) => {
1201
+ const body = { data: c.req.valid('json') };
1202
+ const provider = body.data.provider;
1203
+ const command = providerAuth.loginCommand(provider);
1204
+ const row = (await providerAuth.status({ refresh: true })).providers.find((candidate) => candidate.provider === provider);
1205
+ if (!row) {
1206
+ return c.json({ error: 'Authentication could not be verified. Try again.' }, 500);
1207
+ }
1208
+ if (row.status === 'connected') {
1209
+ return c.json({ opened: false, connected: true, command });
1210
+ }
1211
+ if (row.status === 'not-installed') {
1212
+ return c.json({ error: row.hint ?? providerAuth.installHint(provider), command }, 409);
1213
+ }
1214
+ if (row.status === 'unknown') {
1215
+ return c.json({ error: row.hint ?? 'Authentication could not be verified. Try again.', command }, 409);
1216
+ }
1217
+ if (!capabilities().localHandoff) {
1218
+ return c.json({ error: 'Run this command on the machine hosting cezar.', command }, 409);
1219
+ }
1220
+ let opened = false;
1221
+ try {
1222
+ opened = await openTerminal(bootRoot, command);
1223
+ }
1224
+ catch {
1225
+ // Terminal handoff is best-effort; the exact command remains the safe fallback.
1226
+ }
1227
+ if (!opened) {
1228
+ return c.json({ error: 'No terminal emulator could be opened. Run this command manually.', command }, 409);
1229
+ }
1230
+ return c.json({ opened: true, command });
1231
+ });
917
1232
  // ---- workspace projects (multi-project spec) -----------------------------
918
1233
  // The registered-project list for the cockpit sidebar. Same-origin (unlike
919
1234
  // health), so absolute `root`s are fine here. `listProjects()` TTL-caches
@@ -939,7 +1254,9 @@ export function createApp(deps) {
939
1254
  return defaultWorkspaceConfig().browseRoot;
940
1255
  }
941
1256
  };
942
- app.get('/api/projects', async (c) => {
1257
+ // ---- chained family: project registry (workspace-level) ----
1258
+ const projectsRoutes = new Hono()
1259
+ .get('/projects', async (c) => {
943
1260
  let projects = [];
944
1261
  let projectsDir = defaultWorkspaceConfig().projectsDir;
945
1262
  try {
@@ -958,6 +1275,164 @@ export function createApp(deps) {
958
1275
  projectsDir,
959
1276
  };
960
1277
  return c.json(body);
1278
+ })
1279
+ .post('/projects', jsonZodValidator(() => registerProjectSchema, { message: 'root must be a non-empty path' }), async (c) => {
1280
+ const parsed = { data: c.req.valid('json') };
1281
+ const registered = await registerFolder(parsed.data.root, 'local');
1282
+ if (registered.status !== 200)
1283
+ return c.json(registered.body, registered.status);
1284
+ return c.json(registered.body, 200);
1285
+ })
1286
+ .delete('/projects/:projectId', async (c) => {
1287
+ if (capabilities().singleProject) {
1288
+ return c.json(singleProjectRefusal('removing projects'), 409);
1289
+ }
1290
+ const raw = c.req.param('projectId');
1291
+ // Same gate the scoped-route resolver applies, and the same 404 wording —
1292
+ // a malformed id is an unknown project, not a validation essay.
1293
+ if (!projectIdSchema.safeParse(raw).success) {
1294
+ return c.json({ error: `unknown project: ${raw}` }, 404);
1295
+ }
1296
+ const bootId = await resolveBootProject();
1297
+ // `default` is the boot alias everywhere else in the API; honour it here
1298
+ // too rather than 404ing a spelling the cockpit is allowed to use.
1299
+ const id = raw === 'default' ? bootId : raw;
1300
+ let entry;
1301
+ try {
1302
+ entry = (await loadWorkspaceConfig()).projects.find((p) => p.id === id);
1303
+ }
1304
+ catch {
1305
+ // unreadable workspace — there is nothing to remove, and saying so is
1306
+ // more useful than a 500 the user cannot act on
1307
+ }
1308
+ if (!entry)
1309
+ return c.json({ error: `unknown project: ${id}` }, 404);
1310
+ // The boot project is refused, not removed: `cezar serve` re-registers the
1311
+ // repo it was started in on every boot, so "removing" it would undo itself
1312
+ // at the next restart while breaking this session's sidebar in the
1313
+ // meantime. The pane disables the button and says the same thing.
1314
+ if (id === bootId) {
1315
+ return c.json({
1316
+ error: `cezar is serving ${entry.name} right now — it re-registers itself at every start, so it cannot be removed from here`,
1317
+ }, 409);
1318
+ }
1319
+ const active = activeRunCount(id);
1320
+ if (active > 0) {
1321
+ return c.json({
1322
+ error: `${entry.name} has ${active} running task${active === 1 ? '' : 's'} — cancel or finish ${active === 1 ? 'it' : 'them'} before removing the project`,
1323
+ runningTasks: active,
1324
+ }, 409);
1325
+ }
1326
+ let removed;
1327
+ try {
1328
+ removed = await removeProject(id);
1329
+ }
1330
+ catch (err) {
1331
+ // e.g. a read-only home — nothing was persisted (atomic tmp+rename).
1332
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1333
+ }
1334
+ // Lost a race with another writer (or another cezar process): the entry is
1335
+ // gone, which is what the caller wanted, but say it honestly.
1336
+ if (!removed)
1337
+ return c.json({ error: `unknown project: ${id}` }, 404);
1338
+ // In-process handles for a project no route can reach any more: store
1339
+ // closed (index flushed), manager's timers and usage subscription dropped.
1340
+ contexts.dispose(id);
1341
+ workspaceEvents.emit('project-removed', { id });
1342
+ const body = { removed: true, id };
1343
+ return c.json(body);
1344
+ })
1345
+ .patch('/projects/:projectId', jsonZodValidator(() => updateProjectSchema), async (c) => {
1346
+ if (capabilities().singleProject) {
1347
+ return c.json(singleProjectRefusal('editing projects'), 409);
1348
+ }
1349
+ const raw = c.req.param('projectId');
1350
+ // Same gate + 404 wording as DELETE: a malformed id is an unknown project.
1351
+ if (!projectIdSchema.safeParse(raw).success) {
1352
+ return c.json({ error: `unknown project: ${raw}` }, 404);
1353
+ }
1354
+ const parsed = { data: c.req.valid('json') };
1355
+ // `default` is the boot alias the cockpit is allowed to use everywhere else.
1356
+ const id = raw === 'default' ? await resolveBootProject() : raw;
1357
+ const { maxParallel } = parsed.data;
1358
+ // Read-first (mirroring DELETE, server.ts:1252-1258): a well-formed but
1359
+ // unknown id must 404 WITHOUT rewriting the config — otherwise it would both
1360
+ // do a needless full-config tmp+rename and, on a read-only home, surface the
1361
+ // write failure as a 500 where the honest answer is 404.
1362
+ let known = false;
1363
+ try {
1364
+ known = (await loadWorkspaceConfig()).projects.some((p) => p.id === id);
1365
+ }
1366
+ catch {
1367
+ // unreadable workspace — treat as unknown; the read-only case answers 404,
1368
+ // not a 500 the caller cannot act on (same reasoning as DELETE).
1369
+ }
1370
+ if (!known)
1371
+ return c.json({ error: `unknown project: ${id}` }, 404);
1372
+ let updated;
1373
+ try {
1374
+ await mergeWriteWorkspaceConfig((config) => {
1375
+ const entry = config.projects.find((p) => p.id === id);
1376
+ if (!entry)
1377
+ return; // lost a race with a concurrent remove — answered below
1378
+ // null clears the override; a number sets it. Mutated in place so
1379
+ // `.passthrough()` keys on the entry survive.
1380
+ if (maxParallel === null)
1381
+ delete entry.maxParallel;
1382
+ else
1383
+ entry.maxParallel = maxParallel;
1384
+ updated = entry;
1385
+ });
1386
+ }
1387
+ catch (err) {
1388
+ // e.g. a read-only home — nothing was persisted (atomic tmp+rename).
1389
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1390
+ }
1391
+ // Raced with a concurrent removal between the read and the write.
1392
+ if (!updated)
1393
+ return c.json({ error: `unknown project: ${id}` }, 404);
1394
+ // The new ceiling takes effect WITHOUT a restart: refresh the shared
1395
+ // semaphore's snapshot and pump every manager — the same live-apply hook
1396
+ // `PUT /api/workspace/config` fires for a workspace-cap change.
1397
+ await deps.semaphore?.refresh();
1398
+ const body = {
1399
+ project: { ...updated, ...(await probeProjectStatus(updated.root)) },
1400
+ };
1401
+ return c.json(body);
1402
+ })
1403
+ .post('/projects/checkout', jsonZodValidator(() => checkoutSchema, { message: 'url must be a GitHub repository' }), async (c) => {
1404
+ if (capabilities().singleProject) {
1405
+ return c.json(singleProjectRefusal('adding projects'), 409);
1406
+ }
1407
+ const parsed = { data: c.req.valid('json') };
1408
+ const { url, name, checkoutId } = parsed.data;
1409
+ const result = await checkoutRepo({
1410
+ url,
1411
+ name,
1412
+ checkoutId,
1413
+ projectsDir: expandTilde(await workspaceProjectsDir()),
1414
+ onProgress: (event) => workspaceEvents.emit('checkout-progress', event),
1415
+ // A closed dialog / navigated-away tab aborts the request; the clone is
1416
+ // killed and its partial directory removed rather than left running.
1417
+ signal: c.req.raw.signal,
1418
+ ...(deps.cloneRunner ? { run: deps.cloneRunner } : {}),
1419
+ });
1420
+ if (!result.ok) {
1421
+ // `reason` rides along on the 503 (`gh` unavailable) — the spec's
1422
+ // `{ error, reason }` degradation, mirroring the GitHub pane.
1423
+ return c.json('reason' in result ? { error: result.error, reason: result.reason } : { error: result.error }, result.status);
1424
+ }
1425
+ const registered = await registerFolder(result.target, 'checkout');
1426
+ if (registered.status !== 200) {
1427
+ // The clone SUCCEEDED and its files are legitimately the user's, so this
1428
+ // path deliberately does NOT clean up — an unregisterable checkout is a
1429
+ // registry problem, not a reason to delete a repo we just fetched. Say
1430
+ // where it is so they can register it by hand.
1431
+ const { body } = registered;
1432
+ const error = 'error' in body && body.error ? body.error : 'could not register the checkout';
1433
+ return c.json({ error: `${error} (the clone is at ${result.target})` }, registered.status);
1434
+ }
1435
+ return c.json(registered.body, 200);
961
1436
  });
962
1437
  // Register an existing folder (multi-project spec, "Add project" — the
963
1438
  // folder-browser dialog's commit step, step 4.2). Workspace-level like its
@@ -1079,13 +1554,6 @@ export function createApp(deps) {
1079
1554
  workspaceEvents.emit('project-added', { project });
1080
1555
  return { status: 200, body: { project } };
1081
1556
  };
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
1557
  // Deregister a project (multi-project spec, step 4.4 — Settings → Projects,
1090
1558
  // the per-row "Remove"). READ THIS BEFORE TOUCHING THE HANDLER: the ONLY
1091
1559
  // durable effect allowed here is dropping one entry from
@@ -1120,64 +1588,59 @@ export function createApp(deps) {
1120
1588
  return 0;
1121
1589
  return ctx.store.listRuns().filter((run) => ACTIVE_RUN_STATUSES.has(run.status)).length;
1122
1590
  };
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
- }
1591
+ // Workspace-level by design: update state spans project and global installs,
1592
+ // but the selected registered project supplies the safe, server-owned cwd.
1593
+ const skillsUpdateInputSchema = z.object({ projectId: projectIdSchema }).strict();
1594
+ const resolveSkillsUpdateRoot = async (raw) => {
1595
+ if (!projectIdSchema.safeParse(raw).success)
1596
+ return { status: 404, error: `unknown project: ${raw}` };
1133
1597
  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);
1598
+ if (raw === 'default' || raw === bootId)
1599
+ return { root: bootRoot };
1600
+ const project = (await loadWorkspaceConfig()).projects.find((entry) => entry.id === raw);
1601
+ if (!project)
1602
+ return { status: 404, error: `unknown project: ${raw}` };
1603
+ if ((await probeProjectStatus(project.root)).status === 'missing') {
1604
+ return { status: 409, error: `project folder not found: ${raw}` };
1162
1605
  }
1163
- let removed;
1606
+ return { root: project.root };
1607
+ };
1608
+ const skillsUpdateResponse = async (state) => {
1609
+ const config = await loadWorkspaceConfig();
1610
+ return { ...state, autoUpdateEnabled: effectiveSkillsAutoUpdate(config), inherited: config.skillsAutoUpdate === undefined };
1611
+ };
1612
+ // ---- chained family: skills updates (workspace-level) ----
1613
+ const skillsUpdateRoutes = new Hono()
1614
+ .get('/workspace/skills-update', queryZodValidator(skillsUpdateInputSchema, { message: 'projectId is required' }), async (c) => {
1615
+ const parsed = { data: c.req.valid('query') };
1616
+ const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
1617
+ if ('error' in resolved)
1618
+ return c.json({ error: resolved.error }, resolved.status);
1619
+ const state = skillsUpdate.snapshot(resolved.root);
1620
+ void skillsUpdate.check(resolved.root).catch(() => { });
1621
+ return c.json(await skillsUpdateResponse(state));
1622
+ })
1623
+ .post('/workspace/skills-update/check', jsonZodValidator(skillsUpdateInputSchema, { message: 'body must contain only projectId' }), async (c) => {
1624
+ const parsed = { data: c.req.valid('json') };
1625
+ const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
1626
+ if ('error' in resolved)
1627
+ return c.json({ error: resolved.error }, resolved.status);
1628
+ return c.json(await skillsUpdateResponse(await skillsUpdate.check(resolved.root, true)));
1629
+ })
1630
+ .post('/workspace/skills-update/apply', jsonZodValidator(skillsUpdateInputSchema, { message: 'body must contain only projectId' }), async (c) => {
1631
+ const parsed = { data: c.req.valid('json') };
1632
+ const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
1633
+ if ('error' in resolved)
1634
+ return c.json({ error: resolved.error }, resolved.status);
1164
1635
  try {
1165
- removed = await removeProject(id);
1636
+ return c.json(await skillsUpdateResponse(await skillsUpdate.update(resolved.root, true)));
1166
1637
  }
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);
1638
+ catch (error) {
1639
+ if (error instanceof SkillsUpdateConflictError) {
1640
+ return c.json({ error: 'another skills update operation is running', state: await skillsUpdateResponse(skillsUpdate.snapshot(resolved.root)) }, 409);
1641
+ }
1642
+ throw error;
1170
1643
  }
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
1644
  });
1182
1645
  // Edit one field of an existing registry entry (spec
1183
1646
  // 2026-07-22-per-project-concurrency): the per-project concurrency ceiling.
@@ -1190,67 +1653,6 @@ export function createApp(deps) {
1190
1653
  const updateProjectSchema = z.object({
1191
1654
  maxParallel: z.number().int().min(1).max(16).nullable(),
1192
1655
  });
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);
1253
- });
1254
1656
  // ---- GUI clone (multi-project spec, step 4.3) ----------------------------
1255
1657
  // "Add project → Clone from GitHub": clone into the checkout root, then
1256
1658
  // register the result through `registerFolder` above (same guards, same
@@ -1267,41 +1669,6 @@ export function createApp(deps) {
1267
1669
  name: z.string().trim().max(128).optional(),
1268
1670
  checkoutId: z.string().trim().max(128).optional(),
1269
1671
  });
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
1672
  // ---- workspace settings (multi-project spec, step 2.7) -------------------
1306
1673
  // WORKSPACE-level routes: single-mount (never mirrored under /api/p/),
1307
1674
  // same-origin. The config routes carry the settings UI's slice of
@@ -1311,33 +1678,32 @@ export function createApp(deps) {
1311
1678
  const workspaceConfigBody = (config) => ({
1312
1679
  browseRoot: config.browseRoot,
1313
1680
  projectsDir: config.projectsDir,
1681
+ skillsAutoUpdate: config.skillsAutoUpdate ?? null,
1682
+ effectiveSkillsAutoUpdate: effectiveSkillsAutoUpdate(config),
1683
+ composerDefaults: {
1684
+ autonomous: config.composerDefaults.autonomous ?? null,
1685
+ worktree: config.composerDefaults.worktree ?? null,
1686
+ inheritedAutonomous: process.env.CEZ_AUTONOMOUS_DEFAULT === '0'
1687
+ ? false
1688
+ : process.env.CEZ_AUTONOMOUS_DEFAULT === '1'
1689
+ ? true
1690
+ : 'source-dependent',
1691
+ inheritedWorktree: effectiveComposerDefault(undefined, process.env.CEZ_WORKTREE_DEFAULT, true),
1692
+ },
1314
1693
  resources: {
1315
1694
  maxParallel: config.resources.maxParallel,
1695
+ maxMonitoringSessions: config.resources.maxMonitoringSessions,
1696
+ monitoringWakeIntervalMinutes: config.resources.monitoringWakeIntervalMinutes,
1316
1697
  memoryLimitMb: config.resources.memoryLimitMb,
1317
1698
  worktreeRetentionDefault: config.resources.worktreeRetentionDefault,
1318
1699
  },
1319
1700
  });
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;
1701
+ // ---- chained family: workspace settings + GUI prefs (workspace-level) ----
1702
+ const workspaceConfigRoutes = new Hono()
1703
+ .get('/workspace/config', async (c) => c.json(workspaceConfigBody(await loadWorkspaceConfig())))
1704
+ .put('/workspace/config', jsonZodValidator(() => workspaceConfigUpdateSchema), async (c) => {
1705
+ const parsed = { data: c.req.valid('json') };
1706
+ const { browseRoot, projectsDir, skillsAutoUpdate, composerDefaults, resources } = parsed.data;
1341
1707
  for (const [configuredRoot, create] of [
1342
1708
  [browseRoot, false],
1343
1709
  [projectsDir, true],
@@ -1376,8 +1742,28 @@ export function createApp(deps) {
1376
1742
  config.browseRoot = browseRoot;
1377
1743
  if (projectsDir !== undefined)
1378
1744
  config.projectsDir = projectsDir;
1745
+ if (skillsAutoUpdate === null)
1746
+ delete config.skillsAutoUpdate;
1747
+ else if (skillsAutoUpdate !== undefined)
1748
+ config.skillsAutoUpdate = skillsAutoUpdate;
1749
+ if (composerDefaults?.autonomous === null)
1750
+ delete config.composerDefaults.autonomous;
1751
+ else if (composerDefaults?.autonomous !== undefined) {
1752
+ config.composerDefaults.autonomous = composerDefaults.autonomous;
1753
+ }
1754
+ if (composerDefaults?.worktree === null)
1755
+ delete config.composerDefaults.worktree;
1756
+ else if (composerDefaults?.worktree !== undefined) {
1757
+ config.composerDefaults.worktree = composerDefaults.worktree;
1758
+ }
1379
1759
  if (resources?.maxParallel !== undefined)
1380
1760
  config.resources.maxParallel = resources.maxParallel;
1761
+ if (resources?.maxMonitoringSessions !== undefined) {
1762
+ config.resources.maxMonitoringSessions = resources.maxMonitoringSessions;
1763
+ }
1764
+ if (resources?.monitoringWakeIntervalMinutes !== undefined) {
1765
+ config.resources.monitoringWakeIntervalMinutes = resources.monitoringWakeIntervalMinutes;
1766
+ }
1381
1767
  if (resources?.memoryLimitMb !== undefined)
1382
1768
  config.resources.memoryLimitMb = resources.memoryLimitMb;
1383
1769
  if (resources?.worktreeRetentionDefault !== undefined) {
@@ -1394,15 +1780,19 @@ export function createApp(deps) {
1394
1780
  if (resources !== undefined)
1395
1781
  await deps.semaphore?.refresh();
1396
1782
  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);
1783
+ })
1784
+ // Global GUI state (`~/.cezar/ui-state.json`) — same parse/key-cap/shallow-
1785
+ // merge semantics as the per-repo /api/v1/ui-state route below (the shared half
1786
+ // is `uiStateBodySchema`), but backed by the workspace file.
1787
+ .get('/workspace/ui-state', async (c) => c.json(await readWorkspaceUiState()))
1788
+ // The tighter body cap rides on `use` rather than inline on the route: `bodyLimit` is typed
1789
+ // as a bare MiddlewareHandler, and passing one to `.put()` collapses the route's schema, so
1790
+ // the PUT went missing from `AppType` and `hc` could not see its body at all. `use` runs at
1791
+ // the same point (before the handler, so the cap still precedes any read) and leaves the
1792
+ // chain's type accumulation alone. Method-agnostic here, which the GET does not mind.
1793
+ .use('/workspace/ui-state', bodyLimit({ maxSize: UI_STATE_BODY_LIMIT }))
1794
+ .put('/workspace/ui-state', jsonZodValidator(workspaceUiStateBody), async (c) => {
1795
+ const parsed = { data: c.req.valid('json') };
1406
1796
  try {
1407
1797
  return c.json(await mergeWriteWorkspaceUiState((state) => ({
1408
1798
  ...state,
@@ -1414,67 +1804,103 @@ export function createApp(deps) {
1414
1804
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1415
1805
  }
1416
1806
  });
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) => {
1807
+ // Partial updates only absent keys stay untouched. Bounds mirror the
1808
+ // workspace schema (src/workspace/config.ts, step 1.2) exactly, so a value
1809
+ // this route accepts can never be degraded away by the next load's `.catch`.
1810
+ const workspaceConfigUpdateSchema = z.object({
1811
+ browseRoot: z.string().trim().min(1).max(4096).optional(),
1812
+ projectsDir: z.string().trim().min(1).max(4096).optional(),
1813
+ skillsAutoUpdate: z.boolean().nullable().optional(),
1814
+ composerDefaults: z
1815
+ .object({
1816
+ autonomous: z.boolean().nullable().optional(),
1817
+ worktree: z.boolean().nullable().optional(),
1818
+ })
1819
+ .optional(),
1820
+ resources: z
1821
+ .object({
1822
+ maxParallel: z.number().int().min(1).max(16).optional(),
1823
+ maxMonitoringSessions: z.number().int().min(0).max(16).optional(),
1824
+ monitoringWakeIntervalMinutes: z.number().int().min(1).max(60).nullable().optional(),
1825
+ memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
1826
+ worktreeRetentionDefault: z.number().int().min(0).max(1000).optional(),
1827
+ })
1828
+ .optional(),
1829
+ });
1830
+ // ---- chained family: filesystem browse (workspace-level) ----
1831
+ const fsBrowseRoutes = new Hono()
1832
+ .get('/fs/browse', queryZodValidator(z.object({ path: queryValue, showHidden: queryValue })), async (c) => {
1424
1833
  if (capabilities().singleProject) {
1425
1834
  return c.json(singleProjectRefusal('folder browsing'), 409);
1426
1835
  }
1836
+ const query = c.req.valid('query');
1427
1837
  const root = resolveBrowseRoot(await workspaceBrowseRoot());
1428
1838
  const result = await browseDirectory({
1429
1839
  root,
1430
- path: c.req.query('path'),
1431
- showHidden: c.req.query('showHidden') === '1',
1840
+ path: query.path,
1841
+ showHidden: query.showHidden === '1',
1432
1842
  });
1433
1843
  if (!result.ok)
1434
1844
  return c.json({ error: result.error }, result.status);
1435
1845
  return c.json(result.body);
1436
1846
  });
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) => {
1847
+ // ---- chained family: launch-key (project-scoped) ----
1848
+ const launchKeyRoutes = new Hono()
1849
+ .get('/launch-key', (c) => c.json({ key: c.get('project').launchKey }));
1850
+ // ---- chained family: skills (project-scoped) ----
1851
+ const skillsRoutes = new Hono()
1852
+ .get('/skills', queryZodValidator(waitQuery), async (c) => {
1441
1853
  const repoRoot = c.get('project').root;
1442
1854
  // The default read stays fast and starts the team load in the background.
1443
1855
  // The cockpit follows it with `wait=1`, off the render path, so a cold
1444
1856
  // cache converges without polling or a manual reload (spec 005 / #555).
1445
- if (c.req.query('wait') === '1')
1857
+ if (c.req.valid('query').wait === '1')
1446
1858
  await waitForTeamSkills(repoRoot);
1447
1859
  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) => {
1860
+ })
1861
+ // The opt-in catalog for the "Import skills" panel: every skill a default
1862
+ // (vendor) repo offers — `open-mercato/skills` — regardless of import state,
1863
+ // so the panel can present them all with a per-skill toggle. Empty once a repo
1864
+ // configures its own `skillsRepos` (nothing is gated then). `wait=1` lets the
1865
+ // panel wait out a cold team-skill cache, same as `GET /skills` (spec 005).
1866
+ .get('/skills/importable', queryZodValidator(waitQuery), async (c) => {
1455
1867
  const repoRoot = c.get('project').root;
1456
1868
  const gated = await gatedSkillsRepos(repoRoot);
1457
1869
  if (gated.size === 0)
1458
1870
  return c.json([]);
1459
- if (c.req.query('wait') === '1')
1871
+ if (c.req.valid('query').wait === '1')
1460
1872
  await waitForTeamSkills(repoRoot);
1461
1873
  const importable = getTeamSkillsCached(repoRoot)
1462
1874
  .filter((skill) => skill.team && gated.has(skill.team.repo))
1463
- .map((skill) => ({ name: skill.name, description: skill.description }));
1875
+ // Spread `description` rather than writing it unconditionally: an undefined VALUE is
1876
+ // dropped by JSON.stringify, so the key is absent on the wire, and writing it always
1877
+ // typed the route as sending a key it does not. contract/skills.ts says `.optional()`,
1878
+ // which is what the client actually receives.
1879
+ .map((skill) => ({
1880
+ name: skill.name,
1881
+ ...(skill.description !== undefined ? { description: skill.description } : {}),
1882
+ }));
1464
1883
  return c.json(importable);
1884
+ })
1885
+ // Refresh team skills (spec 005): clone/fetch the configured skills repos,
1886
+ // then return the merged catalog. Degrades quietly — offline just means the
1887
+ // team entries stay as they were (or absent).
1888
+ .post('/skills/refresh', async (c) => {
1889
+ const { root: repoRoot } = c.get('project');
1890
+ await refreshTeamSkills(repoRoot);
1891
+ return c.json(await discoverSkills(repoRoot));
1465
1892
  });
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) => {
1893
+ // ---- chained family: GUI prefs / ui-state (project-scoped) ----
1894
+ const uiStateRoutes = new Hono()
1895
+ .get('/ui-state', async (c) => c.json(await readUiState(c.get('project').root)))
1896
+ // On `use`, not inline on the route — see the workspace ui-state PUT above.
1897
+ .use('/ui-state', bodyLimit({ maxSize: UI_STATE_BODY_LIMIT }))
1898
+ .put('/ui-state', jsonZodValidator(uiStateBody), async (c) => {
1471
1899
  const { root: repoRoot, dataDir } = c.get('project');
1472
1900
  // `.passthrough()` keeps unknown prefs (BACKWARD_COMPATIBILITY §3), but a
1473
1901
  // 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);
1902
+ // schema+cap half of both ui-state routes lives in `uiStateBody`.
1903
+ const parsed = { data: c.req.valid('json') };
1478
1904
  const merged = { ...(await readUiState(repoRoot)), ...parsed.data };
1479
1905
  try {
1480
1906
  await mkdir(dataDir, { recursive: true });
@@ -1485,24 +1911,18 @@ export function createApp(deps) {
1485
1911
  }
1486
1912
  return c.json(merged);
1487
1913
  });
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) => {
1914
+ // ---- chained family: workflows (project-scoped) --------------------------
1915
+ // One chained expression, mounted by `createApp` into BOTH the legacy `api`
1916
+ // table and the versioned `v1` one see `healthRoutes` for why the chain
1917
+ // shape (not the statement shape) is what carries the types.
1918
+ const workflowsRoutes = new Hono()
1919
+ .get('/workflows', async (c) => c.json(await loadWorkflows(c.get('project').root)))
1920
+ // Save an approved plan as a reusable chain (spec 008): YAML in
1921
+ // `.ai/cezar/workflows/<slug>.yaml` — from then on it's in the dropdown
1922
+ // like any other workflow.
1923
+ .post('/workflows', jsonZodValidator(saveWorkflowSchema), async (c) => {
1492
1924
  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) => {
1501
- 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
- }
1925
+ const parsed = { data: c.req.valid('json') };
1506
1926
  const steps = parsed.data.steps ?? skillsToSteps(parsed.data.skills ?? []);
1507
1927
  const issue = stepsIssue(steps);
1508
1928
  if (issue)
@@ -1534,10 +1954,10 @@ export function createApp(deps) {
1534
1954
  return c.json({ error: message }, 500);
1535
1955
  }
1536
1956
  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) => {
1957
+ })
1958
+ // Delete a saved workflow (spec 012 follow-up): file workflows only —
1959
+ // built-ins have no file and always come back.
1960
+ .delete('/workflows/:name', async (c) => {
1541
1961
  const { root: repoRoot } = c.get('project');
1542
1962
  const name = c.req.param('name');
1543
1963
  const { workflows } = await loadWorkflows(repoRoot);
@@ -1559,15 +1979,12 @@ export function createApp(deps) {
1559
1979
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1560
1980
  }
1561
1981
  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
- }
1982
+ })
1983
+ // Import support for the builder (spec 012): parse + validate a pasted
1984
+ // workflow YAML (either form) and hand back the normalized definition. The
1985
+ // server owns YAML parsing — the GUI stays dependency-free.
1986
+ .post('/workflows/parse', jsonZodValidator(parseWorkflowSchema), async (c) => {
1987
+ const parsed = { data: c.req.valid('json') };
1571
1988
  let raw;
1572
1989
  try {
1573
1990
  raw = parseYaml(parsed.data.yaml);
@@ -1586,17 +2003,250 @@ export function createApp(deps) {
1586
2003
  return c.json({ error: issue }, 400);
1587
2004
  return c.json(normalized);
1588
2005
  });
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) => {
2006
+ // ---- chained family: plan (project-scoped) ----
2007
+ const planRoutes = new Hono()
2008
+ .post('/plan', jsonZodValidator(planSchema), async (c) => {
1593
2009
  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
- }
2010
+ const parsed = { data: c.req.valid('json') };
2011
+ const blocked = await providerActionError([(await loadConfig(repoRoot)).defaultRunner]);
2012
+ if (blocked)
2013
+ return c.json({ error: blocked }, 409);
1598
2014
  return c.json(await planChain(repoRoot, parsed.data.task));
1599
2015
  });
2016
+ const manualChecks = new Map();
2017
+ // ---- chained family: GitHub automations (project-scoped) ----
2018
+ // Every handler below reads `c.get('project')` — the definitions, their runtime state and the
2019
+ // execution log are per-project files — so the family is project-scoped and mounted with the
2020
+ // rest of the mirrored table. The one exception is the manual-check read, which touches no
2021
+ // project at all; it is its own workspace-level family below.
2022
+ const automationsRoutes = new Hono()
2023
+ .get('/automations', async (c) => {
2024
+ const { root, automationStore } = c.get('project');
2025
+ const forge = resolveForge(await getRepoInfo(root));
2026
+ // Annotated, so the two branches are ONE shape rather than a union of two: the fallback
2027
+ // literal always carries `reason`, the cached answer only sometimes does, and the route
2028
+ // type is what `contract/src/automations.ts` has to describe.
2029
+ const availability = forge?.detectCached() ?? {
2030
+ available: false,
2031
+ reason: forge ? 'GitHub availability is still being checked' : 'No GitHub remote is configured',
2032
+ };
2033
+ const automations = automationStore.list().map((automation) => {
2034
+ const logs = automationStore.logs({ automationId: automation.id, limit: 100 });
2035
+ const state = automationStore.state(automation.id);
2036
+ const latestLog = logs[0];
2037
+ return {
2038
+ ...automation,
2039
+ // Spread conditionally, never `state: maybeUndefined`: the latter types the key as
2040
+ // always-present while `JSON.stringify` drops it from the wire, so the contract would
2041
+ // have to describe a key consumers never receive.
2042
+ ...(state ? { state } : {}),
2043
+ ...(latestLog ? { latestLog } : {}),
2044
+ counts: {
2045
+ matches: logs.filter((row) => row.result === 'launched' || row.result === 'duplicate').length,
2046
+ launched: logs.filter((row) => row.result === 'launched').length,
2047
+ duplicates: logs.filter((row) => row.result === 'duplicate').length,
2048
+ errors: logs.filter((row) => row.result === 'error' || row.result === 'rate-limited').length,
2049
+ },
2050
+ };
2051
+ });
2052
+ const nextDue = automations.map((item) => item.state?.nextCheckAt).filter(Boolean).sort()[0];
2053
+ return c.json({
2054
+ ...availability,
2055
+ scheduler: {
2056
+ // `as const` on both arms: in an object literal a conditional of two string literals
2057
+ // widens to `string`, which would erase the two states this key can hold.
2058
+ state: automations.some((item) => item.enabled) ? 'scheduled' : 'idle',
2059
+ ...(nextDue ? { nextDue } : {}),
2060
+ },
2061
+ automations,
2062
+ });
2063
+ })
2064
+ .post('/automations', jsonZodValidator(() => automationCreateSchema), async (c) => {
2065
+ const { automationStore } = c.get('project');
2066
+ const parsed = { data: c.req.valid('json') };
2067
+ const promptIssue = validateAutomationPrompt(parsed.data.task.prompt);
2068
+ if (promptIssue)
2069
+ return c.json({ error: promptIssue }, 400);
2070
+ const { enable, ...input } = parsed.data;
2071
+ try {
2072
+ const automation = automationStore.create({ ...input, enabled: enable === true });
2073
+ if (enable) {
2074
+ const baselineAt = new Date().toISOString();
2075
+ automationStore.setState(automation.id, {
2076
+ revision: automation.revision,
2077
+ baselineAt,
2078
+ cursor: { timestamp: baselineAt },
2079
+ nextCheckAt: new Date(Date.now() + automation.intervalSeconds * 1_000).toISOString(),
2080
+ });
2081
+ }
2082
+ emitAutomationChange(c.get('project'), automation.id, automation.revision);
2083
+ automationsChanged();
2084
+ return c.json({ automation }, 201);
2085
+ }
2086
+ catch (error) {
2087
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
2088
+ }
2089
+ })
2090
+ .get('/automations/:id', (c) => {
2091
+ const { automationStore } = c.get('project');
2092
+ const automation = automationStore.get(c.req.param('id'));
2093
+ if (!automation)
2094
+ return c.json({ error: 'not found' }, 404);
2095
+ const state = automationStore.state(automation.id);
2096
+ const latestLog = automationStore.logs({ automationId: automation.id, limit: 1 })[0];
2097
+ return c.json({
2098
+ automation,
2099
+ ...(state ? { state } : {}),
2100
+ ...(latestLog ? { latestLog } : {}),
2101
+ });
2102
+ })
2103
+ .put('/automations/:id', jsonZodValidator(() => automationUpdateSchema), async (c) => {
2104
+ const { automationStore } = c.get('project');
2105
+ const parsed = { data: c.req.valid('json') };
2106
+ const promptIssue = validateAutomationPrompt(parsed.data.task.prompt);
2107
+ if (promptIssue)
2108
+ return c.json({ error: promptIssue }, 400);
2109
+ const { expectedRevision, ...input } = parsed.data;
2110
+ if (!automationStore.get(c.req.param('id')))
2111
+ return c.json({ error: 'not found' }, 404);
2112
+ try {
2113
+ const automation = automationStore.update(c.req.param('id'), expectedRevision, { ...input, enabled: input.enabled ?? false });
2114
+ emitAutomationChange(c.get('project'), automation.id, automation.revision);
2115
+ automationsChanged();
2116
+ return c.json({ automation });
2117
+ }
2118
+ catch (error) {
2119
+ const message = error instanceof Error ? error.message : String(error);
2120
+ return c.json({ error: message }, message.includes('conflict') ? 409 : 400);
2121
+ }
2122
+ })
2123
+ .delete('/automations/:id', (c) => {
2124
+ const id = c.req.param('id');
2125
+ const store = c.get('project').automationStore;
2126
+ const current = store.get(id);
2127
+ if (!current || !store.delete(id))
2128
+ return c.json({ error: 'not found' }, 404);
2129
+ emitAutomationChange(c.get('project'), id, current.revision, true);
2130
+ automationsChanged();
2131
+ return c.body(null, 204);
2132
+ })
2133
+ .post('/automations/:id/enable', (c) => {
2134
+ const store = c.get('project').automationStore;
2135
+ const current = store.get(c.req.param('id'));
2136
+ if (!current)
2137
+ return c.json({ error: 'not found' }, 404);
2138
+ const automation = store.update(current.id, current.revision, { ...editableAutomation(current), enabled: true });
2139
+ const baselineAt = new Date().toISOString();
2140
+ store.setState(automation.id, {
2141
+ ...store.state(automation.id),
2142
+ revision: automation.revision,
2143
+ baselineAt,
2144
+ cursor: { timestamp: baselineAt },
2145
+ nextCheckAt: new Date(Date.now() + automation.intervalSeconds * 1_000).toISOString(),
2146
+ });
2147
+ store.appendLog({ automationId: automation.id, revision: automation.revision, result: 'baseline', reason: 'Enabled from a current-time baseline; existing records were not launched.' });
2148
+ emitAutomationChange(c.get('project'), automation.id, automation.revision);
2149
+ automationsChanged();
2150
+ return c.json({ automation });
2151
+ })
2152
+ .post('/automations/:id/pause', (c) => {
2153
+ const store = c.get('project').automationStore;
2154
+ const current = store.get(c.req.param('id'));
2155
+ if (!current)
2156
+ return c.json({ error: 'not found' }, 404);
2157
+ const automation = store.update(current.id, current.revision, { ...editableAutomation(current), enabled: false });
2158
+ emitAutomationChange(c.get('project'), automation.id, automation.revision);
2159
+ automationsChanged();
2160
+ return c.json({ automation });
2161
+ })
2162
+ // The body is validated as MIDDLEWARE, which is what puts it in the route type — and moves
2163
+ // the 400 ahead of this route's 404: `POST /automations/<unknown>/check` with a malformed
2164
+ // body now answers 400 rather than 404. Nothing else about either answer changed.
2165
+ .post('/automations/:id/check', jsonZodValidator(() => automationCheckRequestSchema), async (c) => {
2166
+ const project = c.get('project');
2167
+ const store = project.automationStore;
2168
+ const automation = store.get(c.req.param('id'));
2169
+ if (!automation)
2170
+ return c.json({ error: 'not found' }, 404);
2171
+ const parsed = { data: c.req.valid('json') };
2172
+ // `string`, not `randomUUID`'s template-literal type: the wire carries an opaque id, and
2173
+ // leaking `${string}-${string}-…` into the route type would make the contract describe the
2174
+ // generator rather than the answer.
2175
+ const id = randomUUID();
2176
+ const check = { id, automationId: automation.id, mode: parsed.data.mode, status: 'queued', createdAt: new Date().toISOString() };
2177
+ if (manualChecks.size >= 200)
2178
+ manualChecks.delete(manualChecks.keys().next().value);
2179
+ manualChecks.set(id, check);
2180
+ void (async () => {
2181
+ check.status = 'running';
2182
+ try {
2183
+ const remote = parseRemote((await getRepoInfo(project.root))?.remote ?? '');
2184
+ if (!remote || remote.host !== 'github.com')
2185
+ throw new Error('No GitHub remote is configured');
2186
+ const scheduler = new ProjectAutomationScheduler({
2187
+ projectId: project.id,
2188
+ owner: remote.owner,
2189
+ repo: remote.repo,
2190
+ store,
2191
+ poller: new GithubPoller(),
2192
+ launch: parsed.data.mode === 'execute'
2193
+ ? (definition, candidate, receiptId) => launchAutomationRun({ root: project.root, manager: project.manager, store: project.store, definition, candidate, receiptId })
2194
+ : undefined,
2195
+ onChange: (automationId, revision) => emitAutomationChange(project, automationId, revision),
2196
+ });
2197
+ const result = await scheduler.check(automation, parsed.data.mode);
2198
+ Object.assign(check, { status: 'complete', completedAt: new Date().toISOString(), matches: result.candidates.length, truncated: result.truncated });
2199
+ }
2200
+ catch (error) {
2201
+ Object.assign(check, { status: 'error', completedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) });
2202
+ }
2203
+ })();
2204
+ return c.json({ checkId: id }, 202);
2205
+ })
2206
+ .get('/automation-log', queryZodValidator(automationLogQuerySchema), (c) => {
2207
+ return c.json({ records: c.get('project').automationStore.logs(c.req.valid('query')) });
2208
+ })
2209
+ .post('/automation-log/:receiptId/retry', async (c) => {
2210
+ const project = c.get('project');
2211
+ const store = project.automationStore;
2212
+ const receipt = [...store.latestReceipts().values()].find((row) => row.receiptId === c.req.param('receiptId'));
2213
+ if (!receipt)
2214
+ return c.json({ error: 'not found' }, 404);
2215
+ if (receipt.status !== 'launch-error' || receipt.runId)
2216
+ return c.json({ error: 'receipt is not retryable' }, 409);
2217
+ if (!receipt.candidate)
2218
+ return c.json({ error: 'receipt predates retry context and cannot be retried safely' }, 409);
2219
+ const definition = store.get(receipt.automationId);
2220
+ if (!definition)
2221
+ return c.json({ error: 'automation not found' }, 404);
2222
+ const lease = store.acquireLease();
2223
+ if (!lease)
2224
+ return c.json({ error: 'automation polling lease is held by another process' }, 409);
2225
+ const reserved = { ...receipt, status: 'reserved', error: undefined, updatedAt: new Date().toISOString() };
2226
+ store.appendReceipt(reserved);
2227
+ try {
2228
+ const launched = await launchAutomationRun({ root: project.root, manager: project.manager, store: project.store, definition, candidate: receipt.candidate, receiptId: receipt.receiptId });
2229
+ store.appendReceipt({ ...reserved, status: 'launched', runId: launched.runId, updatedAt: new Date().toISOString() });
2230
+ emitAutomationChange(project, definition.id, definition.revision);
2231
+ return c.json({ receiptId: receipt.receiptId, runId: launched.runId }, 202);
2232
+ }
2233
+ catch (error) {
2234
+ store.appendReceipt({ ...reserved, status: 'launch-error', error: error instanceof Error ? error.message : String(error), updatedAt: new Date().toISOString() });
2235
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
2236
+ }
2237
+ finally {
2238
+ lease.release();
2239
+ }
2240
+ });
2241
+ // ---- chained family: manual automation checks (workspace-level) ----
2242
+ // Workspace-level because the handler reads no project: a check lives in the server-memory map
2243
+ // above, keyed by an unguessable id that the project-scoped POST hands back. Mounting it under
2244
+ // `/api/v1/p/:projectId` too would be a second spelling of a lookup that consults no project.
2245
+ const automationChecksRoutes = new Hono()
2246
+ .get('/automation-checks/:checkId', (c) => {
2247
+ const check = manualChecks.get(c.req.param('checkId'));
2248
+ return check ? c.json(check) : c.json({ error: 'not found' }, 404);
2249
+ });
1600
2250
  // ---- runs ----------------------------------------------------------------
1601
2251
  // Additive `usage` field (#348): the latest CPU/RSS/proc-count sample of the
1602
2252
  // run's live process tree — absent for finished runs and when `ps` yields
@@ -1625,28 +2275,33 @@ export function createApp(deps) {
1625
2275
  console.warn(`[cezar] could not mark inbox entry ${todoId} started: ${String(err)}`);
1626
2276
  }
1627
2277
  };
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) => {
2278
+ // ---- chained family: runs lifecycle + artifacts (project-scoped) ----
2279
+ const runsRoutes = new Hono()
2280
+ .get('/runs', (c) => c.json(c.get('project').store.listRuns().map(withUsage)))
2281
+ // Registered before the `/:id/...` routes so "archive-finished" and "read-all"
2282
+ // never match as a run id.
2283
+ .post('/runs/archive-finished', (c) => c.json({ archived: c.get('project').store.archiveFinished() }))
2284
+ // The read-receipt sweep (#unread-done-items) — the mark-read twin of the archive
2285
+ // sweep above, and under the same registration-order guard.
2286
+ .post('/runs/read-all', (c) => c.json({ read: c.get('project').store.markAllRead() }))
2287
+ .post('/runs/:id/archive', jsonZodValidator(archiveSchema, { absent: ({}) }), async (c) => {
1633
2288
  const { store } = c.get('project');
1634
2289
  const id = c.req.param('id');
1635
2290
  // An empty/absent body archives (the common case); a malformed body degrades
1636
2291
  // 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
- }
2292
+ const parsed = { data: c.req.valid('json') };
1641
2293
  const run = store.setArchived(id, parsed.data.archived !== false);
1642
2294
  return run ? c.json(run) : c.json({ error: 'not found' }, 404);
1643
- });
1644
- api.post('/runs', async (c) => {
2295
+ })
2296
+ .post('/runs/:id/read', (c) => {
2297
+ // No body: opening a thread marks it read, full stop. Stamps `seenAt = now` and
2298
+ // returns the updated record (which also rides the `run` SSE via `touch`).
2299
+ const run = c.get('project').store.setRead(c.req.param('id'));
2300
+ return run ? c.json(run) : c.json({ error: 'not found' }, 404);
2301
+ })
2302
+ .post('/runs', jsonZodValidator(startRunSchema), async (c) => {
1645
2303
  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);
1649
- }
2304
+ const parsed = { data: c.req.valid('json') };
1650
2305
  let workflow;
1651
2306
  if (parsed.data.steps) {
1652
2307
  // Inline chain (spec 008): an approved plan runs as an ad-hoc workflow.
@@ -1665,6 +2320,10 @@ export function createApp(deps) {
1665
2320
  if (!workflow)
1666
2321
  return c.json({ error: `unknown workflow: ${parsed.data.workflow}` }, 404);
1667
2322
  }
2323
+ const fallback = parsed.data.runner ?? (await loadConfig(repoRoot)).defaultRunner;
2324
+ const blocked = await providerActionError(providersRequiredByWorkflow(workflow, fallback));
2325
+ if (blocked)
2326
+ return c.json({ error: blocked }, 409);
1668
2327
  const images = parsed.data.images?.map((img) => ({
1669
2328
  type: 'image',
1670
2329
  source: { type: 'base64', media_type: img.mediaType, data: img.data },
@@ -1706,113 +2365,24 @@ export function createApp(deps) {
1706
2365
  if (parsed.data.todoId)
1707
2366
  await noteTodoStarted(dataDir, parsed.data.todoId, run.id);
1708
2367
  return c.json(run, 201);
1709
- });
1710
- // ---- parallel variants (spec 010) -----------------------------------------
1711
- const groupRuns = (store, groupId) => store
1712
- .listRuns()
1713
- .filter((r) => r.groupId === groupId)
1714
- .sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
1715
- // Comparison data: per variant the status, cost, `git diff --stat` and the
1716
- // first Progress-log lines from the handoff. The full diff is fetched per
1717
- // variant via the existing GET /api/runs/:id/diff.
1718
- api.get('/groups/:groupId', async (c) => {
1719
- const { dataDir, store } = c.get('project');
1720
- const runs = groupRuns(store, c.req.param('groupId'));
1721
- if (runs.length === 0)
1722
- return c.json({ error: 'not found' }, 404);
1723
- const detailed = await Promise.all(runs.map(async (r) => ({
1724
- id: r.id,
1725
- variant: r.variant ?? '?',
1726
- title: r.title,
1727
- status: r.status,
1728
- archived: r.archived,
1729
- tokensUsed: r.tokensUsed,
1730
- costUsd: r.costUsd,
1731
- diffStat: r.worktreePath && existsSync(r.worktreePath)
1732
- ? await worktreeDiffStat(r.worktreePath, r.baseBranch ?? 'HEAD')
1733
- : '',
1734
- handoffExcerpt: handoffProgressExcerpt(readHandoff(dataDir, r.id)),
1735
- })));
1736
- return c.json({
1737
- groupId: c.req.param('groupId'),
1738
- runs: detailed,
1739
- });
1740
- });
1741
- // "Pick this one": the winner rests at `review` (spec 009 takes it from
1742
- // there — send back / draft PR / finish); the losers are cancelled if
1743
- // alive, archived, and their worktrees + branches removed.
1744
- api.post('/groups/:groupId/pick', async (c) => {
1745
- const { root: repoRoot, dataDir, store, manager } = c.get('project');
1746
- const runs = groupRuns(store, c.req.param('groupId'));
1747
- if (runs.length === 0)
1748
- return c.json({ error: 'not found' }, 404);
1749
- const parsed = pickSchema.safeParse(await c.req.json().catch(() => null));
1750
- if (!parsed.success) {
1751
- return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
1752
- }
1753
- const winner = runs.find((r) => r.id === parsed.data.runId);
1754
- if (!winner)
1755
- return c.json({ error: 'runId is not part of this group' }, 404);
1756
- if (manager.isActive(winner.id)) {
1757
- return c.json({ error: 'this variant is still active — wait for it to finish first' }, 409);
1758
- }
1759
- // Winner: a non-review terminal state with a non-empty diff flips to
1760
- // `review` (the settleSuccess rule) — but only when the review gate applies
1761
- // (#489): it is enabled (`reviewGateEnabled`, default off) AND the winner is
1762
- // not autonomous. An autonomous / gate-off winner keeps its `done` state with
1763
- // the diff left in the worktree; an empty diff (or no worktree) stays too.
1764
- if (winner.status !== 'review' &&
1765
- winner.worktreePath &&
1766
- existsSync(winner.worktreePath) &&
1767
- winner.autonomous !== true &&
1768
- reviewGateEnabled(await loadConfig(repoRoot))) {
1769
- const diff = await worktreeDiff(winner.worktreePath, winner.baseBranch ?? 'HEAD');
1770
- if (diff.trim().length > 0 && !diff.startsWith('(diff failed')) {
1771
- store.updateRun(winner.id, { status: 'review' });
1772
- }
1773
- }
1774
- const losers = runs.filter((r) => r.id !== winner.id);
1775
- store.appendEvent(winner.id, {
1776
- type: 'lifecycle',
1777
- message: `picked from ${runs.length} variants — ${losers.length} other variant(s) archived`,
1778
- });
1779
- appendHandoffHeartbeat(dataDir, winner.id, `picked from ${runs.length} variants`);
1780
- for (const loser of losers) {
1781
- if (manager.isActive(loser.id))
1782
- manager.cancel(loser.id);
1783
- if (loser.worktreePath)
1784
- await removeWorktree(repoRoot, loser.worktreePath, loser.branch);
1785
- store.updateRun(loser.id, { worktreePath: undefined, branch: undefined });
1786
- store.setArchived(loser.id, true);
1787
- store.appendEvent(loser.id, {
1788
- type: 'lifecycle',
1789
- message: `variant ${winner.variant ?? '?'} was picked — this variant is archived, its worktree removed`,
1790
- });
1791
- }
1792
- return c.json({
1793
- winner: store.getRun(winner.id),
1794
- });
1795
- });
1796
- api.get('/runs/:id', (c) => {
2368
+ })
2369
+ .get('/runs/:id', (c) => {
1797
2370
  const { store } = c.get('project');
1798
2371
  const run = store.getRun(c.req.param('id'));
1799
2372
  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) => {
2373
+ })
2374
+ // Editable titles (#389). The UI displays `titleSummary ?? title`, so a
2375
+ // user edit sets BOTH: `title` (the record's own name — the raw task stops
2376
+ // being it the moment the user renames the run) and `titleSummary` (what
2377
+ // actually displays). The auto-summarizer only ever fills an *unset*
2378
+ // titleSummary (RunManager.recordTurnEnd), so an edit wins over any past or
2379
+ // future auto-summary. Answers the updated record.
2380
+ .patch('/runs/:id', jsonZodValidator(patchRunSchema), async (c) => {
1808
2381
  const { store, manager } = c.get('project');
1809
2382
  const id = c.req.param('id');
1810
2383
  if (!store.getRun(id))
1811
2384
  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
- }
2385
+ const parsed = { data: c.req.valid('json') };
1816
2386
  // The prompt is editable only while the run is still queued (#472). Checked
1817
2387
  // BEFORE the title write so a rejected PATCH is a no-op rather than a partial
1818
2388
  // one. `title` itself keeps working on any status — no regression to #389.
@@ -1837,25 +2407,33 @@ export function createApp(deps) {
1837
2407
  });
1838
2408
  }
1839
2409
  return c.json(store.getRun(id));
1840
- });
1841
- api.post('/runs/:id/cancel', (c) => {
2410
+ })
2411
+ .post('/runs/:id/cancel', (c) => {
1842
2412
  const { store, manager } = c.get('project');
1843
2413
  const id = c.req.param('id');
1844
2414
  if (!store.getRun(id))
1845
2415
  return c.json({ error: 'not found' }, 404);
1846
2416
  const cancelled = manager.cancel(id);
1847
2417
  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) => {
2418
+ })
2419
+ // Live-session participation (spec 002): deliver a user message (text +
2420
+ // pasted screenshots) into the run's open claude session.
2421
+ .post('/runs/:id/messages', jsonZodValidator(messageSchema), async (c) => {
1852
2422
  const { store, manager } = c.get('project');
1853
2423
  const id = c.req.param('id');
1854
- if (!store.getRun(id))
2424
+ const run = store.getRun(id);
2425
+ if (!run)
1855
2426
  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);
2427
+ const parsed = { data: c.req.valid('json') };
2428
+ // Stacking onto a queued prompt mutates an existing task and invokes no provider.
2429
+ // Provider availability still gates live delivery after the record leaves `queued`, but
2430
+ // must not strand prompt authoring just because an unrelated fallback provider is
2431
+ // disconnected (provider-auth spec: disabling never blocks existing-task mutations).
2432
+ // In the dequeue race, the ladder below safely turns this into a starting-state buffer.
2433
+ if (run.status !== 'queued') {
2434
+ const blocked = await providerActionError([providerForActiveRun(run)]);
2435
+ if (blocked)
2436
+ return c.json({ error: blocked }, 409);
1859
2437
  }
1860
2438
  const content = [
1861
2439
  ...parsed.data.images.map((img) => ({
@@ -1871,14 +2449,14 @@ export function createApp(deps) {
1871
2449
  // starting up → buffered · anything else → 409, exactly as before
1872
2450
  if (manager.sendMessage(id, content))
1873
2451
  return c.json({ delivered: true });
1874
- const run = store.getRun(id);
1875
- const stack = run?.queuedMessages ?? [];
2452
+ const currentRun = store.getRun(id);
2453
+ const stack = currentRun?.queuedMessages ?? [];
1876
2454
  // Bounds apply only to a message that is actually about to be stacked. Without this
1877
2455
  // gate an over-long message posted to a *finished* run would answer `400 prompt too
1878
2456
  // long` when the truthful answer is `409 session closed`. The status read is safe
1879
2457
  // here because it only decides whether to reject EARLY — `enqueueMessage` still
1880
2458
  // re-checks against the engine's own queue before writing anything.
1881
- if (run?.status === 'queued') {
2459
+ if (currentRun?.status === 'queued') {
1882
2460
  if (stack.length >= MAX_QUEUED_MESSAGES) {
1883
2461
  return c.json({ error: `too many queued messages — ${MAX_QUEUED_MESSAGES} message limit` }, 400);
1884
2462
  }
@@ -1886,7 +2464,7 @@ export function createApp(deps) {
1886
2464
  if (stackedImages + parsed.data.images.length > MAX_QUEUED_IMAGES) {
1887
2465
  return c.json({ error: `too many queued images — ${MAX_QUEUED_IMAGES} image limit across the stack` }, 400);
1888
2466
  }
1889
- const prospective = foldedLength(run.task, [...stack, { text: parsed.data.text }]);
2467
+ const prospective = foldedLength(currentRun.task, [...stack, { text: parsed.data.text }]);
1890
2468
  if (prospective > MAX_FOLDED_TASK_CHARS) {
1891
2469
  return c.json({
1892
2470
  error: `prompt too long — ${MAX_FOLDED_TASK_CHARS} character limit across the task and its queued messages (would be ${prospective})`,
@@ -1899,19 +2477,16 @@ export function createApp(deps) {
1899
2477
  if (manager.deferMessage(id, content))
1900
2478
  return c.json({ deferred: true });
1901
2479
  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) => {
2480
+ })
2481
+ // Edit / remove a stacked message (#472). Registered before any conflicting
2482
+ // `/:id` route so `queued-messages` never matches as a run id.
2483
+ .patch('/runs/:id/queued-messages/:msgId', jsonZodValidator(queuedMessagePatchSchema), async (c) => {
1906
2484
  const { store, manager } = c.get('project');
1907
2485
  const id = c.req.param('id');
1908
2486
  const run = store.getRun(id);
1909
2487
  if (!run)
1910
2488
  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
- }
2489
+ const parsed = { data: c.req.valid('json') };
1915
2490
  const msgId = c.req.param('msgId');
1916
2491
  const stack = run.queuedMessages ?? [];
1917
2492
  const existing = stack.find((m) => m.id === msgId);
@@ -1944,8 +2519,8 @@ export function createApp(deps) {
1944
2519
  if (!message)
1945
2520
  return c.json({ error: 'run already started' }, 409);
1946
2521
  return c.json({ message });
1947
- });
1948
- api.delete('/runs/:id/queued-messages/:msgId', (c) => {
2522
+ })
2523
+ .delete('/runs/:id/queued-messages/:msgId', (c) => {
1949
2524
  const { store, manager } = c.get('project');
1950
2525
  const id = c.req.param('id');
1951
2526
  const run = store.getRun(id);
@@ -1958,9 +2533,9 @@ export function createApp(deps) {
1958
2533
  if (!manager.removeQueuedMessage(id, msgId))
1959
2534
  return c.json({ error: 'run already started' }, 409);
1960
2535
  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) => {
2536
+ })
2537
+ // "Finish": gracefully close a waiting session — the run completes as done.
2538
+ .post('/runs/:id/finish', (c) => {
1964
2539
  const { store, manager } = c.get('project');
1965
2540
  const id = c.req.param('id');
1966
2541
  if (!store.getRun(id))
@@ -1969,19 +2544,20 @@ export function createApp(deps) {
1969
2544
  if (!finished)
1970
2545
  return c.json({ error: 'no open session' }, 409);
1971
2546
  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) => {
2547
+ })
2548
+ // "Continue" (spec 003): reopen a finished run's session in-process.
2549
+ .post('/runs/:id/continue', jsonZodValidator(continueSchema, { absent: ({}) }), async (c) => {
1975
2550
  const { store, manager } = c.get('project');
1976
2551
  const id = c.req.param('id');
1977
- if (!store.getRun(id))
2552
+ const run = store.getRun(id);
2553
+ if (!run)
1978
2554
  return c.json({ error: 'not found' }, 404);
1979
2555
  // Bounded resume text (#429); an empty/absent body still just re-runs on the
1980
2556
  // 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);
1984
- }
2557
+ const parsed = { data: c.req.valid('json') };
2558
+ const blocked = await providerActionError([providerForExistingRun(run, parsed.data.runner)]);
2559
+ if (blocked)
2560
+ return c.json({ error: blocked }, 409);
1985
2561
  const result = manager.continueRun(id, {
1986
2562
  text: parsed.data.text,
1987
2563
  images: parsed.data.images?.map((img) => ({
@@ -1994,10 +2570,10 @@ export function createApp(deps) {
1994
2570
  if (!result.ok)
1995
2571
  return c.json({ error: result.error }, 409);
1996
2572
  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) => {
2573
+ })
2574
+ // "Open in terminal" (spec 003): hand the session off to a real terminal —
2575
+ // in the task's worktree when it still exists (spec 006).
2576
+ .post('/runs/:id/open-in-cli', async (c) => {
2001
2577
  const { root: repoRoot, store } = c.get('project');
2002
2578
  const id = c.req.param('id');
2003
2579
  const run = store.getRun(id);
@@ -2013,6 +2589,9 @@ export function createApp(deps) {
2013
2589
  const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
2014
2590
  if (!sessionId)
2015
2591
  return c.json({ error: 'no agent session to resume' }, 409);
2592
+ const blocked = await providerActionError([providerForExistingRun(run)]);
2593
+ if (blocked)
2594
+ return c.json({ error: blocked }, 409);
2016
2595
  const cwd = run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : repoRoot;
2017
2596
  const command = resumeCommand(run.runner, sessionId);
2018
2597
  // Fails closed on an id we do not recognise — see resumeCommand (#431).
@@ -2026,12 +2605,9 @@ export function createApp(deps) {
2026
2605
  }, 409);
2027
2606
  }
2028
2607
  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) => {
2608
+ })
2609
+ // Open a run's worktree (or the repo root) in the chosen local app.
2610
+ .post('/runs/:id/open-in', jsonZodValidator(openInSchema), async (c) => {
2035
2611
  const { root: repoRoot, store } = c.get('project');
2036
2612
  const id = c.req.param('id');
2037
2613
  const run = store.getRun(id);
@@ -2044,10 +2620,7 @@ export function createApp(deps) {
2044
2620
  }
2045
2621
  // Follows the safeParse convention (#429); the downstream allowlist match is the real
2046
2622
  // 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
- }
2623
+ const parsedBody = { data: c.req.valid('json') };
2051
2624
  const { target, path: relPath } = parsedBody.data;
2052
2625
  const dir = run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : repoRoot;
2053
2626
  // Diff pane "open in OS default app" (#365): one worktree file, opened with the platform's
@@ -2105,6 +2678,9 @@ export function createApp(deps) {
2105
2678
  // and what the client's cliTargetResumes now labels. Resume-after-finish is untouched.
2106
2679
  const cliRunner = agentCliRunner(target);
2107
2680
  if (cliRunner) {
2681
+ const blocked = await providerActionError([cliRunner]);
2682
+ if (blocked)
2683
+ return c.json({ error: blocked }, 409);
2108
2684
  const engineOwnsSession = run.status === 'running' || run.status === 'queued' || run.status === 'waiting';
2109
2685
  const sessionId = engineOwnsSession ? undefined : [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
2110
2686
  // An id resumeCommand refuses (#431) degrades to a fresh CLI in the worktree,
@@ -2124,10 +2700,10 @@ export function createApp(deps) {
2124
2700
  if (!opened)
2125
2701
  return c.json({ error: `could not open ${target}`, path: dir }, 409);
2126
2702
  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) => {
2703
+ })
2704
+ // Handoff journal (spec 007): the per-task handoff.md as markdown. 404 only
2705
+ // when the task is unknown; a task without a (yet) seeded file returns ''.
2706
+ .get('/runs/:id/handoff', (c) => {
2131
2707
  const { dataDir, store } = c.get('project');
2132
2708
  const run = store.getRun(c.req.param('id'));
2133
2709
  if (!run)
@@ -2135,16 +2711,8 @@ export function createApp(deps) {
2135
2711
  return c.text(readHandoff(dataDir, run.id), 200, {
2136
2712
  'content-type': 'text/markdown; charset=utf-8',
2137
2713
  });
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) => {
2714
+ })
2715
+ .get('/runs/:id/images/:file', (c) => {
2148
2716
  const { dataDir, store } = c.get('project');
2149
2717
  const run = store.getRun(c.req.param('id'));
2150
2718
  if (!run)
@@ -2160,9 +2728,9 @@ export function createApp(deps) {
2160
2728
  'cache-control': 'private, max-age=31536000, immutable',
2161
2729
  },
2162
2730
  });
2163
- });
2164
- // Task diff (spec 006): what this run changed — its worktree vs its base.
2165
- api.get('/runs/:id/diff', async (c) => {
2731
+ })
2732
+ // Task diff (spec 006): what this run changed — its worktree vs its base.
2733
+ .get('/runs/:id/diff', async (c) => {
2166
2734
  const { store } = c.get('project');
2167
2735
  const run = store.getRun(c.req.param('id'));
2168
2736
  if (!run)
@@ -2171,94 +2739,111 @@ export function createApp(deps) {
2171
2739
  return c.text('(no worktree — this task ran directly in the repo working tree)');
2172
2740
  }
2173
2741
  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');
2742
+ })
2743
+ .get('/runs/:id/changes', async (c) => {
2744
+ const { root: repoRoot, store } = c.get('project');
2183
2745
  const run = store.getRun(c.req.param('id'));
2184
2746
  if (!run)
2185
2747
  return c.json({ error: 'not found' }, 404);
2186
- const worktree = worktreeOf(run);
2187
- if (!worktree)
2748
+ const workingDirectory = workingDirectoryOf(run, repoRoot);
2749
+ if (!workingDirectory)
2188
2750
  return c.json({ error: NO_WORKTREE }, 409);
2189
- const result = await collectChanges(worktree, run.baseBranch ?? 'HEAD', { taskBranch: run.branch });
2751
+ const result = await collectChanges(workingDirectory, run.baseBranch ?? 'HEAD', {
2752
+ taskBranch: run.branch,
2753
+ // A read-only GET against the user's real checkout must never modify its index.
2754
+ intentToAdd: run.worktreePath ? undefined : false,
2755
+ });
2190
2756
  if (!result.ok)
2191
2757
  return c.json({ error: result.error }, 409);
2192
2758
  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');
2759
+ })
2760
+ // The run's own commits (<base>..HEAD on the worktree branch) — the Commits tab.
2761
+ .get('/runs/:id/commits', async (c) => {
2762
+ const { root: repoRoot, store } = c.get('project');
2197
2763
  const run = store.getRun(c.req.param('id'));
2198
2764
  if (!run)
2199
2765
  return c.json({ error: 'not found' }, 404);
2200
- const worktree = worktreeOf(run);
2201
- if (!worktree)
2766
+ const workingDirectory = workingDirectoryOf(run, repoRoot);
2767
+ if (!workingDirectory)
2202
2768
  return c.json({ error: NO_WORKTREE }, 409);
2203
- const result = await collectRunCommits(worktree, run.baseBranch ?? 'HEAD');
2769
+ const result = await collectRunCommits(workingDirectory, run.baseBranch ?? 'HEAD');
2204
2770
  if (!result.ok)
2205
2771
  return c.json({ error: result.error }, 409);
2206
2772
  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');
2773
+ })
2774
+ // One of the run's commits, structured like the Changes tab (reuses collectCommitChanges).
2775
+ .get('/runs/:id/commit/:sha', async (c) => {
2776
+ const { root: repoRoot, store } = c.get('project');
2211
2777
  const run = store.getRun(c.req.param('id'));
2212
2778
  if (!run)
2213
2779
  return c.json({ error: 'not found' }, 404);
2214
- const worktree = worktreeOf(run);
2215
- if (!worktree)
2780
+ const workingDirectory = workingDirectoryOf(run, repoRoot);
2781
+ if (!workingDirectory)
2216
2782
  return c.json({ error: NO_WORKTREE }, 409);
2217
- const result = await collectCommitChanges(worktree, c.req.param('sha'));
2783
+ const result = await collectCommitChanges(workingDirectory, c.req.param('sha'));
2218
2784
  if (!result.ok)
2219
2785
  return c.json({ error: result.error }, 409);
2220
2786
  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');
2787
+ })
2788
+ // Files tab: directory listing (path omitted or a dir) or file content
2789
+ // (size-capped, binary flagged). Traversal-safe — readWorktreePath rejects
2790
+ // anything escaping the worktree. `raw=1` (R5 Step 1.6) serves the BYTES of
2791
+ // image files only, for the preview's inline <img> — never HTML/JS/etc., so
2792
+ // no worktree file can become a same-origin document, and never past the
2793
+ // size cap. The no-script CSP neutralizes SVG opened as a top-level URL.
2794
+ //
2795
+ // An `Accept` that asks for images reaches the same raw branch without the flag — which is
2796
+ // what an `<img>` sends — while the flag still wins whenever it is present and `*<slash>*`
2797
+ // (every `fetch`) still gets the JSON listing. See `negotiate`.
2798
+ .get('/runs/:id/files', queryZodValidator(z.object({ path: queryValue, raw: queryValue })), async (c) => {
2799
+ const { root: repoRoot, store } = c.get('project');
2800
+ const query = c.req.valid('query');
2801
+ c.header('vary', 'Accept');
2802
+ const wantsRaw = query.raw !== undefined
2803
+ ? query.raw === '1'
2804
+ : negotiate(c.req.header('accept'), FILE_FORMATS) === 'image/*';
2230
2805
  const run = store.getRun(c.req.param('id'));
2231
2806
  if (!run)
2232
2807
  return c.json({ error: 'not found' }, 404);
2233
- const worktree = worktreeOf(run);
2234
- if (!worktree)
2808
+ const workingDirectory = workingDirectoryOf(run, repoRoot);
2809
+ if (!workingDirectory)
2235
2810
  return c.json({ error: NO_WORKTREE }, 409);
2236
- const result = await readWorktreePath(worktree, c.req.query('path') ?? '');
2811
+ const result = await readWorktreePath(workingDirectory, query.path ?? '');
2237
2812
  if (result.kind === 'invalid' || result.kind === 'missing') {
2238
2813
  return c.json({ error: result.error }, 409);
2239
2814
  }
2240
2815
  if (result.kind === 'dir') {
2241
2816
  return c.json({
2817
+ // `as const` or the literal widens to `string` during Hono's route-type inference,
2818
+ // which erases the discriminant a consumer narrows on — `entry.type === 'dir'` then
2819
+ // leaves `never` and every field access on it fails. The wire was always 'dir'.
2242
2820
  type: 'dir',
2243
2821
  path: result.path,
2244
2822
  entries: result.entries,
2245
2823
  });
2246
2824
  }
2247
- if (c.req.query('raw') === '1') {
2825
+ if (wantsRaw) {
2248
2826
  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);
2827
+ if (mime === null || result.tooLarge) {
2828
+ // `?raw=1` ASKED for bytes, so it hears why it cannot have them — that 409 and its
2829
+ // wording are the protected surface (§2). An `Accept` is only a preference, so a
2830
+ // resource with no image representation falls THROUGH to the JSON answer below rather
2831
+ // than turning a browser's navigation to a text file into an error.
2832
+ if (query.raw !== undefined) {
2833
+ const error = mime === null
2834
+ ? `raw serving is limited to images: ${result.path}`
2835
+ : `file too large to serve raw (${result.size} bytes): ${result.path}`;
2836
+ return c.json({ error }, 409);
2837
+ }
2838
+ }
2839
+ else {
2840
+ const bytes = await readFile(join(workingDirectory, result.path));
2841
+ return c.body(new Uint8Array(bytes).buffer, 200, {
2842
+ 'content-type': mime,
2843
+ 'x-content-type-options': 'nosniff',
2844
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
2845
+ });
2255
2846
  }
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
2847
  }
2263
2848
  return c.json({
2264
2849
  type: 'file',
@@ -2268,8 +2853,8 @@ export function createApp(deps) {
2268
2853
  tooLarge: result.tooLarge,
2269
2854
  ...(result.content !== undefined ? { content: result.content } : {}),
2270
2855
  });
2271
- });
2272
- api.post('/runs/:id/git/commit', async (c) => {
2856
+ })
2857
+ .post('/runs/:id/git/commit', jsonZodValidator(gitCommitSchema), async (c) => {
2273
2858
  const { store } = c.get('project');
2274
2859
  const run = store.getRun(c.req.param('id'));
2275
2860
  if (!run)
@@ -2277,16 +2862,13 @@ export function createApp(deps) {
2277
2862
  const worktree = worktreeOf(run);
2278
2863
  if (!worktree)
2279
2864
  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
- }
2865
+ const parsed = { data: c.req.valid('json') };
2284
2866
  const result = await commitAll(worktree, parsed.data.message);
2285
2867
  if (!result.ok)
2286
2868
  return c.json({ error: result.error }, 409);
2287
2869
  return c.json({ committed: true, sha: result.sha });
2288
- });
2289
- api.post('/runs/:id/git/push', async (c) => {
2870
+ })
2871
+ .post('/runs/:id/git/push', async (c) => {
2290
2872
  const { store } = c.get('project');
2291
2873
  const run = store.getRun(c.req.param('id'));
2292
2874
  if (!run)
@@ -2303,12 +2885,12 @@ export function createApp(deps) {
2303
2885
  remote: result.remote,
2304
2886
  upstreamSet: result.upstreamSet,
2305
2887
  });
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) => {
2888
+ })
2889
+ // Draft PR from the review gate (spec 009): final autosave → push →
2890
+ // `gh pr create --draft`; on success the run completes as done with the PR
2891
+ // badge. Failures come back as 409 with a `manual` merge command the GUI
2892
+ // shows next to the toast. CEZ_DRY_RUN=1 fakes the URL (no push, no gh).
2893
+ .post('/runs/:id/pr', async (c) => {
2312
2894
  const { root: repoRoot, dataDir, store, manager } = c.get('project');
2313
2895
  const id = c.req.param('id');
2314
2896
  const run = store.getRun(id);
@@ -2339,10 +2921,10 @@ export function createApp(deps) {
2339
2921
  message: `draft PR created: ${outcome.url}${outcome.dryRun ? ' (dry run — no real PR)' : ''}`,
2340
2922
  });
2341
2923
  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) => {
2924
+ })
2925
+ // Archived tasks keep their worktree for inspection; this is the explicit
2926
+ // "🧹 Remove worktree" cleanup (spec 006).
2927
+ .post('/runs/:id/remove-worktree', async (c) => {
2346
2928
  const { root: repoRoot, store, manager } = c.get('project');
2347
2929
  const id = c.req.param('id');
2348
2930
  const run = store.getRun(id);
@@ -2354,8 +2936,8 @@ export function createApp(deps) {
2354
2936
  await removeWorktree(repoRoot, run.worktreePath, run.branch);
2355
2937
  store.updateRun(id, { worktreePath: undefined, branch: undefined });
2356
2938
  return c.json({ removed: true });
2357
- });
2358
- api.delete('/runs/:id', async (c) => {
2939
+ })
2940
+ .delete('/runs/:id', async (c) => {
2359
2941
  const { root: repoRoot, store, manager } = c.get('project');
2360
2942
  const id = c.req.param('id');
2361
2943
  if (manager.isActive(id))
@@ -2368,11 +2950,116 @@ export function createApp(deps) {
2368
2950
  await removeWorktree(repoRoot, run.worktreePath, run.branch);
2369
2951
  return store.deleteRun(id) ? c.json({ deleted: true }) : c.json({ error: 'not found' }, 404);
2370
2952
  });
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) => {
2953
+ // ---- parallel variants (spec 010) -----------------------------------------
2954
+ const groupRuns = (store, groupId) => store
2955
+ .listRuns()
2956
+ .filter((r) => r.groupId === groupId)
2957
+ .sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
2958
+ // ---- chained family: variant groups (project-scoped) ----
2959
+ const groupsRoutes = new Hono()
2960
+ .get('/groups/:groupId', async (c) => {
2961
+ const { dataDir, store } = c.get('project');
2962
+ const runs = groupRuns(store, c.req.param('groupId'));
2963
+ if (runs.length === 0)
2964
+ return c.json({ error: 'not found' }, 404);
2965
+ const detailed = await Promise.all(runs.map(async (r) => ({
2966
+ id: r.id,
2967
+ variant: r.variant ?? '?',
2968
+ title: r.title,
2969
+ status: r.status,
2970
+ archived: r.archived,
2971
+ tokensUsed: r.tokensUsed,
2972
+ ...(r.inputTokens !== undefined ? { inputTokens: r.inputTokens } : {}),
2973
+ ...(r.outputTokens !== undefined ? { outputTokens: r.outputTokens } : {}),
2974
+ ...(r.costUsd !== undefined ? { costUsd: r.costUsd } : {}),
2975
+ diffStat: r.worktreePath && existsSync(r.worktreePath)
2976
+ ? await worktreeDiffStat(r.worktreePath, r.baseBranch ?? 'HEAD')
2977
+ : '',
2978
+ handoffExcerpt: handoffProgressExcerpt(readHandoff(dataDir, r.id)),
2979
+ })));
2980
+ return c.json({
2981
+ groupId: c.req.param('groupId'),
2982
+ runs: detailed,
2983
+ });
2984
+ })
2985
+ // "Pick this one": the winner rests at `review` (spec 009 takes it from
2986
+ // there — send back / draft PR / finish); the losers are cancelled if
2987
+ // alive, archived, and their worktrees + branches removed.
2988
+ .post('/groups/:groupId/pick', jsonZodValidator(pickSchema), async (c) => {
2989
+ const { root: repoRoot, dataDir, store, manager } = c.get('project');
2990
+ const runs = groupRuns(store, c.req.param('groupId'));
2991
+ if (runs.length === 0)
2992
+ return c.json({ error: 'not found' }, 404);
2993
+ const parsed = { data: c.req.valid('json') };
2994
+ const winner = runs.find((r) => r.id === parsed.data.runId);
2995
+ if (!winner)
2996
+ return c.json({ error: 'runId is not part of this group' }, 404);
2997
+ if (manager.isActive(winner.id)) {
2998
+ return c.json({ error: 'this variant is still active — wait for it to finish first' }, 409);
2999
+ }
3000
+ // Winner: a non-review terminal state with a non-empty diff flips to
3001
+ // `review` (the settleSuccess rule) — but only when the review gate applies
3002
+ // (#489): it is enabled (`reviewGateEnabled`, default off) AND the winner is
3003
+ // not autonomous. An autonomous / gate-off winner keeps its `done` state with
3004
+ // the diff left in the worktree; an empty diff (or no worktree) stays too.
3005
+ if (winner.status !== 'review' &&
3006
+ winner.worktreePath &&
3007
+ existsSync(winner.worktreePath) &&
3008
+ winner.autonomous !== true &&
3009
+ reviewGateEnabled(await loadConfig(repoRoot))) {
3010
+ const diff = await worktreeDiff(winner.worktreePath, winner.baseBranch ?? 'HEAD');
3011
+ if (diff.trim().length > 0 && !diff.startsWith('(diff failed')) {
3012
+ store.updateRun(winner.id, { status: 'review' });
3013
+ }
3014
+ }
3015
+ const losers = runs.filter((r) => r.id !== winner.id);
3016
+ store.appendEvent(winner.id, {
3017
+ type: 'lifecycle',
3018
+ message: `picked from ${runs.length} variants — ${losers.length} other variant(s) archived`,
3019
+ });
3020
+ appendHandoffHeartbeat(dataDir, winner.id, `picked from ${runs.length} variants`);
3021
+ for (const loser of losers) {
3022
+ if (manager.isActive(loser.id))
3023
+ manager.cancel(loser.id);
3024
+ if (loser.worktreePath)
3025
+ await removeWorktree(repoRoot, loser.worktreePath, loser.branch);
3026
+ store.updateRun(loser.id, { worktreePath: undefined, branch: undefined });
3027
+ store.setArchived(loser.id, true);
3028
+ store.appendEvent(loser.id, {
3029
+ type: 'lifecycle',
3030
+ message: `variant ${winner.variant ?? '?'} was picked — this variant is archived, its worktree removed`,
3031
+ });
3032
+ }
3033
+ // Spread: `getRun` may answer undefined, and an undefined VALUE is dropped by
3034
+ // JSON.stringify — so writing the key unconditionally typed the route as sending a key it
3035
+ // does not. contract/workflows.ts says `.optional()`, which is what a client receives.
3036
+ const picked = store.getRun(winner.id);
3037
+ return c.json({ ...(picked !== undefined ? { winner: picked } : {}) });
3038
+ });
3039
+ // ---- chained family: open-targets (project-scoped) ----
3040
+ const openTargetsRoutes = new Hono()
3041
+ .get('/open-targets', (c) => c.json({ targets: capabilities().localHandoff ? detectOpenTargets() : [] }));
3042
+ // Agent screenshots — image blocks the run manager persisted out of tool
3043
+ // results (persistImage). `basename` pins reads inside the run's own dir.
3044
+ const IMAGE_TYPES = {
3045
+ png: 'image/png',
3046
+ jpg: 'image/jpeg',
3047
+ webp: 'image/webp',
3048
+ gif: 'image/gif',
3049
+ };
3050
+ // ---- session git view (redesign R5 Step 1.2 — §"Git/session API additions").
3051
+ // Structured sibling of the text-blob /diff above (which stays untouched —
3052
+ // protected surface). Isolated runs read their worktree; worktree-off runs
3053
+ // read the repo checkout they executed in. Every predictable git failure
3054
+ // degrades to 409 + human-readable reason, 404 only for unknown ids.
3055
+ const worktreeOf = (run) => run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : null;
3056
+ const workingDirectoryOf = (run, repoRoot) => run.worktree === false
3057
+ ? repoRoot
3058
+ : worktreeOf(run);
3059
+ const NO_WORKTREE = 'no worktree — this task ran directly in the repo working tree';
3060
+ // ---- chained family: worktrees (project-scoped) ----
3061
+ const worktreesRoutes = new Hono()
3062
+ .get('/worktrees', async (c) => {
2376
3063
  const { root: repoRoot, store } = c.get('project');
2377
3064
  // The keep-limit the panel reports is the one the enforcer will actually
2378
3065
  // apply — inherited from the workspace default when this repo sets none.
@@ -2393,61 +3080,66 @@ export function createApp(deps) {
2393
3080
  ? null
2394
3081
  : worktrees.reduce((sum, w) => sum + (w.sizeBytes ?? 0), 0);
2395
3082
  return c.json({ worktrees, totalBytes, keep });
2396
- });
2397
- const reclaimBodySchema = z.object({}).passthrough();
2398
- api.post('/worktrees/reclaim', async (c) => {
3083
+ })
3084
+ .post('/worktrees/reclaim', jsonZodValidator(() => reclaimBodySchema, { absent: ({}), message: 'invalid body' }), async (c) => {
2399
3085
  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);
3086
+ // The body is validated (an empty or `{}` one is accepted) but carries nothing this
3087
+ // handler reads; retention is best-effort, so 200 always.
2404
3088
  const reclaimed = await reclaimWorktrees(repoRoot, store, await resolveWorktreeRetention(repoRoot));
2405
3089
  return c.json({ reclaimed });
2406
3090
  });
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) => {
3091
+ const reclaimBodySchema = z.object({}).passthrough();
3092
+ /**
3093
+ * "The inbox is on and this entry exists" the 409/404 half of `POST /todos/:id/start`, lifted
3094
+ * out of the handler and in FRONT of the body validator.
3095
+ *
3096
+ * That position is the whole point. The route's contract is that an unknown id 404s before the
3097
+ * body is looked at, and Hono only records a body in the route type when it is validated as
3098
+ * MIDDLEWARE which necessarily runs before the handler. Registering this guard first satisfies
3099
+ * both: the documented status order is unchanged, and `startTodoSchema` becomes visible to
3100
+ * `AppType` (and so to `hc`) instead of being parsed invisibly inside the handler.
3101
+ *
3102
+ * Deliberately NOT annotated with a return type: the inferred one carries the two typed
3103
+ * responses, which is what keeps the 409 and 404 branches in the route's schema for the client.
3104
+ */
3105
+ const todoMustExist = async (c, next) => {
3106
+ if (!capabilities().followups)
3107
+ return c.json({ error: FOLLOWUPS_OFF }, 409);
3108
+ const todo = (await readTodos(c.get('project').dataDir)).find((t) => t.id === c.req.param('id'));
3109
+ if (!todo)
3110
+ return c.json({ error: 'not found' }, 404);
3111
+ c.set('todo', todo);
3112
+ await next();
3113
+ };
3114
+ // ---- chained family: follow-up inbox / todos (project-scoped) ----
3115
+ const todosRoutes = new Hono()
3116
+ .get('/todos', async (c) => c.json(capabilities().followups ? await readTodos(c.get('project').dataDir) : []))
3117
+ // Check off = delete the entry.
3118
+ .delete('/todos/:id', async (c) => {
2416
3119
  const { dataDir } = c.get('project');
2417
3120
  if (!capabilities().followups)
2418
3121
  return c.json({ error: FOLLOWUPS_OFF }, 409);
2419
3122
  const removed = await removeTodo(dataDir, c.req.param('id'));
2420
3123
  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) => {
3124
+ })
3125
+ // "▶ Run": turn an inbox entry into a task — a one-off single-step workflow
3126
+ // around the suggested skill when it exists, plain quick-task otherwise.
3127
+ //
3128
+ // TWO middlewares, and their ORDER is the contract. This route's documented status order
3129
+ // (pinned by todos-start.test.ts) is that a disabled inbox 409s and an unknown id 404s BEFORE
3130
+ // the body is looked at — which is why the body used to be parsed inline, invisible to `hc`.
3131
+ // Hono runs route middleware in registration order, so `todoMustExist` FIRST keeps that order
3132
+ // exactly while `jsonZodValidator` second is what records the body in the route type.
3133
+ //
3134
+ // The two no-body cases the old inline parse distinguished are carried by the validator's
3135
+ // `absent`/`malformed` options: no body at all is `undefined` (the pre-#401 bodyless POST,
3136
+ // which the optional schema accepts → 201), a truncated payload is `null` (which it rejects
3137
+ // → 400, rather than passing as "no body" and silently starting a run).
3138
+ .post('/todos/:id/start', todoMustExist, jsonZodValidator(startTodoSchema, { absent: undefined, malformed: null }), async (c) => {
2425
3139
  const { root: repoRoot, dataDir, manager } = c.get('project');
2426
- if (!capabilities().followups)
2427
- return c.json({ error: FOLLOWUPS_OFF }, 409);
2428
3140
  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);
2450
- }
3141
+ const todo = c.get('todo');
3142
+ const parsed = { data: c.req.valid('json') };
2451
3143
  if (todo.startedTaskId)
2452
3144
  return c.json({ error: 'already started' }, 409);
2453
3145
  let task = todoTaskText(todo);
@@ -2476,6 +3168,10 @@ export function createApp(deps) {
2476
3168
  const { workflows } = await loadWorkflows(repoRoot);
2477
3169
  workflow = workflows.find((w) => w.name === 'quick-task') ?? QUICK_TASK_WORKFLOW;
2478
3170
  }
3171
+ const fallback = parsed.data?.runner ?? (await loadConfig(repoRoot)).defaultRunner;
3172
+ const blocked = await providerActionError(providersRequiredByWorkflow(workflow, fallback));
3173
+ if (blocked)
3174
+ return c.json({ error: blocked }, 409);
2479
3175
  const run = manager.startRun(workflow, {
2480
3176
  task,
2481
3177
  runner: parsed.data?.runner,
@@ -2484,10 +3180,9 @@ export function createApp(deps) {
2484
3180
  await markStarted(dataDir, id, run.id);
2485
3181
  return c.json({ run }, 201);
2486
3182
  });
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) => {
3183
+ // ---- chained family: SSE streams (project-scoped) ----
3184
+ const sseRoutes = new Hono()
3185
+ .get('/runs/:id/events', (c) => {
2491
3186
  const { store } = c.get('project');
2492
3187
  const id = c.req.param('id');
2493
3188
  if (!store.getRun(id))
@@ -2543,13 +3238,13 @@ export function createApp(deps) {
2543
3238
  await stream.sleep(15_000);
2544
3239
  }
2545
3240
  });
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) => {
3241
+ })
3242
+ // Global SSE: run-summary updates for the list view + inbox changes.
3243
+ // Scoped `/p/:projectId/events` carries that project's stream in today's
3244
+ // shape; the legacy unprefixed alias stays bound to the boot project ONLY
3245
+ // (spec "Legacy aliases" — widening it would be a silent behavioral break;
3246
+ // the all-project stream arrives as `/api/workspace/events` in step 2.8).
3247
+ .get('/events', (c) => {
2553
3248
  const { dataDir, store } = c.get('project');
2554
3249
  return streamSSENoBuffer(c, async (stream) => {
2555
3250
  const onRun = (run) => void stream.writeSSE({ event: 'run', data: JSON.stringify(run) });
@@ -2595,22 +3290,9 @@ export function createApp(deps) {
2595
3290
  }
2596
3291
  });
2597
3292
  });
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) => {
3293
+ // ---- chained family: workspace SSE stream (workspace-level) ----
3294
+ const workspaceEventsRoutes = new Hono()
3295
+ .get('/workspace/events', (c) => {
2614
3296
  return streamSSENoBuffer(c, async (stream) => {
2615
3297
  // One detach bundle per attached project — the id guard makes a double
2616
3298
  // attach (connect-time snapshot vs. the built hook) impossible.
@@ -2682,11 +3364,12 @@ export function createApp(deps) {
2682
3364
  }
2683
3365
  });
2684
3366
  // 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.
3367
+ // checkout-progress plus host-wide unstamped provider-status) relayed
3368
+ // verbatim under their own names. A removal also drops the project's
3369
+ // attach entry: the id guard in `attach` would otherwise pin the
3370
+ // DISPOSED context forever, so a project removed and re-added on the
3371
+ // same slug would rebuild a fresh context whose events never reach this
3372
+ // already-open stream.
2690
3373
  const offWorkspace = workspaceEvents.on((event, data) => {
2691
3374
  if (event === 'project-removed') {
2692
3375
  const removed = data.id;
@@ -2711,23 +3394,25 @@ export function createApp(deps) {
2711
3394
  }
2712
3395
  });
2713
3396
  });
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) => {
3397
+ // ---- chained family: GitHub (project-scoped) ----
3398
+ // These sit ABOVE the routes rather than with the family's other schemas below it, because a
3399
+ // validator argument is evaluated when the route is REGISTERED a schema declared further down
3400
+ // would be in its temporal dead zone. (The schemas below are all read inside a handler, or
3401
+ // passed as a thunk, which defers them past that point.)
3402
+ const mergeNumberParams = z.object({ number: z.coerce.number().int().positive() });
3403
+ const prChangesParams = z.object({ number: z.coerce.number().int().positive().safe() });
3404
+ const prChangesQuery = z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') });
3405
+ const githubRoutes = new Hono()
3406
+ .get('/github',
3407
+ // `limit` stays a bare string: the handler's `Number.parseInt`/`Number.isFinite` fallback to
3408
+ // 30 already accepts `?limit=banana`, and a numeric schema would 400 it instead.
3409
+ queryZodValidator(z.object({ limit: queryValue, refresh: queryValue })), async (c) => {
2719
3410
  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) => {
3411
+ const query = c.req.valid('query');
3412
+ const limit = Number.parseInt(query.limit ?? '', 10);
3413
+ return c.json(await fetchGithub(repoRoot, query.refresh === '1', Number.isFinite(limit) ? limit : 30));
3414
+ })
3415
+ .get('/github/comments/:kind/:number', queryZodValidator(refreshQuery), async (c) => {
2731
3416
  const { root: repoRoot } = c.get('project');
2732
3417
  const parsed = commentsParams.safeParse({
2733
3418
  kind: c.req.param('kind'),
@@ -2735,10 +3420,86 @@ export function createApp(deps) {
2735
3420
  });
2736
3421
  if (!parsed.success)
2737
3422
  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'));
3423
+ return c.json(await fetchGithubComments(repoRoot, parsed.data.kind, parsed.data.number, c.req.valid('query').refresh === '1'));
3424
+ })
3425
+ // Lazy checks glyphs for on-screen PR rows (#664). Additive sibling of /api/github — the list
3426
+ // call dropped `statusCheckRollup` (the dominant cost), so the glyph is hydrated here per
3427
+ // visible row. `prs` is a comma-separated list of positive integers, capped at GH_CHECKS_MAX;
3428
+ // anything malformed is a 400. Same in-payload availability degrade as the list (never a 5xx).
3429
+ // `prs` is the one genuinely REQUIRED query key on this server, so it is the one validated
3430
+ // strictly — `.min(1)` because `?prs=` answered `missing prs query` before it answered
3431
+ // `invalid prs query`, and both spellings must keep their own words.
3432
+ .get('/github/checks', queryZodValidator(z.object({ prs: z.string().min(1) }), { message: 'missing prs query' }), async (c) => {
3433
+ const { root: repoRoot } = c.get('project');
3434
+ const raw = c.req.valid('query').prs;
3435
+ const parts = raw.split(',').map((p) => p.trim()).filter(Boolean);
3436
+ if (parts.length === 0 || parts.length > GH_CHECKS_MAX)
3437
+ return c.json({ error: 'invalid prs query' }, 400);
3438
+ const numbers = [];
3439
+ for (const part of parts) {
3440
+ const n = Number(part);
3441
+ if (!Number.isInteger(n) || n <= 0 || String(n) !== part)
3442
+ return c.json({ error: 'invalid prs query' }, 400);
3443
+ numbers.push(n);
3444
+ }
3445
+ return c.json(await fetchGithubChecks(repoRoot, numbers));
3446
+ })
3447
+ .get('/github/prs/:number/merge-state', paramZodValidator(mergeNumberParams, { message: 'invalid pull request number' }), queryZodValidator(refreshQuery), async (c) => {
3448
+ const { root: repoRoot } = c.get('project');
3449
+ const parsed = { data: c.req.valid('param') };
3450
+ const forge = resolveForge(await getRepoInfo(repoRoot));
3451
+ if (!forge?.prMergeState)
3452
+ return c.json({ available: false, reason: 'GitHub merge state is unavailable' });
3453
+ return c.json(await forge.prMergeState(parsed.data.number, { refresh: c.req.valid('query').refresh === '1' }));
3454
+ })
3455
+ .post('/github/prs/:number/merge', paramZodValidator(mergeNumberParams, { message: 'invalid pull request number' }), jsonZodValidator(() => mergeBodySchema, { message: 'invalid merge request' }), async (c) => {
3456
+ const { root: repoRoot } = c.get('project');
3457
+ const parsedNumber = { data: c.req.valid('param') };
3458
+ const body = { data: c.req.valid('json') };
3459
+ const forge = resolveForge(await getRepoInfo(repoRoot));
3460
+ if (!forge?.mergePR)
3461
+ return c.json({ error: 'GitHub merge is unavailable' }, 409);
3462
+ const result = await forge.mergePR(parsedNumber.data.number, body.data);
3463
+ if (result.merged)
3464
+ return c.json(result);
3465
+ return c.json({
3466
+ error: result.error,
3467
+ ...(result.code ? { code: result.code } : {}),
3468
+ ...(result.current ? { current: result.current } : {}),
3469
+ }, result.status);
3470
+ })
3471
+ .get('/github/prs/:number/changes',
3472
+ // Split out of one `safeParse` over both inputs, because a path param and the query string
3473
+ // are separate validation targets to Hono and only a split makes each visible to the route
3474
+ // type. Both keep the single 400 sentence the combined parse answered. `refresh` stays
3475
+ // STRICT here (`?refresh=true` is a 400 today, unlike everywhere else on this server).
3476
+ paramZodValidator(prChangesParams, { message: 'invalid pull request number or refresh flag' }), queryZodValidator(prChangesQuery, { message: 'invalid pull request number or refresh flag' }), async (c) => {
3477
+ const { root: repoRoot } = c.get('project');
3478
+ const parsed = { data: c.req.valid('param') };
3479
+ try {
3480
+ return c.json(await fetchGithubPrDiff(repoRoot, parsed.data.number, c.req.valid('query').refresh === '1'));
3481
+ }
3482
+ catch (err) {
3483
+ if (err instanceof GithubPrNotFoundError)
3484
+ return c.json({ error: err.message }, 404);
3485
+ throw err;
3486
+ }
3487
+ });
3488
+ // The full comment thread for one issue/PR (#499). Additive sibling of /api/github — lazy
3489
+ // (fetched only while a detail view is open), zod-validated params, 400 on garbage, and the
3490
+ // same in-payload availability degrade (gh missing / offline / 404 all render as a hint).
3491
+ const commentsParams = z.object({
3492
+ kind: z.enum(['issue', 'pr']),
3493
+ number: z.coerce.number().int().positive(),
2739
3494
  });
2740
- // ---- repo view -----------------------------------------------------------
2741
- api.get('/repo', async (c) => {
3495
+ const mergeBodySchema = z.object({
3496
+ method: z.enum(['merge', 'squash', 'rebase']),
3497
+ expectedHeadSha: z.string().regex(/^[0-9a-f]{40}$/),
3498
+ overrideRules: z.boolean().optional().default(false),
3499
+ }).strict();
3500
+ // ---- chained family: repo / git (project-scoped) ----
3501
+ const repoRoutes = new Hono()
3502
+ .get('/repo', async (c) => {
2742
3503
  const { root: repoRoot } = c.get('project');
2743
3504
  const info = await getRepoInfo(repoRoot);
2744
3505
  if (!info)
@@ -2762,6 +3523,75 @@ export function createApp(deps) {
2762
3523
  branches,
2763
3524
  baseBranch: config.baseBranch ?? null,
2764
3525
  });
3526
+ })
3527
+ .get('/repo/diff', async (c) => {
3528
+ const { root: repoRoot } = c.get('project');
3529
+ const info = await getRepoInfo(repoRoot);
3530
+ if (!info)
3531
+ return c.text('not a git repository');
3532
+ return c.text(await getDiff(info.root));
3533
+ })
3534
+ // One commit's message + stat + patch — the Repo view expands it inline.
3535
+ // `?structured=1` is the ADDITIVE sibling (R5 Step 1.7): the new repo view's commit-diff
3536
+ // shape `{sha, subject, author, when, files, stat}` with 409 + reason on failure. The
3537
+ // legacy text answer below is a protected surface (BACKWARD_COMPATIBILITY.md §2) — its
3538
+ // shape, including the in-band failure sentences, stays exactly as it was.
3539
+ //
3540
+ // `Accept: application/json` reaches the same structured answer without the flag, and
3541
+ // `Accept: text/plain` asks for the blob; the flag still wins whenever it is present, and a
3542
+ // request with no opinion still gets the blob. See `negotiate`.
3543
+ .get('/repo/commit/:sha', queryZodValidator(z.object({ structured: queryValue })), async (c) => {
3544
+ const { root: repoRoot } = c.get('project');
3545
+ c.header('vary', 'Accept');
3546
+ const { structured } = c.req.valid('query');
3547
+ const wantsJson = structured !== undefined
3548
+ ? structured === '1'
3549
+ : negotiate(c.req.header('accept'), COMMIT_FORMATS) === 'application/json';
3550
+ const info = await getRepoInfo(repoRoot);
3551
+ if (wantsJson) {
3552
+ if (!info)
3553
+ return c.json({ error: 'not a git repository' }, 409);
3554
+ const result = await collectCommitChanges(info.root, c.req.param('sha'));
3555
+ if (!result.ok)
3556
+ return c.json({ error: result.error }, 409);
3557
+ return c.json(result.commit);
3558
+ }
3559
+ if (!info)
3560
+ return c.text('not a git repository');
3561
+ try {
3562
+ return c.text(await getCommit(info.root, c.req.param('sha')));
3563
+ }
3564
+ catch (err) {
3565
+ return c.text(`(git show failed: ${err instanceof Error ? err.message : String(err)})`);
3566
+ }
3567
+ })
3568
+ // Structured sibling of the text-blob /api/repo/diff above (protected
3569
+ // surface, untouched): the same {files, stat} shape the session /changes
3570
+ // route serves, here for the MAIN working tree's uncommitted changes vs
3571
+ // HEAD (redesign R5 Step 1.3 — §"Git/session API additions").
3572
+ .get('/repo/changes', async (c) => {
3573
+ const { root: repoRoot } = c.get('project');
3574
+ const info = await getRepoInfo(repoRoot);
3575
+ if (!info)
3576
+ return c.json({ error: 'not a git repository' }, 409);
3577
+ // The user's REAL working tree — never stage into their index (a GET must not write).
3578
+ const result = await collectChanges(info.root, 'HEAD', {
3579
+ intentToAdd: false,
3580
+ });
3581
+ if (!result.ok)
3582
+ return c.json({ error: result.error }, 409);
3583
+ return c.json(result.changes);
3584
+ })
3585
+ .post('/repo/branch', jsonZodValidator(() => repoBranchSchema), async (c) => {
3586
+ const { root: repoRoot } = c.get('project');
3587
+ const info = await getRepoInfo(repoRoot);
3588
+ if (!info)
3589
+ return c.json({ error: 'not a git repository' }, 409);
3590
+ const parsed = { data: c.req.valid('json') };
3591
+ const result = await createOrSwitchBranch(info.root, parsed.data.name, parsed.data.from);
3592
+ if (!result.ok)
3593
+ return c.json({ error: result.error }, 409);
3594
+ return c.json({ branch: result.branch, created: result.created });
2765
3595
  });
2766
3596
  // The Settings → Agents knobs in one read (R6 Step 1.5) — an ADDITIVE
2767
3597
  // sibling of PUT /api/config below; /api/health keeps its protected shape.
@@ -2782,45 +3612,12 @@ export function createApp(deps) {
2782
3612
  // CEZ_REVIEW_GATE env default (OFF) decides".
2783
3613
  reviewGate: config.reviewGate ?? null,
2784
3614
  });
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) => {
3615
+ // ---- chained family: per-repo config (project-scoped) ----
3616
+ const configRoutes = new Hono()
3617
+ .get('/config', async (c) => c.json(configAnswer(await loadConfig(c.get('project').root))))
3618
+ .put('/config', jsonZodValidator(() => setConfigSchema), async (c) => {
2819
3619
  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);
2823
- }
3620
+ const parsed = { data: c.req.valid('json') };
2824
3621
  const configPath = join(dataDir, 'config.json');
2825
3622
  let raw = {};
2826
3623
  try {
@@ -2907,14 +3704,53 @@ export function createApp(deps) {
2907
3704
  // Pre-R6 answer shape ({baseBranch, defaultRunner}) + additive R6 fields.
2908
3705
  return c.json(configAnswer(await loadConfig(repoRoot)));
2909
3706
  });
3707
+ // Set/clear the agents' config knobs (Settings → Agents; the Repo tab's
3708
+ // base-branch picker). Merges into the RAW config.json so user keys
3709
+ // (skillsRepos…) survive and schema defaults are never materialized into
3710
+ // the file. All fields optional + additive: `null` (and `''` for the
3711
+ // R6 keys) clears a knob back to its default.
3712
+ const modelPresetSchema = z.string().trim().max(200).nullable().optional();
3713
+ const setConfigSchema = z.object({
3714
+ baseBranch: z.string().trim().min(1).max(200).nullable().optional(),
3715
+ defaultRunner: z.enum(['claude', 'codex', 'opencode']).optional(),
3716
+ systemPrompt: z.string().trim().max(20_000, 'must be at most 20000 characters').nullable().optional(),
3717
+ defaultModels: z
3718
+ .object({
3719
+ claude: modelPresetSchema,
3720
+ codex: modelPresetSchema,
3721
+ opencode: modelPresetSchema,
3722
+ })
3723
+ .optional(),
3724
+ // Concurrency + memory guard (Settings → Resources). maxParallel clamps to
3725
+ // the schema's 1–16; memoryLimitMb null/0 clears the ceiling.
3726
+ maxParallel: z.number().int().min(1).max(16).optional(),
3727
+ memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
3728
+ // Worktree retention count (Settings → Resources, #483). 0 = unlimited;
3729
+ // null clears the key back to the schema default (10). Unlike memoryLimitMb,
3730
+ // 0 is a meaningful value (unlimited), so it is stored, not treated as clear.
3731
+ worktreeRetention: z.number().int().min(0).max(1000).nullable().optional(),
3732
+ // Live title updates toggle (Settings → Agents): null clears the key back
3733
+ // to the env-default behavior.
3734
+ liveTitleUpdates: z.boolean().nullable().optional(),
3735
+ // Optional review gate toggle (Settings → Agents, #489): null clears the key
3736
+ // back to the env-default behavior (OFF).
3737
+ reviewGate: z.boolean().nullable().optional(),
3738
+ });
3739
+ const setAgentConfigSchema = z.object({
3740
+ content: z.string().max(2_000_000),
3741
+ version: z.string().nullable(),
3742
+ });
3743
+ // ---- chained family: agent-config (project-scoped) -----------------------
2910
3744
  // Agent config is project-scoped (spec #404, adapted to the multi-project
2911
3745
  // 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) => {
3746
+ // same ProjectContext as every other mirrored route. Chained (not statements)
3747
+ // so `AppType` carries it — see `healthRoutes`.
3748
+ const agentConfigRoutes = new Hono()
3749
+ .get('/agent-config', async (c) => {
2914
3750
  const editable = capabilities().localHandoff;
2915
3751
  return c.json(await listAgentConfig(c.get('project').root, process.env, editable));
2916
- });
2917
- api.get('/agent-config/:id', async (c) => {
3752
+ })
3753
+ .get('/agent-config/:id', async (c) => {
2918
3754
  const id = c.req.param('id');
2919
3755
  const def = findConfigFile(id);
2920
3756
  if (!def)
@@ -2930,12 +3766,8 @@ export function createApp(deps) {
2930
3766
  if ('error' in read)
2931
3767
  return c.json({ error: read.error }, 500);
2932
3768
  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) => {
3769
+ })
3770
+ .put('/agent-config/:id', jsonZodValidator(setAgentConfigSchema), async (c) => {
2939
3771
  // Config files may define hooks and MCP commands, so writes remain a
2940
3772
  // local-machine capability and are re-gated on every request.
2941
3773
  if (!capabilities().localHandoff) {
@@ -2943,10 +3775,7 @@ export function createApp(deps) {
2943
3775
  error: 'editing agent config is disabled in hosted mode (CEZ_REMOTE) — edit it from the machine that owns the checkout',
2944
3776
  }, 409);
2945
3777
  }
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
- }
3778
+ const parsed = { data: c.req.valid('json') };
2950
3779
  const out = await writeConfigFile(c.req.param('id'), parsed.data.content, parsed.data.version, c.get('project').root);
2951
3780
  if (out === null)
2952
3781
  return c.json({ error: 'unknown config file' }, 404);
@@ -2954,55 +3783,6 @@ export function createApp(deps) {
2954
3783
  return c.json({ error: out.error }, out.status);
2955
3784
  return c.json(out.read);
2956
3785
  });
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
3786
  // Repo view branch actions: switch to an existing branch, or create one
3007
3787
  // (from `from` or HEAD) and switch. Predictable git failures — invalid
3008
3788
  // name, unknown `from`, dirty-tree checkout conflict — are 409 + reason.
@@ -3010,28 +3790,61 @@ export function createApp(deps) {
3010
3790
  name: z.string().trim().min(1).max(200),
3011
3791
  from: z.string().trim().min(1).max(200).optional(),
3012
3792
  });
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);
3021
- }
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 });
3026
- });
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);
3793
+ // ---- assemble the chained families --------------------------------------
3794
+ // Every chained family is registered ONCE and mounted into the versioned table. There is no
3795
+ // second, unversioned spelling: `/api/*` was removed once the whole API was reachable under
3796
+ // `/api/v1` (BACKWARD_COMPATIBILITY.md §2). One surface means one thing to keep working, and
3797
+ // it is the one the typed client describes.
3798
+ //
3799
+ // MOUNT ORDER IS REGISTRATION ORDER. Hono matches in the order routes were added, so each
3800
+ // family keeps its internal order and the families keep theirs relative to each other.
3801
+ //
3802
+ // Written as ONE chained expression because that is the only shape Hono can infer route types
3803
+ // from — it is what puts these routes in `AppType`, and so in the typed client.
3804
+ const v1 = new Hono()
3805
+ .use('*', resolveProjectScope)
3806
+ .route('/', launchKeyRoutes)
3807
+ .route('/', skillsRoutes)
3808
+ .route('/', uiStateRoutes)
3809
+ .route('/', workflowsRoutes)
3810
+ .route('/', planRoutes)
3811
+ .route('/', automationsRoutes)
3812
+ .route('/', runsRoutes)
3813
+ .route('/', groupsRoutes)
3814
+ .route('/', openTargetsRoutes)
3815
+ .route('/', worktreesRoutes)
3816
+ .route('/', todosRoutes)
3817
+ .route('/', sseRoutes)
3818
+ .route('/', githubRoutes)
3819
+ .route('/', repoRoutes)
3820
+ .route('/', configRoutes)
3821
+ .route('/', agentConfigRoutes);
3822
+ // Workspace-level families answer for the whole workspace, so they are single-mount: never a
3823
+ // project-scoped spelling, which would be a second surface to protect with no consumer.
3824
+ const workspaceV1 = new Hono()
3825
+ .route('/', healthRoutes)
3826
+ .route('/', modelsRoutes)
3827
+ .route('/', providersRoutes)
3828
+ .route('/', projectsRoutes)
3829
+ .route('/', skillsUpdateRoutes)
3830
+ .route('/', workspaceConfigRoutes)
3831
+ .route('/', fsBrowseRoutes)
3832
+ .route('/', automationChecksRoutes)
3833
+ .route('/', workspaceEventsRoutes);
3834
+ // ---- mount ---------------------------------------------------------------
3835
+ // Scoped first, then the unscoped alias bound to the boot project. The paths are disjoint (no
3836
+ // route starts with `/p/`), so order between the two never decides a match — but the SPA
3837
+ // catch-all below must still come last. `route()` re-registers the sub-app's routes under
3838
+ // each prefix, handlers shared, internal order preserved.
3839
+ //
3840
+ // Workspace families mount LAST and that is load-bearing: mounting the project table also
3841
+ // mounts its `use('*')` scope resolver over the whole prefix, and Hono runs matched middleware
3842
+ // in registration order. `/health` in particular answers for the workspace, has no project to
3843
+ // resolve, and is the CORS-open discovery route — it must not sit behind the resolver.
3844
+ const routed = app
3845
+ .route(V1_SCOPED_PREFIX, v1)
3846
+ .route(V1_PREFIX, v1)
3847
+ .route(V1_PREFIX, workspaceV1);
3035
3848
  // ---- SPA catch-all -------------------------------------------------------
3036
3849
  // Last, so every route above still wins. Any other GET gets the cockpit shell:
3037
3850
  // react-router owns the route map, including the 404, so `/tasks/:id/changes`
@@ -3040,14 +3853,33 @@ export function createApp(deps) {
3040
3853
  // an unknown API path must never answer with HTML.
3041
3854
  // Without a web/dist build this serves the built-in build-hint page (dev-only
3042
3855
  // state — the published tarball ships web/dist), never a 404.
3043
- app.get('*', (c) => serveShell(c) ?? c.notFound());
3044
- return app;
3856
+ routed.get('*', (c) => serveShell(c) ?? c.notFound());
3857
+ return routed;
3045
3858
  }
3046
3859
  export function startServer(deps, port) {
3860
+ const workspaceEvents = deps.workspaceEvents ?? new WorkspaceEventBus();
3861
+ const skillsUpdate = deps.skillsUpdate ?? new SkillsUpdateService({ invalidateCatalog: refreshTeamSkills });
3047
3862
  // The subscription hub rides the same HTTP server (one port, zero config):
3048
3863
  // createApp registers the topics, the `upgrade` hook below owns the socket.
3049
3864
  const socketHub = deps.socketHub ?? createSocketHub();
3050
- const app = createApp({ ...deps, socketHub });
3865
+ const automationCoordinator = new AutomationCoordinator({ listProjects });
3866
+ const bootProjectId = deps.bootProjectId ?? 'default';
3867
+ const bootAutomationStore = automationCoordinator.store(bootProjectId, deps.repoRoot);
3868
+ const sharedContexts = deps.contexts ?? new ProjectContexts({
3869
+ listProjects,
3870
+ semaphore: deps.semaphore,
3871
+ automationStore: (projectId, root) => automationCoordinator.store(projectId, root),
3872
+ });
3873
+ let rescheduleAutomations = () => { };
3874
+ const app = createApp({
3875
+ ...deps,
3876
+ contexts: sharedContexts,
3877
+ automationStore: bootAutomationStore,
3878
+ workspaceEvents,
3879
+ skillsUpdate,
3880
+ socketHub,
3881
+ automationsChanged: () => rescheduleAutomations(),
3882
+ });
3051
3883
  // SECURITY: default to loopback. This server executes agents locally and its endpoints are
3052
3884
  // same-origin-trusted (only /api/health is CORS-open); binding to a non-loopback host would
3053
3885
  // expose an agent-executing box to the network. `bindHost` exists only for a deliberate
@@ -3058,13 +3890,89 @@ export function startServer(deps, port) {
3058
3890
  port,
3059
3891
  hostname: deps.bindHost ?? '127.0.0.1',
3060
3892
  });
3893
+ const coordinator = new SkillsUpdateCoordinator(skillsUpdate, async () => effectiveSkillsAutoUpdate(await loadWorkspaceConfig()));
3894
+ const automationProjects = new Map();
3895
+ const automationScheduler = new WorkspaceAutomationScheduler({
3896
+ coordinator: automationCoordinator,
3897
+ handle: (projectId, store) => {
3898
+ const project = automationProjects.get(projectId);
3899
+ if (!project)
3900
+ return undefined;
3901
+ return {
3902
+ projectId,
3903
+ owner: project.owner,
3904
+ repo: project.repo,
3905
+ store,
3906
+ poller: new GithubPoller(),
3907
+ onChange: (automationId, revision) => workspaceEvents.emit('automation-change', { project: projectId, automationId, revision }),
3908
+ launch: async (definition, candidate, receiptId) => {
3909
+ const bootId = deps.bootProjectId ?? 'default';
3910
+ const context = projectId === bootId
3911
+ ? { root: deps.repoRoot, manager: deps.manager, store: deps.store }
3912
+ : await sharedContexts.context(projectId);
3913
+ return launchAutomationRun({
3914
+ root: context.root,
3915
+ manager: context.manager,
3916
+ store: context.store,
3917
+ definition,
3918
+ candidate,
3919
+ receiptId,
3920
+ });
3921
+ },
3922
+ };
3923
+ },
3924
+ });
3925
+ rescheduleAutomations = () => { void automationScheduler.reschedule(); };
3926
+ const unsubscribe = workspaceEvents.on((event, data) => {
3927
+ if (event === 'project-added') {
3928
+ const project = data.project;
3929
+ if (project && typeof project.id === 'string' && typeof project.root === 'string' && project.status !== 'missing') {
3930
+ coordinator.add(project.id, project.root);
3931
+ void getRepoInfo(project.root).then((info) => {
3932
+ const parsed = parseRemote(info?.remote ?? '');
3933
+ if (parsed?.host === 'github.com')
3934
+ automationProjects.set(project.id, { root: project.root, owner: parsed.owner, repo: parsed.repo });
3935
+ return automationScheduler.reschedule();
3936
+ });
3937
+ }
3938
+ }
3939
+ else if (event === 'project-removed') {
3940
+ const id = data.id;
3941
+ if (typeof id === 'string')
3942
+ coordinator.remove(id);
3943
+ if (typeof id === 'string') {
3944
+ automationCoordinator.remove(id);
3945
+ automationProjects.delete(id);
3946
+ void automationScheduler.reschedule();
3947
+ }
3948
+ }
3949
+ });
3950
+ server.once('listening', () => {
3951
+ void listProjects().then((projects) => {
3952
+ const all = projects.some((project) => project.root === deps.repoRoot)
3953
+ ? projects : [{ id: deps.bootProjectId ?? 'default', root: deps.repoRoot, status: 'ok' }, ...projects];
3954
+ coordinator.start(all);
3955
+ void Promise.all(all.map(async (project) => {
3956
+ const parsed = parseRemote((await getRepoInfo(project.root))?.remote ?? '');
3957
+ if (parsed?.host === 'github.com')
3958
+ automationProjects.set(project.id, { root: project.root, owner: parsed.owner, repo: parsed.repo });
3959
+ const automationStore = automationCoordinator.store(project.id, project.root);
3960
+ const runStore = project.id === (deps.bootProjectId ?? 'default')
3961
+ ? deps.store
3962
+ : sharedContexts.peek(project.id)?.store;
3963
+ if (automationStore && runStore)
3964
+ reconcileAutomationReceipts(automationStore, runStore);
3965
+ })).then(() => automationScheduler.start()).catch(() => undefined);
3966
+ }).catch(() => undefined);
3967
+ });
3968
+ server.once('close', () => { unsubscribe(); coordinator.stop(); automationScheduler.stop(); });
3061
3969
  socketHub.attach(server, (req) => verifyWsUpgrade(req, deps.bindHost));
3062
3970
  return server;
3063
3971
  }
3064
3972
  /**
3065
3973
  * 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,
3974
+ * before the `/api/v1/ws` handshake. WebSocket is NOT subject to CORS — any web
3975
+ * page may open `ws://127.0.0.1:<port>/api/v1/ws` and, unlike a forced HTTP GET,
3068
3976
  * would get to READ what comes back — so this guard is load-bearing:
3069
3977
  *
3070
3978
  * 1. Host allowlist (local mode): a non-loopback Host is a DNS-rebound