@wjarka/cezarion 0.14.8 → 0.14.9-dev.1009

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 (280) hide show
  1. package/README.md +131 -2
  2. package/dist/application-update/bin-recovery.d.ts +5 -0
  3. package/dist/application-update/bin-recovery.js +96 -0
  4. package/dist/application-update/bin-recovery.js.map +1 -0
  5. package/dist/application-update/discovery.d.ts +22 -0
  6. package/dist/application-update/discovery.js +91 -0
  7. package/dist/application-update/discovery.js.map +1 -0
  8. package/dist/application-update/helper.d.ts +57 -0
  9. package/dist/application-update/helper.js +374 -0
  10. package/dist/application-update/helper.js.map +1 -0
  11. package/dist/application-update/launcher.d.ts +8 -0
  12. package/dist/application-update/launcher.js +53 -0
  13. package/dist/application-update/launcher.js.map +1 -0
  14. package/dist/application-update/lock.d.ts +4 -0
  15. package/dist/application-update/lock.js +70 -0
  16. package/dist/application-update/lock.js.map +1 -0
  17. package/dist/application-update/npm-process.d.ts +17 -0
  18. package/dist/application-update/npm-process.js +133 -0
  19. package/dist/application-update/npm-process.js.map +1 -0
  20. package/dist/application-update/service.d.ts +69 -0
  21. package/dist/application-update/service.js +456 -0
  22. package/dist/application-update/service.js.map +1 -0
  23. package/dist/artifacts/cli.d.ts +1 -0
  24. package/dist/artifacts/cli.js +32 -0
  25. package/dist/artifacts/cli.js.map +1 -0
  26. package/dist/artifacts/lifecycle.d.ts +4 -0
  27. package/dist/artifacts/lifecycle.js +42 -0
  28. package/dist/artifacts/lifecycle.js.map +1 -0
  29. package/dist/artifacts/resolve.d.ts +18 -0
  30. package/dist/artifacts/resolve.js +102 -0
  31. package/dist/artifacts/resolve.js.map +1 -0
  32. package/dist/artifacts/store.d.ts +8 -0
  33. package/dist/artifacts/store.js +323 -0
  34. package/dist/artifacts/store.js.map +1 -0
  35. package/dist/automations/scheduler.d.ts +6 -0
  36. package/dist/automations/scheduler.js +45 -17
  37. package/dist/automations/scheduler.js.map +1 -1
  38. package/dist/automations/store.d.ts +12 -4
  39. package/dist/automations/store.js +199 -22
  40. package/dist/automations/store.js.map +1 -1
  41. package/dist/automations/types.d.ts +2 -0
  42. package/dist/automations/types.js +1 -0
  43. package/dist/automations/types.js.map +1 -1
  44. package/dist/ci-wait/mcp.d.ts +3 -0
  45. package/dist/ci-wait/mcp.js +14 -3
  46. package/dist/ci-wait/mcp.js.map +1 -1
  47. package/dist/contract/application-update.d.ts +32 -0
  48. package/dist/contract/artifacts.d.ts +83 -0
  49. package/dist/contract/ask-schema.d.ts +32 -0
  50. package/dist/contract/ask.d.ts +2 -32
  51. package/dist/contract/automations.d.ts +6 -0
  52. package/dist/contract/conversations.d.ts +165 -0
  53. package/dist/contract/delegation.d.ts +68 -0
  54. package/dist/contract/discovery.d.ts +134 -0
  55. package/dist/contract/events.d.ts +49 -0
  56. package/dist/contract/health.d.ts +12 -0
  57. package/dist/contract/index.d.ts +3 -0
  58. package/dist/contract/index.js +1654 -1454
  59. package/dist/contract/projects.d.ts +40 -0
  60. package/dist/contract/runs.d.ts +186 -0
  61. package/dist/contract/skills.d.ts +33 -0
  62. package/dist/core/agent-runner.d.ts +33 -4
  63. package/dist/core/agent-runner.js +5 -0
  64. package/dist/core/agent-runner.js.map +1 -1
  65. package/dist/core/claude-cli-runner.d.ts +6 -12
  66. package/dist/core/claude-cli-runner.js +90 -20
  67. package/dist/core/claude-cli-runner.js.map +1 -1
  68. package/dist/core/codex-app-server-runner.d.ts +2 -1
  69. package/dist/core/codex-app-server-runner.js +327 -92
  70. package/dist/core/codex-app-server-runner.js.map +1 -1
  71. package/dist/core/codex-app-server-transport.d.ts +13 -1
  72. package/dist/core/codex-app-server-transport.js +58 -17
  73. package/dist/core/codex-app-server-transport.js.map +1 -1
  74. package/dist/core/cursor-acp-runner.d.ts +6 -1
  75. package/dist/core/cursor-acp-runner.js +162 -26
  76. package/dist/core/cursor-acp-runner.js.map +1 -1
  77. package/dist/core/cursor-provider-error.d.ts +3 -0
  78. package/dist/core/cursor-provider-error.js +16 -1
  79. package/dist/core/cursor-provider-error.js.map +1 -1
  80. package/dist/core/input-submissions.d.ts +14 -0
  81. package/dist/core/input-submissions.js +26 -0
  82. package/dist/core/input-submissions.js.map +1 -0
  83. package/dist/core/opencode-server-runner.d.ts +4 -1
  84. package/dist/core/opencode-server-runner.js +156 -26
  85. package/dist/core/opencode-server-runner.js.map +1 -1
  86. package/dist/core/pi-runner.d.ts +2 -1
  87. package/dist/core/pi-runner.js +88 -10
  88. package/dist/core/pi-runner.js.map +1 -1
  89. package/dist/core/provider-auth.d.ts +99 -1
  90. package/dist/core/provider-auth.js +203 -2
  91. package/dist/core/provider-auth.js.map +1 -1
  92. package/dist/delegation/cli.d.ts +1 -0
  93. package/dist/delegation/cli.js +103 -7
  94. package/dist/delegation/cli.js.map +1 -1
  95. package/dist/delegation/conversations.js +13 -5
  96. package/dist/delegation/conversations.js.map +1 -1
  97. package/dist/delegation/input.d.ts +2 -1
  98. package/dist/delegation/input.js +6 -1
  99. package/dist/delegation/input.js.map +1 -1
  100. package/dist/delegation/policy.d.ts +3 -2
  101. package/dist/delegation/policy.js +3 -1
  102. package/dist/delegation/policy.js.map +1 -1
  103. package/dist/delegation/process-liveness.d.ts +66 -0
  104. package/dist/delegation/process-liveness.js +215 -0
  105. package/dist/delegation/process-liveness.js.map +1 -0
  106. package/dist/delegation/provision.d.ts +1 -1
  107. package/dist/delegation/provision.js +10 -5
  108. package/dist/delegation/provision.js.map +1 -1
  109. package/dist/delegation/questions.d.ts +19 -0
  110. package/dist/delegation/questions.js +45 -0
  111. package/dist/delegation/questions.js.map +1 -0
  112. package/dist/delegation/results.d.ts +1 -1
  113. package/dist/delegation/results.js +33 -19
  114. package/dist/delegation/results.js.map +1 -1
  115. package/dist/delegation/routes.d.ts +185 -11
  116. package/dist/delegation/routes.js +6 -2
  117. package/dist/delegation/routes.js.map +1 -1
  118. package/dist/delegation/service.d.ts +23 -5
  119. package/dist/delegation/service.js +240 -17
  120. package/dist/delegation/service.js.map +1 -1
  121. package/dist/delegation/transport.d.ts +178 -15
  122. package/dist/delegation/workspace.d.ts +1 -1
  123. package/dist/delegation/workspace.js +13 -6
  124. package/dist/delegation/workspace.js.map +1 -1
  125. package/dist/discovery/catalog.d.ts +14 -0
  126. package/dist/discovery/catalog.js +31 -0
  127. package/dist/discovery/catalog.js.map +1 -0
  128. package/dist/discovery/cli.d.ts +8 -0
  129. package/dist/discovery/cli.js +104 -0
  130. package/dist/discovery/cli.js.map +1 -0
  131. package/dist/git-worktree.d.ts +3 -1
  132. package/dist/git-worktree.js +26 -4
  133. package/dist/git-worktree.js.map +1 -1
  134. package/dist/index.js +93 -26
  135. package/dist/index.js.map +1 -1
  136. package/dist/open-url.d.ts +2 -0
  137. package/dist/open-url.js +20 -0
  138. package/dist/open-url.js.map +1 -0
  139. package/dist/release/snapshot.d.ts +3 -3
  140. package/dist/release/snapshot.js +6 -6
  141. package/dist/release/snapshot.js.map +1 -1
  142. package/dist/runs/agent-tmpdir.d.ts +2 -0
  143. package/dist/runs/agent-tmpdir.js +5 -2
  144. package/dist/runs/agent-tmpdir.js.map +1 -1
  145. package/dist/runs/client-request.d.ts +11 -0
  146. package/dist/runs/client-request.js +26 -0
  147. package/dist/runs/client-request.js.map +1 -0
  148. package/dist/runs/delegation-state.d.ts +17 -0
  149. package/dist/runs/event-history.js +6 -0
  150. package/dist/runs/event-history.js.map +1 -1
  151. package/dist/runs/retention.d.ts +7 -2
  152. package/dist/runs/retention.js +39 -7
  153. package/dist/runs/retention.js.map +1 -1
  154. package/dist/runs/store.d.ts +100 -6
  155. package/dist/runs/store.js +245 -2
  156. package/dist/runs/store.js.map +1 -1
  157. package/dist/runs/webhook.d.ts +125 -0
  158. package/dist/runs/webhook.js +256 -0
  159. package/dist/runs/webhook.js.map +1 -0
  160. package/dist/server/project-context.d.ts +2 -0
  161. package/dist/server/project-context.js +1 -0
  162. package/dist/server/project-context.js.map +1 -1
  163. package/dist/server/provider-auth-runtime.d.ts +30 -4
  164. package/dist/server/provider-auth-runtime.js +71 -6
  165. package/dist/server/provider-auth-runtime.js.map +1 -1
  166. package/dist/server/server.d.ts +8226 -4860
  167. package/dist/server/server.js +311 -15
  168. package/dist/server/server.js.map +1 -1
  169. package/dist/server-install/platforms/ubuntu-vps.d.ts +5 -1
  170. package/dist/server-install/platforms/ubuntu-vps.js +160 -18
  171. package/dist/server-install/platforms/ubuntu-vps.js.map +1 -1
  172. package/dist/server-install/steps.d.ts +4 -2
  173. package/dist/server-install/steps.js +5 -4
  174. package/dist/server-install/steps.js.map +1 -1
  175. package/dist/skills-remote.d.ts +30 -5
  176. package/dist/skills-remote.js +122 -32
  177. package/dist/skills-remote.js.map +1 -1
  178. package/dist/skills.js +15 -6
  179. package/dist/skills.js.map +1 -1
  180. package/dist/task-cli/cli.d.ts +38 -0
  181. package/dist/task-cli/cli.js +580 -0
  182. package/dist/task-cli/cli.js.map +1 -0
  183. package/dist/task-cli/discovery.d.ts +22 -0
  184. package/dist/task-cli/discovery.js +96 -0
  185. package/dist/task-cli/discovery.js.map +1 -0
  186. package/dist/task-cli/http.d.ts +46 -0
  187. package/dist/task-cli/http.js +101 -0
  188. package/dist/task-cli/http.js.map +1 -0
  189. package/dist/task-cli/projections.d.ts +40 -0
  190. package/dist/task-cli/projections.js +45 -0
  191. package/dist/task-cli/projections.js.map +1 -0
  192. package/dist/task-cli/watch.d.ts +78 -0
  193. package/dist/task-cli/watch.js +336 -0
  194. package/dist/task-cli/watch.js.map +1 -0
  195. package/dist/workflows/run.d.ts +160 -2
  196. package/dist/workflows/run.js +1141 -108
  197. package/dist/workflows/run.js.map +1 -1
  198. package/dist/workspace/agent-profiles.d.ts +22 -0
  199. package/dist/workspace/agent-profiles.js +15 -0
  200. package/dist/workspace/agent-profiles.js.map +1 -1
  201. package/dist/workspace/config.d.ts +9 -0
  202. package/dist/workspace/config.js +13 -1
  203. package/dist/workspace/config.js.map +1 -1
  204. package/dist/workspace/projects-cli.js +1 -1
  205. package/dist/workspace/projects.d.ts +24 -1
  206. package/dist/workspace/projects.js +14 -1
  207. package/dist/workspace/projects.js.map +1 -1
  208. package/package.json +5 -5
  209. package/scripts/mock-claude.mjs +64 -2
  210. package/scripts/mock-codex-app-server.mjs +124 -1
  211. package/scripts/mock-cursor-acp.mjs +54 -3
  212. package/scripts/mock-cursor-hang-stdin-late.mjs +35 -0
  213. package/scripts/mock-cursor-hang-stdin.mjs +30 -0
  214. package/scripts/mock-opencode-serve.mjs +102 -2
  215. package/scripts/mock-pi-rpc.mjs +82 -3
  216. package/web/dist/assets/agent-accounts-CerclGiO.js +1 -0
  217. package/web/dist/assets/centered-state-DH8qtCz-.js +43 -0
  218. package/web/dist/assets/collapsible-De2G0xqQ.js +1 -0
  219. package/web/dist/assets/commit-list-s6JPY3qv.js +1 -0
  220. package/web/dist/assets/compare-variants-BQu_t-_g.js +1 -0
  221. package/web/dist/assets/{diff-tRE59UNb2.js → diff-8M5HlDxq2.js} +2 -2
  222. package/web/dist/assets/diff-controls-B6ok0TtQ.js +1 -0
  223. package/web/dist/assets/{diff-stat-Cgnl_hYF.js → diff-stat-D1AODN2a.js} +1 -1
  224. package/web/dist/assets/{diff-view-DN1ort7Q.js → diff-view-Bs5Gh7UF.js} +2 -2
  225. package/web/dist/assets/dropdown-menu-7KGQMoFj.js +1 -0
  226. package/web/dist/assets/git-toolbar-DqbapMXr.js +1 -0
  227. package/web/dist/assets/github-DCkk6sBm.js +1 -0
  228. package/web/dist/assets/{image-preview-C2JGcBlC.js → image-preview-jzHoEyLM.js} +1 -1
  229. package/web/dist/assets/index-Bnva-ttR.js +7 -0
  230. package/web/dist/assets/index-D5NkwqY7.css +1 -0
  231. package/web/dist/assets/markdown-DvbymqXz.js +2 -0
  232. package/web/dist/assets/pill-BL6ugwXo.js +1 -0
  233. package/web/dist/assets/project-router-ChC5Rb-Y.js +1 -0
  234. package/web/dist/assets/prompt-templates-j8qlK7c-.js +15 -0
  235. package/web/dist/assets/repo-git-_h1NjOVt.js +1 -0
  236. package/web/dist/assets/{run-diff-CyG0Nciv.js → run-diff-BuATgZpx.js} +2 -2
  237. package/web/dist/assets/run-header-DuAWswYM.js +1 -0
  238. package/web/dist/assets/skills-C-fq5BrZ.js +1 -0
  239. package/web/dist/assets/{tab-link-BWNp3mLY.js → tab-link-BmGKj9Rt.js} +1 -1
  240. package/web/dist/assets/task-changes-CHa6JWOS.js +1 -0
  241. package/web/dist/assets/task-commits-BqCqyWm1.js +1 -0
  242. package/web/dist/assets/task-files-BSA0l-YP.js +2 -0
  243. package/web/dist/assets/task-thread-Bw_nQuTx.css +1 -0
  244. package/web/dist/assets/task-thread-Q7Nj33DH.js +9 -0
  245. package/web/dist/assets/textarea-ByZ-txS7.js +1 -0
  246. package/web/dist/assets/thread-loading-DHT9p5o5.js +1 -0
  247. package/web/dist/assets/token-metrics-k4XoovDS.js +1 -0
  248. package/web/dist/assets/utils-BTFBhZ55.js +64 -0
  249. package/web/dist/assets/workflows-HJOd3lVi.js +11 -0
  250. package/web/dist/assets/{zoomable-image-Z3JDnm0b.js → zoomable-image-Ba6y6Wyl.js} +1 -1
  251. package/web/dist/index.html +14 -14
  252. package/web/dist/assets/agent-accounts-BZm_2WAv.js +0 -1
  253. package/web/dist/assets/alert-dialog-B422RI-k.js +0 -43
  254. package/web/dist/assets/collapsible-Cy_Fmkrd.js +0 -1
  255. package/web/dist/assets/commit-list-Dcnk7TnI.js +0 -1
  256. package/web/dist/assets/compare-variants-Ciqwvd_f.js +0 -1
  257. package/web/dist/assets/diff-controls-BGrXGi7O.js +0 -1
  258. package/web/dist/assets/dropdown-menu-dwDGT8MM.js +0 -1
  259. package/web/dist/assets/git-toolbar-UiHnGvE0.js +0 -1
  260. package/web/dist/assets/github-D-MrkbJ7.js +0 -1
  261. package/web/dist/assets/ibm-plex-mono-regular-BF3vfe7_.ttf +0 -0
  262. package/web/dist/assets/index-BszfYvBq.js +0 -7
  263. package/web/dist/assets/index-CWh4pKP3.css +0 -1
  264. package/web/dist/assets/markdown-C_IcKVQd.js +0 -2
  265. package/web/dist/assets/pill-BsbO6G7y.js +0 -1
  266. package/web/dist/assets/project-router-DbLkll25.js +0 -1
  267. package/web/dist/assets/prompt-templates-BlTGJB5W.js +0 -15
  268. package/web/dist/assets/repo-git-Bm3s6Why.js +0 -1
  269. package/web/dist/assets/run-header-IFyIGCey.js +0 -1
  270. package/web/dist/assets/skills-C-C4DVFY.js +0 -1
  271. package/web/dist/assets/task-changes-D2PHtWUW.js +0 -1
  272. package/web/dist/assets/task-commits-N5u9U0_R.js +0 -1
  273. package/web/dist/assets/task-files-BS7c7CFm.js +0 -2
  274. package/web/dist/assets/task-thread-BQFdz1dJ.css +0 -1
  275. package/web/dist/assets/task-thread-DGM2dZV-.js +0 -9
  276. package/web/dist/assets/textarea-FjaU370i.js +0 -1
  277. package/web/dist/assets/thread-loading-77sJ8Yu_.js +0 -1
  278. package/web/dist/assets/token-metrics-Clxh03bE.js +0 -1
  279. package/web/dist/assets/utils-PYYcpCFZ.js +0 -64
  280. package/web/dist/assets/workflows-Czl0ZfXH.js +0 -11
