@wjarka/cezarion 0.16.0 → 0.17.0

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 (907) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +594 -207
  3. package/dist/agent-config/account-identity.d.ts +3 -19
  4. package/dist/agent-config/account-identity.js +8 -5
  5. package/dist/agent-config/account-identity.js.map +1 -1
  6. package/dist/agent-config/catalog.d.ts +3 -14
  7. package/dist/agent-config/catalog.js.map +1 -1
  8. package/dist/agent-config/files.d.ts +2 -6
  9. package/dist/agent-config/files.js +2 -6
  10. package/dist/agent-config/files.js.map +1 -1
  11. package/dist/agent-config/model-settings/types.d.ts +1 -1
  12. package/dist/agent-config/seed.d.ts +11 -3
  13. package/dist/agent-config/seed.js +52 -48
  14. package/dist/agent-config/seed.js.map +1 -1
  15. package/dist/agent-config/service.js +3 -2
  16. package/dist/agent-config/service.js.map +1 -1
  17. package/dist/agent-config/validate.d.ts +1 -4
  18. package/dist/agent-config/validate.js.map +1 -1
  19. package/dist/application-update/helper.d.ts +2 -2
  20. package/dist/application-update/helper.js +7 -12
  21. package/dist/application-update/helper.js.map +1 -1
  22. package/dist/application-update/launcher.d.ts +4 -0
  23. package/dist/application-update/launcher.js +18 -12
  24. package/dist/application-update/launcher.js.map +1 -1
  25. package/dist/application-update/service.d.ts +3 -3
  26. package/dist/application-update/service.js +1 -1
  27. package/dist/application-update/service.js.map +1 -1
  28. package/dist/archive-on-merge-setting.d.ts +15 -0
  29. package/dist/archive-on-merge-setting.js +14 -0
  30. package/dist/archive-on-merge-setting.js.map +1 -0
  31. package/dist/artifacts/cli.d.ts +8 -0
  32. package/dist/artifacts/cli.js +21 -2
  33. package/dist/artifacts/cli.js.map +1 -1
  34. package/dist/artifacts/resolve.js +2 -1
  35. package/dist/artifacts/resolve.js.map +1 -1
  36. package/dist/artifacts/store.js.map +1 -1
  37. package/dist/automations/automation-cli.d.ts +59 -0
  38. package/dist/automations/automation-cli.js +509 -0
  39. package/dist/automations/automation-cli.js.map +1 -0
  40. package/dist/automations/builtin-skill.d.ts +7 -0
  41. package/dist/automations/builtin-skill.js +14 -0
  42. package/dist/automations/builtin-skill.js.map +1 -0
  43. package/dist/automations/github-baseline.d.ts +3 -0
  44. package/dist/automations/github-baseline.js +17 -0
  45. package/dist/automations/github-baseline.js.map +1 -0
  46. package/dist/automations/github-poller.d.ts +23 -4
  47. package/dist/automations/github-poller.js +157 -88
  48. package/dist/automations/github-poller.js.map +1 -1
  49. package/dist/automations/prompts.d.ts +12 -0
  50. package/dist/automations/prompts.js +160 -0
  51. package/dist/automations/prompts.js.map +1 -0
  52. package/dist/automations/schedule-runner.d.ts +43 -24
  53. package/dist/automations/schedule-runner.js +207 -20
  54. package/dist/automations/schedule-runner.js.map +1 -1
  55. package/dist/automations/scheduler.d.ts +10 -2
  56. package/dist/automations/scheduler.js +200 -54
  57. package/dist/automations/scheduler.js.map +1 -1
  58. package/dist/automations/service.d.ts +777 -0
  59. package/dist/automations/service.js +444 -0
  60. package/dist/automations/service.js.map +1 -0
  61. package/dist/automations/store.d.ts +17 -7
  62. package/dist/automations/store.js +88 -17
  63. package/dist/automations/store.js.map +1 -1
  64. package/dist/automations/task-template.d.ts +7 -2
  65. package/dist/automations/task-template.js +93 -44
  66. package/dist/automations/task-template.js.map +1 -1
  67. package/dist/automations/tool-adapter.d.ts +6 -0
  68. package/dist/automations/tool-adapter.js +108 -0
  69. package/dist/automations/tool-adapter.js.map +1 -0
  70. package/dist/automations/types.d.ts +1 -405
  71. package/dist/automations/types.js +1 -218
  72. package/dist/automations/types.js.map +1 -1
  73. package/dist/autosave-cleanup.d.ts +26 -0
  74. package/dist/autosave-cleanup.js +87 -0
  75. package/dist/autosave-cleanup.js.map +1 -0
  76. package/dist/autosave-git.d.ts +3 -0
  77. package/dist/autosave-git.js +67 -35
  78. package/dist/autosave-git.js.map +1 -1
  79. package/dist/autosave-group.d.ts +8 -0
  80. package/dist/autosave-group.js +61 -0
  81. package/dist/autosave-group.js.map +1 -0
  82. package/dist/autosave-holders.d.ts +31 -4
  83. package/dist/autosave-holders.js +92 -7
  84. package/dist/autosave-holders.js.map +1 -1
  85. package/dist/autosave-windows.d.ts +14 -0
  86. package/dist/autosave-windows.js +36 -2
  87. package/dist/autosave-windows.js.map +1 -1
  88. package/dist/ci-wait/client.d.ts +20 -3
  89. package/dist/ci-wait/client.js +40 -44
  90. package/dist/ci-wait/client.js.map +1 -1
  91. package/dist/ci-wait/controller.d.ts +622 -36
  92. package/dist/ci-wait/controller.js +152 -20
  93. package/dist/ci-wait/controller.js.map +1 -1
  94. package/dist/ci-wait/errors.d.ts +19 -0
  95. package/dist/ci-wait/errors.js +180 -2
  96. package/dist/ci-wait/errors.js.map +1 -1
  97. package/dist/ci-wait/github.d.ts +25 -1
  98. package/dist/ci-wait/github.js +76 -9
  99. package/dist/ci-wait/github.js.map +1 -1
  100. package/dist/ci-wait/mcp.d.ts +0 -1
  101. package/dist/ci-wait/mcp.js +0 -2
  102. package/dist/ci-wait/mcp.js.map +1 -1
  103. package/dist/ci-wait/supervisor.d.ts +7 -0
  104. package/dist/ci-wait/supervisor.js +113 -17
  105. package/dist/ci-wait/supervisor.js.map +1 -1
  106. package/dist/ci-wait/tools.d.ts +536 -16
  107. package/dist/ci-wait/tools.js +165 -20
  108. package/dist/ci-wait/tools.js.map +1 -1
  109. package/dist/config.d.ts +3 -17
  110. package/dist/config.js +4 -60
  111. package/dist/config.js.map +1 -1
  112. package/dist/contract/active-provider.d.ts +12 -0
  113. package/dist/contract/agent-profiles.d.ts +2 -10
  114. package/dist/contract/ask-schema.d.ts +22 -4
  115. package/dist/contract/ask.d.ts +6 -4
  116. package/dist/contract/attachments.d.ts +35 -0
  117. package/dist/contract/attention.d.ts +46 -37
  118. package/dist/contract/automation-requests.d.ts +308 -0
  119. package/dist/contract/automation-schedule.d.ts +40 -11
  120. package/dist/contract/automation-storage.d.ts +512 -0
  121. package/dist/contract/automations.d.ts +421 -40
  122. package/dist/contract/ci-wait.d.ts +41 -4
  123. package/dist/contract/config-requests.d.ts +36 -0
  124. package/dist/contract/conversations.d.ts +24 -10
  125. package/dist/contract/defined.d.ts +8 -0
  126. package/dist/contract/delegation.d.ts +312 -8
  127. package/dist/contract/events.d.ts +24 -3
  128. package/dist/contract/github-requests.d.ts +26 -0
  129. package/dist/contract/github.d.ts +60 -27
  130. package/dist/contract/handoff.d.ts +43 -0
  131. package/dist/contract/health.d.ts +2 -2
  132. package/dist/contract/image-path.d.ts +6 -0
  133. package/dist/contract/index.d.ts +31 -0
  134. package/dist/contract/index.js +3756 -1525
  135. package/dist/contract/live-coordination.d.ts +158 -0
  136. package/dist/contract/live-limits.d.ts +8 -0
  137. package/dist/contract/live.d.ts +123 -0
  138. package/dist/contract/pending-ask.d.ts +58 -0
  139. package/dist/contract/permissions.d.ts +54 -0
  140. package/dist/contract/preview.d.ts +33 -0
  141. package/dist/contract/project-limits.d.ts +33 -0
  142. package/dist/contract/projects.d.ts +486 -20
  143. package/dist/contract/provider-requests.d.ts +77 -0
  144. package/dist/contract/query-requests.d.ts +52 -0
  145. package/dist/contract/repo-requests.d.ts +12 -0
  146. package/dist/contract/run-labels.d.ts +8 -0
  147. package/dist/contract/run-limits.d.ts +8 -0
  148. package/dist/contract/run-predicates.d.ts +32 -0
  149. package/dist/contract/run-requests.d.ts +33 -0
  150. package/dist/contract/run-search.d.ts +193 -0
  151. package/dist/contract/runs.d.ts +2232 -1894
  152. package/dist/contract/scheduled-tasks.d.ts +187 -0
  153. package/dist/contract/skills.d.ts +22 -772
  154. package/dist/contract/team-skill-ref.d.ts +10 -0
  155. package/dist/{core → contract}/tool-display.d.ts +4 -10
  156. package/dist/contract/ui-state-requests.d.ts +60 -0
  157. package/dist/contract/version.d.ts +3 -0
  158. package/dist/contract/voice.d.ts +213 -0
  159. package/dist/contract/waits.d.ts +1204 -0
  160. package/dist/contract/workflow-file.d.ts +37 -0
  161. package/dist/contract/workflow-requests.d.ts +7 -0
  162. package/dist/contract/workflows.d.ts +1 -2
  163. package/dist/contract/workspace.d.ts +34 -82
  164. package/dist/contract/zoned-time.d.ts +2 -11
  165. package/dist/core/agent-env.d.ts +4 -16
  166. package/dist/core/agent-env.js +14 -16
  167. package/dist/core/agent-env.js.map +1 -1
  168. package/dist/core/agent-profiles.d.ts +28 -40
  169. package/dist/core/agent-profiles.js +56 -51
  170. package/dist/core/agent-profiles.js.map +1 -1
  171. package/dist/core/agent-runner.d.ts +88 -44
  172. package/dist/core/agent-runner.js +17 -18
  173. package/dist/core/agent-runner.js.map +1 -1
  174. package/dist/core/ask.d.ts +3 -0
  175. package/dist/core/ask.js +12 -0
  176. package/dist/core/ask.js.map +1 -1
  177. package/dist/core/backend-detect.js +16 -2
  178. package/dist/core/backend-detect.js.map +1 -1
  179. package/dist/core/claude-cli-runner.d.ts +23 -4
  180. package/dist/core/claude-cli-runner.js +280 -76
  181. package/dist/core/claude-cli-runner.js.map +1 -1
  182. package/dist/core/claude-model-catalog.d.ts +2 -10
  183. package/dist/core/claude-model-catalog.js +2 -10
  184. package/dist/core/claude-model-catalog.js.map +1 -1
  185. package/dist/core/claude-ui-mapper.d.ts +2 -2
  186. package/dist/core/claude-ui-mapper.js +94 -2
  187. package/dist/core/claude-ui-mapper.js.map +1 -1
  188. package/dist/core/codex-app-server-runner.d.ts +3 -4
  189. package/dist/core/codex-app-server-runner.js +146 -51
  190. package/dist/core/codex-app-server-runner.js.map +1 -1
  191. package/dist/core/codex-app-server-transport.d.ts +4 -3
  192. package/dist/core/codex-app-server-transport.js +11 -7
  193. package/dist/core/codex-app-server-transport.js.map +1 -1
  194. package/dist/core/codex-approvals.d.ts +12 -0
  195. package/dist/core/codex-approvals.js +161 -0
  196. package/dist/core/codex-approvals.js.map +1 -0
  197. package/dist/core/codex-permissions.d.ts +1 -1
  198. package/dist/core/codex-ui-mapper.js +45 -1
  199. package/dist/core/codex-ui-mapper.js.map +1 -1
  200. package/dist/core/cursor-acp-runner.d.ts +1 -1
  201. package/dist/core/cursor-acp-runner.js +39 -15
  202. package/dist/core/cursor-acp-runner.js.map +1 -1
  203. package/dist/core/cursor-cli-flags.d.ts +19 -0
  204. package/dist/core/cursor-cli-flags.js +47 -0
  205. package/dist/core/cursor-cli-flags.js.map +1 -0
  206. package/dist/core/cursor-model-catalog.d.ts +22 -0
  207. package/dist/core/cursor-model-catalog.js +53 -24
  208. package/dist/core/cursor-model-catalog.js.map +1 -1
  209. package/dist/core/cursor-print-capabilities.d.ts +16 -0
  210. package/dist/core/cursor-print-capabilities.js +141 -0
  211. package/dist/core/cursor-print-capabilities.js.map +1 -0
  212. package/dist/core/cursor-print-process.d.ts +31 -0
  213. package/dist/core/cursor-print-process.js +117 -0
  214. package/dist/core/cursor-print-process.js.map +1 -0
  215. package/dist/core/cursor-print-runner.d.ts +31 -0
  216. package/dist/core/cursor-print-runner.js +555 -0
  217. package/dist/core/cursor-print-runner.js.map +1 -0
  218. package/dist/core/cursor-print-tool-catalog.d.ts +10 -0
  219. package/dist/core/cursor-print-tool-catalog.js +91 -0
  220. package/dist/core/cursor-print-tool-catalog.js.map +1 -0
  221. package/dist/core/cursor-print-ui-mapper.d.ts +47 -0
  222. package/dist/core/cursor-print-ui-mapper.js +553 -0
  223. package/dist/core/cursor-print-ui-mapper.js.map +1 -0
  224. package/dist/core/cursor-runner.d.ts +24 -0
  225. package/dist/core/cursor-runner.js +149 -0
  226. package/dist/core/cursor-runner.js.map +1 -0
  227. package/dist/core/cursor-ui-mapper.js +5 -2
  228. package/dist/core/cursor-ui-mapper.js.map +1 -1
  229. package/dist/core/defined.d.ts +1 -0
  230. package/dist/core/defined.js +2 -0
  231. package/dist/core/defined.js.map +1 -0
  232. package/dist/core/hook-items.d.ts +14 -0
  233. package/dist/core/hook-items.js +13 -0
  234. package/dist/core/hook-items.js.map +1 -0
  235. package/dist/core/host-model-catalog.d.ts +2 -9
  236. package/dist/core/host-model-catalog.js +2 -9
  237. package/dist/core/host-model-catalog.js.map +1 -1
  238. package/dist/core/managed-session.js +15 -3
  239. package/dist/core/managed-session.js.map +1 -1
  240. package/dist/core/model-identity.d.ts +4 -21
  241. package/dist/core/model-identity.js.map +1 -1
  242. package/dist/core/model-presets.d.ts +2 -13
  243. package/dist/core/model-presets.js +2 -13
  244. package/dist/core/model-presets.js.map +1 -1
  245. package/dist/core/omp-model-catalog.js.map +1 -1
  246. package/dist/core/omp-runner.d.ts +6 -7
  247. package/dist/core/omp-runner.js +92 -33
  248. package/dist/core/omp-runner.js.map +1 -1
  249. package/dist/core/omp-ui-mapper.js +1 -1
  250. package/dist/core/omp-ui-mapper.js.map +1 -1
  251. package/dist/core/opencode-model-catalog.js.map +1 -1
  252. package/dist/core/opencode-permissions.d.ts +52 -0
  253. package/dist/core/opencode-permissions.js +101 -0
  254. package/dist/core/opencode-permissions.js.map +1 -0
  255. package/dist/core/opencode-server-runner.d.ts +10 -9
  256. package/dist/core/opencode-server-runner.js +286 -28
  257. package/dist/core/opencode-server-runner.js.map +1 -1
  258. package/dist/core/opencode-session-error.d.ts +15 -7
  259. package/dist/core/opencode-session-error.js +25 -9
  260. package/dist/core/opencode-session-error.js.map +1 -1
  261. package/dist/core/opencode-ui-mapper.js +2 -2
  262. package/dist/core/opencode-ui-mapper.js.map +1 -1
  263. package/dist/core/pi-model-catalog.js.map +1 -1
  264. package/dist/core/pi-runner.d.ts +12 -4
  265. package/dist/core/pi-runner.js +171 -63
  266. package/dist/core/pi-runner.js.map +1 -1
  267. package/dist/core/pi-ui-mapper.js +1 -1
  268. package/dist/core/pi-ui-mapper.js.map +1 -1
  269. package/dist/core/process-usage.d.ts +5 -0
  270. package/dist/core/process-usage.js +24 -4
  271. package/dist/core/process-usage.js.map +1 -1
  272. package/dist/core/provider-auth.d.ts +16 -107
  273. package/dist/core/provider-auth.js +16 -132
  274. package/dist/core/provider-auth.js.map +1 -1
  275. package/dist/core/runner-factory.d.ts +4 -1
  276. package/dist/core/runner-factory.js +3 -3
  277. package/dist/core/runner-factory.js.map +1 -1
  278. package/dist/core/runner-runtime.d.ts +22 -0
  279. package/dist/core/runner-runtime.js +66 -5
  280. package/dist/core/runner-runtime.js.map +1 -1
  281. package/dist/core/runner-tool-grants.d.ts +21 -0
  282. package/dist/core/runner-tool-grants.js +64 -0
  283. package/dist/core/runner-tool-grants.js.map +1 -0
  284. package/dist/core/secret-redaction.d.ts +3 -20
  285. package/dist/core/secret-redaction.js +3 -20
  286. package/dist/core/secret-redaction.js.map +1 -1
  287. package/dist/core/session-process.d.ts +22 -2
  288. package/dist/core/session-process.js +170 -12
  289. package/dist/core/session-process.js.map +1 -1
  290. package/dist/core/shell-env.d.ts +2 -15
  291. package/dist/core/shell-env.js +2 -15
  292. package/dist/core/shell-env.js.map +1 -1
  293. package/dist/core/ui-events.d.ts +60 -47
  294. package/dist/core/ui-events.js +0 -23
  295. package/dist/core/ui-events.js.map +1 -1
  296. package/dist/core/usage-limit.js.map +1 -1
  297. package/dist/data-gitignore.js +3 -1
  298. package/dist/data-gitignore.js.map +1 -1
  299. package/dist/delegation/capacity.d.ts +4 -8
  300. package/dist/delegation/capacity.js +4 -8
  301. package/dist/delegation/capacity.js.map +1 -1
  302. package/dist/delegation/cli.js +123 -21
  303. package/dist/delegation/cli.js.map +1 -1
  304. package/dist/delegation/context.d.ts +19 -3
  305. package/dist/delegation/context.js +217 -22
  306. package/dist/delegation/context.js.map +1 -1
  307. package/dist/delegation/conversations.d.ts +4 -3
  308. package/dist/delegation/conversations.js +7 -5
  309. package/dist/delegation/conversations.js.map +1 -1
  310. package/dist/delegation/destroy-observation.d.ts +1 -1
  311. package/dist/delegation/destroy-observation.js.map +1 -1
  312. package/dist/delegation/effort.js.map +1 -1
  313. package/dist/delegation/execution-identity.d.ts +1 -1
  314. package/dist/delegation/execution-identity.js +41 -7
  315. package/dist/delegation/execution-identity.js.map +1 -1
  316. package/dist/delegation/idle-test-app.d.ts +4 -0
  317. package/dist/delegation/idle-test-app.js +174 -0
  318. package/dist/delegation/idle-test-app.js.map +1 -0
  319. package/dist/delegation/input.js.map +1 -1
  320. package/dist/delegation/park-notice.d.ts +42 -0
  321. package/dist/delegation/park-notice.js +71 -0
  322. package/dist/delegation/park-notice.js.map +1 -0
  323. package/dist/delegation/policy.d.ts +2 -0
  324. package/dist/delegation/policy.js +9 -1
  325. package/dist/delegation/policy.js.map +1 -1
  326. package/dist/delegation/process-liveness.d.ts +10 -15
  327. package/dist/delegation/process-liveness.js +11 -35
  328. package/dist/delegation/process-liveness.js.map +1 -1
  329. package/dist/delegation/provision.js +21 -19
  330. package/dist/delegation/provision.js.map +1 -1
  331. package/dist/delegation/questions.d.ts +3 -1
  332. package/dist/delegation/questions.js +10 -5
  333. package/dist/delegation/questions.js.map +1 -1
  334. package/dist/delegation/readiness.d.ts +2 -2
  335. package/dist/delegation/results.d.ts +4 -1
  336. package/dist/delegation/results.js +49 -18
  337. package/dist/delegation/results.js.map +1 -1
  338. package/dist/delegation/retry-backoff.d.ts +8 -0
  339. package/dist/delegation/retry-backoff.js +8 -0
  340. package/dist/delegation/retry-backoff.js.map +1 -1
  341. package/dist/delegation/routes.d.ts +114 -30
  342. package/dist/delegation/routes.js +4 -1
  343. package/dist/delegation/routes.js.map +1 -1
  344. package/dist/delegation/scratch-cleanup.d.ts +4 -5
  345. package/dist/delegation/scratch-cleanup.js +10 -8
  346. package/dist/delegation/scratch-cleanup.js.map +1 -1
  347. package/dist/delegation/service.d.ts +12 -4
  348. package/dist/delegation/service.js +76 -28
  349. package/dist/delegation/service.js.map +1 -1
  350. package/dist/delegation/transport.d.ts +154 -70
  351. package/dist/delegation/wait.d.ts +11 -0
  352. package/dist/delegation/wait.js +24 -0
  353. package/dist/delegation/wait.js.map +1 -1
  354. package/dist/delegation/windows-test-app.d.ts +5 -0
  355. package/dist/delegation/windows-test-app.js +63 -0
  356. package/dist/delegation/windows-test-app.js.map +1 -0
  357. package/dist/delegation/windows-workspace-identity.d.ts +3 -0
  358. package/dist/delegation/windows-workspace-identity.js +22 -0
  359. package/dist/delegation/windows-workspace-identity.js.map +1 -0
  360. package/dist/delegation/workspace.d.ts +5 -10
  361. package/dist/delegation/workspace.js +136 -75
  362. package/dist/delegation/workspace.js.map +1 -1
  363. package/dist/detached-process.d.ts +103 -0
  364. package/dist/detached-process.js +301 -0
  365. package/dist/detached-process.js.map +1 -0
  366. package/dist/discovery/catalog.d.ts +2 -1
  367. package/dist/discovery/catalog.js.map +1 -1
  368. package/dist/discovery/cli.js +3 -1
  369. package/dist/discovery/cli.js.map +1 -1
  370. package/dist/git-diff-base.d.ts +6 -61
  371. package/dist/git-diff-base.js +6 -70
  372. package/dist/git-diff-base.js.map +1 -1
  373. package/dist/git-index-copy.d.ts +4 -0
  374. package/dist/git-index-copy.js +10 -0
  375. package/dist/git-index-copy.js.map +1 -0
  376. package/dist/git-worktree-lock.d.ts +2 -4
  377. package/dist/git-worktree-lock.js +2 -4
  378. package/dist/git-worktree-lock.js.map +1 -1
  379. package/dist/git-worktree-release.d.ts +2 -2
  380. package/dist/git-worktree-release.js.map +1 -1
  381. package/dist/git-worktree.d.ts +47 -76
  382. package/dist/git-worktree.js +240 -94
  383. package/dist/git-worktree.js.map +1 -1
  384. package/dist/handoff.d.ts +51 -41
  385. package/dist/handoff.js +130 -78
  386. package/dist/handoff.js.map +1 -1
  387. package/dist/index.js +60 -39
  388. package/dist/index.js.map +1 -1
  389. package/dist/merge-observations.d.ts +13 -0
  390. package/dist/merge-observations.js +55 -0
  391. package/dist/merge-observations.js.map +1 -0
  392. package/dist/open-url.js +4 -14
  393. package/dist/open-url.js.map +1 -1
  394. package/dist/paths.d.ts +10 -13
  395. package/dist/paths.js +36 -17
  396. package/dist/paths.js.map +1 -1
  397. package/dist/planner.d.ts +2 -1
  398. package/dist/planner.js +9 -6
  399. package/dist/planner.js.map +1 -1
  400. package/dist/preview/agent-address.d.ts +34 -0
  401. package/dist/preview/agent-address.js +51 -0
  402. package/dist/preview/agent-address.js.map +1 -0
  403. package/dist/preview/agent-marker.d.ts +17 -0
  404. package/dist/preview/agent-marker.js +71 -0
  405. package/dist/preview/agent-marker.js.map +1 -0
  406. package/dist/preview/cdp-proxy.d.ts +54 -0
  407. package/dist/preview/cdp-proxy.js +151 -0
  408. package/dist/preview/cdp-proxy.js.map +1 -0
  409. package/dist/preview/cdp.d.ts +39 -3
  410. package/dist/preview/cdp.js +155 -57
  411. package/dist/preview/cdp.js.map +1 -1
  412. package/dist/preview/chromium.d.ts +18 -6
  413. package/dist/preview/chromium.js +112 -25
  414. package/dist/preview/chromium.js.map +1 -1
  415. package/dist/preview/dev-server.d.ts +1 -1
  416. package/dist/preview/dev-server.js +20 -44
  417. package/dist/preview/dev-server.js.map +1 -1
  418. package/dist/preview/host.d.ts +70 -3
  419. package/dist/preview/host.js +314 -28
  420. package/dist/preview/host.js.map +1 -1
  421. package/dist/preview/registration.d.ts +10 -5
  422. package/dist/preview/registration.js +2 -0
  423. package/dist/preview/registration.js.map +1 -1
  424. package/dist/preview/session.d.ts +2 -0
  425. package/dist/preview/session.js +10 -0
  426. package/dist/preview/session.js.map +1 -1
  427. package/dist/process-wait/exit-file.d.ts +29 -0
  428. package/dist/process-wait/exit-file.js +67 -0
  429. package/dist/process-wait/exit-file.js.map +1 -0
  430. package/dist/process-wait/supervisor.d.ts +30 -0
  431. package/dist/process-wait/supervisor.js +71 -0
  432. package/dist/process-wait/supervisor.js.map +1 -0
  433. package/dist/release/snapshot.d.ts +6 -6
  434. package/dist/runs/agent-tmpdir.d.ts +51 -36
  435. package/dist/runs/agent-tmpdir.js +389 -132
  436. package/dist/runs/agent-tmpdir.js.map +1 -1
  437. package/dist/runs/auto-name.d.ts +6 -6
  438. package/dist/runs/auto-name.js +1 -1
  439. package/dist/runs/auto-name.js.map +1 -1
  440. package/dist/runs/client-request.d.ts +3 -3
  441. package/dist/runs/client-request.js +3 -3
  442. package/dist/runs/client-request.js.map +1 -1
  443. package/dist/runs/delegation-state.d.ts +11 -2
  444. package/dist/runs/delegation-state.js.map +1 -1
  445. package/dist/runs/event-history-agent-context.d.ts +14 -0
  446. package/dist/runs/event-history-agent-context.js +105 -0
  447. package/dist/runs/event-history-agent-context.js.map +1 -0
  448. package/dist/runs/event-history-context.d.ts +15 -0
  449. package/dist/runs/event-history-context.js +109 -0
  450. package/dist/runs/event-history-context.js.map +1 -0
  451. package/dist/runs/event-history.d.ts +4 -1
  452. package/dist/runs/event-history.js +29 -130
  453. package/dist/runs/event-history.js.map +1 -1
  454. package/dist/runs/history-stream.d.ts +2 -0
  455. package/dist/runs/history-stream.js +62 -0
  456. package/dist/runs/history-stream.js.map +1 -0
  457. package/dist/runs/legacy-index.d.ts +6 -19
  458. package/dist/runs/legacy-index.js +6 -19
  459. package/dist/runs/legacy-index.js.map +1 -1
  460. package/dist/runs/live-item-snapshots.d.ts +13 -0
  461. package/dist/runs/live-item-snapshots.js +79 -0
  462. package/dist/runs/live-item-snapshots.js.map +1 -0
  463. package/dist/runs/loaded-run-healing.d.ts +3 -0
  464. package/dist/runs/loaded-run-healing.js +17 -0
  465. package/dist/runs/loaded-run-healing.js.map +1 -0
  466. package/dist/runs/outcome-commit.d.ts +98 -0
  467. package/dist/runs/outcome-commit.js +132 -0
  468. package/dist/runs/outcome-commit.js.map +1 -0
  469. package/dist/runs/pull-requests.d.ts +14 -0
  470. package/dist/runs/pull-requests.js +96 -0
  471. package/dist/runs/pull-requests.js.map +1 -0
  472. package/dist/runs/raw-record.d.ts +3 -15
  473. package/dist/runs/raw-record.js +3 -15
  474. package/dist/runs/raw-record.js.map +1 -1
  475. package/dist/runs/retention.d.ts +2 -2
  476. package/dist/runs/retention.js +7 -0
  477. package/dist/runs/retention.js.map +1 -1
  478. package/dist/runs/review-gate.d.ts +1 -1
  479. package/dist/runs/review-gate.js.map +1 -1
  480. package/dist/runs/run-claims.d.ts +2 -9
  481. package/dist/runs/run-claims.js.map +1 -1
  482. package/dist/runs/run-database.d.ts +29 -34
  483. package/dist/runs/run-database.js +124 -46
  484. package/dist/runs/run-database.js.map +1 -1
  485. package/dist/runs/run-index.d.ts +6 -25
  486. package/dist/runs/run-index.js +25 -38
  487. package/dist/runs/run-index.js.map +1 -1
  488. package/dist/runs/run-row.d.ts +5 -34
  489. package/dist/runs/run-row.js +13 -38
  490. package/dist/runs/run-row.js.map +1 -1
  491. package/dist/runs/store.d.ts +1597 -744
  492. package/dist/runs/store.js +1231 -739
  493. package/dist/runs/store.js.map +1 -1
  494. package/dist/runs/task-markers.d.ts +1 -0
  495. package/dist/runs/task-markers.js +5 -1
  496. package/dist/runs/task-markers.js.map +1 -1
  497. package/dist/runs/task-refs.d.ts +3 -3
  498. package/dist/runs/transcript-facts-fold.d.ts +64 -0
  499. package/dist/runs/transcript-facts-fold.js +109 -0
  500. package/dist/runs/transcript-facts-fold.js.map +1 -0
  501. package/dist/runs/transcript-facts-load.d.ts +11 -0
  502. package/dist/runs/transcript-facts-load.js +66 -0
  503. package/dist/runs/transcript-facts-load.js.map +1 -0
  504. package/dist/runs/transcript-facts-queue.d.ts +30 -0
  505. package/dist/runs/transcript-facts-queue.js +227 -0
  506. package/dist/runs/transcript-facts-queue.js.map +1 -0
  507. package/dist/runs/transcript-facts-worker.d.ts +15 -0
  508. package/dist/runs/transcript-facts-worker.js +26 -0
  509. package/dist/runs/transcript-facts-worker.js.map +1 -0
  510. package/dist/runs/transcript-facts.d.ts +13 -51
  511. package/dist/runs/transcript-facts.js +86 -202
  512. package/dist/runs/transcript-facts.js.map +1 -1
  513. package/dist/runs/ui-event-sink.d.ts +5 -30
  514. package/dist/runs/ui-event-sink.js +8 -30
  515. package/dist/runs/ui-event-sink.js.map +1 -1
  516. package/dist/runs/webhook.d.ts +104 -38
  517. package/dist/runs/webhook.js +180 -77
  518. package/dist/runs/webhook.js.map +1 -1
  519. package/dist/server/app-type.d.ts +2 -17
  520. package/dist/server/archive-on-merge.d.ts +35 -0
  521. package/dist/server/archive-on-merge.js +202 -0
  522. package/dist/server/archive-on-merge.js.map +1 -0
  523. package/dist/server/capabilities.d.ts +3 -15
  524. package/dist/server/capabilities.js +6 -19
  525. package/dist/server/capabilities.js.map +1 -1
  526. package/dist/server/checkout-progress.d.ts +10 -0
  527. package/dist/server/checkout-progress.js +27 -0
  528. package/dist/server/checkout-progress.js.map +1 -0
  529. package/dist/server/checkout.d.ts +10 -19
  530. package/dist/server/checkout.js +15 -45
  531. package/dist/server/checkout.js.map +1 -1
  532. package/dist/server/cockpit-ownership.d.ts +2 -0
  533. package/dist/server/cockpit-ownership.js +9 -0
  534. package/dist/server/cockpit-ownership.js.map +1 -1
  535. package/dist/server/cold-repo-handles.d.ts +2 -7
  536. package/dist/server/cold-repo-handles.js +2 -7
  537. package/dist/server/cold-repo-handles.js.map +1 -1
  538. package/dist/server/completion-actions.d.ts +30 -0
  539. package/dist/server/completion-actions.js +204 -0
  540. package/dist/server/completion-actions.js.map +1 -0
  541. package/dist/server/create-project.d.ts +71 -0
  542. package/dist/server/create-project.js +448 -0
  543. package/dist/server/create-project.js.map +1 -0
  544. package/dist/server/forge/github-filters.d.ts +7 -1
  545. package/dist/server/forge/github-filters.js +66 -51
  546. package/dist/server/forge/github-filters.js.map +1 -1
  547. package/dist/server/forge/github-merge-requirements.d.ts +18 -0
  548. package/dist/server/forge/github-merge-requirements.js +137 -0
  549. package/dist/server/forge/github-merge-requirements.js.map +1 -0
  550. package/dist/server/forge/github.d.ts +98 -111
  551. package/dist/server/forge/github.js +625 -306
  552. package/dist/server/forge/github.js.map +1 -1
  553. package/dist/server/forge/types.d.ts +25 -22
  554. package/dist/server/fs-browse.d.ts +3 -28
  555. package/dist/server/fs-browse.js.map +1 -1
  556. package/dist/server/git-changes.d.ts +5 -15
  557. package/dist/server/git-changes.js +3 -50
  558. package/dist/server/git-changes.js.map +1 -1
  559. package/dist/server/git.d.ts +3 -1
  560. package/dist/server/git.js +12 -0
  561. package/dist/server/git.js.map +1 -1
  562. package/dist/server/github.d.ts +1 -1
  563. package/dist/server/github.js +1 -1
  564. package/dist/server/github.js.map +1 -1
  565. package/dist/server/launch-key.d.ts +2 -4
  566. package/dist/server/launch-key.js +2 -4
  567. package/dist/server/launch-key.js.map +1 -1
  568. package/dist/server/live-routes.d.ts +68 -0
  569. package/dist/server/live-routes.js +147 -0
  570. package/dist/server/live-routes.js.map +1 -0
  571. package/dist/server/open-in-app.d.ts +4 -13
  572. package/dist/server/open-in-app.js +5 -41
  573. package/dist/server/open-in-app.js.map +1 -1
  574. package/dist/server/open-in-targets.d.ts +42 -0
  575. package/dist/server/open-in-targets.js +110 -0
  576. package/dist/server/open-in-targets.js.map +1 -0
  577. package/dist/server/open-in-terminal.d.ts +3 -0
  578. package/dist/server/open-in-terminal.js +6 -26
  579. package/dist/server/open-in-terminal.js.map +1 -1
  580. package/dist/server/preview-cdp-socket.d.ts +21 -0
  581. package/dist/server/preview-cdp-socket.js +112 -0
  582. package/dist/server/preview-cdp-socket.js.map +1 -0
  583. package/dist/server/preview-socket.d.ts +11 -11
  584. package/dist/server/preview-socket.js +22 -22
  585. package/dist/server/preview-socket.js.map +1 -1
  586. package/dist/server/preview-sockets.d.ts +14 -0
  587. package/dist/server/preview-sockets.js +18 -0
  588. package/dist/server/preview-sockets.js.map +1 -0
  589. package/dist/server/project-context.d.ts +54 -18
  590. package/dist/server/project-context.js +178 -5
  591. package/dist/server/project-context.js.map +1 -1
  592. package/dist/server/provider-action-gate.d.ts +0 -2
  593. package/dist/server/provider-action-gate.js +0 -21
  594. package/dist/server/provider-action-gate.js.map +1 -1
  595. package/dist/server/provider-auth-runtime.d.ts +3 -18
  596. package/dist/server/provider-auth-runtime.js +3 -18
  597. package/dist/server/provider-auth-runtime.js.map +1 -1
  598. package/dist/server/repo-branches.d.ts +3 -9
  599. package/dist/server/repo-branches.js +6 -22
  600. package/dist/server/repo-branches.js.map +1 -1
  601. package/dist/server/run-event-feed.d.ts +20 -0
  602. package/dist/server/run-event-feed.js +190 -0
  603. package/dist/server/run-event-feed.js.map +1 -0
  604. package/dist/server/server-deps.d.ts +37 -0
  605. package/dist/server/server-deps.js +114 -0
  606. package/dist/server/server-deps.js.map +1 -0
  607. package/dist/server/server.d.ts +14082 -11998
  608. package/dist/server/server.js +785 -1647
  609. package/dist/server/server.js.map +1 -1
  610. package/dist/server/start-run-options.d.ts +82 -0
  611. package/dist/server/start-run-options.js +111 -0
  612. package/dist/server/start-run-options.js.map +1 -0
  613. package/dist/server/validators.d.ts +3 -29
  614. package/dist/server/validators.js +1 -2
  615. package/dist/server/validators.js.map +1 -1
  616. package/dist/server/ws.d.ts +3 -23
  617. package/dist/server/ws.js +3 -23
  618. package/dist/server/ws.js.map +1 -1
  619. package/dist/server-install/engine.d.ts +6 -9
  620. package/dist/server-install/engine.js +2 -9
  621. package/dist/server-install/engine.js.map +1 -1
  622. package/dist/server-install/platforms/ubuntu-vps.d.ts +1 -1
  623. package/dist/server-install/state.d.ts +2 -4
  624. package/dist/server-install/state.js +2 -4
  625. package/dist/server-install/state.js.map +1 -1
  626. package/dist/server-install/steps.d.ts +2 -5
  627. package/dist/server-install/steps.js +3 -6
  628. package/dist/server-install/steps.js.map +1 -1
  629. package/dist/server-install/types.d.ts +3 -6
  630. package/dist/server-install/types.js +2 -5
  631. package/dist/server-install/types.js.map +1 -1
  632. package/dist/server-install/ui.d.ts +2 -8
  633. package/dist/server-install/ui.js +3 -3
  634. package/dist/server-install/ui.js.map +1 -1
  635. package/dist/session-context.d.ts +64 -0
  636. package/dist/session-context.js +264 -0
  637. package/dist/session-context.js.map +1 -0
  638. package/dist/skills-provision.d.ts +62 -0
  639. package/dist/skills-provision.js +325 -0
  640. package/dist/skills-provision.js.map +1 -0
  641. package/dist/skills-remote.d.ts +29 -27
  642. package/dist/skills-remote.js +236 -100
  643. package/dist/skills-remote.js.map +1 -1
  644. package/dist/skills.d.ts +25 -36
  645. package/dist/skills.js +43 -54
  646. package/dist/skills.js.map +1 -1
  647. package/dist/subcommands.d.ts +11 -0
  648. package/dist/subcommands.js +24 -0
  649. package/dist/subcommands.js.map +1 -0
  650. package/dist/task-cli/cli.js +78 -25
  651. package/dist/task-cli/cli.js.map +1 -1
  652. package/dist/task-cli/discovery.d.ts +4 -2
  653. package/dist/task-cli/discovery.js +14 -13
  654. package/dist/task-cli/discovery.js.map +1 -1
  655. package/dist/task-cli/hint.d.ts +20 -0
  656. package/dist/task-cli/hint.js +14 -0
  657. package/dist/task-cli/hint.js.map +1 -0
  658. package/dist/task-cli/http.d.ts +1 -0
  659. package/dist/task-cli/http.js +3 -1
  660. package/dist/task-cli/http.js.map +1 -1
  661. package/dist/task-cli/projections.d.ts +14 -11
  662. package/dist/task-cli/projections.js +15 -11
  663. package/dist/task-cli/projections.js.map +1 -1
  664. package/dist/task-cli/watch.d.ts +18 -10
  665. package/dist/task-cli/watch.js +128 -20
  666. package/dist/task-cli/watch.js.map +1 -1
  667. package/dist/ui-state.d.ts +17 -3
  668. package/dist/ui-state.js +56 -9
  669. package/dist/ui-state.js.map +1 -1
  670. package/dist/update-check.d.ts +0 -10
  671. package/dist/update-check.js +1 -16
  672. package/dist/update-check.js.map +1 -1
  673. package/dist/voice/decide.d.ts +32 -0
  674. package/dist/voice/decide.js +297 -0
  675. package/dist/voice/decide.js.map +1 -0
  676. package/dist/voice/decision-log.d.ts +52 -0
  677. package/dist/voice/decision-log.js +107 -0
  678. package/dist/voice/decision-log.js.map +1 -0
  679. package/dist/voice/engine.d.ts +119 -0
  680. package/dist/voice/engine.js +60 -0
  681. package/dist/voice/engine.js.map +1 -0
  682. package/dist/voice/fake-engine.d.ts +10 -0
  683. package/dist/voice/fake-engine.js +192 -0
  684. package/dist/voice/fake-engine.js.map +1 -0
  685. package/dist/voice/jev-engine.d.ts +17 -0
  686. package/dist/voice/jev-engine.js +110 -0
  687. package/dist/voice/jev-engine.js.map +1 -0
  688. package/dist/voice/pending-ask.d.ts +17 -0
  689. package/dist/voice/pending-ask.js +50 -0
  690. package/dist/voice/pending-ask.js.map +1 -0
  691. package/dist/voice/routes.d.ts +115 -0
  692. package/dist/voice/routes.js +91 -0
  693. package/dist/voice/routes.js.map +1 -0
  694. package/dist/voice/sentence.d.ts +21 -0
  695. package/dist/voice/sentence.js +61 -0
  696. package/dist/voice/sentence.js.map +1 -0
  697. package/dist/voice/thresholds.d.ts +28 -0
  698. package/dist/voice/thresholds.js +29 -0
  699. package/dist/voice/thresholds.js.map +1 -0
  700. package/dist/webhooks/cli.d.ts +18 -0
  701. package/dist/webhooks/cli.js +145 -0
  702. package/dist/webhooks/cli.js.map +1 -0
  703. package/dist/webhooks/prompt.d.ts +5 -0
  704. package/dist/webhooks/prompt.js +26 -0
  705. package/dist/webhooks/prompt.js.map +1 -0
  706. package/dist/workflows/active-run.d.ts +167 -0
  707. package/dist/workflows/active-run.js +21 -0
  708. package/dist/workflows/active-run.js.map +1 -0
  709. package/dist/workflows/continuation-admission.d.ts +913 -0
  710. package/dist/workflows/continuation-admission.js +265 -0
  711. package/dist/workflows/continuation-admission.js.map +1 -0
  712. package/dist/workflows/execution-account.d.ts +13 -0
  713. package/dist/workflows/execution-account.js +18 -0
  714. package/dist/workflows/execution-account.js.map +1 -0
  715. package/dist/workflows/input-acknowledgement.d.ts +152 -0
  716. package/dist/workflows/input-acknowledgement.js +38 -0
  717. package/dist/workflows/input-acknowledgement.js.map +1 -0
  718. package/dist/workflows/line-order.d.ts +5 -0
  719. package/dist/workflows/line-order.js +25 -0
  720. package/dist/workflows/line-order.js.map +1 -0
  721. package/dist/workflows/outcome-batches.d.ts +20 -0
  722. package/dist/workflows/outcome-batches.js +25 -0
  723. package/dist/workflows/outcome-batches.js.map +1 -0
  724. package/dist/workflows/question-routing.d.ts +8 -0
  725. package/dist/workflows/question-routing.js +18 -0
  726. package/dist/workflows/question-routing.js.map +1 -0
  727. package/dist/workflows/run-scheduling.d.ts +35 -0
  728. package/dist/workflows/run-scheduling.js +61 -0
  729. package/dist/workflows/run-scheduling.js.map +1 -0
  730. package/dist/workflows/run-states.d.ts +190 -0
  731. package/dist/workflows/run-states.js +295 -0
  732. package/dist/workflows/run-states.js.map +1 -0
  733. package/dist/workflows/run.d.ts +1296 -278
  734. package/dist/workflows/run.js +5730 -3265
  735. package/dist/workflows/run.js.map +1 -1
  736. package/dist/workflows/turn-end.d.ts +116 -0
  737. package/dist/workflows/turn-end.js +68 -0
  738. package/dist/workflows/turn-end.js.map +1 -0
  739. package/dist/workflows/types.d.ts +35 -130
  740. package/dist/workflows/types.js +47 -100
  741. package/dist/workflows/types.js.map +1 -1
  742. package/dist/workflows/waits.d.ts +74 -0
  743. package/dist/workflows/waits.js +197 -0
  744. package/dist/workflows/waits.js.map +1 -0
  745. package/dist/workspace/agent-accounts.d.ts +5 -38
  746. package/dist/workspace/agent-accounts.js +14 -40
  747. package/dist/workspace/agent-accounts.js.map +1 -1
  748. package/dist/workspace/agent-profiles.d.ts +40 -16
  749. package/dist/workspace/agent-profiles.js +77 -17
  750. package/dist/workspace/agent-profiles.js.map +1 -1
  751. package/dist/workspace/config.d.ts +48 -49
  752. package/dist/workspace/config.js +68 -72
  753. package/dist/workspace/config.js.map +1 -1
  754. package/dist/workspace/migrations.d.ts +3 -13
  755. package/dist/workspace/migrations.js.map +1 -1
  756. package/dist/workspace/projects-cli.d.ts +1 -1
  757. package/dist/workspace/projects-cli.js.map +1 -1
  758. package/dist/workspace/projects.d.ts +15 -18
  759. package/dist/workspace/projects.js +20 -16
  760. package/dist/workspace/projects.js.map +1 -1
  761. package/dist/workspace/semaphore.d.ts +11 -45
  762. package/dist/workspace/semaphore.js +6 -18
  763. package/dist/workspace/semaphore.js.map +1 -1
  764. package/dist/workspace/ui-state.d.ts +6 -4
  765. package/dist/workspace/ui-state.js +6 -4
  766. package/dist/workspace/ui-state.js.map +1 -1
  767. package/dist/workspace/webhooks.d.ts +40 -0
  768. package/dist/workspace/webhooks.js +146 -0
  769. package/dist/workspace/webhooks.js.map +1 -0
  770. package/dist/worktree-manifest.d.ts +165 -0
  771. package/dist/worktree-manifest.js +530 -0
  772. package/dist/worktree-manifest.js.map +1 -0
  773. package/dist/worktree-setup.js.map +1 -1
  774. package/package.json +6 -5
  775. package/scripts/benchmark-run-store-fixture.ts +7 -3
  776. package/scripts/check-pack.mjs +3 -1
  777. package/scripts/inline-contract.mjs +80 -27
  778. package/scripts/install-as-command.mjs +3 -1
  779. package/scripts/measure-transcript-facts-health.mjs +87 -0
  780. package/scripts/measure-transcript-facts-lag.mjs +68 -0
  781. package/scripts/mock-ci-tool.mjs +49 -6
  782. package/scripts/mock-claude-model-refusal.mjs +42 -0
  783. package/scripts/mock-claude.mjs +833 -591
  784. package/scripts/mock-codex-app-server.mjs +785 -418
  785. package/scripts/mock-cursor-acp.mjs +296 -125
  786. package/scripts/mock-cursor-print.mjs +256 -0
  787. package/scripts/mock-deferred-turn-start.mjs +16 -0
  788. package/scripts/mock-delivery-control.d.mts +23 -0
  789. package/scripts/mock-delivery-control.mjs +80 -0
  790. package/scripts/mock-env-record.mjs +11 -0
  791. package/scripts/mock-failure-control.d.mts +13 -0
  792. package/scripts/mock-failure-control.mjs +68 -0
  793. package/scripts/mock-multi-pr.mjs +7 -0
  794. package/scripts/mock-nudge.mjs +45 -0
  795. package/scripts/mock-omp-rpc.mjs +429 -255
  796. package/scripts/mock-opencode-serve.mjs +972 -557
  797. package/scripts/mock-pi-rpc.mjs +558 -331
  798. package/scripts/mock-runner-crash.mjs +18 -2
  799. package/scripts/mock-session-context.d.mts +5 -0
  800. package/scripts/mock-session-context.mjs +56 -0
  801. package/scripts/mock-silent-tail.mjs +10 -3
  802. package/scripts/pi-default-tools.mjs +22 -0
  803. package/scripts/pi-project-skills.mjs +59 -0
  804. package/scripts/probe-cursor-print.mjs +627 -0
  805. package/scripts/transcript-facts-fixture.mjs +53 -0
  806. package/scripts/voice-replay.ts +50 -0
  807. package/scripts/windows-workspace-identity.cs +208 -0
  808. package/scripts/windows-workspace-identity.ps1 +10 -0
  809. package/web/dist/assets/alert-dialog-DfctKUMw.js +1 -0
  810. package/web/dist/assets/button-CeupvoKG.js +41 -0
  811. package/web/dist/assets/centered-state-hDsyXrnH.js +1 -0
  812. package/web/dist/assets/cez-idle-D3aj_9lp.js +1 -0
  813. package/web/dist/assets/diff-BnIXZK3i2.js +3 -0
  814. package/web/dist/assets/diff-controls-GewpW9aF.js +1 -0
  815. package/web/dist/assets/diff-view-Byo7gpvW.js +11 -0
  816. package/web/dist/assets/dropdown-menu-COj_J5wd.js +6 -0
  817. package/web/dist/assets/eye-B8okNWXU.js +1 -0
  818. package/web/dist/assets/git-toolbar-DY0z0n4S.js +1 -0
  819. package/web/dist/assets/github-DwU6ALRK.js +1 -0
  820. package/web/dist/assets/{image-preview-FOEfqpZP.js → image-preview-C_t9q45f.js} +1 -1
  821. package/web/dist/assets/index-BOfE8pyD.js +34 -0
  822. package/web/dist/assets/index-D9HpsmdW.css +1 -0
  823. package/web/dist/assets/live-protocol-Uce2NwEX.js +64 -0
  824. package/web/dist/assets/live-worker-BizfZSoh.ts +33 -0
  825. package/web/dist/assets/markdown-BBDDg-4t.js +1 -0
  826. package/web/dist/assets/nav-row-styles-AoIhhIwf.js +1 -0
  827. package/web/dist/assets/popover-D56fhiXj.js +1 -0
  828. package/web/dist/assets/preview-pane-connected-BmVqfSU5.js +3 -0
  829. package/web/dist/assets/reference-chip-gQNk-4jc.js +1 -0
  830. package/web/dist/assets/repo-git-B5ECDUuv.js +1 -0
  831. package/web/dist/assets/{repo-git-CoKp-g9K.css → repo-git-DVXNOJDT.css} +1 -1
  832. package/web/dist/assets/run-header-C0Y1NvEM.js +2 -0
  833. package/web/dist/assets/run-header-CC7VUTg9.css +1 -0
  834. package/web/dist/assets/skills-DUeTkZ9z.js +1 -0
  835. package/web/dist/assets/status-dot-1PR0i69P.js +1 -0
  836. package/web/dist/assets/tab-link-DmWk4l5O.js +1 -0
  837. package/web/dist/assets/task-changes-DKZXuW89.js +1 -0
  838. package/web/dist/assets/task-commits-BZ6KxWdF.js +1 -0
  839. package/web/dist/assets/task-files-DlF7Apcn.js +2 -0
  840. package/web/dist/assets/task-github-item-CVPo3geZ.js +1 -0
  841. package/web/dist/assets/task-thread-DtQ6uGfQ.css +1 -0
  842. package/web/dist/assets/task-thread-mm4-ECyo.js +9 -0
  843. package/web/dist/assets/thread-state-CIw2BlhT.js +4 -0
  844. package/web/dist/assets/use-hand-to-agent-state-BCDjzHnG.js +1 -0
  845. package/web/dist/assets/use-hand-to-agent-state-C7esPJ-N.css +1 -0
  846. package/web/dist/assets/workflows-D8vYD-ql.js +7 -0
  847. package/web/dist/assets/zoomable-image-CxQSsfcn.js +1 -0
  848. package/web/dist/index.html +13 -17
  849. package/web/dist/live-worker.js +4 -0
  850. package/dist/core/tool-display.js +0 -179
  851. package/dist/core/tool-display.js.map +0 -1
  852. package/dist/runs/human-ask-summary.d.ts +0 -9
  853. package/dist/runs/human-ask-summary.js +0 -39
  854. package/dist/runs/human-ask-summary.js.map +0 -1
  855. package/dist/runs/run-search.d.ts +0 -29
  856. package/dist/runs/run-search.js +0 -63
  857. package/dist/runs/run-search.js.map +0 -1
  858. package/dist/skills-banner.d.ts +0 -22
  859. package/dist/skills-banner.js +0 -41
  860. package/dist/skills-banner.js.map +0 -1
  861. package/dist/skills-update.d.ts +0 -88
  862. package/dist/skills-update.js +0 -392
  863. package/dist/skills-update.js.map +0 -1
  864. package/dist/todos.d.ts +0 -48
  865. package/dist/todos.js +0 -201
  866. package/dist/todos.js.map +0 -1
  867. package/web/dist/assets/agent-accounts-CFOBMVHc.js +0 -1
  868. package/web/dist/assets/alert-dialog-BrpNUwMt.js +0 -1
  869. package/web/dist/assets/button-C_rd7CPp.js +0 -43
  870. package/web/dist/assets/compare-variants-D6WXhvXA.js +0 -1
  871. package/web/dist/assets/diff-DrEARXTV.js +0 -3
  872. package/web/dist/assets/diff-controls-B1-j7nFg.js +0 -1
  873. package/web/dist/assets/diff-view-DvGLjdTk.js +0 -6
  874. package/web/dist/assets/dropdown-menu-BegOZFrj.js +0 -1
  875. package/web/dist/assets/format-DpNe3S0A.js +0 -1
  876. package/web/dist/assets/git-toolbar-Cw4ljmhB.js +0 -1
  877. package/web/dist/assets/github-CfV5sqL0.js +0 -1
  878. package/web/dist/assets/index-DgWWlOLy.js +0 -13
  879. package/web/dist/assets/index-fBFnuvxu.css +0 -1
  880. package/web/dist/assets/nav-row-styles-vp2JbhuJ.js +0 -1
  881. package/web/dist/assets/popover-BohP6WTx.js +0 -1
  882. package/web/dist/assets/preview-pane-connected-qTA5KNoJ.js +0 -3
  883. package/web/dist/assets/preview-state-vuMviq6q.js +0 -1
  884. package/web/dist/assets/prompt-templates-Cubdut_C.js +0 -15
  885. package/web/dist/assets/repo-git-Cf4t5PD0.js +0 -1
  886. package/web/dist/assets/run-diff-BpkUW37Z.js +0 -3
  887. package/web/dist/assets/run-header-68WHf9wV.js +0 -1
  888. package/web/dist/assets/run-header-8GObNx6F.css +0 -1
  889. package/web/dist/assets/search-x-I9Q8pgUa.js +0 -1
  890. package/web/dist/assets/skills-DFBiAuJG.js +0 -1
  891. package/web/dist/assets/skills-DiVIgKeU.js +0 -1
  892. package/web/dist/assets/square-terminal-B6RYYieR.js +0 -1
  893. package/web/dist/assets/status-dot-CqTJHSP9.js +0 -1
  894. package/web/dist/assets/tab-link-B07Hu4QX.js +0 -1
  895. package/web/dist/assets/task-changes--lQF6EYf.js +0 -1
  896. package/web/dist/assets/task-commits-Cx0qy4DN.js +0 -1
  897. package/web/dist/assets/task-files-D50s-gKW.js +0 -2
  898. package/web/dist/assets/task-github-item-DaqmSwYT.js +0 -1
  899. package/web/dist/assets/task-thread-BPQ64lui.js +0 -7
  900. package/web/dist/assets/task-thread-zNE2Q9E-.css +0 -1
  901. package/web/dist/assets/textarea-D20O4Pw0.js +0 -1
  902. package/web/dist/assets/thread-state-DWcFMrvb.js +0 -4
  903. package/web/dist/assets/use-hand-to-agent-state-BLIGmwbd.css +0 -1
  904. package/web/dist/assets/use-hand-to-agent-state-Cy9eyyfL.js +0 -1
  905. package/web/dist/assets/utils-JsL3ZsJf.js +0 -64
  906. package/web/dist/assets/workflows-jwG7uxqi.js +0 -11
  907. package/web/dist/assets/zoomable-image-BJJ64KGc.js +0 -1
