@wjarka/cezarion 0.14.8 → 0.14.9-dev.1004

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