@@ -1,6 +1,6 @@
1
1
  import { DelegationService } from '../delegation/service.js';
2
2
  import { delegationFailure } from '../delegation/routes.js';
3
- import { workerEmptyRequestSchema, runRelationshipsSchema, runDelegationSummarySchema } from '../contract/index.js';
3
+ import { CLIENT_REQUEST_VARIANTS_ERROR, workerEmptyRequestSchema, runRelationshipsSchema, runDelegationSummarySchema } from '../contract/index.js';
4
4
  import { existsSync, readFileSync, statSync } from 'node:fs';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { AutomationStore } from '../automations/store.js';
@@ -17,9 +17,10 @@ import { serve } from '@hono/node-server';
17
17
  import { bodyLimit } from 'hono/body-limit';
18
18
  import { streamSSE } from 'hono/streaming';
19
19
  import { jsonZodValidator, paramZodValidator, queryZodValidator } from './validators.js';
20
+ import { deliverOnce, TaskWebhooks } from '../runs/webhook.js';
20
21
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
21
22
  import { z } from 'zod';
22
- import { repoPullInputSchema, pinRunInputSchema, githubSearchQuerySchema, setWorkspaceUiStateInputSchema, } from '../contract/index.js';
23
+ import { repoPullInputSchema, pinRunInputSchema, notifyRunInputSchema, githubSearchQuerySchema, setWorkspaceUiStateInputSchema, } from '../contract/index.js';
23
24
  // A contract VALUE, like `workspaceUiStateSchema` in workspace/migrations.ts — the request