@@ -1,20 +1,54 @@
1
- import { sidebarLimitsSchema } from '../contract/index.js';
2
- import { worktreeSetupConfigSchema } from '../contract/index.js';
3
- import { ARCHIVED_RUNS_PAGE_DEFAULT, RUNS_SEARCH_PER_PROJECT_MAX, archivedRunsQuerySchema, runSummariesQuerySchema, runsSearchQuerySchema } from '../contract/index.js';
4
- import { automationKindSchema, automationScheduleSchema, localTimeZone, nextOccurrence } from '../contract/index.js';
1
+ import { automationCreateSchema, automationUpdateSchema, automationCheckRequestSchema, automationLogQuerySchema } from '../contract/index.js';
2
+ import { providerConnectSchema, createAgentProfileSchema, openAgentAccountFileSchema, updateAgentProfileSchema, selectAgentProfileSchema, providerEnabledSchema, providerRetrySchema, providerRouteParamsSchema, agentProfileRouteParamsSchema } from '../contract/index.js';
3
+ import { planSchema, parseWorkflowSchema } from '../contract/index.js';
4
+ import { uiStateBody } from '../contract/index.js';
5
+ import { queuedMessagePatchSchema, continueSchema, archiveSchema, openInSchema } from '../contract/index.js';
6
+ import { refreshQuery, waitQuery, modelDiscoveryQuerySchema, strictRefreshQuerySchema, browseQuerySchema, worktreeFilesQuerySchema } from '../contract/index.js';
7
+ import { mergeNumberParams, prChangesParams, prChangesQuery, mergeBodySchema } from '../contract/index.js';
8
+ import { gitCommitSchema, reclaimBodySchema, repoBranchSchema } from '../contract/index.js';
9
+ import { setConfigSchema, setAgentConfigSchema } from '../contract/index.js';
10
+ import { saveWorkflowInputSchema as saveWorkflowSchema } from '../contract/index.js';
11
+ import { patchRunInputSchema as patchRunSchema, messageInputSchema as messageSchema } from '../contract/index.js';
12
+ import { checkoutProjectInputSchema as checkoutSchema, registerProjectInputSchema as registerProjectSchema } from '../contract/index.js';
13
+ import { setWorkspaceConfigInputSchema as workspaceConfigUpdateSchema } from '../contract/index.js';
14
+ import { reclaimRunParamsSchema } from '../contract/index.js';
15
+ import { githubChecksQuerySchema } from '../contract/index.js';
16
+ import { githubRefsQuerySchema } from '../contract/index.js';
17
+ import { commitQuerySchema } from '../contract/index.js';
18
+ import { imageMimeType } from '../contract/index.js';
19
+ import { providerForActiveRun } from '../contract/index.js';
20
+ import { defined } from '../core/defined.js';
21
+ import { ArchiveOnMergeCoordinator } from './archive-on-merge.js';
22
+ import { CompletionActionCoordinator } from './completion-actions.js';
23
+ import { discoverRepoHandle, lookupMergedPullRequests } from './forge/github.js';
24
+ import { resolveArchiveOnMerge, resolveGlobalArchiveOnMerge } from '../archive-on-merge-setting.js';
25
+ import { permissionDecisionSchema } from '../contract/index.js';
26
+ import { runIdParamSchema as permissionRunIdParamSchema } from '../contract/index.js';
27
+ import { autosaveCleanupBlocker } from '../autosave-cleanup.js';
28
+ import { CheckoutProgressCache } from './checkout-progress.js';
29
+ import { checkoutProgressParamsSchema, createProjectInputSchema } from '../contract/index.js';
30
+ import { createLiveRoutes } from './live-routes.js';
31
+ import { subscribeRunFeed } from './run-event-feed.js';
32
+ import { ARCHIVED_RUNS_PAGE_DEFAULT } from '../contract/index.js';
33
+ import { RUNS_SEARCH_PER_PROJECT_MAX, archivedRunsQuerySchema, runSummariesQuerySchema, runsSearchQuerySchema } from '../contract/index.js';
34
+ import { automationListQuerySchema } from '../contract/index.js';
35
+ import { localTimeZone } from '../contract/index.js';
5
36
  import { DelegationService } from '../delegation/service.js';
6
37
  import { workerCapacity } from '../delegation/capacity.js';
7
38
  import { delegationFailure } from '../delegation/routes.js';
8
- import { githubItemParamsSchema, CLIENT_REQUEST_VARIANTS_ERROR, workerEmptyRequestSchema, runRelationshipsSchema } from '../contract/index.js';
39
+ import { githubItemParamsSchema } from '../contract/index.js';
40
+ import { createRunInputSchema } from '../contract/index.js';
41
+ import { workerEmptyRequestSchema, runRelationshipsSchema } from '../contract/index.js';
9
42
  import { existsSync, readFileSync, statSync } from 'node:fs';
10
43
  import { randomUUID } from 'node:crypto';
11
- import { AutomationStore } from '../automations/store.js';
44
+ import { createAutomationService, onceCreateAnswer } from '../automations/service.js';
45
+ import { createAutomationToolService } from '../automations/tool-adapter.js';
12
46
  import { AutomationCoordinator } from '../automations/coordinator.js';
13
- import { GithubPoller } from '../automations/github-poller.js';
47
+ import { AutomationCandidateChangedError, GithubPoller } from '../automations/github-poller.js';
14
48
  import { ProjectAutomationScheduler, WorkspaceAutomationScheduler } from '../automations/scheduler.js';
15
49
  import { AutomationProjectUnavailableError, SCHEDULE_LEASE_HELD_REASON, ScheduleRunner } from '../automations/schedule-runner.js';
16
- import { launchAutomationRun, launchScheduledRun, rebaselineIdleAutomations, reconcileAutomationReceipts, validateAutomationPrompt, } from '../automations/task-template.js';
17
- import { automationEventSchema, automationFiltersSchema, automationLogResultSchema, automationTaskSchema, isGithubAutomation, isScheduleAutomation, } from '../automations/types.js';
50
+ import { launchAutomationRun, launchScheduledRun, rebaselineIdleAutomations, reconcileAutomationReceipts, } from '../automations/task-template.js';
51
+ import { isGithubAutomation, isScheduleAutomation, } from '../automations/types.js';
18
52
  import { access, constants as fsConstants, mkdir, readFile, realpath, stat, unlink, writeFile } from 'node:fs/promises';
19
53
  import { basename, dirname, join, resolve, sep } from 'node:path';
20
54
  import { fileURLToPath } from 'node:url';
@@ -24,35 +58,40 @@ import { bodyLimit } from 'hono/body-limit';
24
58
  import { streamSSE } from 'hono/streaming';
25
59
  import { matchedRoutes } from 'hono/route';
26
60
  import { jsonZodValidator, paramZodValidator, queryZodValidator } from './validators.js';
27
- import { deliverOnce, TaskWebhooks } from '../runs/webhook.js';
61
+ import { RecentVoiceActions } from '../voice/decision-log.js';
62
+ import { selectVoiceEngine } from '../voice/engine.js';
63
+ import { createVoiceRoutes } from '../voice/routes.js';
64
+ import { deliverOnce, registryEntry, sendAgentReport, TaskWebhooks } from '../runs/webhook.js';
28
65
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
29
66
  import { z } from 'zod';
30
- import { repoPullInputSchema, deleteBranchesInputSchema, pinRunInputSchema, archiveFinishedRequestSchema, notifyRunInputSchema, githubSearchQuerySchema, githubProjectsQuerySchema, setWorkspaceUiStateInputSchema, } from '../contract/index.js';
67
+ import { repoPullInputSchema, deleteBranchesInputSchema } from '../contract/index.js';
68
+ import { pinRunInputSchema, archiveFinishedRequestSchema, markAllReadRequestSchema, notifyRunInputSchema, } from '../contract/index.js';
69
+ import { githubSearchQuerySchema, githubProjectsQuerySchema, githubListQuerySchema, githubCommentCountsQuerySchema, } from '../contract/index.js';
70
+ import { setWorkspaceUiStateInputSchema } from '../contract/index.js';
31
71
  // A contract VALUE, like `workspaceUiStateSchema` in workspace/migrations.ts — the request
32
72
  // schema this route validates with is the same one the client compiles against.
33
- import { MODEL_DISCOVERY_RUNNERS, attachmentInputSchema, attachmentFileParamSchema, modelDiscoveryRunnerSchema, openProjectInSchema, runnerDiscoversModels, updateProjectInputSchema, } from '../contract/index.js';
73
+ import { MODEL_DISCOVERY_RUNNERS, openProjectInSchema, } from '../contract/index.js';
74
+ import { attachmentFileParamSchema, } from '../contract/index.js';
75
+ import { agentWebhookParamsSchema, createProjectWebhookInputSchema, projectWebhookParamsSchema, sendAgentReportInputSchema, updateProjectInputSchema, updateProjectWebhookInputSchema, } from '../contract/index.js';
76
+ import { PROJECT_WEBHOOKS_MAX } from '../contract/index.js';
77
+ import { agentWebhook, agentWebhooks, applyTaskWebhookInput, createdWebhook, editedWebhook, nameTaken, projectWebhooks, redactWebhook, setProjectWebhooks, taskWebhooks as taskWebhookList, } from '../workspace/webhooks.js';
34
78
  import { detectEnvironment } from '../core/backend-detect.js';
35
- import { RUNNER_IDS } from '../core/agent-runner.js';
36
79
  import { AGENT_MODELS_LOCKED_ERROR, agentModelsLocked } from '../core/agent-model-policy.js';
37
- import { hostModelCatalogAdapters } from '../core/host-model-catalog.js';
38
- import { PROVIDER_IDS, ProviderAuthService, providerAuthChecksDisabled, } from '../core/provider-auth.js';
80
+ import { PROVIDER_IDS, } from '../core/provider-auth.js';
39
81
  import { applyProviderEnablement } from '../core/provider-availability.js';
40
- import { RunnerModelCatalog } from '../core/runner-model-catalog.js';
41
82
  import { currentUsage, onUsage } from '../core/process-usage.js';
42
83
  import { WORKFLOWS_DIR, loadWorkflows } from '../workflows/load.js';
43
- import { QUICK_TASK_WORKFLOW, normalizeWorkflowDoc, plannedWorkflow, skillStackOf, skillsToSteps, stepsIssue, workflowFileSchema, workflowStepSchema, } from '../workflows/types.js';
84
+ import { normalizeWorkflowDoc, skillStackOf, skillsToSteps, stepsIssue, workflowFileSchema, } from '../workflows/types.js';
44
85
  import { planChain, slugify } from '../planner.js';
45
86
  import { discoverSkills } from '../skills.js';
46
- import { SkillsUpdateConflictError, SkillsUpdateCoordinator, SkillsUpdateService } from '../skills-update.js';
47
87
  import { getTeamSkillsCached, refreshTeamSkills, waitForTeamSkills } from '../skills-remote.js';
48
- import { appendHandoffHeartbeat, handoffProgressExcerpt, readHandoff } from '../handoff.js';
49
- import { markStarted, onTodosChanged, readTodos, removeTodo, todoTaskText } from '../todos.js';
88
+ import { readHandoff, renderHandoffRecords } from '../handoff.js';
50
89
  import { ARCHIVED_WINDOW } from '../runs/store.js';