24
25
  // schema this route validates with is the same one the client compiles against.
25
26
  import { MODEL_DISCOVERY_RUNNERS, attachmentInputSchema, attachmentFileParamSchema, modelDiscoveryRunnerSchema, openProjectInSchema, runnerDiscoversModels, updateProjectInputSchema, } from '../contract/index.js';
@@ -41,10 +42,14 @@ import { appendHandoffHeartbeat, handoffProgressExcerpt, readHandoff } from '../
41
42
  import { markStarted, onTodosChanged, readTodos, removeTodo, todoTaskText } from '../todos.js';
42
43
  import { HistoryCursorError, deriveRunContextEvents, readEventsAfterLiveCursor, readRunHistoryPage, validateLiveCursor, } from '../runs/event-history.js';
43
44
  import { readRunIndexFromDisk } from '../runs/run-index.js';
45
+ import { clientRequestHash } from '../runs/client-request.js';
44
46
  import { ColdRepoHandles } from './cold-repo-handles.js';
45
47
  import { isV2WireEventType } from '../runs/ui-event-sink.js';
46
48
  import { runEventsQuerySchema, runHistoryQuerySchema, runIdParamSchema, } from '../contract/index.js';
47
49
  import { resolveAttachmentPath } from '../workflows/attachment-path.js';
50
+ import { artifactParamsSchema, fileLinkQuerySchema } from '../contract/index.js';
51
+ import { artifactDirectory, listArtifacts, readArtifact } from '../artifacts/store.js';
52
+ import { artifactPreview, loadFileLink, rasterMime } from '../artifacts/resolve.js';
48
53
  import { isUntouchedCancelledRun, toPastedContent } from '../workflows/run.js';
49
54
  import { removeWorktree, worktreeDiff, worktreeDiffStat, worktreeSizeBytes } from '../git-worktree.js';
50
55
  import { isReclaimable, reclaimWorktrees } from '../runs/retention.js';
@@ -64,7 +69,7 @@ import { defaultAgentProfile, listAgentProfiles, profileDirState, resolveProfile
64
69
  import { PROFILE_CAPABLE_PROVIDERS, profileEnv, supportsProfiles } from '../core/agent-profiles.js';
65
70
  import { quoteExecutable, withEnvPrefix } from '../core/shell-env.js';
66
71
  import { resolveCursorExecutable } from '../core/cursor-model-catalog.js';
67
- import { allocateProjectSlug, listProjects, normalizeProjectTags, probeProjectStatus, registerProject, removeProject, shouldRegisterProject, } from '../workspace/projects.js';
72
+ import { allocateProjectSlug, listProjects, normalizeProjectTags, probeProjectStatus, toProjectListEntry, registerProject, removeProject, shouldRegisterProject, } from '../workspace/projects.js';
68
73
  import { mergeWriteWorkspaceUiState, readWorkspaceUiState } from '../workspace/ui-state.js';
69
74
  import { checkoutRepo } from './checkout.js';
70
75
  import { ProjectContextError, ProjectContexts } from './project-context.js';
@@ -72,6 +77,8 @@ import { reviewGateEnabled } from '../runs/review-gate.js';
72
77
  import { readUiState, uiStatePath } from '../ui-state.js';
73
78
  import { agentHomePaths, expandTilde } from '../paths.js';
74
79
  import { isLoopbackHostHeader, normalizeHostname, resolveCapabilities } from './capabilities.js';
80
+ import { applicationUpdateInputSchema } from '../contract/index.js';
81
+ import { ApplicationUpdateConflictError, ApplicationUpdateFailureError } from '../application-update/service.js';
75
82
  import { createSocketHub } from './ws.js';
76
83
  import { browseDirectory, isInsideBrowseRoot, isLexicallyInsideBrowseRoot, resolveBrowseRoot } from './fs-browse.js';
77
84
  import { parseRemote, resolveForge } from './forge/index.js';
@@ -304,9 +311,16 @@ const startRunSchema = z
304
311
  // audit trail survives the composer detour. Bounded like every other
305
312
  // string here; a todo id is a short generated key.
306
313
  todoId: z.string().min(1).max(200, 'must be at most 200 characters').optional(),
314
+ // Idempotent start (#504): the same id and payload answer 200 with the existing run.
315
+ clientRequestId: z.string().uuid().optional(),
316
+ // Task webhook opt-in (#589). Absent = off; not part of the idempotency hash.
317
+ notify: z.boolean().optional(),
307
318
  })
308
319
  .refine((b) => Boolean(b.workflow) !== Boolean(b.steps), {
309
320
  message: 'provide either "workflow" or "steps", not both',
321
+ })
322
+ .refine((b) => !(b.clientRequestId && (b.variants ?? 1) > 1), {
323
+ message: CLIENT_REQUEST_VARIANTS_ERROR,
310
324
  });