51
- import { HistoryCursorError, deriveRunContextEvents, readEventsAfterLiveCursor, readRunHistoryPage, validateLiveCursor, } from '../runs/event-history.js';
90
+ import { HistoryCursorError, deriveRunContextEvents, readRunHistoryPage, validateLiveCursor, } from '../runs/event-history.js';
52
91
  import { readRunIndexFromDisk, searchRunIndexFromDisk } from '../runs/run-index.js';
53
92
  import { workerDestroyView } from '../runs/delegation-state.js';
54
- import { searchTokens } from '../runs/run-search.js';
55
- import { clientRequestHash } from '../runs/client-request.js';
93
+ import { searchTokens } from '../contract/index.js';
94
+ import { ownPullRequestNumbers } from '../runs/pull-requests.js';
56
95
  import { ColdRepoHandles } from './cold-repo-handles.js';
57
96
  import { isV2WireEventType } from '../runs/ui-event-sink.js';
58
97
  import { runEventsQuerySchema, runHistoryQuerySchema, runIdParamSchema, } from '../contract/index.js';
@@ -61,14 +100,14 @@ import { artifactParamsSchema, fileLinkQuerySchema } from '../contract/index.js'
61
100
  import { artifactDirectory, listArtifacts, readArtifact } from '../artifacts/store.js';
62
101
  import { artifactPreview, loadFileLink, rasterMime } from '../artifacts/resolve.js';
63
102
  import { isUntouchedCancelledRun, toPastedContent } from '../workflows/run.js';
64
- import { worktreeDiff, worktreeDiffStat, worktreeSizeBytes } from '../git-worktree.js';
103
+ import { worktreeDiff, worktreeSizeBytes } from '../git-worktree.js';
65
104
  import { releaseThenRemoveWorktree } from '../git-worktree-release.js';
66
105
  import { isReclaimable, reclaimWorktree, reclaimWorktrees, rematerializeReclaimedWorktree, selectReclaimableWorktrees } from '../runs/retention.js';
67
- import { getBranches, getCommit, getDiff, getLogWithParents, getRepoInfo, getStatus, getTracking } from './git.js';
106
+ import { getBranches, getCommit, getDiff, getLogWithParents, getBranchHead, getRepoInfo, getStatus, getTracking } from './git.js';
68
107
  import { attributeLog, deleteBranches, forgetRepoBranches, githubBranchForge, readRepoBranches } from './repo-branches.js';
69
108
  import { claimRepoGitMutation, localPullBranches, pullRepoCheckout } from './repo-pull.js';
70
- import { collectChanges, collectCommitChanges, collectRunCommits, commitAll, createOrSwitchBranch, imageMimeType, isOsOpenableImage, pushCurrentBranch, readWorktreePath, } from './git-changes.js';
71
- import { gatedSkillsRepos, loadConfig, resolveWorktreeRetention } from '../config.js';
109
+ import { collectChanges, collectCommitChanges, collectRunCommits, commitAll, createOrSwitchBranch, pushCurrentBranch, readWorktreePath, } from './git-changes.js';
110
+ import { loadConfig, resolveWorktreeRetention } from '../config.js';
72
111
  import { resolveWorktreeSetup } from '../worktree-setup.js';
73
112
  import { findConfigFile } from '../agent-config/catalog.js';
74
113
  import { readConfigFile, statConfigPath, writeConfigFile } from '../agent-config/files.js';
@@ -76,76 +115,47 @@ import { readAgentModelDefaults } from '../agent-config/models.js';
76
115
  import { listAgentConfig } from '../agent-config/service.js';
77
116
  import { listConfigFiles } from '../agent-config/catalog.js';
78
117
  import { readAccountIdentity } from '../agent-config/account-identity.js';
79
- import { PROJECT_ID_RE, defaultWorkspaceConfig, effectiveSkillsAutoUpdate, loadWorkspaceConfig, mergeWriteWorkspaceConfig, effectiveComposerDefault, } from '../workspace/config.js';
118
+ import { PROJECT_ID_RE, defaultWorkspaceConfig, loadWorkspaceConfig, mergeWriteWorkspaceConfig, effectiveComposerDefault, } from '../workspace/config.js';
80
119
  import { CONTROL_CHARS_RE, DEFAULT_AGENT_ACCOUNT_ID, defaultAgentAccountStore, isAbsoluteConfigDir, loadAgentAccounts, mergeWriteAgentAccounts, } from '../workspace/agent-accounts.js';
81
- import { defaultAgentProfile, listAgentProfiles, profileDirState, resolveProfileEnvForRoot, resolveStoredProfile, sameProfileDir, } from '../workspace/agent-profiles.js';
82
- import { PROFILE_CAPABLE_PROVIDERS, profileEnv, supportsProfiles } from '../core/agent-profiles.js';
120
+ import { defaultAgentProfile, listAgentProfiles, namedAccountUnavailable, ompProfileDirError, profileDirState, resolveStoredProfile, sameProfileDir, } from '../workspace/agent-profiles.js';
121
+ import { PROFILE_CAPABLE_PROVIDERS, profileAgentDir, profileEnv, supportsProfiles } from '../core/agent-profiles.js';
83
122
  import { quoteExecutable, withEnvPrefix } from '../core/shell-env.js';
84
123
  import { resolveCursorExecutable } from '../core/cursor-model-catalog.js';
85
124
  import { resolveOmpExecutable } from '../core/omp-model-catalog.js';
86
125
  import { allocateProjectSlug, listProjects, normalizeProjectTags, probeProjectStatus, toProjectListEntry, registerProject, removeProject, shouldRegisterProject, } from '../workspace/projects.js';
87
126
  import { mergeWriteWorkspaceUiState, readWorkspaceUiState } from '../workspace/ui-state.js';
88
127
  import { checkoutRepo } from './checkout.js';
89
- import { armPreview, ProjectContextError, ProjectContexts, sweepRegisteredPreviewLeftovers } from './project-context.js';
90
- import { reviewGateEnabled } from '../runs/review-gate.js';
91
- import { readUiState, uiStatePath } from '../ui-state.js';
128
+ import { createProject, listGithubOwners } from './create-project.js';
129
+ import { armPreview, ProjectContextError, ProjectContexts, projectWarmupEnabled, sweepRegisteredPreviewLeftovers } from './project-context.js';
130
+ import { readProjectUiState, readUiState, writeProjectUiState } from '../ui-state.js';
92
131
  import { agentHomePaths, expandTilde } from '../paths.js';
93
132
  import { isLoopbackHostHeader, normalizeHostname, resolveCapabilities } from './capabilities.js';
94
133
  import { applicationUpdateInputSchema } from '../contract/index.js';
95
134
  import { ApplicationUpdateConflictError, ApplicationUpdateFailureError } from '../application-update/service.js';
96
135
  import { createSocketHub, socketHubRoute } from './ws.js';
97
136
  import { attachUpgradeRouter } from './upgrade-router.js';
98
- import { createPreviewSocket } from './preview-socket.js';
137
+ import { createPreviewSockets } from './preview-sockets.js';
99
138
  import { browseDirectory, isInsideBrowseRoot, isLexicallyInsideBrowseRoot, resolveBrowseRoot } from './fs-browse.js';
100
- import { parseRemote, resolveForge } from './forge/index.js';
101
- import { fetchGithub, fetchGithubProjects, fetchGithubChecks, fetchGithubComments, fetchGithubItem, forgetGithubItem, fetchGithubPrDiff, fetchGithubRefStatus, forgetRefStatus, readCachedRefStatuses, refNumberFromUrl, searchGithubItems, GithubPrNotFoundError, GH_CHECKS_MAX, GH_REF_STATUS_MAX } from './github.js';
102
- import { ensureLaunchKey } from './launch-key.js';
139
+ import { parseRemote, resolveForge, forgeWebRoot } from './forge/index.js';
140
+ import { fetchGithub, fetchGithubProjects, fetchGithubChecks, fetchGithubCommentCounts, fetchGithubComments, fetchGithubItem, forgetGithubItem, fetchGithubPrDiff, fetchGithubRefStatus, forgetRefStatus, readCachedRefStatuses, refNumberFromUrl, searchGithubItems, GithubPrNotFoundError, GH_CHECKS_MAX, GH_REF_STATUS_MAX } from './github.js';
103
141
  import { CockpitAlreadyRunningError } from './cockpit-ownership.js';
104
142
  import { RunStoreOpenError } from '../runs/store-open-error.js';
105
- import { openInTerminal } from './open-in-terminal.js';
106
- import { agentCliRunner, detectOpenTargets, openFileInDefaultApp, openInApp } from './open-in-app.js';
143
+ import { openRunApp, openRunCli, openRunDefaultApp } from './open-in-targets.js';
144
+ import { agentCliRunner, detectOpenTargets } from './open-in-app.js';
107
145
  import { createDraftPr } from './pr.js';
108
146
  import { ProviderRuntimeAuthObserver } from './provider-auth-runtime.js';
109
- import { providerForActiveRun, providerForExistingRun, providersRequiredByWorkflow, unavailableProviderMessage, } from './provider-action-gate.js';
147
+ import { providerForExistingRun, providersRequiredByWorkflow, } from './provider-action-gate.js';
148
+ import { attachProjectWebhooks, createBootContext, createProviderStatus, resolveServerDeps, watchProviderRuntimeAuth, } from './server-deps.js';
149
+ import { NO_WEBHOOK_ERROR, agentProfileRefusal, clientRequestOf, launchRun, modelLockRefusal, notifyRefusal, providerGateRefusal, replayedRun, resolveStartWorkflow, startRunInput, } from './start-run-options.js';
110
150
  import { ASSET_CACHE_CONTROL, BUILD_HINT_HTML, assetContentType, isSafeAssetFilename, resolveGetRequest, } from './static-ui.js';
111
151
  /** `projectId` gate at the route boundary (spec "Project identity"): the slug
112
152
  * shape or the reserved `default` alias — validated BEFORE touching any map
113
153
  * or path. (`default` matches the slug regex too; the literal keeps the
114
154
  * contract explicit.) */
115
155
  const projectIdSchema = z.union([z.literal('default'), z.string().regex(PROJECT_ID_RE)]);
116
- const providerConnectSchema = z.object({
117
- provider: z.enum(PROVIDER_IDS),
118
- /** Which agent account to sign in (spec 2026-07-29-agent-profiles). Absent = the discovered
119
- * default, which is what every pre-profiles client sends. Without this, "Connect" on a second
120
- * Claude account would open a login for the FIRST one and report success. */
121
- profileId: z.string().max(64).optional(),
122
- }).strict();
123
- /** Agent-account bodies (spec 2026-07-29-agent-profiles). Bounds mirror `agentProfileSchema` in
124
- * src/workspace/config.ts exactly, so a value these accept can never be degraded away by the
125
- * next load's `.catch`. The id is allocated server-side and is never a request field. */
126
- const createAgentProfileSchema = z.object({
127
- provider: z.enum(PROVIDER_IDS),
128
- label: z.string().trim().max(200).optional(),
129
- configDir: z.string().trim().min(1).max(4096),
130
- }).strict();
131
- /** `POST …/agent-profiles/:id/open` — a catalog id (or `folder`) plus an optional open target. */
132
- const openAgentAccountFileSchema = z.object({
133
- file: z.string().min(1).max(200),
134
- target: z.string().min(1).max(64).optional(),
135
- }).strict();
136
- const updateAgentProfileSchema = z.object({
137
- label: z.string().trim().max(200).optional(),
138
- configDir: z.string().trim().min(1).max(4096).optional(),
139
- }).strict().refine((value) => value.label !== undefined || value.configDir !== undefined, 'send label or configDir');
140
- /** `PUT …/agent-profiles/selection` — which account a project uses for one provider. `null`
141
- * clears it back to the discovered account. */
142
- const selectAgentProfileSchema = z.object({
143
- /** `null` targets the machine-wide default rather than one repo. */
144
- projectId: z.string().min(1).max(64).nullable(),
145
- provider: z.enum(PROVIDER_IDS),
146
- profileId: z.string().max(64).nullable(),
147
- }).strict();
148
156
  /** The hosted-mode refusal, worded like the agent-config one it mirrors. */
157
+ const HOSTED_GITHUB_CREATE_REFUSAL = 'Creating GitHub repositories is available from a local cockpit only';
158
+ const HOSTED_GITHUB_OWNERS_REASON = 'GitHub repositories can be created from a local cockpit only';
149
159
  const hostedProfileRefusal = {
150
160
  error: 'agent accounts are managed from the machine that owns the checkout (this cockpit runs in hosted mode)',
151
161
  };
@@ -161,107 +171,6 @@ function allocateAgentProfileId(source, taken) {
161
171
  // `^[a-z0-9][a-z0-9-]{0,63}$` shape, so `~/.claude-klaudiusz` slugs to `claude-klaudiusz`.
162
172
  return allocateProjectSlug(source, taken);
163
173
  }
164
- const providerParamSchema = z.enum(PROVIDER_IDS);
165
- const providerEnabledSchema = z.object({ enabled: z.boolean() }).strict();
166
- const providerRetrySchema = z.object({
167
- authFailureId: z.string().min(1).max(128),
168
- }).strict();
169
- /**
170
- * The editable body of both kinds. Every kind-specific key is optional here because which ones a
171
- * body must (or must not) carry depends on the kind, and on `PUT` the kind is the STORED one when
172
- * the body omits it — `resolveAutomationKind` below owns those rules.
173
- */
174
- const automationEditableSchema = z
175
- .object({
176
- name: z.string().trim().min(1).max(200),
177
- description: z.string().max(2_000).optional(),
178
- enabled: z.boolean().optional(),
179
- kind: automationKindSchema.optional(),
180
- events: z.array(automationEventSchema).min(1).max(4).optional(),
181
- intervalSeconds: z.number().int().min(60).max(86_400).optional(),
182
- filters: automationFiltersSchema.optional(),
183
- schedule: automationScheduleSchema.optional(),
184
- task: automationTaskSchema,
185
- })
186
- .strict();
187
- const automationCreateSchema = automationEditableSchema.extend({ enable: z.boolean().optional() });
188
- const automationUpdateSchema = automationEditableSchema.extend({ expectedRevision: z.number().int().positive() });
189
- const automationCheckRequestSchema = z.object({ mode: z.enum(['preview', 'execute']) }).strict();
190
- const automationLogQuerySchema = z.object({
191
- automationId: z.string().optional(),
192
- result: automationLogResultSchema.optional(),
193
- event: automationEventSchema.optional(),
194
- since: z.string().datetime().optional(),
195
- cursor: z.coerce.number().int().positive().optional(),
196
- // Optional, not `.default(100)`: `AutomationStore.logs` already clamps `limit ?? 100` into
197
- // 1..100, so the default here was a second copy of it — and a defaulted key is REQUIRED in the
198
- // request type `queryZodValidator` publishes (see validators.ts on why the request side falls
199
- // back to the schema's output), which would have made every caller send a `?limit=` this route
200
- // never needed.
201
- limit: z.coerce.number().int().min(1).max(100).optional(),
202
- });
203
- /** enable/pause carry no client revision, so a conflict is a race inside the write: 409, never 500. */
204
- function toggleFailure(error) {
205
- const message = error instanceof Error ? error.message : String(error);
206
- if (message === 'automation not found')
207
- return { error: 'not found', status: 404 };
208
- if (message === 'automation revision conflict')
209
- return { error: 'the automation changed elsewhere; reload and try again', status: 409 };
210
- throw error;
211
- }
212
- function editableAutomation(definition) {
213
- return {
214
- name: definition.name,
215
- description: definition.description,
216
- enabled: definition.enabled,
217
- kind: definition.kind,
218
- ...(definition.events ? { events: definition.events } : {}),
219
- ...(definition.intervalSeconds !== undefined ? { intervalSeconds: definition.intervalSeconds } : {}),
220
- ...(definition.filters ? { filters: definition.filters } : {}),
221
- ...(definition.schedule ? { schedule: definition.schedule } : {}),
222
- task: definition.task,
223
- };
224
- }
225
- /**
226
- * Which kind a create/update body is, and whether its keys fit it (spec
227
- * 2026-10-02-scheduled-automations § Data model). A body without `kind` is `github` on create —
228
- * every pre-schedule client — and the STORED kind on update, so an old client editing a schedule
229
- * edits a schedule. Changing the kind is refused: receipts and cursors are kind-specific.
230
- */
231
- function resolveAutomationKind(body, stored) {
232
- const kind = body.kind ?? stored?.kind ?? 'github';
233
- if (stored && kind !== stored.kind)
234
- return { error: 'change the kind by creating a new automation', status: 409 };
235
- if (kind === 'schedule') {
236
- if (body.events !== undefined || body.intervalSeconds !== undefined || body.filters !== undefined) {
237
- return { error: 'a scheduled automation has no GitHub filter', status: 400 };
238
- }
239
- if (!body.schedule)
240
- return { error: 'a scheduled automation needs a schedule', status: 400 };
241
- }
242
- else {
243
- if (body.schedule !== undefined)
244
- return { error: 'a GitHub automation has no schedule', status: 400 };
245
- if (!body.events || body.intervalSeconds === undefined || !body.filters) {
246
- return { error: 'a GitHub automation needs events, intervalSeconds and filters', status: 400 };
247
- }
248
- }
249
- return { kind };
250
- }
251
- function sameSchedule(a, b) {
252
- return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
253
- }
254
- /**
255
- * The `nextRunAt` a schedule definition arms with: its next occurrence from now when enabled,
256
- * cleared when paused. Passed to the store's `create`/`update` as `arm`, which writes it BEFORE
257
- * the definition, so another cockpit's timer never pairs the new definition with the old instant.
258
- */
259
- function armScheduleFromNow(definition) {
260
- if (!isScheduleAutomation(definition))
261
- return undefined;
262
- const next = definition.enabled ? nextOccurrence(definition.schedule, Date.now(), localTimeZone()) : null;
263
- return { nextRunAt: next === null ? undefined : new Date(next).toISOString() };
264
- }
265
174
  /**
266
175
  * The public API surface (spec 2026-07-23-independent-server-web-packages).
267
176
  *
@@ -296,8 +205,6 @@ export function projectRouteManifest(app) {
296
205
  }
297
206
  return manifest;
298
207
  }
299
- /** 409 body for the inbox mutators while the follow-up inbox is off (#471). */
300
- const FOLLOWUPS_OFF = 'the follow-up inbox is disabled — set CEZ_FOLLOWUPS=1 to enable it';
301
208
  /** 409 body for every automations route while GitHub automations are off (#801). */
302
209
  const AUTOMATIONS_OFF = 'Automations are disabled — set CEZ_AUTOMATIONS=1 to enable them';
303
210
  /** 404 body for a run whose row is in runs.db but whose record this cezar cannot decode (#779). */
@@ -336,243 +243,6 @@ const streamSSENoBuffer = (c, cb, onError) => {
336
243
  res.headers.set('X-Accel-Buffering', 'no');
337
244
  return res;
338
245
  };
339
- // A run starts from a named workflow OR an inline chain of steps (spec 008 —
340
- // the approved plan is posted as-is, never written to a file).
341
- const startRunSchema = z
342
- .object({
343
- workflow: z.string().min(1).optional(),
344
- steps: z.array(workflowStepSchema).min(1).max(8).optional(),
345
- // The primary agent prompt handed to the spawned runner. Bounded like the
346
- // other prompt fields (`systemPrompt` 20k, message `text` 100k) so an
347
- // unbounded body can't be piped into a spawned process (#429). 100k chars
348
- // (~25k tokens) is well past any hand-written task.
349
- task: z.string().min(1).max(100_000, 'must be at most 100000 characters'),
350
- model: z.string().optional(),
351
- // Reasoning-effort pin (#45). Omit for the harness default.
352
- effort: z.string().max(32).optional(),
353
- // Agent backend for this task (falls back to config `defaultRunner`).
354
- runner: z.enum(RUNNER_IDS).optional(),
355
- // Agent account for this task (spec 2026-07-29-agent-profiles). Falls back to the project's
356
- // own selection, then the discovered default. Bounded like a profile id in the workspace
357
- // schema, so a value this route accepts can never be degraded away by the next load.
358
- agentProfile: z.string().max(64).optional(),
359
- // Parallel variants (spec 010): ×2/×3 runs the task as 2–3 competing
360
- // agents in separate worktrees; the user compares diffs and picks one.
361
- variants: z.number().int().min(1).max(3).optional(),
362
- // Composer worktree opt-out (#worktree-toggle): false runs in the repo
363
- // working tree. Ignored when variants > 1.
364
- worktree: z.boolean().optional(),
365
- // Autonomous mode (#autonomous): the run never parks at `waiting` — it
366
- // auto-continues until the agent signals done. No "needs you" is raised.
367
- autonomous: z.boolean().optional(),
368
- // Generate follow-up inbox entries (spec 007, #444). Honoured only while
369
- // the `followups` capability is on (#471) — off, the server pins it to
370
- // false whatever the client asked for. Omitted still means "enabled" for
371
- // old clients, but only within an already-enabled server. The handoff
372
- // journal is unaffected either way.
373
- generateFollowups: z.boolean().optional(),
374
- // Per-run system-prompt override (R2 2.3) — programmatic callers only
375
- // (bookmarklets, scripts); deliberately NOT a composer-UI control. Wins
376
- // over the config.json default; whitespace-only degrades to absent.
377
- systemPrompt: z
378
- .string()
379
- .trim()
380
- .max(20_000, 'must be at most 20000 characters')
381
- .optional()
382
- .transform((s) => (s ? s : undefined)),
383
- // Attachments pasted into the new-task form — same shape and limits as a
384
- // live-session message; delivered with the first agent step's opening.
385
- images: z.array(attachmentInputSchema).max(4).optional(),
386
- // Inbox follow-up (#374): the todo the composer was prefilled from
387
- // (`/new?skill=&ref=&todo=t1`). On a successful start the entry is marked
388
- // started — the same bookkeeping POST /api/todos/:id/start does, so the
389
- // audit trail survives the composer detour. Bounded like every other
390
- // string here; a todo id is a short generated key.
391
- todoId: z.string().min(1).max(200, 'must be at most 200 characters').optional(),
392
- // Idempotent start (#504): the same id and payload answer 200 with the existing run.
393
- clientRequestId: z.string().uuid().optional(),
394
- // Task webhook opt-in (#589). Absent = off; not part of the idempotency hash.
395
- notify: z.boolean().optional(),
396
- })
397
- .refine((b) => Boolean(b.workflow) !== Boolean(b.steps), {
398
- message: 'provide either "workflow" or "steps", not both',
399
- })
400
- .refine((b) => !(b.clientRequestId && (b.variants ?? 1) > 1), {
401
- message: CLIENT_REQUEST_VARIANTS_ERROR,
402
- });
403
- const pickSchema = z.object({
404
- runId: z.string().min(1),
405
- });
406
- const planSchema = z.object({
407
- // Same bound as `startRunSchema.task` — this flows into `planChain` (#429).
408
- task: z.string().trim().min(1).max(100_000, 'must be at most 100000 characters'),
409
- });
410
- // A saved workflow carries full `steps` OR the builder's `skills` stack
411
- // (spec 012). `overwrite: true` is the builder's Save on an existing file —
412
- // the GUI asks first; a plain POST still refuses to clobber.
413
- const saveWorkflowSchema = z
414
- .object({
415
- name: z.string().trim().min(1).max(80),
416
- // Written into a YAML file on disk (#429) — a workflow description is a
417
- // short blurb, so a 2k cap is generous without allowing a file-bloat write.
418
- description: z.string().max(2_000, 'must be at most 2000 characters').optional(),
419
- steps: z.array(workflowStepSchema).min(1).max(8).optional(),
420
- skills: z.array(z.string().trim().min(1)).min(1).max(8).optional(),
421
- overwrite: z.boolean().optional(),
422
- })
423
- .refine((b) => Boolean(b.steps) !== Boolean(b.skills), {
424
- message: 'provide either "steps" or "skills", not both',
425
- });
426
- const parseWorkflowSchema = z.object({
427
- yaml: z.string().min(1).max(100_000),
428
- });
429
- // Small GUI preferences persisted in `.ai/cezar/ui-state.json` (files, not a
430
- // DB): today just the last-used task source, so the form preselects what you
431
- // actually run. Unknown keys pass through — future prefs won't need a schema
432
- // dance.
433
- /** Entry cap on the `skillUsage` map (#408). A real skill catalog is dozens of entries; this
434
- * bounds the ui-state.json write without ever rejecting a legitimate one. */
435
- const SKILL_USAGE_MAX_ENTRIES = 200;
436
- // Belt-and-braces cap on the number of top-level ui-state keys so a
437
- // `.passthrough()` schema can't accumulate an unbounded key set (#429). Very
438
- // generous for GUI prefs; over-limit is a 400, never a silent strip. Shared by
439
- // BOTH ui-state routes (per-repo and workspace) via `parseUiStateBody`.
440
- const UI_STATE_MAX_KEYS = 200;
441
- /** Settings → Appearance (redesign R6): accent + density + reading width. ONE
442
- * schema for both ui-state files — per-repo (the legacy home, kept so an older
443
- * cezar in the same repo still honours it) and workspace
444
- * (`~/.cezar/ui-state.json`, its post-migration home — multi-project spec,
445
- * Data Model).
446
- *
447
- * Every key is `.optional()` so an older ui-state.json parses unchanged, but
448
- * each one must be listed HERE: the enclosing `workspaceUiStateSchema` is
449
- * `.passthrough()` at the top level only, so an unlisted key inside
450
- * `appearance` is stripped by zod and then wiped from the file by the shallow
451
- * merge-on-write. The cockpit adopts the PUT response as authoritative, so a
452
- * stripped key does not merely fail to persist — it visibly reverts the
453
- * control the user just touched. Adding an appearance preference means adding
454
- * it here in the same change. */
455
- const appearanceSchema = z.object({
456
- accent: z.enum(['cezarion', 'lime', 'violet']).optional(),
457
- density: z.enum(['comfortable', 'compact', 'ultra']).optional(),
458
- width: z.enum(['narrow', 'wide']).optional(),
459
- });
460
- const uiStateSchema = z
461
- .object({
462
- sidebarLimits: sidebarLimitsSchema.optional(),
463
- // `null` clears the recorded choice — the composer's "no skill, no workflow" state,
464
- // which is a plain quick-task run. Written through to the file like any other value, so an
465
- // older cockpit reading it falls back to its own default instead of restoring a stale skill.
466
- lastTask: z
467
- .object({
468
- source: z.enum(['workflow', 'skill']),
469
- ref: z.string().min(1).max(200),
470
- })
471
- .nullable()
472
- .optional(),
473
- // Composer picker recency (newest first, capped) + the remembered worktree
474
- // choice for single-skill runs. Additive prefs, like the rest of ui-state.
475
- recentSources: z
476
- .array(z.object({
477
- source: z.enum(['workflow', 'skill']),
478
- ref: z.string().min(1).max(200),
479
- }))
480
- .max(50)
481
- .optional(),
482
- lastWorktree: z.boolean().optional(),
483
- lastAutonomous: z.boolean().optional(),
484
- lastGenerateFollowups: z.boolean().optional(),
485
- // Skill selection frequency (#408): name → times chosen, incremented on a successful run
486
- // start from EITHER composer (`/new`'s SourcePill and the follow-up `SkillsPicker`). Drives
487
- // the shared `orderSkillsByUsage` sort (web/app/src/lib/skills.ts) so both pickers float the
488
- // skills a user actually reaches for above the rest, within the existing project-first
489
- // grouping. ADDITIVE, like the rest of ui-state — the client always PUTs the whole map
490
- // because the top-level merge below is shallow.
491
- //
492
- // Bounded on all three axes (key length, value, entry count) like every neighbour here: this
493
- // map is written straight to `ui-state.json`, which the cockpit GETs on every load and this
494
- // route re-reads on every PUT, so an unbounded map is an unbounded file write. Keys are skill
495
- // names (`.min(1).max(200)`, matching `lastTask.ref`); SKILL_USAGE_MAX_ENTRIES sits far above
496
- // any real catalog while capping the file at a few tens of KB.
497
- skillUsage: z
498
- .record(z.string().min(1).max(200), z.number().int().min(0).max(1_000_000))
499
- .refine((usage) => Object.keys(usage).length <= SKILL_USAGE_MAX_ENTRIES, {
500
- message: `skillUsage must have at most ${SKILL_USAGE_MAX_ENTRIES} entries`,
501
- })
502
- .optional(),
503
- // Runs area presentation (#348): the sidebar-list + detail pane, or the
504
- // full-width table ("task manager") view.
505
- runsView: z.enum(['list', 'table']).optional(),
506
- // The GitHub tab's last-selected sub-tab (#417): issues or PRs. ADDITIVE — an old
507
- // ui-state.json without the key behaves as the default (issues).
508
- githubView: z.enum(['issues', 'prs']).optional(),
509
- // Settings → Appearance (redesign R6): accent + density. ADDITIVE — the theme itself
510
- // stays in the browser (`cez-theme` localStorage, pre-paint). The cockpit always PUTs
511
- // the whole object because the top-level merge below is shallow.
512
- appearance: appearanceSchema.optional(),
513
- // Follow-up prompt templates (#413): reusable snippets insertable into the GitHub hand-over
514
- // and Inbox follow-up composers. Absent → the client's built-in defaults; present (even `[]`)
515
- // is the user's own edited list, from Settings → Prompt templates. Additive, like the rest of
516
- // ui-state — the cockpit is the only writer, so validation stays generous but bounded.
517
- promptTemplates: z
518
- .array(z.object({
519
- id: z.string().min(1).max(64),
520
- label: z.string().trim().min(1).max(80),
521
- text: z.string().trim().min(1).max(2000),
522
- // Skill names this template auto-applies for. Optional and additive: templates
523
- // written before this key existed keep validating, and stay manual-only.
524
- skills: z.array(z.string().trim().min(1).max(200)).max(50).optional(),
525
- }))
526
- .max(50)
527
- .optional(),
528
- // Skills promo banner (#391): set once the cockpit banner is dismissed, never unset.
529
- // Server-persisted (not a cookie) so the "shown once" promise holds across browsers.
530
- // Retained for backward compatibility — the banner is gone, replaced by the workspace-level
531
- // `importedSkills` curation (see `workspaceUiStateSchema`); `.passthrough()` would preserve
532
- // the key regardless, but keep it typed.
533
- dismissedSkillsBanner: z.boolean().optional(),
534
- })
535
- .passthrough();
536
- // Editable titles (#389), and the initial prompt while the run is still queued
537
- // (#472 — rejected with 409 on any other status by the handler).
538
- const patchRunSchema = z.object({
539
- title: z.string().trim().min(1).max(300).optional(),
540
- task: z.string().trim().min(1).max(100_000).optional(),
541
- });
542
- // Session commit (redesign R5 — §"Git/session API additions").
543
- const gitCommitSchema = z.object({
544
- message: z.string().trim().min(1, 'must not be empty').max(5_000),
545
- });
546
- // "Open in…" (#open-in / #365): `target` selects the app; `path` (optional, worktree-relative)
547
- // narrows the target's own worktree/repo-root default to one file — used by the diff pane's
548
- // "open in default app" action for images. Containment is re-checked server-side via
549
- // `readWorktreePath`; this schema only shapes the request.
550
- const openInSchema = z.object({
551
- // A short bound (#429): matched against a downstream allowlist, so an editor id is never long.
552
- target: z.string().trim().min(1, 'target required').max(200),
553
- path: z.string().max(1_000).optional(),
554
- });
555
- // Attachment-carrying bodies validate with the CONTRACT's `attachmentInputSchema` (#950) —
556
- // images plus the short PDF/TXT/MD allowlist, 7,000,000 encoded characters each. Imported rather
557
- // than mirrored here, so the wire cannot drift from what the cockpit compiles against.
558
- const messageSchema = z
559
- .object({
560
- text: z.string().max(100_000).default(''),
561
- images: z.array(attachmentInputSchema).max(4).default([]),
562
- })
563
- .refine((m) => m.text.trim().length > 0 || m.images.length > 0, {
564
- message: 'message needs text or at least one attachment',
565
- });
566
- // PATCH semantics are load-bearing here: an omitted field keeps its current value.
567
- // In particular, the cockpit edits text without re-uploading existing attachments.
568
- const queuedMessagePatchSchema = z
569
- .object({
570
- text: z.string().max(100_000).optional(),
571
- images: z.array(attachmentInputSchema).max(4).optional(),
572
- })
573
- .refine((m) => m.text !== undefined || m.images !== undefined, {
574
- message: 'message edit needs text or attachments',
575
- });
576
246
  // Queued prompt stack bounds (#472). The per-message bounds mirror `messageSchema`
577
247
  // above; the one that actually matters is the FOLDED total, because 20 messages of
578
248
  // 100 000 chars each would otherwise compose a ~2 M-character {{task}}.
@@ -589,48 +259,6 @@ function foldedLength(task, stack) {
589
259
  .filter((part) => part.length > 0)
590
260
  .join('\n\n').length;
591
261
  }
592
- // "Continue"/"Send back" body (spec 003 / #401): every field optional, so an empty POST reopens
593
- // the last session on the run's current backend (backward compat). A runner/model/account override
594
- // lets the follow-up composer choose which engine handles the continuation. `text` stays bounded
595
- // like the live-session message `text` (#429), and `images` like a live-session message's — the
596
- // follow-up composer is a full composer, so a screenshot pasted into it must reach the reopened
597
- // session rather than being silently dropped.
598
- const continueSchema = z.object({
599
- text: z.string().max(100_000, 'must be at most 100000 characters').optional(),
600
- images: z.array(attachmentInputSchema).max(4).optional(),
601
- runner: z.enum(RUNNER_IDS).optional(),
602
- model: z.string().max(200).optional(),
603
- /** Reasoning-effort pin (#45). Omitted keeps the run's pin; empty string clears it. */
604
- effort: z.string().max(32).optional(),
605
- /** Agent account for the reopened session (spec 2026-07-29-agent-profiles). Bound mirrors
606
- * `POST /runs`' own `agentProfile`. Omitted = keep the account the run is already on. */
607
- agentProfile: z.string().max(64).optional(),
608
- });
609
- // Inbox "▶ Run" body (spec 007 / #401 / #413): every field optional, and the whole body is
610
- // optional too, so an empty POST — every client before the pills and the composer — starts on
611
- // the host's `defaultRunner` with no extra instructions, exactly as before. This is a START
612
- // path, not a continue: there is no prior backend to preserve, so an omitted `runner`/`model`
613
- // means "host default" rather than "keep what the run had". `prompt` (#413) is extra
614
- // instructions appended to the entry's suggested/summary task text; whitespace-only degrades to
615
- // absent so it never touches `task`.
616
- const startTodoSchema = z
617
- .object({
618
- runner: z.enum(RUNNER_IDS).optional(),
619
- model: z.string().max(200).optional(),
620
- effort: z.string().max(32).optional(),
621
- prompt: z
622
- .string()
623
- .trim()
624
- .max(20_000, 'must be at most 20000 characters')
625
- .optional()
626
- .transform((s) => (s ? s : undefined)),
627
- })
628
- .optional();
629
- // `POST /api/runs/:id/archive` (#429) — no body archives; `{archived:false}`
630
- // un-archives. A tiny schema so the route follows the safeParse convention.
631
- const archiveSchema = z.object({
632
- archived: z.boolean().optional(),
633
- });
634
262
  // Request-body size guards (#429). A generous global cap keeps a single
635
263
  // localhost request from being unbounded (the largest legit body is 4 pasted
636
264
  // images at ~7 MB base64 each); the ui-state PUT gets a much tighter cap since
@@ -647,47 +275,6 @@ function stripHostPort(host) {
647
275
  return bracketed[1];
648
276
  return host.replace(/:\d+$/, '');
649
277
  }