311
325
  const pickSchema = z.object({
312
326
  runId: z.string().min(1),
@@ -811,6 +825,11 @@ export function createApp(deps) {
811
825
  automationStore: deps.automationStore ?? AutomationStore.open(bootDataDir),
812
826
  launchKey: ensureLaunchKey(bootDataDir), // bookmarklet auto-start secret (spec 011)
813
827
  };
828
+ // The task webhook (#589): attached before any recovery — the boot store by `index.ts`, lazy
829
+ // projects through `prepareManager` below. Attaching is idempotent, so the calls here only
830
+ // cover an app built without boot's wiring (tests, embedded servers).
831
+ const taskWebhooks = deps.taskWebhooks ?? new TaskWebhooks({ fetch: deps.taskWebhookFetch, origin: '' });
832
+ taskWebhooks.attach(bootContext);
814
833
  // Non-boot projects build lazily on first scoped request; their managers
815
834
  // count against the same workspace semaphore as the boot manager (step 2.5).
816
835
  const contexts = deps.contexts ?? new ProjectContexts({
@@ -821,7 +840,11 @@ export function createApp(deps) {
821
840
  return listProjects(selector);
822
841
  },
823
842
  semaphore: deps.semaphore,
824
- prepareManager: project => deps.delegation?.attachProject(project),
843
+ prepareManager: (project) => {
844
+ taskWebhooks.attach(project);
845
+ return deps.delegation?.attachProject(project);
846
+ },
847
+ afterRecover: (project) => deps.delegation?.service.armDestroyRetries(project.id),
825
848
  });
826
849
  // Workspace-level SSE bus (step 2.8) — the registry mutators and the
827
850
  // checkout flow (Phase 4) emit here; /api/workspace/events relays.
@@ -845,6 +868,17 @@ export function createApp(deps) {
845
868
  }
846
869
  contexts.onStoreCreated((store) => providerRuntimeAuth.watch(store));
847
870
  contexts.onContextBuilt((ctx) => providerRuntimeAuth.watch(ctx.store));
871
+ // Injected contexts (tests) skip `prepareManager`; attach those when they are built.
872
+ for (const id of contexts.ids()) {
873
+ const ctx = contexts.peek(id);
874
+ if (ctx)
875
+ taskWebhooks.attach(ctx);
876
+ }
877
+ contexts.onContextBuilt((ctx) => taskWebhooks.attach(ctx));
878
+ const projectRegistryEntry = async (projectId) => {
879
+ const id = projectId === 'default' ? await resolveBootProject() : projectId;
880
+ return (await loadWorkspaceConfig()).projects.find((entry) => entry.id === id);
881
+ };
848
882
  const app = new Hono();
849
883
  // Reject oversized request bodies before they reach any handler (#429). GETs
850
884
  // and SSE carry no body, so this only ever gates the mutating routes.
@@ -1088,6 +1122,9 @@ export function createApp(deps) {
1088
1122
  // the key as always-present, which is a shape no client ever receives. The contract schema
1089
1123
  // says `.optional()`, and contract-parity.test.ts holds the two together.
1090
1124
  ...(update?.latest !== undefined ? { latestVersion: update.latest } : {}),
1125
+ ...(deps.applicationUpdate ? { applicationUpdate: caps.localHandoff
1126
+ ? deps.applicationUpdate.snapshot()
1127
+ : { status: 'idle', supported: false, message: 'Update this installation manually.' } } : {}),
1091
1128
  // Health is CORS-open and, in hosted mode, reachable off the loopback —
1092
1129
  // so any site/host that reads it would learn the developer's absolute
1093
1130
  // checkout path and username (#431). Local mode keeps the full path (the
@@ -1163,6 +1200,10 @@ export function createApp(deps) {
1163
1200
  })();
1164
1201
  return healthInFlight;
1165
1202
  };
1203
+ deps.applicationUpdate?.onStateChange?.(() => {
1204
+ healthCache = undefined;
1205
+ void refreshHealth();
1206
+ });
1166
1207
  // The read the GET and the topic snapshot share. On the live server (a hub is
1167
1208
  // injected) it answers from cache instantly and revalidates behind the
1168
1209
  // response; without a hub — a bare app in tests — there is no refresher
@@ -2039,7 +2080,7 @@ export function createApp(deps) {
2039
2080
  const parsed = { data: c.req.valid('json') };
2040
2081
  // `default` is the boot alias the cockpit is allowed to use everywhere else.
2041
2082
  const id = raw === 'default' ? await resolveBootProject() : raw;
2042
- const { maxParallel, tags } = parsed.data;
2083
+ const { maxParallel, tags, webhook } = parsed.data;
2043
2084
  // Read-first (mirroring DELETE, server.ts:1252-1258): a well-formed but
2044
2085
  // unknown id must 404 WITHOUT rewriting the config — otherwise it would both
2045
2086
  // do a needless full-config tmp+rename and, on a read-only home, surface the
@@ -2081,6 +2122,16 @@ export function createApp(deps) {
2081
2122
  else
2082
2123
  entry.tags = normalized;
2083
2124
  }
2125
+ if (webhook !== undefined) {
2126
+ // The token is write-only (#589): the form never has it back, so an omitted token
2127
+ // keeps the stored one and only `''` clears it.
2128
+ if (webhook === null)
2129
+ delete entry.webhook;
2130
+ else {
2131
+ const token = webhook.token === undefined ? entry.webhook?.token : webhook.token || undefined;
2132
+ entry.webhook = { url: webhook.url, ...(token ? { token } : {}) };
2133
+ }
2134
+ }
2084
2135
  updated = entry;
2085
2136
  });
2086
2137
  }
@@ -2096,7 +2147,41 @@ export function createApp(deps) {
2096
2147
  // `PUT /api/workspace/config` fires for a workspace-cap change.
2097
2148
  await deps.semaphore?.refresh();
2098
2149
  const body = {
2099
- project: { ...updated, ...(await probeProjectStatus(updated.root)) },
2150
+ project: toProjectListEntry(updated, await probeProjectStatus(updated.root)),
2151
+ };
2152
+ return c.json(body);
2153
+ })
2154
+ // "Send test" (#589): one `task.test` delivery to the STORED webhook, now, with the timeout
2155
+ // the real deliveries use and no retries — the settings page wants the answer, not a queue.
2156
+ // Refused in single-project mode like every other registry edit route.
2157
+ .post('/projects/:projectId/webhook/test', async (c) => {
2158
+ if (capabilities().singleProject) {
2159
+ return c.json(singleProjectRefusal('editing projects'), 409);
2160
+ }
2161
+ const raw = c.req.param('projectId');
2162
+ if (!projectIdSchema.safeParse(raw).success) {
2163
+ return c.json({ error: `unknown project: ${raw}` }, 404);
2164
+ }
2165
+ const entry = await projectRegistryEntry(raw).catch(() => undefined);
2166
+ if (!entry)
2167
+ return c.json({ error: `unknown project: ${raw}` }, 404);
2168
+ if (!entry.webhook)
2169
+ return c.json({ error: NO_WEBHOOK_ERROR }, 400);
2170
+ const payload = {
2171
+ event: 'task.test',
2172
+ deliveryId: randomUUID(),
2173
+ projectId: entry.id,
2174
+ occurredAt: new Date().toISOString(),
2175
+ };
2176
+ if (process.env.CEZ_DRY_RUN === '1') {
2177
+ const body = { ok: true, dryRun: true };
2178
+ return c.json(body);
2179
+ }
2180
+ const result = await deliverOnce(entry.webhook, payload, { fetch: deps.taskWebhookFetch });
2181
+ const body = {
2182
+ ok: result.ok,
2183
+ ...(result.status === undefined ? {} : { status: result.status }),
2184
+ ...(result.error === undefined ? {} : { error: result.error }),
2100
2185
  };
2101
2186
  return c.json(body);
2102
2187
  })
@@ -2232,7 +2317,7 @@ export function createApp(deps) {
2232
2317
  let project;
2233
2318
  try {
2234
2319
  const entry = await registerProject(requested, source);
2235
- project = { ...entry, ...(await probeProjectStatus(entry.root)) };
2320
+ project = toProjectListEntry(entry, await probeProjectStatus(entry.root));
2236
2321
  }
2237
2322
  catch (err) {
2238
2323
  // e.g. a read-only home — nothing was persisted (atomic tmp+rename).
@@ -2342,6 +2427,32 @@ export function createApp(deps) {
2342
2427
  throw error;
2343
2428
  }
2344
2429
  });
2430
+ // Workspace-level, server-selected target and executable. The empty JSON body is strict.
2431
+ const applicationUpdateRoutes = new Hono()
2432
+ .post('/workspace/application-update/apply', jsonZodValidator(applicationUpdateInputSchema), async (c) => {
2433
+ if (!capabilities().localHandoff)
2434
+ return c.json({ error: 'Application updates require a local cockpit.' }, 409);
2435
+ if (!deps.applicationUpdate)
2436
+ return c.json({ error: 'Application update is unavailable.' }, 409);
2437
+ try {
2438
+ return c.json({ state: await deps.applicationUpdate.apply() });
2439
+ }
2440
+ catch (error) {
2441
+ return c.json({ error: error instanceof ApplicationUpdateConflictError || error instanceof ApplicationUpdateFailureError ? error.message : 'Application update preparation failed.' }, error instanceof ApplicationUpdateConflictError ? 409 : error instanceof ApplicationUpdateFailureError ? error.status : 500);
2442
+ }
2443
+ })
2444
+ .post('/workspace/application-update/restart', jsonZodValidator(applicationUpdateInputSchema), async (c) => {
2445
+ if (!capabilities().localHandoff)
2446
+ return c.json({ error: 'Application updates require a local cockpit.' }, 409);
2447
+ if (!deps.applicationUpdate)
2448
+ return c.json({ error: 'Application update is unavailable.' }, 409);
2449
+ try {
2450
+ return c.json({ state: await deps.applicationUpdate.restart() });
2451
+ }
2452
+ catch (error) {
2453
+ return c.json({ error: error instanceof ApplicationUpdateConflictError ? error.message : 'Application restart is unavailable.' }, error instanceof ApplicationUpdateConflictError ? 409 : 500);
2454
+ }
2455
+ });
2345
2456
  // ---- GUI clone (multi-project spec, step 4.3) ----------------------------