650
- /** The shared write-side half of BOTH ui-state routes (per-repo `/api/v1/ui-state`
651
- * and workspace `/api/v1/workspace/ui-state`) — the factored split the
652
- * multi-project spec calls for instead of a copy: the route's own schema, plus a
653
- * cap on the top-level key count so a `.passthrough()` schema can't accumulate an
654
- * unbounded key set (#429). The cap rides as a refinement so the whole thing is one
655
- * schema and can go through `jsonBody` like every other mutating route. The
656
- * merge-on-write stays with each route (they write different files) but is shallow
657
- * in both. */
658
- function capUiStateKeys(data, ctx) {
659
- if (Object.keys(data).length > UI_STATE_MAX_KEYS) {
660
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `ui-state has too many keys (max ${UI_STATE_MAX_KEYS})` });
661
- }
662
- }
663
- // Derived once per route instead of by a generic `wrap(schema)` helper called at the route: a
664
- // generic wrapper leaves the schema type unresolved where `jsonBody` needs it, and Hono answers
665
- // that by dropping the whole PUT from the route schema rather than erroring — both ui-state PUTs
666
- // silently vanished from `AppType`. Concrete consts keep them visible to `hc`.
667
- const uiStateBody = uiStateSchema.superRefine(capUiStateKeys);
668
- /**
669
- * Query schemas for the READ routes — deliberately permissive.
670
- *
671
- * These exist to make a query key VISIBLE to the route type (`hc` refuses a `query` argument for
672
- * a key no validator declares), NOT to narrow what the route accepts. Every one of these handlers
673
- * compares `=== '1'` and treats everything else as false, so `?refresh=0` is a successful request
674
- * today; a literal schema would silently turn it into a 400. The comparison stays in the handler
675
- * and the validator stays out of its way.
676
- *
677
- * The one route that really is strict on the wire — `GET /github/prs/:number/changes`, which 400s
678
- * on `?refresh=true` — keeps its own `z.enum(['1'])` schema next to the route.
679
- */
680
- /**
681
- * One query value, matching what `c.req.query('k')` did before these routes were validated.
682
- *
683
- * Hono's query validator hands a REPEATED key as an array (`?wait=1&wait=1` → `['1','1']`), where
684
- * `c.req.query()` silently took the first. A plain `z.string()` therefore turns a request that
685
- * used to answer 200 into a 400 — a wire change no caller asked for. Collapsing to the first
686
- * value keeps the old behaviour, and the client still sees a plain `key?: string`.
687
- */
688
- const queryValue = z.union([z.string(), z.array(z.string()).transform((v) => v[0])]).optional();
689
- const refreshQuery = z.object({ refresh: queryValue });
690
- const waitQuery = z.object({ wait: queryValue });
691
278
  function parseAccept(header) {
692
279
  const ranges = [];
693
280
  for (const part of header.split(',')) {
@@ -775,62 +362,9 @@ export function createApp(deps) {
775
362
  // turns into a compile error instead.
776
363
  const bootRoot = deps.repoRoot;
777
364
  const bootDataDir = join(bootRoot, '.ai/cezar');
778
- // One adapter per contract-advertised runner; the map is typed over `modelDiscoveryRunnerSchema`
779
- // so a runner added to the contract without an adapter fails typecheck (AGENT_PROTOCOL.md §10).
780
- const modelCatalog = deps.modelCatalog ?? new RunnerModelCatalog({ adapters: hostModelCatalogAdapters(bootRoot) });
781
- const invalidateHostModels = (provider) => {
782
- if (runnerDiscoversModels(provider))
783
- modelCatalog.invalidate(provider);
784
- };
785
- const providerAuth = deps.providerAuth ?? new ProviderAuthService();
786
- const workspaceConfig = deps.workspaceConfig ?? {
787
- load: loadWorkspaceConfig,
788
- mergeWrite: mergeWriteWorkspaceConfig,
789
- };
790
- const providerStatus = async (options) => {
791
- if (providerAuthChecksDisabled()) {
792
- return applyProviderEnablement(await providerAuth.status(options?.refresh ? { refresh: true } : undefined), []);
793
- }
794
- const [discovered, workspace] = await Promise.all([
795
- providerAuth.status(options?.refresh ? { refresh: true } : undefined),
796
- workspaceConfig.load(),
797
- ]);
798
- return applyProviderEnablement(discovered, workspace.disabledProviders);
799
- };
800
- /**
801
- * The gate: why a run cannot start against `required`, or null when it can.
802
- *
803
- * VERIFY BEFORE YOU REFUSE. Auth state is served stale-while-revalidate (see
804
- * `ProviderAuthService.status`), so a cached "disconnected" may predate a login cezar could not
805
- * observe — someone running `claude auth login` in a terminal. Refusing on that would lock a user
806
- * out of their own cockpit with no way back but waiting. So a believed-unavailable provider is
807
- * re-probed, and only a refusal that survives the fresh answer is returned.
808
- *
809
- * The cost lands where it belongs: the common path (connected, warm) pays nothing at all, and the
810
- * probe is only spawned when cezar is about to say no — a rare, interactive moment. This is also
811
- * what lets the cache hold a negative for a minute instead of five seconds, which is what made
812
- * every reader of `GET /providers/status` periodically pay for a CLI spawn.
813
- *
814
- * A runtime auth latch is deliberately NOT escaped by this: `withRuntimeFailures` keeps forcing
815
- * the row disconnected until the user acknowledges that exact incident, so the re-probe cannot
816
- * talk cezar out of a rejection it actually observed.
817
- */
818
- const providerActionError = async (required) => {
819
- const known = await providerStatus();
820
- const message = unavailableProviderMessage(required, known);
821
- if (message === null)
822
- return null;
823
- // A DISABLED provider is a settings fact, not a probe result — re-probing it learns nothing and
824
- // would spawn a CLI to re-read something the user typed.
825
- const disabled = required.some((provider) => known.providers.find((row) => row.provider === provider)?.enabled === false);
826
- if (disabled)
827
- return message;
828
- return unavailableProviderMessage(required, await providerStatus({ refresh: true }));
829
- };
830
- const openTerminal = deps.openTerminal ?? openInTerminal;
831
- const openFile = deps.openFile ?? openFileInDefaultApp;
832
- const openApp = deps.openApp ?? openInApp;
833
- const skillsUpdate = deps.skillsUpdate ?? new SkillsUpdateService();
365
+ // The host-wide services and the provider closures built on them live in `./server-deps.ts`.
366
+ const { modelCatalog, providerAuth, workspaceConfig, openTerminal, openFile, openApp } = resolveServerDeps(deps, bootRoot);
367
+ const { providerStatus, providerActionError, invalidateHostModels } = createProviderStatus({ modelCatalog, providerAuth, workspaceConfig });
834
368
  // ---- workspace boot-project identity (multi-project spec) ----------------
835
369
  // The boot flow (`initWorkspace` in src/index.ts) registers the boot repo
836
370
  // and plumbs its registry id in via `deps.bootProjectId`. Legacy callers and
@@ -889,21 +423,8 @@ export function createApp(deps) {
889
423
  // and since step 2.3 the per-dataDir watch is created lazily by the first
890
424
  // SSE subscription (and torn down with the last), nothing to start here.
891
425
  // ---- project contexts (multi-project spec, step 2.2) ---------------------
892
- // The boot project's context is SEEDED from the deps the caller already
893
- // built (src/index.ts `serveCommand` did the recover/prune/launch-key work
894
- // at startup — observable boot behavior unchanged); it never enters the
895
- // lazy map, so its `.ai/cezar` state is never double-opened. `id` starts as
896
- // the reserved alias when registration was suppressed — handlers never read
897
- // it; API payloads name the boot project via `resolveBootProject` instead.
898
- const bootContext = {
899
- id: bootProjectId ?? 'default',
900
- root: bootRoot,
901
- dataDir: bootDataDir,
902
- store: deps.store,
903
- manager: deps.manager,
904
- automationStore: deps.automationStore ?? AutomationStore.open(bootDataDir),
905
- launchKey: ensureLaunchKey(bootDataDir), // bookmarklet auto-start secret (spec 011)
906
- };
426
+ // The boot project's context is SEEDED from the deps the caller already built (`createBootContext`).
427
+ const bootContext = createBootContext(deps, bootRoot, bootDataDir);
907
428
  // The task webhook (#589): attached before any recovery — the boot store by `index.ts`, lazy
908
429
  // projects through `prepareManager` below. Attaching is idempotent, so the calls here only
909
430
  // cover an app built without boot's wiring (tests, embedded servers).
@@ -929,6 +450,8 @@ export function createApp(deps) {
929
450
  });
930
451
  // Workspace-level SSE bus (step 2.8) — the registry mutators and the
931
452
  // checkout flow (Phase 4) emit here; /api/workspace/events relays.
453
+ const serverGeneration = randomUUID();
454
+ const checkoutProgress = new CheckoutProgressCache();
932
455
  const workspaceEvents = deps.workspaceEvents ?? new WorkspaceEventBus();
933
456
  const emitAutomationChange = (project, automationId, revision, deleted = false) => workspaceEvents.emit('automation-change', {
934
457
  project: project.id,
@@ -941,61 +464,84 @@ export function createApp(deps) {
941
464
  ?? new ProviderRuntimeAuthObserver(providerAuth, (status) => {
942
465
  workspaceEvents.emit('provider-status', status);
943
466
  });
944
- providerRuntimeAuth.watch(bootContext.store);
945
- for (const id of contexts.ids()) {
946
- const ctx = contexts.peek(id);
947
- if (ctx)
948
- providerRuntimeAuth.watch(ctx.store);
949
- }
950
- contexts.onStoreCreated((store) => providerRuntimeAuth.watch(store));
951
- contexts.onContextBuilt((ctx) => providerRuntimeAuth.watch(ctx.store));
952
- // Injected contexts (tests) skip `prepareManager`; attach those when they are built.
953
- for (const id of contexts.ids()) {
954
- const ctx = contexts.peek(id);
955
- if (ctx)
956
- taskWebhooks.attach(ctx);
957
- }
958
- contexts.onContextBuilt((ctx) => taskWebhooks.attach(ctx));
467
+ watchProviderRuntimeAuth(providerRuntimeAuth, bootContext, contexts);
468
+ attachProjectWebhooks(taskWebhooks, contexts);
469
+ // #667: recovery ran inside the build, before any workspace stream attached to this project's
470
+ // store, so the runs it resumed sent no `run` event. Until a cockpit refetches, its cross-project
471
+ // index keeps the cold read's answer: every run the restart interrupted, shown as failed.
472
+ contexts.onContextBuilt((ctx) => workspaceEvents.emit('project-ready', { project: ctx.id }));
959
473
  const projectRegistryEntry = async (projectId) => {
960
474
  const id = projectId === 'default' ? await resolveBootProject() : projectId;
961
475
  return (await loadWorkspaceConfig()).projects.find((entry) => entry.id === id);
962
476
  };
477
+ /** One `task.test` POST to `webhook`, or a dry run under `CEZ_DRY_RUN=1`. */
478
+ const sendTestDelivery = async (projectId, webhook) => {
479
+ if (process.env.CEZ_DRY_RUN === '1')
480
+ return { ok: true, dryRun: true };
481
+ const payload = {
482
+ event: 'task.test',
483
+ deliveryId: randomUUID(),
484
+ projectId,
485
+ webhook: { id: webhook.id, name: webhook.name },
486
+ occurredAt: new Date().toISOString(),
487
+ };
488
+ const result = await deliverOnce(webhook, payload, { fetch: deps.taskWebhookFetch });
489
+ return {
490
+ ok: result.ok,
491
+ ...(result.status === undefined ? {} : { status: result.status }),
492
+ ...(result.error === undefined ? {} : { error: result.error }),
493
+ };
494
+ };
495
+ /**
496
+ * Read-modify-write one project's webhook list (hearsay-tools/cezarion#1576), with the PATCH
497
+ * route's guards: single-project mode refuses, an unknown project 404s without a write, and a
498
+ * failed write (a read-only home) answers 500 having persisted nothing.
499
+ */
500
+ const editProjectWebhooks = async (raw, edit) => {
501
+ if (capabilities().singleProject)
502
+ return { ...singleProjectRefusal('editing projects'), status: 409 };
503
+ if (!projectIdSchema.safeParse(raw).success)
504
+ return { error: `unknown project: ${raw}`, status: 404 };
505
+ const id = raw === 'default' ? await resolveBootProject() : raw;
506
+ const known = await loadWorkspaceConfig().then((config) => config.projects.some((p) => p.id === id), () => false);
507
+ if (!known)
508
+ return { error: `unknown project: ${id}`, status: 404 };
509
+ let updated;
510
+ let outcome;
511
+ try {
512
+ await mergeWriteWorkspaceConfig((config) => {
513
+ const entry = config.projects.find((p) => p.id === id);
514
+ if (!entry)
515
+ return;
516
+ outcome = edit([...projectWebhooks(entry)]);
517
+ if ('error' in outcome)
518
+ return;
519
+ setProjectWebhooks(entry, outcome.webhooks);
520
+ updated = entry;
521
+ });
522
+ }
523
+ catch (err) {
524
+ return { error: err instanceof Error ? err.message : String(err), status: 500 };
525
+ }
526
+ if (outcome && 'error' in outcome)
527
+ return outcome;
528
+ if (!updated || !outcome)
529
+ return { error: `unknown project: ${id}`, status: 404 };
530
+ return {
531
+ body: {
532
+ project: toProjectListEntry(updated, await probeProjectStatus(updated.root)),
533
+ webhook: redactWebhook(outcome.webhook),
534
+ },
535
+ };
536
+ };
963
537
  const app = new Hono();
964
538
  // Reject oversized request bodies before they reach any handler (#429). GETs
965
539
  // and SSE carry no body, so this only ever gates the mutating routes.
966
540
  app.use('*', bodyLimit({ maxSize: GLOBAL_BODY_LIMIT }));
967
- // ---- request-origin guard (#426) -----------------------------------------
968
- // This server executes agents with shell access — "start a task" ≈ run code
969
- // as the user — so "bind 127.0.0.1 + same-origin" is not a perimeter on its
970
- // own: any page the user visits can still POST to us (CSRF), and DNS
971
- // rebinding can point a foreign domain at loopback and read our responses.
972
- // Two zero-config checks close both holes on every /api route EXCEPT
973
- // /api/v1/health (the intentional cross-origin discovery endpoint, spec 011 —
974
- // it exposes nothing sensitive, see #431):
975
- // 1. Host allowlist (loopback deployments only) — a request whose Host is
976
- // not a loopback name did not really originate from this machine. A
977
- // rebound `evil.com` still sends `Host: evil.com`, so this kills DNS
978
- // rebinding for reads AND writes. Skipped in hosted mode (CEZ_REMOTE /
979
- // non-loopback bind), where the reverse proxy forwards the real public
980
- // Host and TLS+auth own the perimeter.
981
- // The match runs through `isLoopbackHostHeader`, whose 127.0.0.0/8 test
982
- // is *anchored* and whose missing-Host answer is "untrusted". Both are
983
- // load-bearing: a `startsWith('127.')` prefix would accept the
984
- // attacker-registrable `127.0.0.1.evil.com`, and such a page is really
985
- // same-origin with us, so checks 2 and 3 would wave it through too.
986
- // 2. Same-origin write guard — a cross-origin write always carries an
987
- // `Origin` header (browsers attach it to every non-GET), so its full
988
- // authority (host AND port) must match the served Host. A blind CSRF
989
- // POST from evil.tld is rejected; the cockpit's own same-origin fetch
990
- // (Origin === Host, or no Origin at all for non-browser callers) passes
991
- // untouched. Works in both local and hosted mode because it compares
992
- // Origin to the actual Host.
993
- // Scope note: check 2 covers writes only. A cross-origin GET from any site
994
- // still reaches the read routes — but its Host is ours, so it is a *forced
995
- // request*, not a read: the same-origin policy stops the attacker seeing any
996
- // response body (we send CORS headers on /api/v1/health alone), and no GET
997
- // handler mutates state. Rebinding, which WOULD make those reads legible, is
998
- // what check 1 stops.
541
+ // Local APIs require a loopback Host, including health. Browser writes require
542
+ // matching full authority or the verified local dev-proxy case. Health alone is
543
+ // CORS-readable. Hosted mode delegates Host/auth policy to the deployment;
544
+ // absent Origin remains valid for native clients. Keep WS trust checks separate.
999
545
  const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
1000
546
  const isHostedMode = () => !resolveCapabilities(process.env, bindHost).localHandoff;
1001
547
  app.use('/api/*', async (c, next) => {
@@ -1055,17 +601,8 @@ export function createApp(deps) {
1055
601
  }
1056
602
  return next();
1057
603
  });
1058
- // The mirrored project-route table (spec "API Contracts → Project-scoped").
1059
- // Every route below registers ONCE on this sub-app; `createApp` mounts it
1060
- // twice — under `/api/v1/p/:projectId` (scoped) and under `/api/v1` (bound to
1061
- // the boot project) — so both spellings share one handler and can never
1062
- // drift. The resolver middleware binds `c.get('project')`:
1063
- // no `projectId` param (unscoped mount) → the boot context, byte-identical to
1064
- // the pre-workspace closures; `default` or the boot project's own id → the
1065
- // boot context too; anything else → the lazy context map, with
1066
- // `ProjectContextError` mapped to 404 (unknown) / 409 (missing root).
1067
- // Named rather than inlined because both mounts share it — one function, so
1068
- // the two spellings can never disagree about what `default` means.
604
+ // Register project routes once; mount them under scoped and boot-alias prefixes.
605
+ // Resolve default and boot ids to the boot context; unknown/missing projects map to 404/409.
1069
606
  if (deps.projectLookup) {
1070
607
  deps.projectLookup.resolve = async (raw) => {
1071
608
  if (raw === undefined || raw === 'default' || raw === (await resolveBootProject()))
@@ -1129,6 +666,12 @@ export function createApp(deps) {
1129
666
  return c.json({ error: 'not found' }, 404);
1130
667
  return new Response(readFileSync(path), { headers: { 'content-type': type } });
1131
668
  };
669
+ // Stable SharedWorker identity across same-origin tabs; its imported chunks stay hashed.
670
+ app.get('/live-worker.js', (c) => {
671
+ const response = staticFile('live-worker.js', 'text/javascript; charset=utf-8')(c);
672
+ response.headers.set('Cache-Control', 'no-cache');
673
+ return response;
674
+ });
1132
675
  let hintLogged = false;
1133
676
  const serveShell = (c) => {
1134
677
  const distIndex = join(distDir, 'index.html');
@@ -1435,6 +978,9 @@ export function createApp(deps) {
1435
978
  for (const account of listAgentProfiles(store, PROVIDER_IDS)) {
1436
979
  if (account.isDefault)
1437
980
  continue; // covered by `status()` above
981
+ // Probing it would sign in or report on another folder (#1283); its status route says why.
982
+ if (namedAccountUnavailable(account.provider, account.path) !== null)
983
+ continue;
1438
984
  await providerAuth
1439
985
  .profileStatus(account.provider, { id: account.id, configDir: account.path })
1440
986
  .catch(() => { });
@@ -1450,7 +996,7 @@ export function createApp(deps) {
1450
996
  // `modelDiscoveryRunnerSchema` is the contract's own list of the runners with an
1451
997
  // authoritative host-local catalog (#794), so the client compiles against exactly what this
1452
998
  // validates, including Claude stream-json discovery (#89).
1453
- .get('/models', queryZodValidator(z.object({ runner: z.union([z.string(), z.array(z.string()).transform((v) => v[0])]).pipe(modelDiscoveryRunnerSchema) }), { message: `runner must be ${MODEL_DISCOVERY_RUNNERS.slice(0, -1).join(', ')}, or ${MODEL_DISCOVERY_RUNNERS.at(-1)}` }), async (c) => {
999
+ .get('/models', queryZodValidator(modelDiscoveryQuerySchema, { message: `runner must be ${MODEL_DISCOVERY_RUNNERS.slice(0, -1).join(', ')}, or ${MODEL_DISCOVERY_RUNNERS.at(-1)}` }), async (c) => {
1454
1000
  const query = { data: c.req.valid('query') };
1455
1001
  return c.json(query.data.runner === 'cursor'
1456
1002
  ? modelCatalog.getCached('cursor')
@@ -1495,7 +1041,10 @@ export function createApp(deps) {
1495
1041
  return { error: `this session belongs to an account that no longer exists (${profileId})` };
1496
1042
  }
1497
1043
  const { profile } = resolved;
1498
- return { env: profile.isDefault ? {} : profileEnv(provider, profile.path) };
1044
+ if (profile.isDefault)
1045
+ return { env: {} };
1046
+ const unavailable = namedAccountUnavailable(provider, profile.path);
1047
+ return unavailable !== null ? { error: unavailable } : { env: profileEnv(provider, profile.path) };
1499
1048
  };
1500
1049
  /** The copy-paste fallback shown when no terminal could be opened — same account, spelled for
1501
1050
  * this platform's shell. `null` when the dir cannot be embedded safely, which is a refusal. */
@@ -1514,7 +1063,7 @@ export function createApp(deps) {
1514
1063
  };
1515
1064
  // ---- chained family: agent providers (workspace-level) ----
1516
1065
  const providersRoutes = new Hono()
1517
- .get('/providers/status', queryZodValidator(z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') }), { message: 'refresh must be 1 when provided' }), async (c) => {
1066
+ .get('/providers/status', queryZodValidator(strictRefreshQuerySchema, { message: 'refresh must be 1 when provided' }), async (c) => {
1518
1067
  const query = { data: c.req.valid('query') };
1519
1068
  const refresh = query.data.refresh === '1';
1520
1069
  if (refresh) {
@@ -1523,7 +1072,7 @@ export function createApp(deps) {
1523
1072
  }
1524
1073
  return c.json(await providerStatus({ refresh }));
1525
1074
  })
1526
- .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) => {
1075
+ .put('/providers/:provider/enabled', paramZodValidator(providerRouteParamsSchema, { message: 'provider and enabled boolean are required' }), jsonZodValidator(providerEnabledSchema, { message: 'provider and enabled boolean are required' }), async (c) => {
1527
1076
  const provider = { data: c.req.valid('param').provider };
1528
1077
  const body = { data: c.req.valid('json') };
1529
1078
  let workspace;
@@ -1546,7 +1095,7 @@ export function createApp(deps) {
1546
1095
  workspaceEvents.emit('provider-status', row);
1547
1096
  return c.json(result);
1548
1097
  })
1549
- .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) => {
1098
+ .post('/providers/:provider/retry', paramZodValidator(providerRouteParamsSchema, { message: 'provider and current authFailureId are required' }), jsonZodValidator(providerRetrySchema, { message: 'provider and current authFailureId are required' }), async (c) => {
1550
1099
  const provider = { data: c.req.valid('param').provider };
1551
1100
  const body = { data: c.req.valid('json') };
1552
1101
  if (!providerAuth.clearRuntimeAuthFailure(provider.data, body.data.authFailureId)) {
@@ -1581,6 +1130,10 @@ export function createApp(deps) {
1581
1130
  if ('error' in resolved)
1582
1131
  return c.json({ error: resolved.error }, 400);
1583
1132
  const { profile } = resolved;
1133
+ // A login aimed at a folder the agent would not open signs in another account (#1283).
1134
+ const unavailable = profile.isDefault ? null : namedAccountUnavailable(provider, profile.path);
1135
+ if (unavailable !== null)
1136
+ return c.json({ error: unavailable }, 409);
1584
1137
  const command = providerAuth.loginCommand(provider, profile.isDefault ? null : profile.path);
1585
1138
  // Fail closed: a config dir that cannot be embedded safely in this platform's shell has no
1586
1139
  // safe degradation. Running the bare command would sign the user into a DIFFERENT account
@@ -1655,6 +1208,10 @@ export function createApp(deps) {
1655
1208
  ...(profile.provider === 'claude' ? { claude: profile.path } : {}),
1656
1209
  ...(profile.provider === 'codex' ? { codex: profile.path } : {}),
1657
1210
  ...(profile.provider === 'opencode' ? { opencodeConfig: profile.path } : {}),
1211
+ ...(profile.provider === 'pi' ? { pi: profile.path } : {}),
1212
+ // The discovered omp account is already an agent dir; a named one is a profile folder that
1213
+ // keeps its config in `agent/`.
1214
+ ...(profile.provider === 'omp' ? { omp: profile.isDefault ? profile.path : profileAgentDir('omp', profile.path) } : {}),
1658
1215
  };
1659
1216
  const defs = listConfigFiles().filter((def) => def.scope === 'user' && def.runners.includes(profile.provider));
1660
1217
  return Promise.all(defs.map(async (def) => {
@@ -1711,7 +1268,7 @@ export function createApp(deps) {
1711
1268
  }
1712
1269
  };
1713
1270
  /** Validate a client-supplied config dir. Returns the error text, or null when it is usable. */
1714
- const checkProfileDir = (configDir) => {
1271
+ const checkProfileDir = async (configDir, provider) => {
1715
1272
  if (CONTROL_CHARS_RE.test(configDir))
1716
1273
  return 'folder must not contain control characters';
1717
1274
  const expanded = expandTilde(configDir);
@@ -1721,13 +1278,18 @@ export function createApp(deps) {
1721
1278
  // real Windows path, and this is the only gate the Add-account dialog has.
1722
1279
  if (!isAbsoluteConfigDir(expanded))
1723
1280
  return `folder must be an absolute path: ${configDir}`;
1281
+ // An omp account is selected by profile NAME, so only a folder in omp's profiles dir can be one.
1282
+ if (provider === 'omp')
1283
+ return ompProfileDirError(expanded);
1724
1284
  return null;
1725
1285
  };
1726
1286
  /** Refuse a dir that is already some other account's (or the default's), compared through
1727
1287
  * `realpath` — two spellings of one directory would be two accounts silently sharing one
1728
1288
  * session store, and "which one am I logged into?" would stop having an answer. */
1729
1289
  const conflictingProfile = async (profiles, provider, path, exceptId) => {
1730
- if (await sameProfileDir(path, defaultAgentProfile(provider).path)) {
1290
+ // Compared as agent dirs: the discovered omp account is one (a host `OMP_PROFILE=work` makes it
1291
+ // `…/profiles/work/agent`), while a named omp account is the profile folder holding it.
1292
+ if (await sameProfileDir(profileAgentDir(provider, path), defaultAgentProfile(provider).path)) {
1731
1293
  return 'that is already this agent\'s default folder';
1732
1294
  }
1733
1295
  for (const candidate of profiles) {
@@ -1780,7 +1342,7 @@ export function createApp(deps) {
1780
1342
  if (!supportsProfiles(provider)) {
1781
1343
  return c.json({ error: `${provider} cannot carry more than one account` }, 400);
1782
1344
  }
1783
- const dirError = checkProfileDir(configDir);
1345
+ const dirError = await checkProfileDir(configDir, provider);
1784
1346
  if (dirError)
1785
1347
  return c.json({ error: dirError }, 400);
1786
1348
  // Read-first, exactly like `POST /projects`: the duplicate check needs `realpath`, and the
@@ -1828,13 +1390,13 @@ export function createApp(deps) {
1828
1390
  .catch(() => { });
1829
1391
  return c.json({ profile: created201 }, 201);
1830
1392
  })
1831
- .patch('/workspace/agent-profiles/:id', paramZodValidator(z.object({ id: z.string() })), jsonZodValidator(() => updateAgentProfileSchema), async (c) => {
1393
+ .patch('/workspace/agent-profiles/:id', paramZodValidator(agentProfileRouteParamsSchema), jsonZodValidator(() => updateAgentProfileSchema), async (c) => {
1832
1394
  if (!capabilities().localHandoff)
1833
1395
  return c.json(hostedProfileRefusal, 409);
1834
1396
  const id = c.req.param('id');
1835
1397
  const { label, configDir } = c.req.valid('json');
1836
1398
  if (configDir !== undefined) {
1837
- const dirError = checkProfileDir(configDir);
1399
+ const dirError = await checkProfileDir(configDir);
1838
1400
  if (dirError)
1839
1401
  return c.json({ error: dirError }, 400);
1840
1402
  }
@@ -1851,6 +1413,10 @@ export function createApp(deps) {
1851
1413
  if (!current)
1852
1414
  return c.json({ error: `unknown account: ${id}` }, 404);
1853
1415
  if (configDir !== undefined) {
1416
+ // The provider's own folder rule needs the account, so it runs once the id is known.
1417
+ const dirError = await checkProfileDir(configDir, current.provider);
1418
+ if (dirError)
1419
+ return c.json({ error: dirError }, 400);
1854
1420
  const conflict = await conflictingProfile(existing, current.provider, expandTilde(configDir), id);
1855
1421
  if (conflict !== null)
1856
1422
  return c.json({ error: conflict }, 409);
@@ -1896,12 +1462,14 @@ export function createApp(deps) {
1896
1462
  * the "Check again" affordance — the cache itself holds a connected answer for minutes and an
1897
1463
  * unsettled one for a minute (`cacheTtlFor` in `core/provider-auth.ts`).
1898
1464
  */
1899
- .get('/workspace/agent-profiles/:id/status', paramZodValidator(z.object({ id: z.string() })), queryZodValidator(z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') }), { message: 'refresh must be 1 when provided' }), async (c) => {
1465
+ .get('/workspace/agent-profiles/:id/status', paramZodValidator(agentProfileRouteParamsSchema), queryZodValidator(strictRefreshQuerySchema, { message: 'refresh must be 1 when provided' }), async (c) => {
1466
+ // jscpd:ignore-start -- hearsay-tools/cezarion#1888: unchanged gate; only the validator arguments around it moved.
1900
1467
  if (!capabilities().localHandoff)
1901
1468
  return c.json(hostedProfileRefusal, 409);
1902
1469
  const account = await accountById(c.req.param('id'));
1903
1470
  if (!account)
1904
1471
  return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
1472
+ // jscpd:ignore-end
1905
1473
  const refresh = c.req.valid('query').refresh === '1';
1906
1474
  if (refresh)
1907
1475
  invalidateHostModels(account.provider);
@@ -1912,6 +1480,12 @@ export function createApp(deps) {
1912
1480
  const row = all.providers.find((candidate) => candidate.provider === account.provider);
1913
1481
  return c.json({ status: row ?? { provider: account.provider, status: 'unknown' } });
1914
1482
  }
1483
+ // A probe of an account the agent would not open reports on another folder (#1283), so the
1484
+ // row says why instead, without a spawn.
1485
+ const unavailable = namedAccountUnavailable(account.provider, account.path);
1486
+ if (unavailable !== null) {
1487
+ return c.json({ status: { provider: account.provider, status: 'unknown', hint: unavailable, profileId: account.id } });
1488
+ }
1915
1489
  // "Check again" re-probes THIS account only — the other accounts' warm answers are not
1916
1490
  // invalidated by asking about this one.
1917
1491
  if (refresh)
@@ -1924,20 +1498,17 @@ export function createApp(deps) {
1924
1498
  });
1925
1499
  })
1926
1500
  /**
1927
- * Who this account is signed in AS — the pane's "Show details".
1928
- *
1929
- * A separate, on-demand route rather than a field on the listing, and that is what makes
1930
- * "hidden by default" real: an email carried by the listing would already be in the response,
1931
- * the query cache and devtools, whatever the UI chose to render. `provider-auth.ts` keeps
1932
- * identity out of ITS boundary on purpose; this is the deliberate, local-only, opt-in
1933
- * exception — not a widening of that rule. Never logged, never persisted.
1501
+ * Local-only account details, fetched separately on request. Keep identity out of normal
1502
+ * listings, logs and run persistence.
1934
1503
  */
1935
- .get('/workspace/agent-profiles/:id/details', paramZodValidator(z.object({ id: z.string() })), async (c) => {
1504
+ .get('/workspace/agent-profiles/:id/details', paramZodValidator(agentProfileRouteParamsSchema), async (c) => {
1505
+ // jscpd:ignore-start -- hearsay-tools/cezarion#1888: unchanged gate; only the validator arguments around it moved.
1936
1506
  if (!capabilities().localHandoff)
1937
1507
  return c.json(hostedProfileRefusal, 409);
1938
1508
  const account = await accountById(c.req.param('id'));
1939
1509
  if (!account)
1940
1510
  return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
1511
+ // jscpd:ignore-end
1941
1512
  return c.json(await readAccountIdentity(account.provider, account.path));
1942
1513
  })
1943
1514
  /**
@@ -1948,12 +1519,14 @@ export function createApp(deps) {
1948
1519
  * `/api/v1/agent-config/:id` follows). `folder` is the one extra keyword, and it resolves to
1949
1520
  * the account's dir rather than anything the caller spelled.
1950
1521
  */
1951
- .post('/workspace/agent-profiles/:id/open', paramZodValidator(z.object({ id: z.string() })), jsonZodValidator(() => openAgentAccountFileSchema), async (c) => {
1522
+ .post('/workspace/agent-profiles/:id/open', paramZodValidator(agentProfileRouteParamsSchema), jsonZodValidator(() => openAgentAccountFileSchema), async (c) => {
1523
+ // jscpd:ignore-start -- hearsay-tools/cezarion#1888: unchanged gate; only the validator arguments around it moved.
1952
1524
  if (!capabilities().localHandoff)
1953
1525
  return c.json(hostedProfileRefusal, 409);
1954
1526
  const account = await accountById(c.req.param('id'));
1955
1527
  if (!account)
1956
1528
  return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
1529
+ // jscpd:ignore-end
1957
1530
  const { file, target } = c.req.valid('json');
1958
1531
  let path;
1959
1532
  if (file === 'folder') {
@@ -2055,7 +1628,7 @@ export function createApp(deps) {
2055
1628
  }
2056
1629
  return c.json({ selections: store.selections, defaults: store.defaults });
2057
1630
  })
2058
- .delete('/workspace/agent-profiles/:id', paramZodValidator(z.object({ id: z.string() })), async (c) => {
1631
+ .delete('/workspace/agent-profiles/:id', paramZodValidator(agentProfileRouteParamsSchema), async (c) => {
2059
1632
  if (!capabilities().localHandoff)
2060
1633
  return c.json(hostedProfileRefusal, 409);
2061
1634
  const id = c.req.param('id');
@@ -2182,6 +1755,9 @@ export function createApp(deps) {
2182
1755
  error: `cezar is serving ${entry.name} right now — it re-registers itself at every start, so it cannot be removed from here`,
2183
1756
  }, 409);
2184
1757
  }
1758
+ // A background warm-up may be building this project right now: count its runs, and dispose
1759
+ // it, only once that build has landed (#667).
1760
+ await contexts.settle(id);
2185
1761
  const active = activeRunCount(id);
2186
1762
  if (active > 0) {
2187
1763
  return c.json({
@@ -2278,13 +1854,10 @@ export function createApp(deps) {
2278
1854
  }
2279
1855
  if (webhook !== undefined) {
2280
1856
  // The token is write-only (#589): the form never has it back, so an omitted token
2281
- // keeps the stored one and only `''` clears it.
2282
- if (webhook === null)
2283
- delete entry.webhook;
2284
- else {
2285
- const token = webhook.token === undefined ? entry.webhook?.token : webhook.token || undefined;
2286
- entry.webhook = { url: webhook.url, ...(token ? { token } : {}) };
2287
- }
1857
+ // keeps the stored one and only `''` clears it. An omitted `triggers` keeps the stored
1858
+ // selection the same way, so a URL or token edit never resets it (#1356).
1859
+ // Since #1576 this edits the first webhook with task notifications on.
1860
+ applyTaskWebhookInput(entry, webhook);
2288
1861
  }
2289
1862
  updated = entry;
2290
1863
  });
@@ -2319,26 +1892,83 @@ export function createApp(deps) {
2319
1892
  const entry = await projectRegistryEntry(raw).catch(() => undefined);
2320
1893
  if (!entry)
2321
1894
  return c.json({ error: `unknown project: ${raw}` }, 404);
2322
- if (!entry.webhook)
1895
+ // Since #1576: the first webhook with task notifications on.
1896
+ const webhook = taskWebhookList(entry)[0];
1897
+ if (!webhook)
2323
1898
  return c.json({ error: NO_WEBHOOK_ERROR }, 400);
2324
- const payload = {
2325
- event: 'task.test',
2326
- deliveryId: randomUUID(),
2327
- projectId: entry.id,
2328
- occurredAt: new Date().toISOString(),
2329
- };
2330
- if (process.env.CEZ_DRY_RUN === '1') {
2331
- const body = { ok: true, dryRun: true };
2332
- return c.json(body);
1899
+ const body = await sendTestDelivery(entry.id, webhook);
1900
+ return c.json(body);
1901
+ })
1902
+ // ---- project webhooks (hearsay-tools/cezarion#1576) ----
1903
+ // Several webhooks per project, each with its own task-state selection and an explicit
1904
+ // "Expose to agents" switch. Registry edits like the PATCH above: refused in single-project
1905
+ // mode, 404 on an unknown project or webhook, and a write that names nothing else leaves the
1906
+ // rest of the entry alone. The first write stores `webhooks` and drops the old `webhook` key.
1907
+ .post('/projects/:projectId/webhooks', jsonZodValidator(createProjectWebhookInputSchema), async (c) => {
1908
+ const input = c.req.valid('json');
1909
+ const answer = await editProjectWebhooks(c.req.param('projectId'), (webhooks) => {
1910
+ if (webhooks.length >= PROJECT_WEBHOOKS_MAX)
1911
+ return { error: `a project holds at most ${PROJECT_WEBHOOKS_MAX} webhooks`, status: 409 };
1912
+ if (nameTaken(webhooks, input.name))
1913
+ return { error: webhookNameTakenError(input.name), status: 409 };
1914
+ const webhook = createdWebhook(webhooks, input);
1915
+ return { webhooks: [...webhooks, webhook], webhook };
1916
+ });
1917
+ if ('error' in answer)
1918
+ return c.json({ error: answer.error }, answer.status);
1919
+ const body = answer.body;
1920
+ return c.json(body, 201);
1921
+ })
1922
+ .patch('/projects/:projectId/webhooks/:webhookId', paramZodValidator(projectWebhookParamsSchema), jsonZodValidator(updateProjectWebhookInputSchema), async (c) => {
1923
+ const { projectId, webhookId } = c.req.valid('param');
1924
+ const input = c.req.valid('json');
1925
+ const answer = await editProjectWebhooks(projectId, (webhooks) => {
1926
+ const index = webhooks.findIndex((webhook) => webhook.id === webhookId);
1927
+ if (index < 0)
1928
+ return { error: `unknown webhook: ${webhookId}`, status: 404 };
1929
+ if (input.name !== undefined && nameTaken(webhooks, input.name, webhookId))
1930
+ return { error: webhookNameTakenError(input.name), status: 409 };
1931
+ const webhook = editedWebhook(webhooks[index], input);
1932
+ return { webhooks: webhooks.map((entry, at) => (at === index ? webhook : entry)), webhook };
1933
+ });
1934
+ if ('error' in answer)
1935
+ return c.json({ error: answer.error }, answer.status);
1936
+ const body = answer.body;
1937
+ return c.json(body);
1938
+ })
1939
+ .delete('/projects/:projectId/webhooks/:webhookId', paramZodValidator(projectWebhookParamsSchema), async (c) => {
1940
+ const { projectId, webhookId } = c.req.valid('param');
1941
+ const answer = await editProjectWebhooks(projectId, (webhooks) => {
1942
+ const webhook = webhooks.find((entry) => entry.id === webhookId);
1943
+ if (!webhook)
1944
+ return { error: `unknown webhook: ${webhookId}`, status: 404 };
1945
+ return { webhooks: webhooks.filter((entry) => entry !== webhook), webhook };
1946
+ });
1947
+ if ('error' in answer)
1948
+ return c.json({ error: answer.error }, answer.status);
1949
+ const project = answer.body.project;
1950
+ const body = { project: defined({ ...project, webhook: project.webhook && defined(project.webhook) }) };
1951
+ return c.json(body);
1952
+ })
1953
+ // "Send test" for one webhook: one `task.test` delivery, now, no retries.
1954
+ .post('/projects/:projectId/webhooks/:webhookId/test', paramZodValidator(projectWebhookParamsSchema), async (c) => {
1955
+ if (capabilities().singleProject) {
1956
+ return c.json(singleProjectRefusal('editing projects'), 409);
2333
1957
  }
2334
- const result = await deliverOnce(entry.webhook, payload, { fetch: deps.taskWebhookFetch });
2335
- const body = {
2336
- ok: result.ok,
2337
- ...(result.status === undefined ? {} : { status: result.status }),
2338
- ...(result.error === undefined ? {} : { error: result.error }),
2339
- };
1958
+ const { projectId, webhookId } = c.req.valid('param');
1959
+ if (!projectIdSchema.safeParse(projectId).success) {
1960
+ return c.json({ error: `unknown project: ${projectId}` }, 404);
1961
+ }
1962
+ const entry = await projectRegistryEntry(projectId).catch(() => undefined);
1963
+ if (!entry)
1964
+ return c.json({ error: `unknown project: ${projectId}` }, 404);
1965
+ const webhook = projectWebhooks(entry).find((candidate) => candidate.id === webhookId);
1966
+ if (!webhook)
1967
+ return c.json({ error: `unknown webhook: ${webhookId}` }, 404);
1968
+ const body = await sendTestDelivery(entry.id, webhook);
2340
1969
  return c.json(body);
2341
1970
  })
1971
+ .get('/projects/checkout/:checkoutId/progress', paramZodValidator(checkoutProgressParamsSchema), c => c.json({ progress: checkoutProgress.get(c.req.valid('param').checkoutId) }))
2342
1972
  .post('/projects/checkout', jsonZodValidator(() => checkoutSchema, { message: 'url must be a GitHub repository' }), async (c) => {
2343
1973
  if (capabilities().singleProject) {
2344
1974
  return c.json(singleProjectRefusal('adding projects'), 409);
@@ -2350,7 +1980,7 @@ export function createApp(deps) {
2350
1980
  name,
2351
1981
  checkoutId,
2352
1982
  projectsDir: expandTilde(await workspaceProjectsDir()),
2353
- onProgress: (event) => workspaceEvents.emit('checkout-progress', event),
1983
+ onProgress: (event) => { checkoutProgress.record(event); workspaceEvents.emit('checkout-progress', event); },
2354
1984
  // A closed dialog / navigated-away tab aborts the request; the clone is
2355
1985
  // killed and its partial directory removed rather than left running.
2356
1986
  signal: c.req.raw.signal,
@@ -2372,14 +2002,95 @@ export function createApp(deps) {
2372
2002
  return c.json({ error: `${error} (the clone is at ${result.target})` }, registered.status);
2373
2003
  }
2374
2004
  return c.json(registered.body, 200);
2005
+ })
2006
+ // ---- new project (spec 2026-10-15-create-new-project) ------------------
2007
+ // "Add project → New project": a fresh git repo at `<projectsDir>/<name>`,
2008
+ // optionally backed by a new PRIVATE GitHub repository (never public — the
2009
+ // body has no visibility field to set), optionally from a template. Same
2010
+ // progress stream and cache, same `registerFolder` commit, same
2011
+ // `{ error, reason }` degradation as the clone route above.
2012
+ //
2013
+ // A hosted cockpit creates LOCAL projects only: GitHub creation would act
2014
+ // with the host's `gh` account on a remote viewer's behalf, and the owner
2015
+ // list would show that viewer the host's login and organisations.
2016
+ .get('/projects/github-owners', async (c) => {
2017
+ if (!capabilities().localHandoff) {
2018
+ const body = { available: false, reason: HOSTED_GITHUB_OWNERS_REASON };
2019
+ return c.json(body);
2020
+ }
2021
+ return c.json(await listGithubOwners(deps.projectCommandRunner));
2022
+ })
2023
+ .post('/projects/create', jsonZodValidator(() => createProjectInputSchema, { message: 'project name must be a valid GitHub repository name — letters, digits, ".", "-" and "_" only, no spaces' }), async (c) => {
2024
+ if (capabilities().singleProject) {
2025
+ return c.json(singleProjectRefusal('adding projects'), 409);
2026
+ }
2027
+ const { name, github, checkoutId } = c.req.valid('json');
2028
+ if (github && !capabilities().localHandoff) {
2029
+ return c.json({ error: HOSTED_GITHUB_CREATE_REFUSAL }, 409);
2030
+ }
2031
+ const projectsDir = expandTilde(await workspaceProjectsDir());
2032
+ // `registerFolder` re-checks hosted containment, but only AFTER the
2033
+ // repository exists; asked here first, an out-of-root checkout root
2034
+ // refuses before anything is created.
2035
+ if (!capabilities().localHandoff && !(await isInsideHostedBrowseRoot(join(projectsDir, name)))) {
2036
+ return c.json({ error: 'folder is outside the browsable root' }, 400);
2037
+ }
2038
+ const result = await createProject({
2039
+ name,
2040
+ github,
2041
+ checkoutId,
2042
+ projectsDir,
2043
+ onProgress: (event) => { checkoutProgress.record(event); workspaceEvents.emit('checkout-progress', event); },
2044
+ // Cancels only the steps before `gh repo create` (create-project.ts).
2045
+ signal: c.req.raw.signal,
2046
+ ...(deps.projectCommandRunner ? { run: deps.projectCommandRunner } : {}),
2047
+ });
2048
+ if (!result.ok) {
2049
+ return c.json('reason' in result ? { error: result.error, reason: result.reason } : { error: result.error }, result.status);
2050
+ }
2051
+ // GitHub-backed projects register as `checkout` (they have an origin the
2052
+ // clone flow would also produce); a local-only repo is `local`.
2053
+ const registered = await registerFolder(result.target, result.slug ? 'checkout' : 'local');
2054
+ // A new entry (200), or the existing one for the same root (409 "already registered") — what
2055
+ // creating a registered project whose folder had been deleted produces. The user asked for
2056
+ // that project and it is there, so both are a success. (An if/else chain, not a helper:
2057
+ // only narrowing `registered` keeps the error bodies out of the route's 200 type.)
2058
+ let project;
2059
+ if (registered.status === 200)
2060
+ project = registered.body.project;
2061
+ else if (registered.status === 409 && 'project' in registered.body)
2062
+ project = registered.body.project;
2063
+ else {
2064
+ // As with a clone: the repository is the user's now, so it stays — and a GitHub warning
2065
+ // rides along, since its finishing commands are the only record of what is left to do.
2066
+ const { body } = registered;
2067
+ const error = 'error' in body && body.error ? body.error : 'could not register the new project';
2068
+ const warning = result.warning ? `\n${result.warning}` : '';
2069
+ return c.json({ error: `${error} (the project is at ${result.target})${warning}` }, registered.status);
2070
+ }
2071
+ const body = { project, ...(result.warning ? { warning: result.warning } : {}) };
2072
+ return c.json(body, 200);
2375
2073
  });
2074
+ /** Hosted containment for a path that may not exist yet: lexical for the
2075
+ * path itself, realpath for its nearest existing ancestor (a symlink inside
2076
+ * the browse root may point out of it). Same rule as `registerFolder`. */
2077
+ const isInsideHostedBrowseRoot = async (candidate) => {
2078
+ const browseRoot = resolveBrowseRoot(await workspaceBrowseRoot());
2079
+ if (!(await isLexicallyInsideBrowseRoot(browseRoot, candidate)))
2080
+ return false;
2081
+ let existing = candidate;
2082
+ while (!(await stat(existing).then(() => true, () => false))) {
2083
+ const parent = dirname(existing);
2084
+ if (parent === existing)
2085
+ return false;
2086
+ existing = parent;
2087
+ }
2088
+ return isInsideBrowseRoot(browseRoot, existing);
2089
+ };
2376
2090
  // Register an existing folder (multi-project spec, "Add project" — the
2377
2091
  // folder-browser dialog's commit step, step 4.2). Workspace-level like its
2378
2092
  // GET twin. Everything here is a guard; the registry write itself is one
2379
2093
  // idempotent `registerProject` call.
2380
- const registerProjectSchema = z.object({
2381
- root: z.string().trim().min(1).max(4096),
2382
- });
2383
2094
  /**
2384
2095
  * The register-a-folder half of `POST /api/projects`, factored out so the
2385
2096
  * checkout route (step 4.3) commits its fresh clone through the SAME guards
@@ -2527,60 +2238,6 @@ export function createApp(deps) {
2527
2238
  return 0;
2528
2239
  return ctx.store.listRuns().filter((run) => ACTIVE_RUN_STATUSES.has(run.status)).length;
2529
2240
  };
2530
- // Workspace-level by design: update state spans project and global installs,
2531
- // but the selected registered project supplies the safe, server-owned cwd.
2532
- const skillsUpdateInputSchema = z.object({ projectId: projectIdSchema }).strict();
2533
- const resolveSkillsUpdateRoot = async (raw) => {
2534
- if (!projectIdSchema.safeParse(raw).success)
2535
- return { status: 404, error: `unknown project: ${raw}` };
2536
- const bootId = await resolveBootProject();
2537
- if (raw === 'default' || raw === bootId)
2538
- return { root: bootRoot };
2539
- const project = (await loadWorkspaceConfig()).projects.find((entry) => entry.id === raw);
2540
- if (!project)
2541
- return { status: 404, error: `unknown project: ${raw}` };
2542
- if ((await probeProjectStatus(project.root)).status === 'missing') {
2543
- return { status: 409, error: `project folder not found: ${raw}` };
2544
- }
2545
- return { root: project.root };
2546
- };
2547
- const skillsUpdateResponse = async (state) => {
2548
- const config = await loadWorkspaceConfig();
2549
- return { ...state, autoUpdateEnabled: effectiveSkillsAutoUpdate(config), inherited: config.skillsAutoUpdate === undefined };
2550
- };
2551
- // ---- chained family: skills updates (workspace-level) ----
2552
- const skillsUpdateRoutes = new Hono()
2553
- .get('/workspace/skills-update', queryZodValidator(skillsUpdateInputSchema, { message: 'projectId is required' }), async (c) => {
2554
- const parsed = { data: c.req.valid('query') };
2555
- const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
2556
- if ('error' in resolved)
2557
- return c.json({ error: resolved.error }, resolved.status);
2558
- const state = skillsUpdate.snapshot(resolved.root);
2559
- void skillsUpdate.check(resolved.root).catch(() => { });
2560
- return c.json(await skillsUpdateResponse(state));
2561
- })
2562
- .post('/workspace/skills-update/check', jsonZodValidator(skillsUpdateInputSchema, { message: 'body must contain only projectId' }), async (c) => {
2563
- const parsed = { data: c.req.valid('json') };
2564
- const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
2565
- if ('error' in resolved)
2566
- return c.json({ error: resolved.error }, resolved.status);
2567
- return c.json(await skillsUpdateResponse(await skillsUpdate.check(resolved.root, true)));
2568
- })
2569
- .post('/workspace/skills-update/apply', jsonZodValidator(skillsUpdateInputSchema, { message: 'body must contain only projectId' }), async (c) => {
2570
- const parsed = { data: c.req.valid('json') };
2571
- const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
2572
- if ('error' in resolved)
2573
- return c.json({ error: resolved.error }, resolved.status);
2574
- try {
2575
- return c.json(await skillsUpdateResponse(await skillsUpdate.update(resolved.root, true)));
2576
- }
2577
- catch (error) {
2578
- if (error instanceof SkillsUpdateConflictError) {
2579
- return c.json({ error: 'another skills update operation is running', state: await skillsUpdateResponse(skillsUpdate.snapshot(resolved.root)) }, 409);
2580
- }
2581
- throw error;
2582
- }
2583
- });
2584
2241
  // Workspace-level, server-selected target and executable. The empty JSON body is strict.
2585
2242
  const applicationUpdateRoutes = new Hono()
2586
2243
  .post('/workspace/application-update/apply', jsonZodValidator(applicationUpdateInputSchema), async (c) => {
@@ -2618,11 +2275,6 @@ export function createApp(deps) {
2618
2275
  // events on the workspace stream. `checkoutId` is the cockpit's own
2619
2276
  // correlation token, echoed on every event so two tabs cloning at once never
2620
2277
  // render each other's progress.
2621
- const checkoutSchema = z.object({
2622
- url: z.string().trim().min(1).max(512),
2623
- name: z.string().trim().max(128).optional(),
2624
- checkoutId: z.string().trim().max(128).optional(),
2625
- });
2626
2278
  // ---- workspace settings (multi-project spec, step 2.7) -------------------
2627
2279
  // WORKSPACE-level routes: single-mount (never mirrored under /api/p/),
2628
2280
  // same-origin. The config routes carry the settings UI's slice of
@@ -2632,8 +2284,9 @@ export function createApp(deps) {
2632
2284
  const workspaceConfigBody = (config) => ({
2633
2285
  browseRoot: config.browseRoot,
2634
2286
  projectsDir: config.projectsDir,
2635
- skillsAutoUpdate: config.skillsAutoUpdate ?? null,
2636
- effectiveSkillsAutoUpdate: effectiveSkillsAutoUpdate(config),
2287
+ archiveOnMerge: config.archiveOnMerge ?? null,
2288
+ effectiveArchiveOnMerge: resolveGlobalArchiveOnMerge(config).enabled,
2289
+ archiveOnMergeSource: resolveGlobalArchiveOnMerge(config).source,
2637
2290
  composerDefaults: {
2638
2291
  autonomous: config.composerDefaults.autonomous ?? null,
2639
2292
  worktree: config.composerDefaults.worktree ?? null,
@@ -2665,7 +2318,7 @@ export function createApp(deps) {
2665
2318
  .get('/workspace/config', async (c) => c.json(workspaceConfigBody(await loadWorkspaceConfig())))
2666
2319
  .put('/workspace/config', jsonZodValidator(() => workspaceConfigUpdateSchema), async (c) => {
2667
2320
  const parsed = { data: c.req.valid('json') };
2668
- const { browseRoot, projectsDir, skillsAutoUpdate, composerDefaults, resources, agentDefaults } = parsed.data;
2321
+ const { browseRoot, projectsDir, archiveOnMerge, composerDefaults, resources, agentDefaults } = parsed.data;
2669
2322
  for (const [configuredRoot, create] of [
2670
2323
  [browseRoot, false],
2671
2324
  [projectsDir, true],
@@ -2704,10 +2357,10 @@ export function createApp(deps) {
2704
2357
  config.browseRoot = browseRoot;
2705
2358
  if (projectsDir !== undefined)
2706
2359
  config.projectsDir = projectsDir;
2707
- if (skillsAutoUpdate === null)
2708
- delete config.skillsAutoUpdate;
2709
- else if (skillsAutoUpdate !== undefined)
2710
- config.skillsAutoUpdate = skillsAutoUpdate;
2360
+ if (archiveOnMerge === null)
2361
+ delete config.archiveOnMerge;
2362
+ else if (archiveOnMerge !== undefined)
2363
+ config.archiveOnMerge = archiveOnMerge;
2711
2364
  if (composerDefaults?.autonomous === null)
2712
2365
  delete config.composerDefaults.autonomous;
2713
2366
  else if (composerDefaults?.autonomous !== undefined) {
@@ -2761,6 +2414,8 @@ export function createApp(deps) {
2761
2414
  // e.g. a read-only home — nothing was persisted (atomic tmp+rename).
2762
2415
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2763
2416
  }
2417
+ if (archiveOnMerge !== undefined)
2418
+ deps.archiveOnMergeChanged?.();
2764
2419
  // A resource change takes effect WITHOUT a restart: refresh the shared
2765
2420
  // semaphore's in-memory snapshot and pump every manager (step 2.5's hook).
2766
2421
  if (resources !== undefined)
@@ -2793,47 +2448,9 @@ export function createApp(deps) {
2793
2448
  // Partial updates only — absent keys stay untouched. Bounds mirror the
2794
2449
  // workspace schema (src/workspace/config.ts, step 1.2) exactly, so a value
2795
2450
  // this route accepts can never be degraded away by the next load's `.catch`.
2796
- const workspaceConfigUpdateSchema = z.object({
2797
- browseRoot: z.string().trim().min(1).max(4096).optional(),
2798
- projectsDir: z.string().trim().min(1).max(4096).optional(),
2799
- skillsAutoUpdate: z.boolean().nullable().optional(),
2800
- composerDefaults: z
2801
- .object({
2802
- autonomous: z.boolean().nullable().optional(),
2803
- worktree: z.boolean().nullable().optional(),
2804
- })
2805
- .optional(),
2806
- resources: z
2807
- .object({
2808
- maxParallel: z.number().int().min(1).max(16).optional(),
2809
- maxMonitoringSessions: z.number().int().min(0).max(16).optional(),
2810
- monitoringWakeIntervalMinutes: z.number().int().min(1).max(60).nullable().optional(),
2811
- autoResumeOnUsageLimit: z.boolean().optional(),
2812
- memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
2813
- worktreeRetentionDefault: z.number().int().min(0).max(1000).optional(),
2814
- })
2815
- .optional(),
2816
- // Bounds mirror `src/workspace/config.ts`, so a value this accepts is never degraded away by
2817
- // the next load's `.catch`. `null` clears a key back to "no opinion".
2818
- agentDefaults: z
2819
- .object({
2820
- runner: z.enum(PROVIDER_IDS).nullable().optional(),
2821
- models: z
2822
- .object({
2823
- claude: z.string().trim().min(1).max(200).nullable().optional(),
2824
- codex: z.string().trim().min(1).max(200).nullable().optional(),
2825
- opencode: z.string().trim().min(1).max(200).nullable().optional(),
2826
- pi: z.string().trim().min(1).max(200).nullable().optional(),
2827
- omp: z.string().trim().min(1).max(200).nullable().optional(),
2828
- cursor: z.string().trim().min(1).max(200).nullable().optional(),
2829
- })
2830
- .optional(),
2831
- })
2832
- .optional(),
2833
- });
2834
2451
  // ---- chained family: filesystem browse (workspace-level) ----
2835
2452
  const fsBrowseRoutes = new Hono()
2836
- .get('/fs/browse', queryZodValidator(z.object({ path: queryValue, showHidden: queryValue })), async (c) => {
2453
+ .get('/fs/browse', queryZodValidator(browseQuerySchema), async (c) => {
2837
2454
  if (capabilities().singleProject) {
2838
2455
  return c.json(singleProjectRefusal('folder browsing'), 409);
2839
2456
  }
@@ -2860,31 +2477,17 @@ export function createApp(deps) {
2860
2477
  // cache converges without polling or a manual reload (spec 005 / #555).
2861
2478
  if (c.req.valid('query').wait === '1')
2862
2479
  await waitForTeamSkills(repoRoot);
2863
- return c.json(await discoverSkills(repoRoot));
2480
+ return c.json(await discoverSkills(repoRoot, { builtin: builtinSkillsOf(c.get('project').manager) }));
2864
2481
  })
2865
- // The opt-in catalog for the "Import skills" panel: every skill a default
2866
- // (vendor) repo offers — `open-mercato/skills` — regardless of import state,
2867
- // so the panel can present them all with a per-skill toggle. Empty once a repo
2868
- // configures its own `skillsRepos` (nothing is gated then). `wait=1` lets the
2869
- // panel wait out a cold team-skill cache, same as `GET /skills` (spec 005).
2482
+ // Every team skill the configured sources offer, including those this project switched
2483
+ // off, so the Skills catalog can show each with its toggle: one entry per source copy, so a
2484
+ // name two sources offer comes twice (#1494). Empty without `skillsRepos`.
2485
+ // `wait=1` lets the panel wait out a cold team-skill cache, same as `GET /skills` (spec 005).
2870
2486
  .get('/skills/importable', queryZodValidator(waitQuery), async (c) => {
2871
2487
  const repoRoot = c.get('project').root;
2872
- const gated = await gatedSkillsRepos(repoRoot);
2873
- if (gated.size === 0)
2874
- return c.json([]);
2875
2488
  if (c.req.valid('query').wait === '1')
2876
2489
  await waitForTeamSkills(repoRoot);
2877
- const importable = getTeamSkillsCached(repoRoot)
2878
- .filter((skill) => skill.team && gated.has(skill.team.repo))
2879
- // Spread `description` rather than writing it unconditionally: an undefined VALUE is
2880
- // dropped by JSON.stringify, so the key is absent on the wire, and writing it always
2881
- // typed the route as sending a key it does not. contract/skills.ts says `.optional()`,
2882
- // which is what the client actually receives.
2883
- .map((skill) => ({
2884
- name: skill.name,
2885
- ...(skill.description !== undefined ? { description: skill.description } : {}),
2886
- }));
2887
- return c.json(importable);
2490
+ return c.json(getTeamSkillsCached(repoRoot));
2888
2491
  })
2889
2492
  // Refresh team skills (spec 005): clone/fetch the configured skills repos,
2890
2493
  // then return the merged catalog. Degrades quietly — offline just means the
@@ -2892,7 +2495,7 @@ export function createApp(deps) {
2892
2495
  .post('/skills/refresh', async (c) => {
2893
2496
  const { root: repoRoot } = c.get('project');
2894
2497
  await refreshTeamSkills(repoRoot);
2895
- return c.json(await discoverSkills(repoRoot));
2498
+ return c.json(await discoverSkills(repoRoot, { builtin: builtinSkillsOf(c.get('project').manager) }));
2896
2499
  });
2897
2500
  // ---- chained family: GUI prefs / ui-state (project-scoped) ----
2898
2501
  const uiStateRoutes = new Hono()
@@ -2905,10 +2508,10 @@ export function createApp(deps) {
2905
2508
  // single request may not stuff an unbounded key set (#429) — the shared
2906
2509
  // schema+cap half of both ui-state routes lives in `uiStateBody`.
2907
2510
  const parsed = { data: c.req.valid('json') };
2908
- const merged = { ...(await readUiState(repoRoot)), ...parsed.data };
2511
+ const merged = { ...(await readProjectUiState(repoRoot)), ...parsed.data };
2909
2512
  try {
2910
2513
  await mkdir(dataDir, { recursive: true });
2911
- await writeFile(uiStatePath(repoRoot), `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
2514
+ await writeProjectUiState(repoRoot, merged);
2912
2515
  }
2913
2516
  catch (err) {
2914
2517
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
@@ -3015,7 +2618,7 @@ export function createApp(deps) {
3015
2618
  const blocked = await providerActionError([(await loadConfig(repoRoot)).defaultRunner]);
3016
2619
  if (blocked)
3017
2620
  return c.json({ error: blocked }, 409);
3018
- return c.json(await planChain(repoRoot, parsed.data.task));
2621
+ return c.json(await planChain(repoRoot, parsed.data.task, builtinSkillsOf(c.get('project').manager)));
3019
2622
  });
3020
2623
  const manualChecks = new Map();
3021
2624
  /**
@@ -3055,8 +2658,37 @@ export function createApp(deps) {
3055
2658
  timeZone,
3056
2659
  }),
3057
2660
  onChange: (automationId, revision) => emitAutomationChange(project, automationId, revision),
2661
+ // A route's write settles a crash-left reservation first, as the timer does (#1465).
2662
+ reconcile: async () => { reconcileAutomationReceipts(project.automationStore, project.store); },
3058
2663
  };
3059
2664
  };
2665
+ /** The automations family's behavior, shared with the session tools (#1465). */
2666
+ const automationService = createAutomationService({
2667
+ timeZone: localTimeZone,
2668
+ discovery: { models: modelCatalog, providers: () => providerStatus() },
2669
+ scheduleHandle: (project) => scheduleHandle(project),
2670
+ emitChange: (project, automationId, revision, deleted) => emitAutomationChange(project, automationId, revision, deleted),
2671
+ changed: automationsChanged,
2672
+ // Awaits the driver's settled probe (per-repo cache + in-flight dedupe, 5 s timeout, never
2673
+ // throws): a "still being checked" answer would leave the cockpit's GitHub option disabled
2674
+ // because it does not refetch when the probe completes. `/health` keeps `detectCached`.
2675
+ githubAvailability: async (root) => {
2676
+ const forge = resolveForge(await getRepoInfo(root));
2677
+ return forge ? forge.detect() : { available: false, reason: 'No GitHub remote is configured' };
2678
+ },
2679
+ hasGithubRemote: async (root) => Boolean(resolveForge(await getRepoInfo(root))),
2680
+ });
2681
+ // #1465: every project's manager gets the schedule tools over this service. A manager reads
2682
+ // them at call time, so a session recovered before its project's context finished building
2683
+ // reaches them once this runs. The tools themselves stay behind CEZ_AUTOMATIONS=1.
2684
+ const attachAutomationTools = (project) => project.manager.setAutomationTools?.(createAutomationToolService(automationService, project, { timeZone: localTimeZone }));
2685
+ attachAutomationTools(bootContext);
2686
+ for (const id of contexts.ids()) {
2687
+ const ctx = contexts.peek(id);
2688
+ if (ctx)
2689
+ attachAutomationTools(ctx);
2690
+ }
2691
+ contexts.onContextBuilt(attachAutomationTools);
3060
2692
  // ---- chained family: GitHub automations (project-scoped) ----
3061
2693
  // Every handler below reads `c.get('project')` — the definitions, their runtime state and the
3062
2694
  // execution log are per-project files — so the family is project-scoped and mounted with the
@@ -3067,216 +2699,61 @@ export function createApp(deps) {
3067
2699
  .use('/automations/*', requireAutomations)
3068
2700
  .use('/automation-log', requireAutomations)
3069
2701
  .use('/automation-log/*', requireAutomations)
3070
- .get('/automations', async (c) => {
3071
- const { root, automationStore } = c.get('project');
3072
- const forge = resolveForge(await getRepoInfo(root));
3073
- // Awaits the driver's settled probe (per-repo cache + in-flight dedupe, 5 s timeout, never
3074
- // throws): a "still being checked" answer would leave the cockpit's GitHub option disabled
3075
- // because it does not refetch when the probe completes. `/health` keeps `detectCached`.
3076
- // Annotated, so both branches are ONE shape: the route type is what
3077
- // `contract/src/automations.ts` has to describe.
3078
- const availability = forge
3079
- ? await forge.detect()
3080
- : { available: false, reason: 'No GitHub remote is configured' };
3081
- const automations = automationStore.list().map((automation) => {
3082
- const logs = automationStore.logs({ automationId: automation.id, limit: 100 });
3083
- const state = automationStore.state(automation.id);
3084
- const latestLog = logs[0];
3085
- // Enabled only: a paused definition's stored instant is stale, not a next run.
3086
- const nextRunAt = automation.enabled
3087
- ? (automation.kind === 'schedule' ? state?.nextRunAt : state?.nextCheckAt)
3088
- : undefined;
3089
- return {
3090
- ...automation,
3091
- // Spread conditionally, never `state: maybeUndefined`: the latter types the key as
3092
- // always-present while `JSON.stringify` drops it from the wire, so the contract would
3093
- // have to describe a key consumers never receive.
3094
- ...(state ? { state } : {}),
3095
- ...(latestLog ? { latestLog } : {}),
3096
- ...(nextRunAt ? { nextRunAt } : {}),
3097
- counts: {
3098
- matches: logs.filter((row) => row.result === 'launched' || row.result === 'duplicate').length,
3099
- launched: logs.filter((row) => row.result === 'launched' || row.result === 'manual' || row.result === 'catch-up').length,
3100
- duplicates: logs.filter((row) => row.result === 'duplicate').length,
3101
- errors: logs.filter((row) => row.result === 'error' || row.result === 'rate-limited' || row.result === 'failed').length,
3102
- },
3103
- };
3104
- });
3105
- const nextDue = automations.map((item) => item.nextRunAt).filter(Boolean).sort()[0];
3106
- return c.json({
3107
- ...availability,
3108
- timeZone: localTimeZone(),
3109
- scheduler: {
3110
- // `as const` on both arms: in an object literal a conditional of two string literals
3111
- // widens to `string`, which would erase the two states this key can hold.
3112
- state: automations.some((item) => item.enabled) ? 'scheduled' : 'idle',
3113
- ...(nextDue ? { nextDue } : {}),
3114
- },
3115
- automations,
3116
- });
2702
+ .get('/automations', queryZodValidator(automationListQuerySchema), async (c) => {
2703
+ return c.json(await automationService.list(c.get('project'), c.req.valid('query')));
3117
2704
  })
3118
2705
  .post('/automations', jsonZodValidator(() => automationCreateSchema), async (c) => {
3119
- const { root, automationStore } = c.get('project');
3120
- const parsed = { data: c.req.valid('json') };
3121
- const resolved = resolveAutomationKind(parsed.data);
3122
- if ('error' in resolved)
3123
- return c.json({ error: resolved.error }, resolved.status);
3124
- const promptIssue = validateAutomationPrompt(parsed.data.task.prompt, resolved.kind);
3125
- if (promptIssue)
3126
- return c.json({ error: promptIssue }, 400);
3127
- // A poll needs a GitHub remote to poll; a schedule needs nothing but this project.
3128
- if (resolved.kind === 'github' && !resolveForge(await getRepoInfo(root))) {
3129
- return c.json({ error: 'No GitHub remote is configured' }, 400);
3130
- }
3131
- const { enable, ...input } = parsed.data;
3132
- try {
3133
- const automation = automationStore.create({ ...input, kind: resolved.kind, enabled: enable === true }, undefined, enable ? armScheduleFromNow : undefined);
3134
- if (enable && isGithubAutomation(automation)) {
3135
- const baselineAt = new Date().toISOString();
3136
- automationStore.setState(automation.id, (current) => ({
3137
- ...current,
3138
- revision: automation.revision,
3139
- baselineAt,
3140
- cursor: { timestamp: baselineAt },
3141
- nextCheckAt: new Date(Date.now() + automation.intervalSeconds * 1_000).toISOString(),
3142
- }));
3143
- }
3144
- emitAutomationChange(c.get('project'), automation.id, automation.revision);
3145
- automationsChanged();
3146
- return c.json({ automation }, 201);
3147
- }
3148
- catch (error) {
3149
- return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
3150
- }
2706
+ const result = await automationService.create(c.get('project'), c.req.valid('json'));
2707
+ if (!result.ok)
2708
+ return c.json({ error: result.error }, result.status);
2709
+ const once = onceCreateAnswer(result.automation);
2710
+ return c.json({ automation: result.automation, ...(once ? { once } : {}) }, 201);
3151
2711
  })
3152
2712
  .get('/automations/:id', (c) => {
3153
- const { automationStore } = c.get('project');
3154
- const automation = automationStore.get(c.req.param('id'));
3155
- if (!automation)
3156
- return c.json({ error: 'not found' }, 404);
3157
- const state = automationStore.state(automation.id);
3158
- const latestLog = automationStore.logs({ automationId: automation.id, limit: 1 })[0];
3159
- return c.json({
3160
- automation,
3161
- ...(state ? { state } : {}),
3162
- ...(latestLog ? { latestLog } : {}),
3163
- });
2713
+ const result = automationService.get(c.get('project'), c.req.param('id'));
2714
+ if (!result.ok)
2715
+ return c.json({ error: result.error }, result.status);
2716
+ return c.json(result.body);
3164
2717
  })
3165
2718
  .put('/automations/:id', jsonZodValidator(() => automationUpdateSchema), async (c) => {
3166
- const { automationStore } = c.get('project');
3167
- const parsed = { data: c.req.valid('json') };
3168
- const { expectedRevision, ...input } = parsed.data;
3169
- const stored = automationStore.get(c.req.param('id'));
3170
- if (!stored)
3171
- return c.json({ error: 'not found' }, 404);
3172
- const resolved = resolveAutomationKind(input, stored);
3173
- if ('error' in resolved)
3174
- return c.json({ error: resolved.error }, resolved.status);
3175
- const promptIssue = validateAutomationPrompt(input.task.prompt, resolved.kind);
3176
- if (promptIssue)
3177
- return c.json({ error: promptIssue }, 400);
3178
- try {
3179
- // The armed instant belongs to the schedule it was computed from, and to an enabled
3180
- // definition: a new schedule, or a resume through PUT, re-arms from now — never a catch-up
3181
- // of an occurrence that passed while it was paused. Pausing clears it in the same write, so
3182
- // another cockpit never reads the still-enabled definition beside a due instant. Armed in
3183
- // the request, as `enable` does, rather than left to the timer: the list refetched on the
3184
- // change event must already see it. Compared with the definition on disk, not the one read above.
3185
- const automation = automationStore.update(c.req.param('id'), expectedRevision, { ...input, kind: resolved.kind, enabled: input.enabled ?? false }, (next, previous) => previous && (!sameSchedule(previous.schedule, next.schedule) || previous.enabled !== next.enabled) ? armScheduleFromNow(next) : undefined);
3186
- emitAutomationChange(c.get('project'), automation.id, automation.revision);
3187
- automationsChanged();
3188
- return c.json({ automation });
3189
- }
3190
- catch (error) {
3191
- const message = error instanceof Error ? error.message : String(error);
3192
- return c.json({ error: message }, message.includes('conflict') ? 409 : 400);
3193
- }
2719
+ const result = await automationService.update(c.get('project'), c.req.param('id'), c.req.valid('json'));
2720
+ if (!result.ok)
2721
+ return c.json({ error: result.error }, result.status);
2722
+ return c.json({ automation: result.automation });
3194
2723
  })
3195
- .delete('/automations/:id', (c) => {
3196
- const id = c.req.param('id');
3197
- const store = c.get('project').automationStore;
3198
- const current = store.get(id);
3199
- if (!current || !store.delete(id))
3200
- return c.json({ error: 'not found' }, 404);
3201
- emitAutomationChange(c.get('project'), id, current.revision, true);
3202
- automationsChanged();
2724
+ .delete('/automations/:id', async (c) => {
2725
+ const result = await automationService.remove(c.get('project'), c.req.param('id'));
2726
+ if (!result.ok)
2727
+ return c.json({ error: result.error }, result.status);
3203
2728
  return c.body(null, 204);
3204
2729
  })
3205
2730
  .post('/automations/:id/enable', async (c) => {
3206
- const store = c.get('project').automationStore;
3207
- // No client revision: the toggle applies to what is on disk now, not to this store's last read.
3208
- store.reload();
3209
- const current = store.get(c.req.param('id'));
3210
- if (!current)
3211
- return c.json({ error: 'not found' }, 404);
3212
- try {
3213
- if (isScheduleAutomation(current)) {
3214
- // A schedule has no backlog to baseline against: it arms its next occurrence from now.
3215
- const automation = store.update(current.id, current.revision, { ...editableAutomation(current), enabled: true }, armScheduleFromNow);
3216
- emitAutomationChange(c.get('project'), automation.id, automation.revision);
3217
- automationsChanged();
3218
- return c.json({ automation });
3219
- }
3220
- if (!isGithubAutomation(current))
3221
- return c.json({ error: 'automation definition is incomplete' }, 409);
3222
- const automation = store.update(current.id, current.revision, { ...editableAutomation(current), enabled: true });
3223
- const baselineAt = new Date().toISOString();
3224
- store.setState(automation.id, (state) => ({
3225
- ...state,
3226
- revision: automation.revision,
3227
- baselineAt,
3228
- cursor: { timestamp: baselineAt },
3229
- nextCheckAt: new Date(Date.now() + current.intervalSeconds * 1_000).toISOString(),
3230
- }));
3231
- await store.appendLog({ automationId: automation.id, revision: automation.revision, result: 'baseline', reason: 'Enabled from a current-time baseline; existing records were not launched.' });
3232
- emitAutomationChange(c.get('project'), automation.id, automation.revision);
3233
- automationsChanged();
3234
- return c.json({ automation });
3235
- }
3236
- catch (error) {
3237
- const { status, ...body } = toggleFailure(error);
3238
- return c.json(body, status);
3239
- }
2731
+ const result = await automationService.enable(c.get('project'), c.req.param('id'));
2732
+ if (!result.ok)
2733
+ return c.json({ error: result.error }, result.status);
2734
+ return c.json({ automation: result.automation });
3240
2735
  })
3241
- .post('/automations/:id/pause', (c) => {
3242
- const store = c.get('project').automationStore;
3243
- store.reload();
3244
- const current = store.get(c.req.param('id'));
3245
- if (!current)
3246
- return c.json({ error: 'not found' }, 404);
3247
- try {
3248
- const automation = store.update(current.id, current.revision, { ...editableAutomation(current), enabled: false }, armScheduleFromNow);
3249
- emitAutomationChange(c.get('project'), automation.id, automation.revision);
3250
- automationsChanged();
3251
- return c.json({ automation });
3252
- }
3253
- catch (error) {
3254
- const { status, ...body } = toggleFailure(error);
3255
- return c.json(body, status);
3256
- }
2736
+ .post('/automations/:id/pause', async (c) => {
2737
+ const result = await automationService.pause(c.get('project'), c.req.param('id'));
2738
+ if (!result.ok)
2739
+ return c.json({ error: result.error }, result.status);
2740
+ return c.json({ automation: result.automation });
2741
+ })
2742
+ // One-time schedules only (#1465): archive without launching. 409 for any other automation.
2743
+ .post('/automations/:id/cancel', async (c) => {
2744
+ const result = await automationService.cancel(c.get('project'), c.req.param('id'));
2745
+ if (!result.ok)
2746
+ return c.json({ error: result.error }, result.status);
2747
+ return c.json({ automation: result.automation });
3257
2748
  })
3258
2749
  // Run now: a schedule fired once by hand, paused or not, outside the timer and under the same
3259
- // lease. `nextRunAt` and `enabled` are untouched; a GitHub automation's equivalent is `check`.
2750
+ // lease. A repeating schedule keeps `nextRunAt` and `enabled`; a one-time schedule is consumed
2751
+ // (archived) by it. A GitHub automation's equivalent is `check`.
3260
2752
  .post('/automations/:id/run', async (c) => {
3261
- const project = c.get('project');
3262
- const store = project.automationStore;
3263
- const automation = store.get(c.req.param('id'));
3264
- if (!automation)
3265
- return c.json({ error: 'not found' }, 404);
3266
- if (!isScheduleAutomation(automation))
3267
- return c.json({ error: 'a GitHub automation runs on its events; use check with mode execute' }, 409);
3268
- const outcome = await new ScheduleRunner(scheduleHandle(project)).runNow(automation);
3269
- // Gone, or no longer a schedule, once re-read under the lease: nothing was launched.
3270
- if (outcome.result === 'skipped')
3271
- return c.json({ error: 'not found' }, 404);
3272
- if (outcome.result === 'manual') {
3273
- emitAutomationChange(project, automation.id, store.get(automation.id)?.revision ?? automation.revision);
3274
- return c.json({ runId: outcome.runId }, 202);
3275
- }
3276
- if (outcome.result === 'lease-held')
3277
- return c.json({ error: SCHEDULE_LEASE_HELD_REASON }, 409);
3278
- const reason = store.logs({ automationId: automation.id, limit: 1 })[0]?.reason;
3279
- return c.json({ error: reason ?? 'the run could not be launched' }, 409);
2753
+ const result = await automationService.runNow(c.get('project'), c.req.param('id'));
2754
+ if (!result.ok)
2755
+ return c.json({ error: result.error }, result.status);
2756
+ return c.json({ runId: result.runId }, 202);
3280
2757
  })
3281
2758
  // The body is validated as MIDDLEWARE, which is what puts it in the route type — and moves
3282
2759
  // the 400 ahead of this route's 404: `POST /automations/<unknown>/check` with a malformed
@@ -3372,7 +2849,7 @@ export function createApp(deps) {
3372
2849
  return c.json({ receiptId: receipt.receiptId, runId: launched.runId }, 202);
3373
2850
  }
3374
2851
  catch (error) {
3375
- store.appendReceipt({ ...reserved, status: 'launch-error', error: error instanceof Error ? error.message : String(error), updatedAt: new Date().toISOString() });
2852
+ store.appendReceipt({ ...reserved, status: error instanceof AutomationCandidateChangedError ? 'released' : 'launch-error', error: error instanceof Error ? error.message : String(error), updatedAt: new Date().toISOString() });
3376
2853
  return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
3377
2854
  }
3378
2855
  finally {
@@ -3410,40 +2887,10 @@ export function createApp(deps) {
3410
2887
  const usage = currentUsage(summary.id);
3411
2888
  return usage ? { ...summary, usage } : summary;
3412
2889
  };
3413
- // The inbox half of a composer launch (#374). Since the cockpit's "▶ Run"
3414
- // prefills `/new` instead of calling POST /api/todos/:id/start (never launch
3415
- // blind — #355), the todo id rides along on the composer's POST /api/runs and
3416
- // lands here: `markStarted` writes `startedTaskId`, so the entry leaves the
3417
- // inbox (`visibleTodos()`) and stays in todos.json as the audit trail, and a
3418
- // second launch of the same entry no longer double-starts it.
3419
- //
3420
- // Deliberately best-effort: bookkeeping must never cost the user their task,
3421
- // so an unknown, stale or already-started id (markStarted → false) and any I/O
3422
- // failure only log. The run has already been created by the time we get here.
3423
- const noteTodoStarted = async (dataDir, todoId, taskId) => {
3424
- try {
3425
- if (!(await markStarted(dataDir, todoId, taskId))) {
3426
- console.warn(`[cezar] inbox entry ${todoId} not marked started (unknown or already started)`);
3427
- }
3428
- }
3429
- catch (err) {
3430
- console.warn(`[cezar] could not mark inbox entry ${todoId} started: ${String(err)}`);
3431
- }
3432
- };
3433
2890
  /**
3434
- * Every control on one run (any `/runs/:id` request that is not a read) first asks who owns it
3435
- * (#779, plan step 3): a `serve` and a headless `cez run` can share a project's runs.
3436
- * - Another live cezar process's run answers `409 { error }`, the shape these routes already
3437
- * use for a run that exists but cannot be acted on now. Reads are never refused.
3438
- * - A run whose owner is proven dead is adopted first — claimed, loaded and settled as
3439
- * interrupted, never resumed (`settleOrphanedRun`) — and the control then applies. Otherwise
3440
- * Stop on a crashed `cez run` would be a dead end until this cockpit restarted. Stop is the one
3441
- * control the adoption carries out itself: its run settles as cancelled, and Stop answers that
3442
- * it stopped it (`stoppedOnAdoption`).
3443
- * - A run this process stopped writing after a conflicting write answers 409 too.
3444
- *
3445
- * Registered against explicit paths, like `requireAutomations`: `route()` re-registers it under
3446
- * the mount prefix, and it gates every family's `/runs/:id` routes registered after it.
2891
+ * Gate every run control by family ownership: refuse foreign or quarantined writers with 409;
2892
+ * adopt proven dead owners without resuming agents. Reads remain available. Stop carries its
2893
+ * cancellation through adoption.
3447
2894
  */
3448
2895
  const requireRunControl = async (c, next) => {
3449
2896
  const id = c.req.param('id');
@@ -3482,8 +2929,14 @@ export function createApp(deps) {
3482
2929
  // The archived roots past that window, a page at a time, newest first (#864).
3483
2930
  .get('/run-summaries/archived', queryZodValidator(archivedRunsQuerySchema), (c) => {
3484
2931
  const { before, limit, q } = c.req.valid('query');
3485
- const page = c.get('project').store.listArchivedRuns({
2932
+ const project = c.get('project');
2933
+ // The page shows each task's origin line, so `q` matches what it names: the automation's
2934
+ // name, or its recorded id while automations are off, as the cockpit's filter does (#1616).
2935
+ const automationNames = new Map(capabilities().automations
2936
+ ? project.automationStore.list().map((automation) => [automation.id, automation.name]) : []);
2937
+ const page = project.store.listArchivedRuns({
3486
2938
  limit: limit ?? ARCHIVED_RUNS_PAGE_DEFAULT,
2939
+ automationNames,
3487
2940
  ...(before !== undefined ? { before } : {}),
3488
2941
  ...(q !== undefined ? { q } : {}),
3489
2942
  });
@@ -3509,7 +2962,6 @@ export function createApp(deps) {
3509
2962
  return [];
3510
2963
  return [{ workerId: worker.id, parentRunId: run.id, status: worker.status, workspace: owned.workspace,
3511
2964
  ...(worker.currentStepId === undefined ? {} : { currentStepId: worker.currentStepId }),
3512
- ...(worker.activity === undefined ? {} : { activity: worker.activity }),
3513
2965
  ...(owned.destroy ? { destroy: workerDestroyView(worker) } : {}),
3514
2966
  }];
3515
2967
  });
@@ -3543,10 +2995,10 @@ export function createApp(deps) {
3543
2995
  })
3544
2996
  // Registered before the `/:id/...` routes so "archive-finished" and "read-all"
3545
2997
  // never match as a run id.
3546
- .post('/runs/archive-finished', jsonZodValidator(archiveFinishedRequestSchema, { absent: {}, malformed: null }), (c) => c.json(c.get('project').store.archiveFinished(c.req.valid('json').scope)))
2998
+ .post('/runs/archive-finished', jsonZodValidator(archiveFinishedRequestSchema, { absent: {}, malformed: null }), (c) => c.json(c.get('project').store.archiveFinished(c.req.valid('json').scope, c.req.valid('json').automation)))
3547
2999
  // The read-receipt sweep (#unread-done-items) — the mark-read twin of the archive
3548
3000
  // sweep above, and under the same registration-order guard.
3549
- .post('/runs/read-all', (c) => c.json({ read: c.get('project').store.markAllRead() }))
3001
+ .post('/runs/read-all', jsonZodValidator(markAllReadRequestSchema, { absent: {}, malformed: null }), (c) => c.json({ read: c.get('project').store.markAllRead(c.req.valid('json').automation) }))
3550
3002
  .post('/runs/:id/archive', jsonZodValidator(archiveSchema, { absent: ({}) }), async (c) => {
3551
3003
  const { store } = c.get('project');
3552
3004
  const id = c.req.param('id');
@@ -3581,7 +3033,7 @@ export function createApp(deps) {
3581
3033
  return c.json({ error: 'not found' }, 404);
3582
3034
  if (notify) {
3583
3035
  const entry = await projectRegistryEntry(project.id).catch(() => undefined);
3584
- if (!entry?.webhook)
3036
+ if (taskWebhookList(entry).length === 0)
3585
3037
  return c.json({ error: NO_WEBHOOK_ERROR }, 400);
3586
3038
  }
3587
3039
  const run = project.store.updateRun(id, { notify: notify ? true : undefined });
@@ -3620,111 +3072,38 @@ export function createApp(deps) {
3620
3072
  const run = c.get('project').store.setUnread(c.req.param('id'));
3621
3073
  return run ? c.json(run) : c.json({ error: 'not found' }, 404);
3622
3074
  })
3623
- .post('/runs', jsonZodValidator(startRunSchema), async (c) => {
3624
- const { root: repoRoot, dataDir, manager, store } = c.get('project');
3625
- const parsed = { data: c.req.valid('json') };
3626
- const clientRequest = parsed.data.clientRequestId
3627
- ? { id: parsed.data.clientRequestId, hash: clientRequestHash(parsed.data) }
3628
- : undefined;
3629
- // A retry only retrieves a persisted result: mutable start prerequisites (workflow,
3630
- // provider, account and model policy) must not invalidate an already accepted request.
3075
+ .post('/runs', jsonZodValidator(createRunInputSchema), async (c) => {
3076
+ const { id: projectId, root: repoRoot, manager, store } = c.get('project');
3077
+ const body = c.req.valid('json');
3078
+ const clientRequest = clientRequestOf(body);
3631
3079
  if (clientRequest) {
3632
- const existing = store.findRunByClientRequestId(clientRequest.id);
3633
- if (existing) {
3634
- if (existing.clientRequestHash !== clientRequest.hash)
3635
- return c.json({ error: 'request id payload conflict' }, 409);
3636
- return c.json(existing, 200);
3637
- }
3638
- }
3639
- if (agentModelsLocked(repoRoot) &&
3640
- (parsed.data.model?.trim() || parsed.data.effort?.trim())) {
3641
- return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
3642
- }
3643
- let workflow;
3644
- if (parsed.data.steps) {
3645
- // Inline chain (spec 008): an approved plan runs as an ad-hoc workflow.
3646
- const issue = stepsIssue(parsed.data.steps);
3647
- if (issue)
3648
- return c.json({ error: issue }, 400);
3649
- workflow = plannedWorkflow(parsed.data.steps);
3650
- }
3651
- else {
3652
- const { workflows } = await loadWorkflows(repoRoot);
3653
- workflow = workflows.find((w) => w.name === parsed.data.workflow);
3654
- if (!workflow)
3655
- return c.json({ error: `unknown workflow: ${parsed.data.workflow}` }, 404);
3656
- }
3657
- const fallback = parsed.data.runner ?? (await loadConfig(repoRoot)).defaultRunner;
3658
- const blocked = await providerActionError(providersRequiredByWorkflow(workflow, fallback));
3080
+ const replay = replayedRun(store, clientRequest);
3081
+ if (replay && 'error' in replay)
3082
+ return c.json({ error: replay.error }, replay.status);
3083
+ if (replay)
3084
+ return c.json(replay.run, 200);
3085
+ }
3086
+ const locked = modelLockRefusal(repoRoot, body);
3087
+ if (locked)
3088
+ return c.json({ error: locked.error }, locked.status);
3089
+ const resolved = await resolveStartWorkflow(repoRoot, body);
3090
+ if ('error' in resolved)
3091
+ return c.json({ error: resolved.error }, resolved.status);
3092
+ const { workflow } = resolved;
3093
+ const runner = body.runner ?? (await loadConfig(repoRoot)).defaultRunner;
3094
+ const blocked = await providerGateRefusal(providerActionError, workflow, runner);
3659
3095
  if (blocked)
3660
- return c.json({ error: blocked }, 409);
3661
- // A composer override names an account the user just picked, so a stale id (deleted since
3662
- // the page loaded) is answered honestly instead of quietly running on the default — the
3663
- // opposite of how RESOLUTION treats a dangling reference, and deliberately so: a run
3664
- // replaying a stored id has no better answer than the default, a user does.
3665
- if (parsed.data.agentProfile !== undefined) {
3666
- const account = await resolveWorkspaceProfile(fallback, parsed.data.agentProfile);
3667
- if ('error' in account)
3668
- return c.json({ error: account.error }, 400);
3669
- }
3670
- // Task webhook (#589): an explicit `true` on a project with no webhook is refused, so a
3671
- // bot learns in one round trip. `cez task start`'s implicit default never sends it there.
3672
- if (parsed.data.notify === true && !(await projectRegistryEntry(c.get('project').id).catch(() => undefined))?.webhook) {
3673
- return c.json({ error: NO_WEBHOOK_ERROR }, 400);
3674
- }
3675
- const images = parsed.data.images?.map(toPastedContent);
3676
- const input = {
3677
- task: parsed.data.task,
3678
- notify: parsed.data.notify,
3679
- model: parsed.data.model,
3680
- effort: parsed.data.effort,
3681
- runner: parsed.data.runner,
3682
- agentProfile: parsed.data.agentProfile,
3683
- images,
3684
- systemPrompt: parsed.data.systemPrompt,
3685
- worktree: parsed.data.worktree,
3686
- autonomous: parsed.data.autonomous,
3687
- // Opt-in inbox (#471): the capability is the ceiling, so a client asking
3688
- // for follow-ups on a server that has them off gets a plain `false`
3689
- // rather than an error — the run is still perfectly valid without them.
3690
- // One decision here feeds the run record, the system prompt and
3691
- // CEZ_TODOS_FILE alike (RunManager.agentEnv).
3692
- generateFollowups: capabilities().followups ? parsed.data.generateFollowups : false,
3693
- };
3694
- if (clientRequest) {
3695
- // Recheck after every await: concurrent first requests may both miss the early lookup.
3696
- // The manager's lookup and create are synchronous, so exactly one can create (#504).
3697
- const result = manager.startRunIdempotent(workflow, input, clientRequest);
3698
- if ('conflict' in result)
3699
- return c.json({ error: 'request id payload conflict' }, 409);
3700
- if (!result.created)
3701
- return c.json(result.run, 200);
3702
- if (parsed.data.todoId)
3703
- await noteTodoStarted(dataDir, parsed.data.todoId, result.run.id);
3704
- return c.json(result.run, 201);
3705
- }
3706
- const variants = parsed.data.variants ?? 1;
3707
- if (variants > 1) {
3708
- // Variants live in worktrees — without git there's nothing to isolate
3709
- // them with, so this degrades to a clear 400 instead of stepping on
3710
- // one shared working tree.
3711
- const repo = await getRepoInfo(repoRoot);
3712
- if (!repo) {
3713
- return c.json({
3714
- error: 'parallel variants need a git repository (each variant runs in its own worktree) — run ×1 here, or start cezar inside a git repo',
3715
- }, 400);
3716
- }
3717
- const runs = manager.startVariants(workflow, input, variants);
3718
- // The entry points at the first variant — the thread the composer navigates to.
3719
- const first = runs[0];
3720
- if (parsed.data.todoId && first)
3721
- await noteTodoStarted(dataDir, parsed.data.todoId, first.id);
3722
- return c.json({ runs }, 201);
3723
- }
3724
- const run = manager.startRun(workflow, input);
3725
- if (parsed.data.todoId)
3726
- await noteTodoStarted(dataDir, parsed.data.todoId, run.id);
3727
- return c.json(run, 201);
3096
+ return c.json({ error: blocked.error }, blocked.status);
3097
+ const badProfile = await agentProfileRefusal(resolveWorkspaceProfile, runner, body.agentProfile);
3098
+ if (badProfile)
3099
+ return c.json({ error: badProfile.error }, badProfile.status);
3100
+ const noWebhook = await notifyRefusal(body.notify, projectId, projectRegistryEntry);
3101
+ if (noWebhook)
3102
+ return c.json({ error: noWebhook.error }, noWebhook.status);
3103
+ const started = launchRun(manager, workflow, startRunInput(body), clientRequest);
3104
+ if ('error' in started)
3105
+ return c.json({ error: started.error }, started.status);
3106
+ return c.json(started.run, started.status);
3728
3107
  })
3729
3108
  .get('/runs/:id', (c) => {
3730
3109
  const { store, manager } = c.get('project');
@@ -3791,6 +3170,12 @@ export function createApp(deps) {
3791
3170
  titleOrigin: 'user',
3792
3171
  });
3793
3172
  }
3173
+ if (parsed.data.archiveWhenDone !== undefined || parsed.data.autoMergePr !== undefined) {
3174
+ store.updateRun(id, {
3175
+ ...(parsed.data.archiveWhenDone === undefined ? {} : { archiveWhenDone: parsed.data.archiveWhenDone }),
3176
+ ...(parsed.data.autoMergePr === undefined ? {} : { autoMergePr: parsed.data.autoMergePr }),
3177
+ });
3178
+ }
3794
3179
  return c.json(store.getRun(id));
3795
3180
  })
3796
3181
  .post('/runs/:id/cancel', (c) => {
@@ -3804,6 +3189,17 @@ export function createApp(deps) {
3804
3189
  })
3805
3190
  // Live-session participation (spec 002): deliver a user message (text +
3806
3191
  // pasted screenshots) into the run's open claude session.
3192
+ .post('/runs/:id/permissions', paramZodValidator(permissionRunIdParamSchema), jsonZodValidator(permissionDecisionSchema), (c) => {
3193
+ const { store, manager } = c.get('project');
3194
+ const { id } = c.req.valid('param');
3195
+ if (!store.getRun(id))
3196
+ return c.json({ error: 'not found' }, 404);
3197
+ const { requestId, optionId } = c.req.valid('json');
3198
+ if (!manager.respondPermission(id, requestId, optionId)) {
3199
+ return c.json({ error: 'permission request is no longer available or option is invalid' }, 409);
3200
+ }
3201
+ return c.json({ delivered: true });
3202
+ })
3807
3203
  .post('/runs/:id/messages', jsonZodValidator(messageSchema), async (c) => {
3808
3204
  const { store, manager } = c.get('project');
3809
3205
  const id = c.req.param('id');
@@ -4036,109 +3432,25 @@ export function createApp(deps) {
4036
3432
  const parsedBody = { data: c.req.valid('json') };
4037
3433
  const { target, path: relPath } = parsedBody.data;
4038
3434
  const dir = run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : repoRoot;
4039
- // Diff pane "open in OS default app" (#365): one worktree file, opened with the platform's
4040
- // default handler for its type — not a directory in the file manager (that's `finder`
4041
- // above) and not a session takeover (editors/CLIs above). `path` is re-validated against
4042
- // the worktree here regardless of what the schema allowed, so a stale/forged path can never
4043
- // escape it.
4044
3435
  if (target === 'default') {
4045
- if (!run.worktreePath || !existsSync(run.worktreePath)) {
4046
- return c.json({ error: NO_WORKTREE }, 409);
4047
- }
4048
- if (!relPath)
4049
- return c.json({ error: 'path required for the default-app target' }, 400);
4050
- const result = await readWorktreePath(run.worktreePath, relPath);
4051
- if (result.kind !== 'file') {
4052
- return c.json({
4053
- error: result.kind === 'dir' ? `not a file: ${relPath}` : result.error,
4054
- }, 409);
4055
- }
4056
- // This route's whole contract is "preview an image in its default app", and containment
4057
- // alone does not enforce it. Without this gate any regular file in the worktree — a
4058
- // `.command`/`.desktop` an agent just wrote, an `.exe` — would be handed to the OS
4059
- // launcher, which EXECUTES it. Not remotely reachable (random run ids, same-origin, local
4060
- // mode), so: defense in depth.
4061
- //
4062
- // Deliberately `isOsOpenableImage`, NOT the raw route's `imageMimeType`: that list allows
4063
- // SVG on the strength of an `<img>` + no-script CSP the OS launcher never applies (the
4064
- // default `.svg` handler is usually a browser, which would run the file's `<script>`).
4065
- if (!isOsOpenableImage(result.path)) {
4066
- // Say which rule refused, in the route's own words — "limited to images" would be a lie
4067
- // to someone holding an SVG, which IS an image and DOES preview inline.
4068
- return c.json({
4069
- error: imageMimeType(result.path)
4070
- ? `SVG can carry scripts, so it previews inline but is never handed to the OS: ${result.path}`
4071
- : `opening in the default app is limited to images: ${result.path}`,
4072
- }, 409);
4073
- }
4074
- const filePath = join(run.worktreePath, result.path);
4075
- const opened = await openFile(filePath);
4076
- if (!opened)
4077
- return c.json({ error: `could not open ${result.path}`, path: filePath }, 409);
4078
- return c.json({ opened: true, path: filePath });
4079
- }
4080
- // Coding-agent CLI handoff (#cli-handoff, #402): open a terminal in the worktree that resumes
4081
- // THIS run's latest session when the chosen CLI owns it (and a session exists), or
4082
- // starts a fresh CLI there otherwise. Same terminal launcher the Terminal button uses.
4083
- // Records that predate the runner choice carry no `runner` at all — they default to Claude
4084
- // everywhere else (resumeCommand, the client's resumeHint/cliTargetResumes), so the match
4085
- // check defaults the same way here; without it, a legacy run's own Claude CLI would never
4086
- // resume its own session, only ever launch fresh.
4087
- // A run the engine still owns never resumes: `sessionId` is seeded when the agent step STARTS
4088
- // (workflows/run.ts), so a running/queued/waiting run already carries one, and resuming it
4089
- // would attach a SECOND CLI process to the transcript the engine is actively writing. Those
4090
- // picks launch the CLI fresh in the worktree — the same degradation as a cross-runner pick,
4091
- // and what the client's cliTargetResumes now labels. Resume-after-finish is untouched.
3436
+ return openRunDefaultApp(c, run, relPath, openFile, NO_WORKTREE);
3437
+ }
4092
3438
  const cliRunner = agentCliRunner(target);
4093
3439
  if (cliRunner) {
4094
- const blocked = await providerActionError([cliRunner]);
4095
- if (blocked)
4096
- return c.json({ error: blocked }, 409);
4097
- const engineOwnsSession = run.status === 'running' || run.status === 'queued' || run.status === 'waiting';
4098
- const sessionStep = engineOwnsSession ? undefined : [...run.steps].reverse().find((s) => s.sessionId);
4099
- const sessionId = sessionStep?.sessionId;
4100
- // An id resumeCommand refuses (#431) degrades to a fresh CLI in the worktree,
4101
- // exactly like a run that never recorded a session.
4102
- const resume = sessionId && cliRunner === (sessionStep?.backend ?? run.runner ?? 'claude') ? resumeCommand(cliRunner, sessionId) : null;
4103
- // Runners with a CEZ_*_BIN override launch the configured binary, not the PATH name.
4104
- const command = resume ?? (cliRunner === 'cursor'
4105
- ? quoteExecutable(resolveCursorExecutable(), process.platform)
4106
- : cliRunner === 'omp' ? quoteExecutable(resolveOmpExecutable(), process.platform) : cliRunner);
4107
- if (command === null) {
4108
- return c.json({ error: `the configured ${cliRunner === 'omp' ? 'OMP' : 'Cursor'} executable cannot be used in a terminal command` }, 409);
4109
- }
4110
- // BOTH branches carry the account (spec 2026-07-29-agent-profiles): a resume needs the
4111
- // config dir that holds its session, and a FRESH CLI in this worktree should still open
4112
- // on the account the project works under — otherwise "Open in → Claude CLI" quietly
4113
- // hands the user a different subscription than every task in the same project uses.
4114
- const account = resume
4115
- ? await handoffEnv(cliRunner, sessionStep?.profileId)
4116
- : { env: (await resolveProfileEnvForRoot(repoRoot, cliRunner)).env };
4117
- if ('error' in account)
4118
- return c.json({ error: account.error }, 409);
4119
- const fallback = handoffFallbackCommand(dir, command, account.env);
4120
- if (fallback === null) {
4121
- return c.json({ error: 'this account\'s folder cannot be used in a terminal command' }, 409);
4122
- }
4123
- const opened = await openTerminal(dir, command, account.env);
4124
- if (!opened) {
4125
- return c.json({ error: 'no terminal emulator found', command: fallback }, 409);
4126
- }
4127
- return c.json({ opened: true, path: dir, command });
3440
+ return openRunCli(c, run, cliRunner, dir, repoRoot, {
3441
+ providerActionError, resumeCommand, handoffEnv, handoffFallbackCommand, openTerminal,
3442
+ });
4128
3443
  }
4129
- const opened = await openApp(target, dir);
4130
- if (!opened)
4131
- return c.json({ error: `could not open ${target}`, path: dir }, 409);
4132
- return c.json({ opened: true, path: dir });
3444
+ return openRunApp(c, target, dir, openApp);
4133
3445
  })
4134
- // Handoff journal (spec 007): the per-task handoff.md as markdown. 404 only
4135
- // when the task is unknown; a task without a (yet) seeded file returns ''.
3446
+ // Handoff journal (spec 007): the per-task handoff.md as markdown, with cezar's records from the
3447
+ // run store (hearsay-tools/cezarion#1809). 404 only when the task is unknown; no file returns ''.
4136
3448
  .get('/runs/:id/handoff', (c) => {
4137
3449
  const { dataDir, store } = c.get('project');
4138
3450
  const run = store.getRun(c.req.param('id'));
4139
3451
  if (!run)
4140
3452
  return c.json({ error: 'not found' }, 404);
4141
- return c.text(readHandoff(dataDir, run.id), 200, {
3453
+ return c.text(renderHandoffRecords(readHandoff(dataDir, run.id), run.handoffRecords), 200, {
4142
3454
  'content-type': 'text/markdown; charset=utf-8',
4143
3455
  });
4144
3456
  })
@@ -4174,20 +3486,26 @@ export function createApp(deps) {
4174
3486
  })
4175
3487
  // Task diff (spec 006): what this run changed — its worktree vs its base.
4176
3488
  .get('/runs/:id/diff', async (c) => {
4177
- const { store } = c.get('project');
3489
+ const { dataDir, store } = c.get('project');
4178
3490
  const run = store.getRun(c.req.param('id'));
4179
3491
  if (!run)
4180
3492
  return c.json({ error: 'not found' }, 404);
3493
+ const blocker = autosaveCleanupBlocker(dataDir, run.id);
3494
+ if (blocker)
3495
+ return c.json({ error: blocker }, 409);
4181
3496
  if (!run.worktreePath || !existsSync(run.worktreePath)) {
4182
3497
  return c.text('(no worktree — this task ran directly in the repo working tree)');
4183
3498
  }
4184
3499
  return c.text(await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD'));
4185
3500
  })
4186
3501
  .get('/runs/:id/changes', async (c) => {
4187
- const { root: repoRoot, store } = c.get('project');
3502
+ const { root: repoRoot, dataDir, store } = c.get('project');
4188
3503
  const run = store.getRun(c.req.param('id'));
4189
3504
  if (!run)
4190
3505
  return c.json({ error: 'not found' }, 404);
3506
+ const blocker = autosaveCleanupBlocker(dataDir, run.id);
3507
+ if (blocker)
3508
+ return c.json({ error: blocker }, 409);
4191
3509
  const workingDirectory = workingDirectoryOf(run, repoRoot);
4192
3510
  if (!workingDirectory)
4193
3511
  return c.json({ error: NO_WORKTREE }, 409);
@@ -4304,7 +3622,7 @@ export function createApp(deps) {
4304
3622
  // An `Accept` that asks for images reaches the same raw branch without the flag — which is
4305
3623
  // what an `<img>` sends — while the flag still wins whenever it is present and `*<slash>*`
4306
3624
  // (every `fetch`) still gets the JSON listing. See `negotiate`.
4307
- .get('/runs/:id/files', queryZodValidator(z.object({ path: queryValue, raw: queryValue })), async (c) => {
3625
+ .get('/runs/:id/files', queryZodValidator(worktreeFilesQuerySchema), async (c) => {
4308
3626
  const { root: repoRoot, store } = c.get('project');
4309
3627
  const query = c.req.valid('query');
4310
3628
  c.header('vary', 'Accept');
@@ -4364,10 +3682,13 @@ export function createApp(deps) {
4364
3682
  });
4365
3683
  })
4366
3684
  .post('/runs/:id/git/commit', jsonZodValidator(gitCommitSchema), async (c) => {
4367
- const { store } = c.get('project');
3685
+ const { dataDir, store } = c.get('project');
4368
3686
  const run = store.getRun(c.req.param('id'));
4369
3687
  if (!run)
4370
3688
  return c.json({ error: 'not found' }, 404);
3689
+ const blocker = autosaveCleanupBlocker(dataDir, run.id);
3690
+ if (blocker)
3691
+ return c.json({ error: blocker }, 409);
4371
3692
  const worktree = worktreeOf(run);
4372
3693
  if (!worktree)
4373
3694
  return c.json({ error: NO_WORKTREE }, 409);
@@ -4378,10 +3699,13 @@ export function createApp(deps) {
4378
3699
  return c.json({ committed: true, sha: result.sha });
4379
3700
  })
4380
3701
  .post('/runs/:id/git/push', async (c) => {
4381
- const { root: repoRoot, store } = c.get('project');
3702
+ const { root: repoRoot, dataDir, store } = c.get('project');
4382
3703
  const run = store.getRun(c.req.param('id'));
4383
3704
  if (!run)
4384
3705
  return c.json({ error: 'not found' }, 404);
3706
+ const blocker = autosaveCleanupBlocker(dataDir, run.id);
3707
+ if (blocker)
3708
+ return c.json({ error: blocker }, 409);
4385
3709
  const worktree = worktreeOf(run);
4386
3710
  if (!worktree)
4387
3711
  return c.json({ error: NO_WORKTREE }, 409);
@@ -4393,7 +3717,9 @@ export function createApp(deps) {
4393
3717
  // per number, so without this the cockpit would keep showing the pre-push answer (up to a
4394
3718
  // minute of "Ready to merge" for a branch that has just been rewritten) about a push the
4395
3719
  // user watched this server make.
4396
- for (const number of runPrNumbers(run))
3720
+ const remote = parseRemote((await getRepoInfo(repoRoot))?.remote ?? '');
3721
+ const repoBase = remote?.host === 'github.com' ? `https://github.com/${remote.owner}/${remote.repo}` : undefined;
3722
+ for (const number of ownPullRequestNumbers(run, repoBase))
4397
3723
  forgetRefStatus(repoRoot, number);
4398
3724
  return c.json({
4399
3725
  pushed: true,
@@ -4469,10 +3795,18 @@ export function createApp(deps) {
4469
3795
  return c.json({ error: 'run is active — cancel it first' }, 409);
4470
3796
  if (run.delegation?.role === 'worker' || !store.canDeleteRun(id))
4471
3797
  return c.json({ error: 'owned resources require verified worker cleanup' }, 409);
4472
- if (run.worktreePath)
4473
- await releaseThenRemoveWorktree({ previewHost: deps.previewHost }, id, repoRoot, run.worktreePath, run.branch);
4474
- store.updateRun(id, { worktreePath: undefined, branch: undefined });
4475
- return c.json({ removed: true });
3798
+ const release = manager.claimForPublish(id);
3799
+ if (!release)
3800
+ return c.json({ error: 'worktree cleanup pending — retry after cleanup is confirmed' }, 409);
3801
+ try {
3802
+ if (run.worktreePath)
3803
+ await releaseThenRemoveWorktree({ previewHost: deps.previewHost }, id, repoRoot, run.worktreePath, run.branch);
3804
+ store.updateRun(id, { worktreePath: undefined, branch: undefined });
3805
+ return c.json({ removed: true });
3806
+ }
3807
+ finally {
3808
+ release();
3809
+ }
4476
3810
  })
4477
3811
  .delete('/runs/:id', async (c) => {
4478
3812
  const { root: repoRoot, store, manager } = c.get('project');
@@ -4485,97 +3819,17 @@ export function createApp(deps) {
4485
3819
  // Delete cleans up after itself: worktree + branch go with the run (spec 006).
4486
3820
  if (!store.canDeleteRun(id))
4487
3821
  return c.json({ error: 'owned resources require verified worker cleanup and explicit child history deletion first' }, 409);
4488
- if (run.delegation?.role !== 'worker' && run.worktreePath)
4489
- await releaseThenRemoveWorktree({ previewHost: deps.previewHost }, id, repoRoot, run.worktreePath, run.branch);
4490
- return store.deleteRun(id) ? c.json({ deleted: true }) : c.json({ error: 'history cleanup incomplete — retry deletion' }, 409);
4491
- });
4492
- // ---- parallel variants (spec 010) -----------------------------------------
4493
- const groupRuns = (store, groupId) => store
4494
- .listGroupRuns(groupId)
4495
- .sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
4496
- // ---- chained family: variant groups (project-scoped) ----
4497
- const groupsRoutes = new Hono()
4498
- .get('/groups/:groupId', async (c) => {
4499
- const { dataDir, store } = c.get('project');
4500
- const runs = groupRuns(store, c.req.param('groupId'));
4501
- if (runs.length === 0)
4502
- return c.json({ error: 'not found' }, 404);
4503
- const detailed = await Promise.all(runs.map(async (r) => ({
4504
- id: r.id,
4505
- variant: r.variant ?? '?',
4506
- title: r.title,
4507
- status: r.status,
4508
- archived: r.archived,
4509
- tokensUsed: r.tokensUsed,
4510
- ...(r.inputTokens !== undefined ? { inputTokens: r.inputTokens } : {}),
4511
- ...(r.outputTokens !== undefined ? { outputTokens: r.outputTokens } : {}),
4512
- ...(r.costUsd !== undefined ? { costUsd: r.costUsd } : {}),
4513
- diffStat: r.worktreePath && existsSync(r.worktreePath)
4514
- ? await worktreeDiffStat(r.worktreePath, r.baseBranch ?? 'HEAD')
4515
- : '',
4516
- handoffExcerpt: handoffProgressExcerpt(readHandoff(dataDir, r.id)),
4517
- })));
4518
- return c.json({
4519
- groupId: c.req.param('groupId'),
4520
- runs: detailed,
4521
- });
4522
- })
4523
- // "Pick this one": the winner rests at `review` (spec 009 takes it from
4524
- // there — send back / draft PR / finish); the losers are cancelled if
4525
- // alive, archived, and their worktrees + branches removed.
4526
- .post('/groups/:groupId/pick', jsonZodValidator(pickSchema), async (c) => {
4527
- const { root: repoRoot, dataDir, store, manager } = c.get('project');
4528
- const runs = groupRuns(store, c.req.param('groupId'));
4529
- if (runs.length === 0)
4530
- return c.json({ error: 'not found' }, 404);
4531
- const parsed = { data: c.req.valid('json') };
4532
- const winner = runs.find((r) => r.id === parsed.data.runId);
4533
- if (!winner)
4534
- return c.json({ error: 'runId is not part of this group' }, 404);
4535
- if (manager.isActive(winner.id)) {
4536
- return c.json({ error: 'this variant is still active — wait for it to finish first' }, 409);
4537
- }
4538
- // Winner: a non-review terminal state with a non-empty diff flips to
4539
- // `review` (the settleSuccess rule) — but only when the review gate applies
4540
- // (#489): it is enabled (`reviewGateEnabled`, default off) AND the winner is
4541
- // not autonomous. An autonomous / gate-off winner keeps its `done` state with
4542
- // the diff left in the worktree; an empty diff (or no worktree) stays too.
4543
- if (winner.status !== 'review' &&
4544
- winner.worktreePath &&
4545
- existsSync(winner.worktreePath) &&
4546
- winner.autonomous !== true &&
4547
- reviewGateEnabled(await loadConfig(repoRoot))) {
4548
- const diff = await worktreeDiff(winner.worktreePath, winner.baseBranch ?? 'HEAD');
4549
- if (diff.trim().length > 0 && !diff.startsWith('(diff failed')) {
4550
- store.updateRun(winner.id, { status: 'review' });
4551
- }
3822
+ const release = manager.claimForPublish(id);
3823
+ if (!release)
3824
+ return c.json({ error: 'worktree cleanup pending — retry after cleanup is confirmed' }, 409);
3825
+ try {
3826
+ if (run.delegation?.role !== 'worker' && run.worktreePath)
3827
+ await releaseThenRemoveWorktree({ previewHost: deps.previewHost }, id, repoRoot, run.worktreePath, run.branch);
3828
+ return store.deleteRun(id) ? c.json({ deleted: true }) : c.json({ error: 'history cleanup incomplete — retry deletion' }, 409);
4552
3829
  }
4553
- const losers = runs.filter((r) => r.id !== winner.id);
4554
- store.appendEvent(winner.id, {
4555
- type: 'lifecycle',
4556
- message: `picked from ${runs.length} variants — ${losers.length} other variant(s) archived`,
4557
- });
4558
- appendHandoffHeartbeat(dataDir, winner.id, `picked from ${runs.length} variants`);
4559
- for (const loser of losers) {
4560
- if (manager.isActive(loser.id))
4561
- manager.cancel(loser.id);
4562
- // Owned workers use verified cleanup exclusively, even after history becomes deletable.
4563
- if (loser.delegation?.role === 'worker' || !store.canDeleteRun(loser.id))
4564
- continue;
4565
- if (loser.worktreePath)
4566
- await releaseThenRemoveWorktree({ previewHost: deps.previewHost }, loser.id, repoRoot, loser.worktreePath, loser.branch);
4567
- store.updateRun(loser.id, { worktreePath: undefined, branch: undefined });
4568
- store.setArchived(loser.id, true);
4569
- store.appendEvent(loser.id, {
4570
- type: 'lifecycle',
4571
- message: `variant ${winner.variant ?? '?'} was picked — this variant is archived, its worktree removed`,
4572
- });
3830
+ finally {
3831
+ release();
4573
3832
  }
4574
- // Spread: `getRun` may answer undefined, and an undefined VALUE is dropped by
4575
- // JSON.stringify — so writing the key unconditionally typed the route as sending a key it
4576
- // does not. contract/workflows.ts says `.optional()`, which is what a client receives.
4577
- const picked = store.getRun(winner.id);
4578
- return c.json({ ...(picked !== undefined ? { winner: picked } : {}) });
4579
3833
  });
4580
3834
  // ---- chained family: open-targets (project-scoped) ----
4581
3835
  const openTargetsRoutes = new Hono()
@@ -4636,17 +3890,6 @@ export function createApp(deps) {
4636
3890
  ? repoRoot
4637
3891
  : worktreeOf(run);
4638
3892
  const NO_WORKTREE = 'no worktree — this task ran directly in the repo working tree';
4639
- /** Every pull request number this run points at — the one it created and the one it is about
4640
- * (#901), from whichever field carries it. Used to invalidate what the forge told us about
4641
- * them when this server does something that changes the answer. Deliberately tolerant: an
4642
- * unrecognized URL shape yields nothing rather than a guessed number. */
4643
- const runPrNumbers = (run) => {
4644
- const numbers = [
4645
- run.prNumber,
4646
- ...[run.pullRequestUrl, run.referencedPullRequestUrl].map((url) => url ? refNumberFromUrl(url) : null),
4647
- ];
4648
- return [...new Set(numbers.filter((n) => typeof n === 'number'))];
4649
- };
4650
3893
  // ---- chained family: worktrees (project-scoped) ----
4651
3894
  const worktreesRoutes = new Hono()
4652
3895
  .get('/worktrees', async (c) => {
@@ -4693,7 +3936,7 @@ export function createApp(deps) {
4693
3936
  // One row's reclaim (issue 08 §B4): the DIRECTORY only, by retention's own rule and helper —
4694
3937
  // the `cez/<id8>` branch is kept, which is what makes this safe to offer on every finished
4695
3938
  // row. `/runs/:id/remove-worktree` (directory AND branch) stays the task screen's action.
4696
- .post('/worktrees/:runId/reclaim', paramZodValidator(z.object({ runId: z.string().min(1) })), async (c) => {
3939
+ .post('/worktrees/:runId/reclaim', paramZodValidator(reclaimRunParamsSchema), async (c) => {
4697
3940
  const { root: repoRoot, store, manager } = c.get('project');
4698
3941
  const run = store.getRun(c.req.valid('param').runId);
4699
3942
  if (!run)
@@ -4716,103 +3959,6 @@ export function createApp(deps) {
4716
3959
  forgetRepoBranches(repoRoot);
4717
3960
  return c.json({ runId: run.id, worktreeReclaimedAt });
4718
3961
  });
4719
- const reclaimBodySchema = z.object({}).passthrough();
4720
- /**
4721
- * "The inbox is on and this entry exists" — the 409/404 half of `POST /todos/:id/start`, lifted
4722
- * out of the handler and in FRONT of the body validator.
4723
- *
4724
- * That position is the whole point. The route's contract is that an unknown id 404s before the
4725
- * body is looked at, and Hono only records a body in the route type when it is validated as
4726
- * MIDDLEWARE — which necessarily runs before the handler. Registering this guard first satisfies
4727
- * both: the documented status order is unchanged, and `startTodoSchema` becomes visible to
4728
- * `AppType` (and so to `hc`) instead of being parsed invisibly inside the handler.
4729
- *
4730
- * Deliberately NOT annotated with a return type: the inferred one carries the two typed
4731
- * responses, which is what keeps the 409 and 404 branches in the route's schema for the client.
4732
- */
4733
- const todoMustExist = async (c, next) => {
4734
- if (!capabilities().followups)
4735
- return c.json({ error: FOLLOWUPS_OFF }, 409);
4736
- const todo = (await readTodos(c.get('project').dataDir)).find((t) => t.id === c.req.param('id'));
4737
- if (!todo)
4738
- return c.json({ error: 'not found' }, 404);
4739
- c.set('todo', todo);
4740
- await next();
4741
- };
4742
- // ---- chained family: follow-up inbox / todos (project-scoped) ----
4743
- const todosRoutes = new Hono()
4744
- .get('/todos', async (c) => c.json(capabilities().followups ? await readTodos(c.get('project').dataDir) : []))
4745
- // Check off = delete the entry.
4746
- .delete('/todos/:id', async (c) => {
4747
- const { dataDir } = c.get('project');
4748
- if (!capabilities().followups)
4749
- return c.json({ error: FOLLOWUPS_OFF }, 409);
4750
- const removed = await removeTodo(dataDir, c.req.param('id'));
4751
- return removed ? c.json({ removed: true }) : c.json({ error: 'not found' }, 404);
4752
- })
4753
- // "▶ Run": turn an inbox entry into a task — a one-off single-step workflow
4754
- // around the suggested skill when it exists, plain quick-task otherwise.
4755
- //
4756
- // TWO middlewares, and their ORDER is the contract. This route's documented status order
4757
- // (pinned by todos-start.test.ts) is that a disabled inbox 409s and an unknown id 404s BEFORE
4758
- // the body is looked at — which is why the body used to be parsed inline, invisible to `hc`.
4759
- // Hono runs route middleware in registration order, so `todoMustExist` FIRST keeps that order
4760
- // exactly while `jsonZodValidator` second is what records the body in the route type.
4761
- //
4762
- // The two no-body cases the old inline parse distinguished are carried by the validator's
4763
- // `absent`/`malformed` options: no body at all is `undefined` (the pre-#401 bodyless POST,
4764
- // which the optional schema accepts → 201), a truncated payload is `null` (which it rejects
4765
- // → 400, rather than passing as "no body" and silently starting a run).
4766
- .post('/todos/:id/start', todoMustExist, jsonZodValidator(startTodoSchema, { absent: undefined, malformed: null }), async (c) => {
4767
- const { root: repoRoot, dataDir, manager } = c.get('project');
4768
- const id = c.req.param('id');
4769
- const todo = c.get('todo');
4770
- const parsed = { data: c.req.valid('json') };
4771
- if (agentModelsLocked(repoRoot) &&
4772
- (parsed.data?.model?.trim() || parsed.data?.effort?.trim())) {
4773
- return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
4774
- }
4775
- if (todo.startedTaskId)
4776
- return c.json({ error: 'already started' }, 409);
4777
- let task = todoTaskText(todo);
4778
- if (parsed.data?.prompt)
4779
- task += `\n\n${parsed.data.prompt}`;
4780
- let workflow;
4781
- if (todo.suggestedSkill) {
4782
- const skills = await discoverSkills(repoRoot);
4783
- if (skills.some((s) => s.name === todo.suggestedSkill)) {
4784
- workflow = {
4785
- name: '(inbox)',
4786
- description: `Follow-up from the inbox — skill "${todo.suggestedSkill}"`,
4787
- source: 'built-in',
4788
- steps: [
4789
- {
4790
- id: 'task',
4791
- name: 'Do the task',
4792
- skill: todo.suggestedSkill,
4793
- prompt: '{{task}}',
4794
- },
4795
- ],
4796
- };
4797
- }
4798
- }
4799
- if (!workflow) {
4800
- const { workflows } = await loadWorkflows(repoRoot);
4801
- workflow = workflows.find((w) => w.name === 'quick-task') ?? QUICK_TASK_WORKFLOW;
4802
- }
4803
- const fallback = parsed.data?.runner ?? (await loadConfig(repoRoot)).defaultRunner;
4804
- const blocked = await providerActionError(providersRequiredByWorkflow(workflow, fallback));
4805
- if (blocked)
4806
- return c.json({ error: blocked }, 409);
4807
- const run = manager.startRun(workflow, {
4808
- task,
4809
- runner: parsed.data?.runner,
4810
- model: parsed.data?.model,
4811
- effort: parsed.data?.effort,
4812
- });
4813
- await markStarted(dataDir, id, run.id);
4814
- return c.json({ run }, 201);
4815
- });
4816
3962
  // ---- chained family: SSE streams (project-scoped) ----
4817
3963
  const sseRoutes = new Hono()
4818
3964
  .get('/runs/:id/events', paramZodValidator(runIdParamSchema), queryZodValidator(runEventsQuerySchema), async (c) => {
@@ -4834,61 +3980,38 @@ export function createApp(deps) {
4834
3980
  const lastEventId = Number.parseInt(c.req.header('Last-Event-ID') ?? '', 10);
4835
3981
  const requestedAfter = Math.max(query.afterSeq ?? 0, Number.isSafeInteger(lastEventId) && lastEventId >= 0 ? lastEventId : 0);
4836
3982
  return streamSSENoBuffer(c, async (stream) => {
4837
- let replaying = true;
4838
- let maxSeq = requestedAfter;
4839
- const buffered = [];
4840
- // One endpoint, two SSE event names: v1 lines stay `run-event` (the name
4841
- // the legacy UI listened to — its default branch JSON-dumped unknown
4842
- // types into the transcript, which is why v2 never rode that name; the
4843
- // split outlives the R7 retirement as wire shape); protocol-v2 lines
4844
- // (dotted types, persisted snapshots AND ephemeral coalesced deltas)
4845
- // ride `ui-event`, which only v2-aware clients subscribe to.
4846
- // EventSource ignores names it has no listener for.
4847
- const writeEvent = (event) => stream.writeSSE({
4848
- id: String(event.seq),
4849
- event: isV2WireEventType(event.type) ? 'ui-event' : 'run-event',
4850
- data: JSON.stringify(event),
4851
- });
4852
- const onEvent = (payload) => {
4853
- if (payload.runId !== id)
4854
- return;
4855
- if (replaying)
4856
- buffered.push(payload.event);
4857
- else
4858
- void writeEvent(payload.event);
4859
- };
3983
+ const controller = new AbortController();
3984
+ const stop = () => controller.abort();
4860
3985
  const onRun = (run) => {
4861
- if (run.id !== id)
4862
- return;
4863
- void stream.writeSSE({ event: 'run', data: JSON.stringify(run) });
3986
+ if (run.id === id)
3987
+ void stream.writeSSE({ event: 'run', data: JSON.stringify(run) });
4864
3988
  };
4865
- store.on('event', onEvent);
4866
3989
  store.on('run', onRun);
4867
- stream.onAbort(() => {
4868
- store.off('event', onEvent);
4869
- store.off('run', onRun);
4870
- });
4871
- const replay = query.cursor
4872
- ? await readEventsAfterLiveCursor(dataDir, id, query.cursor)
4873
- : { events: store.readEvents(id), boundarySeq: 0 };
4874
- maxSeq = Math.max(maxSeq, replay.boundarySeq);
4875
- for (const event of replay.events) {
4876
- if (event.seq <= maxSeq)
4877
- continue;
4878
- await writeEvent(event);
4879
- maxSeq = event.seq;
4880
- }
4881
- replaying = false;
4882
- for (const event of buffered) {
4883
- if (event.seq > maxSeq)
4884
- await writeEvent(event);
4885
- }
3990
+ stream.onAbort(() => { stop(); store.off('run', onRun); });
3991
+ const feed = subscribeRunFeed({ store, dataDir }, {
3992
+ projectId: c.get('project').id, runId: id, afterSeq: requestedAfter,
3993
+ ...(query.cursor ? { cursor: query.cursor } : {}),
3994
+ }, {
3995
+ event: async (event) => {
3996
+ await stream.writeSSE({ id: String(event.seq), event: isV2WireEventType(event.type) ? 'ui-event' : 'run-event', data: JSON.stringify(event) });
3997
+ },
3998
+ reset: () => { stop(); void stream.close(); },
3999
+ // The legacy full-replay endpoint predates the finite batch limit. Preserve its
4000
+ // ability to deliver a large persisted event; new multiplex clients rehydrate it.
4001
+ }, controller.signal, Infinity);
4886
4002
  const run = store.getRun(id);
4887
4003
  if (run)
4888
4004
  await stream.writeSSE({ event: 'run', data: JSON.stringify(run) });
4889
- while (!stream.aborted) {
4890
- await stream.writeSSE({ event: 'ping', data: '' });
4891
- await stream.sleep(15_000);
4005
+ try {
4006
+ while (!stream.aborted && !controller.signal.aborted) {
4007
+ await stream.writeSSE({ event: 'ping', data: '' });
4008
+ await stream.sleep(15_000);
4009
+ }
4010
+ }
4011
+ finally {
4012
+ stop();
4013
+ store.off('run', onRun);
4014
+ await feed;
4892
4015
  }
4893
4016
  });
4894
4017
  })
@@ -4905,15 +4028,6 @@ export function createApp(deps) {
4905
4028
  event: 'run-deleted',
4906
4029
  data: JSON.stringify({ id }),
4907
4030
  });
4908
- const sendTodos = async () => {
4909
- const items = await readTodos(dataDir).catch(() => []);
4910
- await stream.writeSSE({ event: 'todos', data: JSON.stringify(items) });
4911
- };
4912
- // Opt-in inbox (#471): subscribing is what creates this project's
4913
- // watcher (step 2.3), so with the capability off we never subscribe —
4914
- // no watcher, no fd. Scoped to this stream's dataDir: another
4915
- // project's todos.json writes never reach this connection.
4916
- const offTodos = capabilities().followups ? onTodosChanged(dataDir, () => void sendTodos()) : () => undefined;
4917
4031
  // Live resource telemetry (#348): the sampler ticks ~every 2 s only
4918
4032
  // while some run has a registered process; each tick is relayed as one
4919
4033
  // `usage` message (runId → {cpuPct, rssBytes, procCount}). Never
@@ -4934,7 +4048,6 @@ export function createApp(deps) {
4934
4048
  stream.onAbort(() => {
4935
4049
  store.off('run', onRun);
4936
4050
  store.off('deleted', onDeleted);
4937
- offTodos();
4938
4051
  offUsage();
4939
4052
  });
4940
4053
  while (!stream.aborted) {
@@ -4953,7 +4066,7 @@ export function createApp(deps) {
4953
4066
  const attach = (project, ctx) => {
4954
4067
  if (attached.has(project))
4955
4068
  return;
4956
- const { store, dataDir } = ctx;
4069
+ const { store } = ctx;
4957
4070
  const onRun = (run) => void stream.writeSSE({
4958
4071
  event: 'run',
4959
4072
  data: JSON.stringify({ ...run, project }),
@@ -4962,16 +4075,6 @@ export function createApp(deps) {
4962
4075
  event: 'run-deleted',
4963
4076
  data: JSON.stringify({ id, project }),
4964
4077
  });
4965
- const sendTodos = async () => {
4966
- const items = await readTodos(dataDir).catch(() => []);
4967
- await stream.writeSSE({
4968
- event: 'todos',
4969
- data: JSON.stringify({ project, items }),
4970
- });
4971
- };
4972
- // Same opt-in gate as the per-project stream (#471): no capability, no
4973
- // watcher — and each subscription is scoped to its own dataDir (2.3).
4974
- const offTodos = capabilities().followups ? onTodosChanged(dataDir, () => void sendTodos()) : () => undefined;
4975
4078
  store.on('run', onRun);
4976
4079
  store.on('deleted', onDeleted);
4977
4080
  attached.set(project, {
@@ -4979,7 +4082,6 @@ export function createApp(deps) {
4979
4082
  detach: () => {
4980
4083
  store.off('run', onRun);
4981
4084
  store.off('deleted', onDeleted);
4982
- offTodos();
4983
4085
  },
4984
4086
  });
4985
4087
  };
@@ -5041,18 +4143,13 @@ export function createApp(deps) {
5041
4143
  detach();
5042
4144
  attached.clear();
5043
4145
  });
4146
+ await stream.writeSSE({ event: 'ready', data: JSON.stringify({ generation: serverGeneration }) });
5044
4147
  while (!stream.aborted) {
5045
4148
  await stream.writeSSE({ event: 'ping', data: '' });
5046
4149
  await stream.sleep(15_000);
5047
4150
  }
5048
4151
  });
5049
4152
  });
5050
- // ---- chained family: GitHub (project-scoped) ----
5051
- // These sit ABOVE the routes rather than with the family's other schemas below it, because a
5052
- // validator argument is evaluated when the route is REGISTERED — a schema declared further down
5053
- // would be in its temporal dead zone. (The schemas below are all read inside a handler, or
5054
- // passed as a thunk, which defers them past that point.)
5055
- const mergeNumberParams = z.object({ number: z.coerce.number().int().positive() });
5056
4153
  /** A ref-status list: `null` means malformed (the caller answers 400), `[]` means "not asked
5057
4154
  * for". Absent and empty are the same request — neither names a number. */
5058
4155
  const parseRefNumbers = (raw) => {
@@ -5068,17 +4165,22 @@ export function createApp(deps) {
5068
4165
  }
5069
4166
  return numbers;
5070
4167
  };
5071
- const prChangesParams = z.object({ number: z.coerce.number().int().positive().safe() });
5072
- const prChangesQuery = z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') });
5073
4168
  const githubRoutes = new Hono()
5074
4169
  .get('/github',
5075
4170
  // `limit` stays a bare string: the handler's `Number.parseInt`/`Number.isFinite` fallback to
5076
- // 30 already accepts `?limit=banana`, and a numeric schema would 400 it instead.
5077
- queryZodValidator(z.object({ limit: queryValue, refresh: queryValue })), async (c) => {
4171
+ // 30 already accepts `?limit=banana`, and a numeric schema would 400 it instead. The
4172
+ // cursors and done flags page each side on its own (hearsay-tools/cezarion#1183); the
4173
+ // schema refuses a done side that also carries its cursor, and both sides done.
4174
+ queryZodValidator(githubListQuerySchema), async (c) => {
5078
4175
  const { root: repoRoot } = c.get('project');
5079
4176
  const query = c.req.valid('query');
5080
4177
  const limit = Number.parseInt(query.limit ?? '', 10);
5081
- return c.json(await fetchGithub(repoRoot, query.refresh === '1', Number.isFinite(limit) ? limit : 30));
4178
+ return c.json(await fetchGithub(repoRoot, query.refresh === '1', Number.isFinite(limit) ? limit : 30, {
4179
+ issuesCursor: query.issuesCursor,
4180
+ prsCursor: query.prsCursor,
4181
+ issuesDone: query.issuesDone === '1',
4182
+ prsDone: query.prsDone === '1',
4183
+ }));
5082
4184
  })
5083
4185
  .get('/github/projects', queryZodValidator(githubProjectsQuerySchema), async (c) => {
5084
4186
  return c.json(await fetchGithubProjects(c.get('project').root, c.req.valid('query').generation));
@@ -5108,7 +4210,7 @@ export function createApp(deps) {
5108
4210
  // `prs` is the one genuinely REQUIRED query key on this server, so it is the one validated
5109
4211
  // strictly — `.min(1)` because `?prs=` answered `missing prs query` before it answered
5110
4212
  // `invalid prs query`, and both spellings must keep their own words.
5111
- .get('/github/checks', queryZodValidator(z.object({ prs: z.string().min(1) }), { message: 'missing prs query' }), async (c) => {
4213
+ .get('/github/checks', queryZodValidator(githubChecksQuerySchema, { message: 'missing prs query' }), async (c) => {
5112
4214
  const { root: repoRoot } = c.get('project');
5113
4215
  const raw = c.req.valid('query').prs;
5114
4216
  const parts = raw.split(',').map((p) => p.trim()).filter(Boolean);
@@ -5122,6 +4224,14 @@ export function createApp(deps) {
5122
4224
  numbers.push(n);
5123
4225
  }
5124
4226
  return c.json(await fetchGithubChecks(repoRoot, numbers));
4227
+ })
4228
+ // Comment counts for loaded list rows (hearsay-tools/cezarion#1183). The list defers them
4229
+ // (`commentsDeferred`) because they were ~0.4 s of every page; the cockpit asks here after
4230
+ // paint. Same shape and in-payload degrade as /github/checks: at most 100 positive numbers,
4231
+ // 400 on anything malformed, never a 5xx.
4232
+ .get('/github/comment-counts', queryZodValidator(githubCommentCountsQuerySchema, { message: 'invalid comment-counts query' }), async (c) => {
4233
+ const { kind, numbers, refresh } = c.req.valid('query');
4234
+ return c.json(await fetchGithubCommentCounts(c.get('project').root, kind, numbers.split(',').map(Number), refresh === '1'));
5125
4235
  })
5126
4236
  // Search across ALL states (#730). Additive sibling of `/github`, which lists the OPEN set
5127
4237
  // only (`gh issue/pr list` defaults to `--state open`) — so the tab's in-memory filter can
@@ -5138,7 +4248,7 @@ export function createApp(deps) {
5138
4248
  // GH_REF_STATUS_MAX per kind, malformed input is a 400, and an unreachable forge degrades in
5139
4249
  // the payload. Unlike /github/checks BOTH keys are optional — a table may hold only issues —
5140
4250
  // but at least one must name something, or the request asks for nothing.
5141
- .get('/github/ref-status', queryZodValidator(z.object({ prs: z.string().optional(), issues: z.string().optional() })), async (c) => {
4251
+ .get('/github/ref-status', queryZodValidator(githubRefsQuerySchema), async (c) => {
5142
4252
  const { root: repoRoot } = c.get('project');
5143
4253
  const { prs, issues } = c.req.valid('query');
5144
4254
  const parsedPrs = parseRefNumbers(prs);
@@ -5205,11 +4315,6 @@ export function createApp(deps) {
5205
4315
  kind: z.enum(['issue', 'pr']),
5206
4316
  number: z.coerce.number().int().positive(),
5207
4317
  });
5208
- const mergeBodySchema = z.object({
5209
- method: z.enum(['merge', 'squash', 'rebase']),
5210
- expectedHeadSha: z.string().regex(/^[0-9a-f]{40}$/),
5211
- overrideRules: z.boolean().optional().default(false),
5212
- }).strict();
5213
4318
  /** What the branch classifier reads for one project, or null outside a repository. */
5214
4319
  const branchClassifyInput = async (project) => {
5215
4320
  const info = await getRepoInfo(project.root);
@@ -5309,7 +4414,7 @@ export function createApp(deps) {
5309
4414
  // `Accept: application/json` reaches the same structured answer without the flag, and
5310
4415
  // `Accept: text/plain` asks for the blob; the flag still wins whenever it is present, and a
5311
4416
  // request with no opinion still gets the blob. See `negotiate`.
5312
- .get('/repo/commit/:sha', queryZodValidator(z.object({ structured: queryValue })), async (c) => {
4417
+ .get('/repo/commit/:sha', queryZodValidator(commitQuerySchema), async (c) => {
5313
4418
  const { root: repoRoot } = c.get('project');
5314
4419
  c.header('vary', 'Accept');
5315
4420
  const { structured } = c.req.valid('query');
@@ -5397,10 +4502,14 @@ export function createApp(deps) {
5397
4502
  // The Settings → Agents knobs in one read (R6 Step 1.5) — an ADDITIVE
5398
4503
  // sibling of PUT /api/config below; /api/health keeps its protected shape.
5399
4504
  const configAnswer = async (repoRoot, config) => {
4505
+ const archive = resolveArchiveOnMerge(config, await loadWorkspaceConfig());
5400
4506
  const nativeModels = await readAgentModelDefaults(repoRoot);
5401
4507
  const setup = resolveWorktreeSetup(config.worktreeSetup);
5402
4508
  const modelsLocked = agentModelsLocked(repoRoot);
5403
4509
  return {
4510
+ archiveOnMerge: config.archiveOnMerge ?? null,
4511
+ effectiveArchiveOnMerge: archive.enabled,
4512
+ archiveOnMergeSource: archive.source,
5404
4513
  baseBranch: config.baseBranch ?? null,
5405
4514
  defaultRunner: config.defaultRunner,
5406
4515
  systemPrompt: config.systemPrompt ?? null,
@@ -5486,6 +4595,12 @@ export function createApp(deps) {
5486
4595
  else
5487
4596
  raw.liveTitleUpdates = parsed.data.liveTitleUpdates;
5488
4597
  }
4598
+ if (parsed.data.archiveOnMerge !== undefined) {
4599
+ if (parsed.data.archiveOnMerge === null)
4600
+ delete raw.archiveOnMerge;
4601
+ else
4602
+ raw.archiveOnMerge = parsed.data.archiveOnMerge;
4603
+ }
5489
4604
  if (parsed.data.reviewGate !== undefined) {
5490
4605
  if (parsed.data.reviewGate === null)
5491
4606
  delete raw.reviewGate;
@@ -5537,50 +4652,11 @@ export function createApp(deps) {
5537
4652
  catch (err) {
5538
4653
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
5539
4654
  }
4655
+ if (parsed.data.archiveOnMerge !== undefined)
4656
+ deps.archiveOnMergeChanged?.();
5540
4657
  // Pre-R6 answer shape ({baseBranch, defaultRunner}) + additive R6 fields.
5541
4658
  return c.json(await configAnswer(repoRoot, await loadConfig(repoRoot)));
5542
4659
  });
5543
- // Set/clear the agents' config knobs (Settings → Agents; the Repo tab's
5544
- // base-branch picker). Merges into the RAW config.json so user keys
5545
- // (skillsRepos…) survive and schema defaults are never materialized into
5546
- // the file. All fields optional + additive: `null` (and `''` for the
5547
- // R6 keys) clears a knob back to its default.
5548
- const modelPresetSchema = z.string().trim().max(200).nullable().optional();
5549
- const setConfigSchema = z.object({
5550
- baseBranch: z.string().trim().min(1).max(200).nullable().optional(),
5551
- defaultRunner: z.enum(RUNNER_IDS).optional(),
5552
- systemPrompt: z.string().trim().max(20_000, 'must be at most 20000 characters').nullable().optional(),
5553
- defaultModels: z
5554
- .object({
5555
- claude: modelPresetSchema,
5556
- codex: modelPresetSchema,
5557
- opencode: modelPresetSchema,
5558
- pi: modelPresetSchema,
5559
- omp: modelPresetSchema,
5560
- cursor: modelPresetSchema,
5561
- })
5562
- .optional(),
5563
- // Concurrency + memory guard (Settings → Resources). maxParallel clamps to
5564
- // the schema's 1–16; memoryLimitMb null/0 clears the ceiling.
5565
- maxParallel: z.number().int().min(1).max(16).optional(),
5566
- memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
5567
- // Worktree retention count (Settings → Resources, #483). 0 = unlimited;
5568
- // null clears the key back to the schema default (10). Unlike memoryLimitMb,
5569
- // 0 is a meaningful value (unlimited), so it is stored, not treated as clear.
5570
- worktreeRetention: z.number().int().min(0).max(1000).nullable().optional(),
5571
- // Live title updates toggle (Settings → Agents): null clears the key back
5572
- // to the env-default behavior.
5573
- liveTitleUpdates: z.boolean().nullable().optional(),
5574
- // Optional review gate toggle (Settings → Agents, #489): null clears the key
5575
- // back to the env-default behavior (OFF).
5576
- reviewGate: z.boolean().nullable().optional(),
5577
- // Worktree setup (Settings → Worktrees, #917): null or [] deletes the key.
5578
- worktreeSetup: worktreeSetupConfigSchema.nullable().optional(),
5579
- });
5580
- const setAgentConfigSchema = z.object({
5581
- content: z.string().max(2_000_000),
5582
- version: z.string().nullable(),
5583
- });
5584
4660
  // ---- chained family: agent-config (project-scoped) -----------------------
5585
4661
  // Agent config is project-scoped (spec #404, adapted to the multi-project
5586
4662
  // route seam from #521): handlers resolve the selected repo through the
@@ -5624,24 +4700,51 @@ export function createApp(deps) {
5624
4700
  return c.json({ error: out.error }, out.status);
5625
4701
  return c.json(out.read);
5626
4702
  });
5627
- // Repo view branch actions: switch to an existing branch, or create one
5628
- // (from `from` or HEAD) and switch. Predictable git failures — invalid
5629
- // name, unknown `from`, dirty-tree checkout conflict — are 409 + reason.
5630
- const repoBranchSchema = z.object({
5631
- name: z.string().trim().min(1).max(200),
5632
- from: z.string().trim().min(1).max(200).optional(),
5633
- });
5634
4703
  // ---- assemble the chained families --------------------------------------
5635
4704
  // Every chained family is registered ONCE and mounted into the versioned table. There is no
5636
4705
  // second, unversioned spelling: `/api/*` was removed once the whole API was reachable under
5637
4706
  // `/api/v1` (BACKWARD_COMPATIBILITY.md §2). One surface means one thing to keep working, and
5638
4707
  // it is the one the typed client describes.
5639
4708
  //
4709
+ // ---- chained family: voice mode (project-scoped, gated on CEZ_VOICE) ----
4710
+ const voiceRoutes = createVoiceRoutes({
4711
+ voiceEnabled: () => capabilities().voice,
4712
+ engine: () => selectVoiceEngine(process.env),
4713
+ recent: new RecentVoiceActions(),
4714
+ });
5640
4715
  // MOUNT ORDER IS REGISTRATION ORDER. Hono matches in the order routes were added, so each
5641
4716
  // family keeps its internal order and the families keep theirs relative to each other.
5642
4717
  //
5643
4718
  // Written as ONE chained expression because that is the only shape Hono can infer route types
5644
4719
  // from — it is what puts these routes in `AppType`, and so in the typed client.
4720
+ // ---- chained family: agent webhooks (hearsay-tools/cezarion#1576, project-scoped) ----
4721
+ // What `cez webhook` calls from inside a task: list the webhooks the owner exposed to agents in
4722
+ // this project, and send one report through one of them. A webhook closed to agents answers
4723
+ // the same 404 as an unknown one, so this interface cannot even confirm it exists.
4724
+ const agentWebhooksRoutes = new Hono()
4725
+ .get('/agent-webhooks', async (c) => {
4726
+ const project = c.get('project');
4727
+ const body = { webhooks: agentWebhooks(await registryEntry(project.id, project.root).catch(() => undefined)) };
4728
+ return c.json(body);
4729
+ })
4730
+ .post('/agent-webhooks/:webhook', paramZodValidator(agentWebhookParamsSchema.pick({ webhook: true })), jsonZodValidator(sendAgentReportInputSchema), async (c) => {
4731
+ const project = c.get('project');
4732
+ const { webhook: ref } = c.req.valid('param');
4733
+ const entry = await registryEntry(project.id, project.root).catch(() => undefined);
4734
+ const webhook = agentWebhook(entry, ref);
4735
+ if (!entry || !webhook)
4736
+ return c.json({ error: `no webhook named "${ref}" is open to agents in this project — run \`cez webhook list\`` }, 404);
4737
+ const outcome = await sendAgentReport({
4738
+ projectId: entry.id,
4739
+ webhook,
4740
+ input: c.req.valid('json'),
4741
+ store: project.store,
4742
+ origin: taskWebhooks.currentOrigin(),
4743
+ fetch: deps.taskWebhookFetch,
4744
+ });
4745
+ const body = { ...outcome, webhook: { id: webhook.id, name: webhook.name } };
4746
+ return c.json(body);
4747
+ });
5645
4748
  const v1 = new Hono()
5646
4749
  .use('*', resolveProjectScope)
5647
4750
  .route('/', launchKeyRoutes)
@@ -5650,16 +4753,16 @@ export function createApp(deps) {
5650
4753
  .route('/', workflowsRoutes)
5651
4754
  .route('/', planRoutes)
5652
4755
  .route('/', automationsRoutes)
4756
+ .route('/', agentWebhooksRoutes)
5653
4757
  .route('/', runsRoutes)
5654
- .route('/', groupsRoutes)
5655
4758
  .route('/', openTargetsRoutes)
5656
4759
  .route('/', worktreesRoutes)
5657
- .route('/', todosRoutes)
5658
4760
  .route('/', sseRoutes)
5659
4761
  .route('/', githubRoutes)
5660
4762
  .route('/', repoRoutes)
5661
4763
  .route('/', configRoutes)
5662
- .route('/', agentConfigRoutes);
4764
+ .route('/', agentConfigRoutes)
4765
+ .route('/', voiceRoutes);
5663
4766
  projectRoutePaths = new Set(v1.routes.filter((route) => route.method !== 'ALL')
5664
4767
  .flatMap((route) => [V1_PREFIX, V1_SCOPED_PREFIX].map((prefix) => `${route.method} ${prefix}${route.path}`)));
5665
4768
  // ---- chained family: the cross-project run index (workspace-level) -------
@@ -5684,7 +4787,7 @@ export function createApp(deps) {
5684
4787
  * the client will not paint is free and asking about one it will is the whole point.
5685
4788
  */
5686
4789
  const mentionedReferenceNumbers = (run) => {
5687
- const numbers = [];
4790
+ const numbers = (run.pullRequests ?? []).map(pr => pr.number);
5688
4791
  for (const url of [run.pullRequestUrl, run.referencedPullRequestUrl, run.referencedIssueUrl]) {
5689
4792
  const number = url ? refNumberFromUrl(url) : null;
5690
4793
  if (number !== null)
@@ -5832,13 +4935,22 @@ export function createApp(deps) {
5832
4935
  });
5833
4936
  // Workspace-level families answer for the whole workspace, so they are single-mount: never a
5834
4937
  // project-scoped spelling, which would be a second surface to protect with no consumer.
4938
+ const liveRoutes = createLiveRoutes({
4939
+ serverGeneration, resolveBootProject,
4940
+ resolveProject: async (id) => {
4941
+ const bootId = await resolveBootProject();
4942
+ if (id === 'default' || id === bootId)
4943
+ return { ...bootContext, id: bootId };
4944
+ return contexts.context(id);
4945
+ },
4946
+ });
5835
4947
  const workspaceV1 = new Hono()
4948
+ .route('/', liveRoutes)
5836
4949
  .route('/', healthRoutes)
5837
4950
  .route('/', modelsRoutes)
5838
4951
  .route('/', providersRoutes)
5839
4952
  .route('/', projectsRoutes)
5840
4953
  .route('/', agentProfilesRoutes)
5841
- .route('/', skillsUpdateRoutes)
5842
4954
  .route('/', applicationUpdateRoutes)
5843
4955
  .route('/', workspaceConfigRoutes)
5844
4956
  .route('/', fsBrowseRoutes)
@@ -5871,15 +4983,22 @@ export function createApp(deps) {
5871
4983
  return routed;
5872
4984
  }
5873
4985
  /** The host a thread link names (#589): the bound one, or loopback for a wildcard bind. */
4986
+ /** The built-in skills a project's manager offers (#774). Test apps inject partial managers. */
4987
+ function builtinSkillsOf(manager) {
4988
+ return manager.builtinSkills?.() ?? [];
4989
+ }
5874
4990
  function taskWebhookHost(bindHost) {
5875
4991
  if (!bindHost || bindHost === '0.0.0.0' || bindHost === '::')
5876
4992
  return '127.0.0.1';
5877
4993
  return bindHost.includes(':') ? `[${bindHost}]` : bindHost;
5878
4994
  }
5879
- const NO_WEBHOOK_ERROR = 'this project has no task webhook — set one in Settings → General → Task webhook, or start without notify';
4995
+ /** The origin a local client reaches this cockpit at: `CEZ_COCKPIT_URL` and the task webhook's links (#774, #594). */
4996
+ export function cockpitOriginFor(bindHost, port) {
4997
+ return `http://${taskWebhookHost(bindHost)}:${port}`;
4998
+ }
4999
+ const webhookNameTakenError = (name) => `this project already has a webhook named "${name.trim()}" — pick another name`;
5880
5000
  export function startServer(deps, port, listener) {
5881
5001
  const workspaceEvents = deps.workspaceEvents ?? new WorkspaceEventBus();
5882
- const skillsUpdate = deps.skillsUpdate ?? new SkillsUpdateService({ invalidateCatalog: refreshTeamSkills });
5883
5002
  // The subscription hub rides the same HTTP server (one port, zero config):
5884
5003
  // createApp registers the topics, the `upgrade` hook below owns the socket.
5885
5004
  const socketHub = deps.socketHub ?? createSocketHub();
@@ -5891,6 +5010,14 @@ export function startServer(deps, port, listener) {
5891
5010
  const bootProjectId = deps.bootProjectId ?? 'default';
5892
5011
  const bootAutomationStore = automationCoordinator.store(bootProjectId, deps.repoRoot);
5893
5012
  const taskWebhooks = deps.taskWebhooks ?? new TaskWebhooks({ fetch: deps.taskWebhookFetch });
5013
+ // #774: the bound origin every agent env carries (`CEZ_COCKPIT_URL`), so `cez automation` calls
5014
+ // this cockpit instead of probing ports. Unset until `listening`; a project built after that is
5015
+ // addressed in `prepareManager`, before its recovery resumes anything.
5016
+ let cockpitOrigin;
5017
+ const addressManager = (project) => {
5018
+ if (cockpitOrigin)
5019
+ project.manager.setCockpitAddress?.({ origin: cockpitOrigin, projectId: project.id });
5020
+ };
5894
5021
  const sharedContexts = deps.contexts ?? new ProjectContexts({
5895
5022
  ownership: deps.ownership,
5896
5023
  listProjects,
@@ -5900,10 +5027,44 @@ export function startServer(deps, port, listener) {
5900
5027
  automationStore: (projectId, root) => automationCoordinator.store(projectId, root),
5901
5028
  prepareManager: (project) => {
5902
5029
  taskWebhooks.attach(project);
5030
+ addressManager(project);
5903
5031
  return deps.delegation?.attachProject(project);
5904
5032
  },
5905
5033
  afterRecover: (project) => deps.delegation?.service.armDestroyRetries(project.id),
5906
5034
  });
5035
+ const archiveDeps = {
5036
+ projects: async () => {
5037
+ const boot = { id: bootProjectId, root: deps.repoRoot, status: 'ok' };
5038
+ if (resolveCapabilities(process.env, deps.bindHost).singleProject)
5039
+ return [boot];
5040
+ return [boot, ...(await listProjects()).filter(project => project.id !== bootProjectId && project.root !== deps.repoRoot)];
5041
+ },
5042
+ enabled: async (project) => resolveArchiveOnMerge(await loadConfig(project.root), await loadWorkspaceConfig()).enabled,
5043
+ repository: async (project) => {
5044
+ const repository = await discoverRepoHandle(project.root);
5045
+ return repository.status === 'resolved' ? `https://github.com/${repository.handle.owner}/${repository.handle.name}` : undefined;
5046
+ },
5047
+ withStore: async (project, work) => {
5048
+ if (project.root === deps.repoRoot)
5049
+ return work(deps.store, id => deps.manager.isActive?.(id) ?? false, () => server.listening);
5050
+ return sharedContexts.withMaintenanceStore(project.id, (store, valid) => work(store, id => sharedContexts.peek(project.id)?.manager.isActive(id) ?? false, valid));
5051
+ },
5052
+ lookup: lookupMergedPullRequests,
5053
+ };
5054
+ const archiveOnMerge = new ArchiveOnMergeCoordinator(archiveDeps);
5055
+ const completionActions = new CompletionActionCoordinator({
5056
+ projects: archiveDeps.projects,
5057
+ withStore: (project, work) => archiveDeps.withStore(project, (store, _active, valid) => work(store, valid)),
5058
+ forge: async (project) => {
5059
+ const info = await getRepoInfo(project.root);
5060
+ const driver = resolveForge(info);
5061
+ const repository = info?.remote ? forgeWebRoot(info.remote) : null;
5062
+ return driver && repository ? { driver, repository } : undefined;
5063
+ },
5064
+ head: (project, branch) => getBranchHead(project.root, branch),
5065
+ });
5066
+ // Injected contexts (tests) skip `prepareManager`.
5067
+ sharedContexts.onContextBuilt(addressManager);
5907
5068
  // #801: GitHub automations are opt-in. Off, the flag must remove the BEHAVIOR and not merely
5908
5069
  // the UI — no scheduler, no GitHub polling, no launched runs — so every entry point into the
5909
5070
  // workspace scheduler below is gated on it. Read per call rather than captured, for the same
@@ -5916,10 +5077,10 @@ export function startServer(deps, port, listener) {
5916
5077
  contexts: sharedContexts,
5917
5078
  automationStore: bootAutomationStore,
5918
5079
  workspaceEvents,
5919
- skillsUpdate,
5920
5080
  socketHub,
5921
5081
  cezarPort,
5922
5082
  projectLookup,
5083
+ archiveOnMergeChanged: () => archiveOnMerge.invalidate(),
5923
5084
  automationsChanged: () => rescheduleAutomations(),
5924
5085
  });
5925
5086
  // SECURITY: default to loopback. This server executes agents locally and its endpoints are
@@ -5957,10 +5118,9 @@ export function startServer(deps, port, listener) {
5957
5118
  // handler without closing/rebinding; direct server callers retain normal startup.
5958
5119
  if (listener) {
5959
5120
  listener.removeAllListeners('request');
5960
- listener.on('request', getRequestListener(serverOptions.fetch, { hostname: serverOptions.hostname }));
5121
+ listener.on('request', getRequestListener(serverOptions.fetch, defined({ hostname: serverOptions.hostname })));
5961
5122
  }
5962
5123
  const server = listener ?? serve(serverOptions);
5963
- const coordinator = new SkillsUpdateCoordinator(skillsUpdate, async () => effectiveSkillsAutoUpdate(await loadWorkspaceConfig()));
5964
5124
  // EVERY registered project, remote or not: a schedule needs none. `github` is present only for
5965
5125
  // a github.com remote, and only then does the project's handle poll.
5966
5126
  const automationProjects = new Map();
@@ -6055,7 +5215,6 @@ export function startServer(deps, port, listener) {
6055
5215
  if (event === 'project-added') {
6056
5216
  const project = data.project;
6057
5217
  if (project && typeof project.id === 'string' && typeof project.root === 'string' && project.status !== 'missing') {
6058
- coordinator.add(project.id, project.root);
6059
5218
  const { id, root } = project;
6060
5219
  void rememberAutomationProject(id, root).then(() => {
6061
5220
  if (!automationsEnabled())
@@ -6067,8 +5226,6 @@ export function startServer(deps, port, listener) {
6067
5226
  }
6068
5227
  else if (event === 'project-removed') {
6069
5228
  const id = data.id;
6070
- if (typeof id === 'string')
6071
- coordinator.remove(id);
6072
5229
  if (typeof id === 'string') {
6073
5230
  automationCoordinator.remove(id);
6074
5231
  automationProjects.delete(id);
@@ -6077,15 +5234,26 @@ export function startServer(deps, port, listener) {
6077
5234
  }
6078
5235
  });
6079
5236
  const startBackgroundServices = () => {
5237
+ archiveOnMerge.start();
5238
+ completionActions.start();
6080
5239
  void listProjects().then((projects) => {
6081
5240
  const all = projects.some((project) => project.root === deps.repoRoot)
6082
5241
  ? projects : [{ id: deps.bootProjectId ?? 'default', root: deps.repoRoot, status: 'ok' }, ...projects];
6083
- coordinator.start(all);
6084
5242
  // #781: every registered project's preview leftovers, not only the boot project's.
6085
5243
  void sweepRegisteredPreviewLeftovers(all, deps.previewHost).catch(() => 0);
5244
+ // #667: build every other project's context now, so the first click on one does not pay
5245
+ // its store open, worktree sweeps and recovery. The boot project is built already.
5246
+ // Not after a close that landed while the registry was being read: `disposeAll` has run, and
5247
+ // a warm-up started now would build contexts nothing will ever dispose.
5248
+ // Not in single-project mode either: every other project is out of reach there, and
5249
+ // building one would resume its interrupted runs behind the operator's back.
5250
+ if (projectWarmupEnabled() && server.listening && !resolveCapabilities(process.env, deps.bindHost).singleProject) {
5251
+ const bootId = deps.bootProjectId ?? 'default';
5252
+ void sharedContexts.warmAll(all.filter((project) => project.id !== bootId && project.root !== deps.repoRoot))
5253
+ .catch(() => undefined);
5254
+ }
6086
5255
  // #801: with automations off there is nothing to warm — no remote to resolve, no receipts
6087
- // to reconcile, and above all no scheduler to start. The skills-update coordinator above is
6088
- // a separate feature and starts either way.
5256
+ // to reconcile, and above all no scheduler to start.
6089
5257
  if (!automationsEnabled())
6090
5258
  return;
6091
5259
  void Promise.all(all.map(async (project) => {
@@ -6108,31 +5276,34 @@ export function startServer(deps, port, listener) {
6108
5276
  server.once('listening', startBackgroundServices);
6109
5277
  // The boot store's preview leftovers and run deletions; lazy projects arm theirs at build.
6110
5278
  armPreview(deps.store, join(deps.repoRoot, '.ai/cezar'), deps.previewHost);
6111
- const previewSocket = createPreviewSocket({
6112
- host: deps.previewHost,
5279
+ const previewSockets = createPreviewSockets({
5280
+ host: deps.previewHost, cockpitOrigin: () => cockpitOrigin,
6113
5281
  verify: (req) => verifyWsUpgrade(req, deps.bindHost),
6114
5282
  resolveProject: async (projectId) => projectLookup.resolve?.(projectId),
6115
5283
  });
6116
5284
  server.once('close', () => {
6117
5285
  unsubscribe();
6118
- coordinator.stop();
5286
+ archiveOnMerge.stop();
5287
+ completionActions.stop();
6119
5288
  automationScheduler.stop();
6120
5289
  sharedContexts.disposeAll();
6121
5290
  void deps.delegation?.close();
6122
5291
  socketHub.close();
6123
- previewSocket.close();
5292
+ previewSockets.close();
6124
5293
  void deps.previewHost?.close().catch(() => undefined);
6125
5294
  });
6126
- // The one `upgrade` listener: the subscription bus and the preview pane's socket (#781).
6127
- attachUpgradeRouter(server, [socketHubRoute(socketHub, (req) => verifyWsUpgrade(req, deps.bindHost)), previewSocket.route]);
5295
+ // The one `upgrade` listener: the subscription bus and the preview's sockets (hearsay-tools/cezarion#781).
5296
+ attachUpgradeRouter(server, [socketHubRoute(socketHub, (req) => verifyWsUpgrade(req, deps.bindHost)), ...previewSockets.routes]);
6128
5297
  const shutdownForRestart = async () => {
5298
+ archiveOnMerge.stop();
5299
+ completionActions.stop();
6129
5300
  sharedContexts.disposeAll(); // close every built secondary project before old process exits
6130
5301
  deps.store.flush();
6131
5302
  socketHub.close();
6132
5303
  // Dev servers run in their own process groups: they outlive cezar unless stopped here.
6133
5304
  // A failed release must not abort the restart halfway, with the store flushed and the port still bound.
6134
5305
  await deps.previewHost?.close().catch(() => undefined);
6135
- previewSocket.close();
5306
+ previewSockets.close();
6136
5307
  await deps.delegation?.close();
6137
5308
  await new Promise((resolve) => {
6138
5309
  server.close(() => resolve());
@@ -6150,7 +5321,14 @@ export function startServer(deps, port, listener) {
6150
5321
  const address = server.address();
6151
5322
  const bound = address && typeof address === 'object' ? address.port : port;
6152
5323
  listeningPort = bound;
6153
- taskWebhooks.setOrigin(`http://${taskWebhookHost(deps.bindHost)}:${bound}`);
5324
+ cockpitOrigin = cockpitOriginFor(deps.bindHost, bound);
5325
+ taskWebhooks.setOrigin(cockpitOrigin);
5326
+ addressManager({ id: bootProjectId, manager: deps.manager });
5327
+ for (const id of sharedContexts.ids()) {
5328
+ const context = sharedContexts.peek(id);
5329
+ if (context)
5330
+ addressManager(context);
5331
+ }
6154
5332
  };
6155
5333
  if (server.listening)
6156
5334
  setTaskWebhookOrigin();
@@ -6159,37 +5337,9 @@ export function startServer(deps, port, listener) {
6159
5337
  return Object.assign(server, { shutdownForRestart });
6160
5338
  }
6161
5339
  /**
6162
- * The WebSocket twin of the `/api/*` request-origin guard (#426), applied
6163
- * before the `/api/v1/ws` handshake. WebSocket is NOT subject to CORS — any web
6164
- * page may open `ws://127.0.0.1:<port>/api/v1/ws` and, unlike a forced HTTP GET,
6165
- * would get to READ what comes back — so this guard is load-bearing:
6166
- *
6167
- * 1. Host allowlist (local mode): a non-loopback Host is a DNS-rebound
6168
- * request; kill it before the handshake. Same anchored
6169
- * `isLoopbackHostHeader` rules as the HTTP guard.
6170
- * 2. Origin check: browsers always attach `Origin` to a WS handshake. A
6171
- * same-authority Origin is the cockpit itself. A LOOPBACK origin with a
6172
- * loopback Host is also admitted — that is the `npm run dev` Vite proxy
6173
- * (`changeOrigin` rewrites Host, the browser's `localhost:5173` Origin
6174
- * survives). Unlike the HTTP write guard we cannot REQUIRE `Sec-Fetch-Site`
6175
- * here — Safari sends no `Sec-Fetch-*` at all and requiring it would lock
6176
- * the dev proxy out of it — but we do honor it when it is there: Chromium
6177
- * does send it on a WS handshake, and page JS cannot forge it (forbidden
6178
- * header name), so a cross-port attacker page announcing `same-site` is
6179
- * rejected on the browser that ships it while Safari/Firefox still fall
6180
- * back to the loopback rule. Best available, not fail-open.
6181
- * No Origin at all is a non-browser client — same stance as the HTTP guard.
6182
- *
6183
- * The loopback-origin fallback still admits, on a browser that sends no
6184
- * `Sec-Fetch-Site`, a page served from ANOTHER loopback port. That is no longer
6185
- * a caveat the caller must remember: the verdict carries a `trusted` flag, and
6186
- * the hub only lets an UNtrusted connection subscribe to topics a publisher
6187
- * marked `loopbackReadable`. `health` is flagged so (the CORS-open discovery
6188
- * payload, #431); every other topic stays trusted-only by default, so a topic
6189
- * carrying run or repo content is mechanically unreachable from a foreign local
6190
- * page without any per-topic vigilance. A connection is `trusted` when it is
6191
- * provably the cockpit itself: a same-authority Origin, a no-Origin native
6192
- * client, or a dev proxy the browser vouches for via `Sec-Fetch-Site`.
5340
+ * Validate topic-bus upgrades before handshake. Local Host must be loopback. Same-authority or
5341
+ * native clients are trusted; verified dev proxies are trusted. Metadata-free foreign loopback
5342
+ * origins are untrusted and may read only loopbackReadable topics.
6193
5343
  */
6194
5344
  export function verifyWsUpgrade(req, bindHost) {
6195
5345
  const host = req.headers.host;
@@ -6287,20 +5437,8 @@ export function isSafeSessionId(sessionId) {
6287
5437
  return SAFE_SESSION_ID.test(sessionId);
6288
5438
  }
6289
5439
  /**
6290
- * The CLI command that reopens a run's session for interactive take-over, per
6291
- * backend. Legacy/undefined records default to Claude. Returns null when the id
6292
- * is not a shape we recognise — callers degrade (no take-over) rather than
6293
- * splice it into a shell.
6294
- *
6295
- * Validate, don't quote (#431): the session id is the only variable spliced
6296
- * into the command string, and `openInTerminal` runs that string through bash
6297
- * on darwin/linux but through `cmd /K` on win32. cmd.exe does not treat `'` as
6298
- * a quote character, so POSIX-quoting the id handed Windows users a literal
6299
- * `claude --resume '9f8e…'` and Claude answered "no conversation found".
6300
- * Constraining the charset to one with no metacharacter in ANY of those shells
6301
- * needs no quoting at all and fails closed on an unexpected id — a stronger
6302
- * guarantee than escaping, and platform-independent. Ids are UUID/CLI-minted
6303
- * today; this keeps a future source safe.
5440
+ * Build native session takeover commands. Legacy/undefined backend defaults to Claude. Refuse
5441
+ * unrecognized session-id shapes rather than embedding unsafe or platform-misquoted values.
6304
5442
  */
6305
5443
  export function resumeCommand(runner, sessionId) {
6306
5444
  if (!isSafeSessionId(sessionId))