2346
2457
  // "Add project → Clone from GitHub": clone into the checkout root, then
2347
2458
  // register the result through `registerFolder` above (same guards, same
@@ -2891,7 +3002,7 @@ export function createApp(deps) {
2891
3002
  automationsChanged();
2892
3003
  return c.body(null, 204);
2893
3004
  })
2894
- .post('/automations/:id/enable', (c) => {
3005
+ .post('/automations/:id/enable', async (c) => {
2895
3006
  const store = c.get('project').automationStore;
2896
3007
  const current = store.get(c.req.param('id'));
2897
3008
  if (!current)
@@ -2905,7 +3016,7 @@ export function createApp(deps) {
2905
3016
  cursor: { timestamp: baselineAt },
2906
3017
  nextCheckAt: new Date(Date.now() + automation.intervalSeconds * 1_000).toISOString(),
2907
3018
  });
2908
- store.appendLog({ automationId: automation.id, revision: automation.revision, result: 'baseline', reason: 'Enabled from a current-time baseline; existing records were not launched.' });
3019
+ await store.appendLog({ automationId: automation.id, revision: automation.revision, result: 'baseline', reason: 'Enabled from a current-time baseline; existing records were not launched.' });
2909
3020
  emitAutomationChange(c.get('project'), automation.id, automation.revision);
2910
3021
  automationsChanged();
2911
3022
  return c.json({ automation });
@@ -3044,6 +3155,7 @@ export function createApp(deps) {
3044
3155
  };
3045
3156
  // ---- chained family: runs lifecycle + artifacts (project-scoped) ----
3046
3157
  const delegationService = deps.delegation?.service ?? new DelegationService();
3158
+ delegationService.setDiscovery({ models: modelCatalog, providers: providerStatus });
3047
3159
  const runsRoutes = new Hono()
3048
3160
  .get('/runs', (c) => c.json(c.get('project').store.listRuns().map(run => withUsage(run))))
3049
3161
  .get('/runs/:id/relationships', paramZodValidator(runIdParamSchema), queryZodValidator(workerEmptyRequestSchema), (c) => {
@@ -3105,6 +3217,34 @@ export function createApp(deps) {
3105
3217
  const { store } = c.get('project');
3106
3218
  const run = store.setPinned(c.req.valid('param').id, c.req.valid('json').pinned !== false);
3107
3219
  return run ? c.json(run) : c.json({ error: 'not found' }, 404);
3220
+ })
3221
+ // Task webhook opt-in for one run (#589), valid in every state — handing a finished task to a
3222
+ // bot for follow-up is a real case. Turning it on sends one `task.subscribed` delivery with
3223
+ // the note; the note never reaches the agent session. Either direction leaves a durable
3224
+ // `handoff` event so the thread shows when and why. Idempotent: repeating `true` with a
3225
+ // new note is how "Send a note…" works.
3226
+ .post('/runs/:id/notify', paramZodValidator(runIdParamSchema), jsonZodValidator(notifyRunInputSchema), async (c) => {
3227
+ const project = c.get('project');
3228
+ const { id } = c.req.valid('param');
3229
+ const { notify, message } = c.req.valid('json');
3230
+ if (!project.store.getRun(id))
3231
+ return c.json({ error: 'not found' }, 404);
3232
+ if (notify) {
3233
+ const entry = await projectRegistryEntry(project.id).catch(() => undefined);
3234
+ if (!entry?.webhook)
3235
+ return c.json({ error: NO_WEBHOOK_ERROR }, 400);
3236
+ }
3237
+ const run = project.store.updateRun(id, { notify: notify ? true : undefined });
3238
+ if (!run)
3239
+ return c.json({ error: 'not found' }, 404);
3240
+ project.store.appendEvent(id, {
3241
+ type: 'handoff',
3242
+ notify,
3243
+ ...(message === undefined ? {} : { message }),
3244
+ });
3245
+ if (notify)
3246
+ taskWebhooks.attach(project).subscribed(id, message);
3247
+ return c.json(run);
3108
3248
  })
3109
3249
  // The per-task off switch for that resume (the workspace setting is Settings → Resources).
3110
3250
  // Idempotent: a run with nothing pending answers 200 too, because "this task will not
@@ -3131,8 +3271,21 @@ export function createApp(deps) {
3131
3271
  return run ? c.json(run) : c.json({ error: 'not found' }, 404);
3132
3272
  })
3133
3273
  .post('/runs', jsonZodValidator(startRunSchema), async (c) => {
3134
- const { root: repoRoot, dataDir, manager } = c.get('project');
3274
+ const { root: repoRoot, dataDir, manager, store } = c.get('project');
3135
3275
  const parsed = { data: c.req.valid('json') };
3276
+ const clientRequest = parsed.data.clientRequestId
3277
+ ? { id: parsed.data.clientRequestId, hash: clientRequestHash(parsed.data) }
3278
+ : undefined;
3279
+ // A retry only retrieves a persisted result: mutable start prerequisites (workflow,
3280
+ // provider, account and model policy) must not invalidate an already accepted request.
3281
+ if (clientRequest) {
3282
+ const existing = store.findRunByClientRequestId(clientRequest.id);
3283
+ if (existing) {
3284
+ if (existing.clientRequestHash !== clientRequest.hash)
3285
+ return c.json({ error: 'request id payload conflict' }, 409);
3286
+ return c.json(existing, 200);
3287
+ }
3288
+ }
3136
3289
  if (agentModelsLocked(repoRoot) &&
3137
3290
  (parsed.data.model?.trim() || parsed.data.effort?.trim())) {
3138
3291
  return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
@@ -3168,9 +3321,15 @@ export function createApp(deps) {
3168
3321
  if ('error' in account)
3169
3322
  return c.json({ error: account.error }, 400);
3170
3323
  }
3324
+ // Task webhook (#589): an explicit `true` on a project with no webhook is refused, so a
3325
+ // bot learns in one round trip. `cez task start`'s implicit default never sends it there.
3326
+ if (parsed.data.notify === true && !(await projectRegistryEntry(c.get('project').id).catch(() => undefined))?.webhook) {
3327
+ return c.json({ error: NO_WEBHOOK_ERROR }, 400);
3328
+ }
3171
3329
  const images = parsed.data.images?.map(toPastedContent);
3172
3330
  const input = {
3173
3331
  task: parsed.data.task,
3332
+ notify: parsed.data.notify,
3174
3333
  model: parsed.data.model,
3175
3334
  effort: parsed.data.effort,
3176
3335
  runner: parsed.data.runner,
@@ -3186,6 +3345,18 @@ export function createApp(deps) {
3186
3345
  // CEZ_TODOS_FILE alike (RunManager.agentEnv).
3187
3346
  generateFollowups: capabilities().followups ? parsed.data.generateFollowups : false,
3188
3347
  };
3348
+ if (clientRequest) {
3349
+ // Recheck after every await: concurrent first requests may both miss the early lookup.
3350
+ // The manager's lookup and create are synchronous, so exactly one can create (#504).
3351
+ const result = manager.startRunIdempotent(workflow, input, clientRequest);
3352
+ if ('conflict' in result)
3353
+ return c.json({ error: 'request id payload conflict' }, 409);
3354
+ if (!result.created)
3355
+ return c.json(result.run, 200);
3356
+ if (parsed.data.todoId)
3357
+ await noteTodoStarted(dataDir, parsed.data.todoId, result.run.id);
3358
+ return c.json(result.run, 201);
3359
+ }
3189
3360
  const variants = parsed.data.variants ?? 1;
3190
3361
  if (variants > 1) {
3191
3362
  // Variants live in worktrees — without git there's nothing to isolate
@@ -3703,6 +3874,70 @@ export function createApp(deps) {
3703
3874
  if (!result.ok)
3704
3875
  return c.json({ error: result.error }, 409);
3705
3876
  return c.json(result.commit);
3877
+ })
3878
+ .get('/runs/:id/file-link', paramZodValidator(artifactParamsSchema.omit({ artifactId: true })), queryZodValidator(fileLinkQuerySchema), async (c) => {
3879
+ const { root, dataDir, store } = c.get('project');
3880
+ const run = store.getRun(c.req.valid('param').id);
3881
+ if (!run)
3882
+ return c.json({ error: 'not found' }, 404);
3883
+ const query = c.req.valid('query');
3884
+ const loaded = await loadFileLink({ dataDir, runId: run.id, projectRoot: root, workingDirectory: run.worktreePath ?? root, path: query.path });
3885
+ if (query.raw === '1') {
3886
+ if (!loaded.imageBytes || loaded.preview.type !== 'file')
3887
+ return c.json({ error: 'Only bounded raster images can be served inline.' }, 409);
3888
+ return c.body(new Uint8Array(loaded.imageBytes).buffer, 200, {
3889
+ 'content-type': rasterMime(loaded.preview.path), 'x-content-type-options': 'nosniff',
3890
+ 'content-security-policy': "default-src 'none'; sandbox", 'cache-control': 'private, no-store',
3891
+ });
3892
+ }
3893
+ return c.json(loaded.preview);
3894
+ })
3895
+ .get('/runs/:id/artifacts', paramZodValidator(artifactParamsSchema.omit({ artifactId: true })), async (c) => {
3896
+ const { dataDir, store } = c.get('project');
3897
+ const id = c.req.valid('param').id;
3898
+ if (!store.getRun(id))
3899
+ return c.json({ error: 'not found' }, 404);
3900
+ return c.json({ artifacts: await listArtifacts(artifactDirectory(dataDir, id), id) });
3901
+ })
3902
+ .get('/runs/:id/artifacts/:artifactId', paramZodValidator(artifactParamsSchema), async (c) => {
3903
+ const { dataDir, store } = c.get('project');
3904
+ const { id, artifactId } = c.req.valid('param');
3905
+ if (!store.getRun(id))
3906
+ return c.json({ error: 'not found' }, 404);
3907
+ const entry = await readArtifact(artifactDirectory(dataDir, id), id, artifactId);
3908
+ if (!entry)
3909
+ return c.json({ error: 'Published file is missing or unavailable.' }, 404);
3910
+ return c.json(artifactPreview(entry.metadata, entry.bytes));
3911
+ })
3912
+ .get('/runs/:id/artifacts/:artifactId/download', paramZodValidator(artifactParamsSchema), async (c) => {
3913
+ const { dataDir, store } = c.get('project');
3914
+ const { id, artifactId } = c.req.valid('param');
3915
+ if (!store.getRun(id))
3916
+ return c.json({ error: 'not found' }, 404);
3917
+ const entry = await readArtifact(artifactDirectory(dataDir, id), id, artifactId);
3918
+ if (!entry)
3919
+ return c.json({ error: 'Published file is missing or unavailable.' }, 404);
3920
+ return c.body(new Uint8Array(entry.bytes).buffer, 200, {
3921
+ 'content-type': 'application/octet-stream', 'x-content-type-options': 'nosniff',
3922
+ 'content-disposition': `attachment; filename="${entry.metadata.name.replace(/[^A-Za-z0-9._-]/g, '_')}"`,
3923
+ 'content-security-policy': "default-src 'none'; sandbox", 'cache-control': 'private, no-store',
3924
+ });
3925
+ })
3926
+ .get('/runs/:id/artifacts/:artifactId/image', paramZodValidator(artifactParamsSchema), async (c) => {
3927
+ const { dataDir, store } = c.get('project');
3928
+ const { id, artifactId } = c.req.valid('param');
3929
+ if (!store.getRun(id))
3930
+ return c.json({ error: 'not found' }, 404);
3931
+ const entry = await readArtifact(artifactDirectory(dataDir, id), id, artifactId);
3932
+ if (!entry)
3933
+ return c.json({ error: 'Published file is missing or unavailable.' }, 404);
3934
+ const mime = rasterMime(entry.metadata.name);
3935
+ if (!mime)
3936
+ return c.json({ error: 'Only raster images can be served inline.' }, 409);
3937
+ return c.body(new Uint8Array(entry.bytes).buffer, 200, {
3938
+ 'content-type': mime, 'x-content-type-options': 'nosniff',
3939
+ 'content-security-policy': "default-src 'none'; sandbox", 'cache-control': 'private, no-store',
3940
+ });
3706
3941
  })
3707
3942
  // Files tab: directory listing (path omitted or a dir) or file content
3708
3943
  // (size-capped, binary flagged). Traversal-safe — readWorktreePath rejects
@@ -4052,7 +4287,10 @@ export function createApp(deps) {
4052
4287
  // The keep-limit the panel reports is the one the enforcer will actually
4053
4288
  // apply — inherited from the workspace default when this repo sets none.
4054
4289
  const keep = await resolveWorktreeRetention(repoRoot);
4055
- const runs = store.listRuns().filter((r) => r.worktreePath && existsSync(r.worktreePath));
4290
+ const allRuns = store.listRuns();
4291
+ // Listing is on-disk dirs only; parent liveness for #575 uses the full store
4292
+ // so a live `worktree: false` parent is not treated as gone (#570 honesty).
4293
+ const runs = allRuns.filter((r) => r.worktreePath && existsSync(r.worktreePath));
4056
4294
  const worktrees = await Promise.all(runs.map(async (r) => ({
4057
4295
  runId: r.id,
4058
4296
  title: r.title ?? r.id,
@@ -4061,7 +4299,7 @@ export function createApp(deps) {
4061
4299
  // POSIX `du` — degrades to null (Windows / du missing / error); never blocks.
4062
4300
  sizeBytes: await worktreeSizeBytes(r.worktreePath),
4063
4301
  finishedAt: r.finishedAt ?? null,
4064
- reclaimable: isReclaimable(r),
4302
+ reclaimable: isReclaimable(r, allRuns),
4065
4303
  })));
4066
4304
  // Total is null when any size degraded, so the panel never shows a wrong sum.
4067
4305
  const totalBytes = worktrees.some((w) => w.sizeBytes === null)
@@ -5080,6 +5318,7 @@ export function createApp(deps) {
5080
5318
  .route('/', projectsRoutes)
5081
5319
  .route('/', agentProfilesRoutes)
5082
5320
  .route('/', skillsUpdateRoutes)
5321
+ .route('/', applicationUpdateRoutes)
5083
5322
  .route('/', workspaceConfigRoutes)
5084
5323
  .route('/', fsBrowseRoutes)
5085
5324
  .route('/', automationChecksRoutes)
@@ -5110,6 +5349,13 @@ export function createApp(deps) {
5110
5349
  routed.get('*', (c) => serveShell(c) ?? c.notFound());
5111
5350
  return routed;
5112
5351
  }
5352
+ /** The host a thread link names (#589): the bound one, or loopback for a wildcard bind. */
5353
+ function taskWebhookHost(bindHost) {
5354
+ if (!bindHost || bindHost === '0.0.0.0' || bindHost === '::')
5355
+ return '127.0.0.1';
5356
+ return bindHost.includes(':') ? `[${bindHost}]` : bindHost;
5357
+ }
5358
+ const NO_WEBHOOK_ERROR = 'this project has no task webhook — set one in Settings → General → Task webhook, or start without notify';
5113
5359
  export function startServer(deps, port) {
5114
5360
  const workspaceEvents = deps.workspaceEvents ?? new WorkspaceEventBus();
5115
5361
  const skillsUpdate = deps.skillsUpdate ?? new SkillsUpdateService({ invalidateCatalog: refreshTeamSkills });
@@ -5119,11 +5365,16 @@ export function startServer(deps, port) {
5119
5365
  const automationCoordinator = new AutomationCoordinator({ listProjects });
5120
5366
  const bootProjectId = deps.bootProjectId ?? 'default';
5121
5367
  const bootAutomationStore = automationCoordinator.store(bootProjectId, deps.repoRoot);
5368
+ const taskWebhooks = deps.taskWebhooks ?? new TaskWebhooks({ fetch: deps.taskWebhookFetch });
5122
5369
  const sharedContexts = deps.contexts ?? new ProjectContexts({
5123
5370
  listProjects,
5124
5371
  semaphore: deps.semaphore,
5125
5372
  automationStore: (projectId, root) => automationCoordinator.store(projectId, root),
5126
- prepareManager: project => deps.delegation?.attachProject(project),
5373
+ prepareManager: (project) => {
5374
+ taskWebhooks.attach(project);
5375
+ return deps.delegation?.attachProject(project);
5376
+ },
5377
+ afterRecover: (project) => deps.delegation?.service.armDestroyRetries(project.id),
5127
5378
  });
5128
5379
  // #801: GitHub automations are opt-in. Off, the flag must remove the BEHAVIOR and not merely
5129
5380
  // the UI — no scheduler, no GitHub polling, no launched runs — so every entry point into the
@@ -5133,6 +5384,7 @@ export function startServer(deps, port) {
5133
5384
  let rescheduleAutomations = () => { };
5134
5385
  const app = createApp({
5135
5386
  ...deps,
5387
+ taskWebhooks,
5136
5388
  contexts: sharedContexts,
5137
5389
  automationStore: bootAutomationStore,
5138
5390
  workspaceEvents,
@@ -5146,7 +5398,28 @@ export function startServer(deps, port) {
5146
5398
  // hosted/VPS deployment (which also flips CEZ_REMOTE to gate the local-handoff endpoints) —
5147
5399
  // src/index.ts never passes it, so the loopback guarantee holds for the normal CLI.
5148
5400
  const server = serve({
5149
- fetch: app.fetch,
5401
+ fetch: async (request, env) => {
5402
+ const result = await app.fetch(request, env);
5403
+ if (request.method === 'POST' && new URL(request.url).pathname === '/api/v1/workspace/application-update/restart'
5404
+ && result.status === 200) {
5405
+ // Arming is server-owned. A connected caller must receive the complete
5406
+ // acknowledgement before shutdown; a caller that already disconnected
5407
+ // cannot leave its explicitly requested, armed restart stranded.
5408
+ const response = env.outgoing;
5409
+ const handoff = () => {
5410
+ response.off('finish', handoff);
5411
+ response.off('close', handoff);
5412
+ deps.applicationUpdate?.afterResponse?.();
5413
+ };
5414
+ if (response.destroyed)
5415
+ handoff();
5416
+ else {
5417
+ response.once('finish', handoff);
5418
+ response.once('close', handoff);
5419
+ }
5420
+ }
5421
+ return result;
5422
+ },
5150
5423
  port,
5151
5424
  hostname: deps.bindHost ?? '127.0.0.1',
5152
5425
  });
@@ -5239,7 +5512,30 @@ export function startServer(deps, port) {
5239
5512
  });
5240
5513
  server.once('close', () => { unsubscribe(); coordinator.stop(); automationScheduler.stop(); sharedContexts.disposeAll(); void deps.delegation?.close(); });
5241
5514
  socketHub.attach(server, (req) => verifyWsUpgrade(req, deps.bindHost));
5242
- return server;
5515
+ const shutdownForRestart = async () => {
5516
+ sharedContexts.disposeAll(); // flush every built secondary project before old process exits
5517
+ deps.store.flush();
5518
+ socketHub.close();
5519
+ await deps.delegation?.close();
5520
+ await new Promise((resolve) => {
5521
+ server.close(() => resolve());
5522
+ if ('closeAllConnections' in server)
5523
+ server.closeAllConnections();
5524
+ });
5525
+ };
5526
+ // The task webhook's thread links name the port the listener BOUND (#594 review): `--port 0`
5527
+ // asks for an ephemeral one, so the requested number is not an address. Deliveries queue
5528
+ // behind this, so none goes out before it is set.
5529
+ const setTaskWebhookOrigin = () => {
5530
+ const address = server.address();
5531
+ const bound = address && typeof address === 'object' ? address.port : port;
5532
+ taskWebhooks.setOrigin(`http://${taskWebhookHost(deps.bindHost)}:${bound}`);
5533
+ };
5534
+ if (server.listening)
5535
+ setTaskWebhookOrigin();
5536
+ else
5537
+ server.once('listening', setTaskWebhookOrigin);
5538
+ return Object.assign(server, { shutdownForRestart });
5243
5539
  }
5244
5540
  /**
5245
5541
  * The WebSocket twin of the `/api/*` request-origin guard (#426), applied