@qodeca/xezar 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/README.md +72 -39
  2. package/dist/agent-config/account-identity.d.ts +2 -8
  3. package/dist/agent-config/account-identity.js +54 -10
  4. package/dist/agent-config/account-identity.js.map +1 -1
  5. package/dist/agent-config/catalog.d.ts +49 -7
  6. package/dist/agent-config/catalog.js +107 -0
  7. package/dist/agent-config/catalog.js.map +1 -1
  8. package/dist/agent-config/model-settings/pi.d.ts +12 -6
  9. package/dist/agent-config/model-settings/pi.js +12 -6
  10. package/dist/agent-config/model-settings/pi.js.map +1 -1
  11. package/dist/agent-config/seed.js +10 -0
  12. package/dist/agent-config/seed.js.map +1 -1
  13. package/dist/contract/github.d.ts +15 -0
  14. package/dist/contract/index.d.ts +9 -0
  15. package/dist/contract/index.js +1527 -895
  16. package/dist/contract/mcp-api-reference.d.ts +174 -0
  17. package/dist/contract/mcp-audit.d.ts +103 -0
  18. package/dist/contract/mcp-discovery.d.ts +257 -0
  19. package/dist/contract/mcp-event-catalog.d.ts +121 -0
  20. package/dist/contract/mcp-event-catalog.test.d.ts +1 -0
  21. package/dist/contract/mcp-idempotency.d.ts +200 -0
  22. package/dist/contract/mcp-journal.d.ts +268 -0
  23. package/dist/contract/mcp-leader.d.ts +88 -0
  24. package/dist/contract/mcp-ownership.d.ts +78 -0
  25. package/dist/contract/mcp-versioning.d.ts +139 -0
  26. package/dist/contract/runs.d.ts +81 -0
  27. package/dist/core/agent-profiles.d.ts +15 -4
  28. package/dist/core/agent-profiles.js +22 -8
  29. package/dist/core/agent-profiles.js.map +1 -1
  30. package/dist/core/opencode-server-runner.js +71 -11
  31. package/dist/core/opencode-server-runner.js.map +1 -1
  32. package/dist/core/secret-redaction.d.ts +7 -1
  33. package/dist/core/secret-redaction.js +44 -10
  34. package/dist/core/secret-redaction.js.map +1 -1
  35. package/dist/index.js +138 -27
  36. package/dist/index.js.map +1 -1
  37. package/dist/machine-identity.d.ts +66 -0
  38. package/dist/machine-identity.js +96 -0
  39. package/dist/machine-identity.js.map +1 -0
  40. package/dist/mcp/adapters/claude-code.d.ts +41 -0
  41. package/dist/mcp/adapters/claude-code.js +40 -0
  42. package/dist/mcp/adapters/claude-code.js.map +1 -0
  43. package/dist/mcp/adapters/codex.d.ts +140 -0
  44. package/dist/mcp/adapters/codex.js +323 -0
  45. package/dist/mcp/adapters/codex.js.map +1 -0
  46. package/dist/mcp/adapters/opencode.d.ts +148 -0
  47. package/dist/mcp/adapters/opencode.js +528 -0
  48. package/dist/mcp/adapters/opencode.js.map +1 -0
  49. package/dist/mcp/adapters/pi-link.d.ts +102 -0
  50. package/dist/mcp/adapters/pi-link.js +235 -0
  51. package/dist/mcp/adapters/pi-link.js.map +1 -0
  52. package/dist/mcp/adapters/pi.d.ts +188 -0
  53. package/dist/mcp/adapters/pi.js +418 -0
  54. package/dist/mcp/adapters/pi.js.map +1 -0
  55. package/dist/mcp/api-reference.d.ts +26 -0
  56. package/dist/mcp/api-reference.js +133 -0
  57. package/dist/mcp/api-reference.js.map +1 -0
  58. package/dist/mcp/audit-trail.d.ts +162 -0
  59. package/dist/mcp/audit-trail.js +306 -0
  60. package/dist/mcp/audit-trail.js.map +1 -0
  61. package/dist/mcp/bridge.d.ts +81 -0
  62. package/dist/mcp/bridge.js +407 -0
  63. package/dist/mcp/bridge.js.map +1 -0
  64. package/dist/mcp/connection-file.d.ts +49 -0
  65. package/dist/mcp/connection-file.js +63 -0
  66. package/dist/mcp/connection-file.js.map +1 -0
  67. package/dist/mcp/echo-guard.d.ts +94 -0
  68. package/dist/mcp/echo-guard.js +160 -0
  69. package/dist/mcp/echo-guard.js.map +1 -0
  70. package/dist/mcp/event-catalog.d.ts +120 -0
  71. package/dist/mcp/event-catalog.js +306 -0
  72. package/dist/mcp/event-catalog.js.map +1 -0
  73. package/dist/mcp/event-controller.d.ts +230 -0
  74. package/dist/mcp/event-controller.js +596 -0
  75. package/dist/mcp/event-controller.js.map +1 -0
  76. package/dist/mcp/event-journal.d.ts +57 -0
  77. package/dist/mcp/event-journal.js +423 -0
  78. package/dist/mcp/event-journal.js.map +1 -0
  79. package/dist/mcp/index.d.ts +63 -0
  80. package/dist/mcp/index.js +386 -0
  81. package/dist/mcp/index.js.map +1 -0
  82. package/dist/mcp/ipc.d.ts +202 -0
  83. package/dist/mcp/ipc.js +206 -0
  84. package/dist/mcp/ipc.js.map +1 -0
  85. package/dist/mcp/leader-delivery.d.ts +119 -0
  86. package/dist/mcp/leader-delivery.js +450 -0
  87. package/dist/mcp/leader-delivery.js.map +1 -0
  88. package/dist/mcp/operation-receipts.d.ts +210 -0
  89. package/dist/mcp/operation-receipts.js +723 -0
  90. package/dist/mcp/operation-receipts.js.map +1 -0
  91. package/dist/mcp/project-catalogs.d.ts +23 -0
  92. package/dist/mcp/project-catalogs.js +25 -0
  93. package/dist/mcp/project-catalogs.js.map +1 -0
  94. package/dist/mcp/project-leaders.d.ts +26 -0
  95. package/dist/mcp/project-leaders.js +17 -0
  96. package/dist/mcp/project-leaders.js.map +1 -0
  97. package/dist/mcp/protocol.d.ts +50 -0
  98. package/dist/mcp/protocol.js +47 -0
  99. package/dist/mcp/protocol.js.map +1 -0
  100. package/dist/mcp/reconnect.d.ts +297 -0
  101. package/dist/mcp/reconnect.js +465 -0
  102. package/dist/mcp/reconnect.js.map +1 -0
  103. package/dist/mcp/resource-ownership.d.ts +228 -0
  104. package/dist/mcp/resource-ownership.js +390 -0
  105. package/dist/mcp/resource-ownership.js.map +1 -0
  106. package/dist/mcp/service-adapter.d.ts +16488 -0
  107. package/dist/mcp/service-adapter.js +148 -0
  108. package/dist/mcp/service-adapter.js.map +1 -0
  109. package/dist/mcp/service.d.ts +83 -0
  110. package/dist/mcp/service.js +233 -0
  111. package/dist/mcp/service.js.map +1 -0
  112. package/dist/mcp/session-binding.d.ts +105 -0
  113. package/dist/mcp/session-binding.js +163 -0
  114. package/dist/mcp/session-binding.js.map +1 -0
  115. package/dist/mcp/stale-write.d.ts +145 -0
  116. package/dist/mcp/stale-write.js +219 -0
  117. package/dist/mcp/stale-write.js.map +1 -0
  118. package/dist/mcp/tool.d.ts +48 -0
  119. package/dist/mcp/tool.js +24 -0
  120. package/dist/mcp/tool.js.map +1 -0
  121. package/dist/mcp/tools/discovery.d.ts +40 -0
  122. package/dist/mcp/tools/discovery.js +232 -0
  123. package/dist/mcp/tools/discovery.js.map +1 -0
  124. package/dist/mcp/tools/execution-control.d.ts +171 -0
  125. package/dist/mcp/tools/execution-control.js +513 -0
  126. package/dist/mcp/tools/execution-control.js.map +1 -0
  127. package/dist/mcp/tools/handoff-git.d.ts +26 -0
  128. package/dist/mcp/tools/handoff-git.js +544 -0
  129. package/dist/mcp/tools/handoff-git.js.map +1 -0
  130. package/dist/mcp/tools/index.d.ts +2 -0
  131. package/dist/mcp/tools/index.js +23 -0
  132. package/dist/mcp/tools/index.js.map +1 -0
  133. package/dist/mcp/tools/leader-events.d.ts +46 -0
  134. package/dist/mcp/tools/leader-events.js +126 -0
  135. package/dist/mcp/tools/leader-events.js.map +1 -0
  136. package/dist/mcp/tools/local-handoff.d.ts +102 -0
  137. package/dist/mcp/tools/local-handoff.js +306 -0
  138. package/dist/mcp/tools/local-handoff.js.map +1 -0
  139. package/dist/mcp/tools/project-config.d.ts +340 -0
  140. package/dist/mcp/tools/project-config.js +1075 -0
  141. package/dist/mcp/tools/project-config.js.map +1 -0
  142. package/dist/mcp/tools/results-evidence.d.ts +112 -0
  143. package/dist/mcp/tools/results-evidence.js +1045 -0
  144. package/dist/mcp/tools/results-evidence.js.map +1 -0
  145. package/dist/mcp/tools/task-create.d.ts +70 -0
  146. package/dist/mcp/tools/task-create.js +473 -0
  147. package/dist/mcp/tools/task-create.js.map +1 -0
  148. package/dist/mcp/tools/task-reads.d.ts +102 -0
  149. package/dist/mcp/tools/task-reads.js +689 -0
  150. package/dist/mcp/tools/task-reads.js.map +1 -0
  151. package/dist/mcp/tools/work-organisation.d.ts +2 -0
  152. package/dist/mcp/tools/work-organisation.js +502 -0
  153. package/dist/mcp/tools/work-organisation.js.map +1 -0
  154. package/dist/paths.d.ts +17 -7
  155. package/dist/paths.js +18 -8
  156. package/dist/paths.js.map +1 -1
  157. package/dist/runs/project-writer.js +76 -11
  158. package/dist/runs/project-writer.js.map +1 -1
  159. package/dist/runs/retention.d.ts +25 -2
  160. package/dist/runs/retention.js +63 -2
  161. package/dist/runs/retention.js.map +1 -1
  162. package/dist/server/forge/github.js +44 -0
  163. package/dist/server/forge/github.js.map +1 -1
  164. package/dist/server/forge/types.d.ts +16 -0
  165. package/dist/server/project-context.d.ts +81 -8
  166. package/dist/server/project-context.js +143 -35
  167. package/dist/server/project-context.js.map +1 -1
  168. package/dist/server/server.d.ts +1050 -53
  169. package/dist/server/server.js +326 -120
  170. package/dist/server/server.js.map +1 -1
  171. package/dist/server/validators.d.ts +26 -6
  172. package/dist/server/validators.js +17 -0
  173. package/dist/server/validators.js.map +1 -1
  174. package/dist/skills-remote.js +64 -6
  175. package/dist/skills-remote.js.map +1 -1
  176. package/dist/workflows/run.d.ts +277 -12
  177. package/dist/workflows/run.js +599 -79
  178. package/dist/workflows/run.js.map +1 -1
  179. package/dist/workspace/agent-profiles.d.ts +30 -3
  180. package/dist/workspace/agent-profiles.js +39 -4
  181. package/dist/workspace/agent-profiles.js.map +1 -1
  182. package/dist/workspace/config.js +25 -4
  183. package/dist/workspace/config.js.map +1 -1
  184. package/dist/workspace/project-owner.d.ts +210 -0
  185. package/dist/workspace/project-owner.js +518 -0
  186. package/dist/workspace/project-owner.js.map +1 -0
  187. package/package.json +5 -3
  188. package/scripts/pi-leader-extension.ts +420 -0
  189. package/scripts/sync-readme.mjs +83 -2
  190. package/web/dist/assets/{alert-dialog-BVsVcbFy.js → alert-dialog-D6rP6d39.js} +1 -1
  191. package/web/dist/assets/arrow-down-DMOfFIzW.js +1 -0
  192. package/web/dist/assets/arrow-left-C4Ec2BUg.js +1 -0
  193. package/web/dist/assets/{centered-state-Djgly6Ni.js → centered-state-B0ZqrPaa.js} +12 -12
  194. package/web/dist/assets/chevron-right-mHJ-crvn.js +1 -0
  195. package/web/dist/assets/{collapsible-DZ4D-DHG.js → collapsible-DchEHgUM.js} +1 -1
  196. package/web/dist/assets/{commit-list-B-z1JL9G.js → commit-list-BpMim1Rv.js} +1 -1
  197. package/web/dist/assets/{compare-variants-CdS8r8E2.js → compare-variants-D5ItQbAq.js} +1 -1
  198. package/web/dist/assets/{diff-BKew9rQX.js → diff-HqqZ_WDR.js} +2 -2
  199. package/web/dist/assets/{diff-stat-D0mc5V6Z.js → diff-stat-NsU-3ecr.js} +1 -1
  200. package/web/dist/assets/{diff-view-xpIcuLIC.js → diff-view-B7c11Xux.js} +1 -1
  201. package/web/dist/assets/{dropdown-menu-CEQziH7X.js → dropdown-menu-CtTpnyod.js} +1 -1
  202. package/web/dist/assets/{editable-title-BCX3T2BY.js → editable-title-BXtuGQry.js} +1 -1
  203. package/web/dist/assets/{ellipsis-vertical-ByVdDc4z.js → ellipsis-vertical-MHtGZSH6.js} +1 -1
  204. package/web/dist/assets/{file-xBSB5hXO.js → file-DKLw5SOb.js} +1 -1
  205. package/web/dist/assets/{folder-CldgbZ5A.js → folder-Drw6-ufH.js} +1 -1
  206. package/web/dist/assets/{git-toolbar-BYlnf4qf.js → git-toolbar-BQH8bORV.js} +1 -1
  207. package/web/dist/assets/github-ynnpKFCJ.js +1 -0
  208. package/web/dist/assets/{image-preview-Cv5mZr0n.js → image-preview-DLhMN_R9.js} +1 -1
  209. package/web/dist/assets/index-BEsrNdXt.js +29 -0
  210. package/web/dist/assets/index-Da8DOBau.css +2 -0
  211. package/web/dist/assets/{markdown-CgEEE2ND.js → markdown-q365eN6Y.js} +1 -1
  212. package/web/dist/assets/{new-task-form-1r2bgsdD.js → new-task-form-CW-hNIWQ.js} +1 -1
  213. package/web/dist/assets/{pill-q-x22_ej.js → pill-B_TFulXa.js} +1 -1
  214. package/web/dist/assets/{project-router-VDN4jQM8.js → project-router-CLPc-ImZ.js} +1 -1
  215. package/web/dist/assets/{prompt-templates-BmZtCe-L.js → prompt-templates-CLc4RxyO.js} +1 -1
  216. package/web/dist/assets/{repo-git-C2On8SlI.js → repo-git-Is7R6-Tj.js} +1 -1
  217. package/web/dist/assets/{run-diff-vguEMJzh.js → run-diff-DHMvDlVo.js} +2 -2
  218. package/web/dist/assets/run-header-DvZJbQ8I.js +1 -0
  219. package/web/dist/assets/{search-x-QmhaAbg5.js → search-x-Ce2MMetu.js} +1 -1
  220. package/web/dist/assets/{skill-empty-hint-BrzUwByZ.js → skill-empty-hint-C_xCSvzB.js} +1 -1
  221. package/web/dist/assets/skills-CivqD4si.js +1 -0
  222. package/web/dist/assets/{sparkles-CP8QDOgX.js → sparkles-BCqPoj5g.js} +1 -1
  223. package/web/dist/assets/{square-terminal-CGypshSD.js → square-terminal-ikj5g-oK.js} +1 -1
  224. package/web/dist/assets/{tab-link-D4lFzzI1.js → tab-link-VsEjFV1Y.js} +1 -1
  225. package/web/dist/assets/{task-changes-6aopqAmV.js → task-changes-CH-E81-_.js} +1 -1
  226. package/web/dist/assets/{task-commits-u-dwaCyw.js → task-commits-DqfkMFxp.js} +1 -1
  227. package/web/dist/assets/{task-files-IMOZnIUK.js → task-files-D_nkHZ_a.js} +2 -2
  228. package/web/dist/assets/task-thread-bxQompxJ.js +9 -0
  229. package/web/dist/assets/{textarea-CyBCyKIk.js → textarea-xajuW1L_.js} +1 -1
  230. package/web/dist/assets/{thread-loading-Cuq2jchC.js → thread-loading-C3JQZbIE.js} +1 -1
  231. package/web/dist/assets/{trash-2-B6Dp3ZNs.js → trash-2-hJrBhpUD.js} +1 -1
  232. package/web/dist/assets/{triangle-alert-xBya8FUp.js → triangle-alert-D1u82y1a.js} +1 -1
  233. package/web/dist/assets/{upload-D6MXEB9p.js → upload-Bn6FBZ59.js} +1 -1
  234. package/web/dist/assets/{use-desktop-fLDKEJoB.js → use-desktop-DOIMfHfY.js} +1 -1
  235. package/web/dist/assets/{use-submit-shortcut-JTpNzU-u.js → use-submit-shortcut-Dj4DHTdE.js} +1 -1
  236. package/web/dist/assets/utils-YwBpOoRN.js +64 -0
  237. package/web/dist/assets/{workflows-BY3KTkFw.js → workflows-CTaAKz2m.js} +3 -3
  238. package/web/dist/assets/{zoomable-image-GTYwLtkS.js → zoomable-image-C1-6P3tg.js} +1 -1
  239. package/web/dist/index.html +23 -23
  240. package/web/dist/assets/arrow-down-CYgAlZtW.js +0 -1
  241. package/web/dist/assets/arrow-left-BOqE0a3k.js +0 -1
  242. package/web/dist/assets/chevron-right-Cc9BWnbN.js +0 -1
  243. package/web/dist/assets/github-CqGK62Lw.js +0 -1
  244. package/web/dist/assets/index-3nxVmdn5.css +0 -2
  245. package/web/dist/assets/index-qX7n11FX.js +0 -9
  246. package/web/dist/assets/refresh-cw-DcNCj2nu.js +0 -1
  247. package/web/dist/assets/run-header-DM7MiKj9.js +0 -1
  248. package/web/dist/assets/skills-Bj-uG_Hz.js +0 -1
  249. package/web/dist/assets/task-thread-CbMPmrWq.js +0 -9
  250. package/web/dist/assets/utils--z9hRtl3.js +0 -64
@@ -17,7 +17,7 @@ import { Hono } from 'hono';
17
17
  import { serve } from '@hono/node-server';
18
18
  import { bodyLimit } from 'hono/body-limit';
19
19
  import { streamSSE } from 'hono/streaming';
20
- import { jsonZodValidator, paramZodValidator, queryZodValidator } from './validators.js';
20
+ import { jsonZodValidator, optionalJsonZodValidator, paramZodValidator, queryZodValidator } from './validators.js';
21
21
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
22
22
  import { z } from 'zod';
23
23
  import { setWorkspaceUiStateInputSchema, } from '../contract/index.js';
@@ -36,6 +36,8 @@ import { applyProviderEnablement } from '../core/provider-availability.js';
36
36
  import { RunnerModelCatalog } from '../core/runner-model-catalog.js';
37
37
  import { currentUsage, onUsage } from '../core/process-usage.js';
38
38
  import { projectWorkflowsDir, loadWorkflows } from '../workflows/load.js';
39
+ import { reportProjectChange } from '../mcp/project-catalogs.js';
40
+ import { projectLeader } from '../mcp/project-leaders.js';
39
41
  import { QUICK_TASK_WORKFLOW, normalizeWorkflowDoc, skillStackOf, skillsToSteps, stepsIssue, workflowFileSchema, workflowStepSchema, } from '../workflows/types.js';
40
42
  import { planChain, slugify } from '../planner.js';
41
43
  import { discoverSkills } from '../skills.js';
@@ -46,10 +48,15 @@ import { markStarted, onTodosChanged, readTodos, removeTodo, todoTaskText } from
46
48
  import { HistoryCursorError, deriveRunContextEvents, readEventsAfterLiveCursor, readRunHistoryPage, validateLiveCursor, } from '../runs/event-history.js';
47
49
  import { readRunIndexFromDisk } from '../runs/run-index.js';
48
50
  import { isV2WireEventType } from '../runs/ui-event-sink.js';
49
- import { runEventsQuerySchema, runHistoryQuerySchema, runIdParamSchema, } from '../contract/index.js';
51
+ import { mcpLeaderActionInputSchema } from '../contract/index.js';
52
+ import { githubPrReadyInputSchema, runEventsQuerySchema, runHistoryQuerySchema, runIdParamSchema, } from '../contract/index.js';
53
+ // The run-mutation request shapes, each carrying the optional stale-write guard (#250).
54
+ import { archiveRunInputSchema, continueRunInputSchema, gitCommitInputSchema, messageInputSchema, patchRunInputSchema, pickVariantInputSchema, pinRunInputSchema, queuedMessagePatchInputSchema, runVersionGuardInputSchema, } from '../contract/index.js';
55
+ import { ownGroup, ownershipScope } from '../mcp/resource-ownership.js';
56
+ import { runVersion, staleRunWrite } from '../mcp/stale-write.js';
50
57
  import { toPastedContent } from '../workflows/run.js';
51
58
  import { removeWorktree, worktreeDiff, worktreeDiffStat, worktreeSizeBytes } from '../git-worktree.js';
52
- import { isReclaimable, reclaimWorktrees } from '../runs/retention.js';
59
+ import { isReclaimable, ownRunAndWorktree, reclaimWorktrees } from '../runs/retention.js';
53
60
  import { getBranches, getCommit, getDiff, getLog, getRepoInfo, getStatus } from './git.js';
54
61
  import { collectChanges, collectCommitChanges, collectRunCommits, commitAll, createOrSwitchBranch, imageMimeType, isOsOpenableImage, pushCurrentBranch, readWorktreePath, } from './git-changes.js';
55
62
  import { gatedSkillsRepos, loadConfig, resolveWorktreeRetention } from '../config.js';
@@ -61,7 +68,7 @@ import { listConfigFiles } from '../agent-config/catalog.js';
61
68
  import { readAccountIdentity } from '../agent-config/account-identity.js';
62
69
  import { DEFAULT_MEMORY_LIMIT_MB, PROJECT_ID_RE, defaultWorkspaceConfig, effectiveSkillsAutoUpdate, loadWorkspaceConfig, mergeWriteWorkspaceConfig, effectiveComposerDefault, } from '../workspace/config.js';
63
70
  import { CONTROL_CHARS_RE, DEFAULT_AGENT_ACCOUNT_ID, defaultAgentAccountStore, isAbsoluteConfigDir, loadAgentAccounts, mergeWriteAgentAccounts, } from '../workspace/agent-accounts.js';
64
- import { defaultAgentProfile, listAgentProfiles, profileDirState, resolveProfileEnvForRoot, resolveStoredProfile, sameProfileDir, } from '../workspace/agent-profiles.js';
71
+ import { accountHomePatch, defaultAgentProfile, listAgentProfiles, profileDirState, resolveProfileEnvForRoot, resolveStoredProfile, sameProfileDir, } from '../workspace/agent-profiles.js';
65
72
  import { PROFILE_CAPABLE_PROVIDERS, profileEnv, supportsProfiles } from '../core/agent-profiles.js';
66
73
  import { withEnvPrefix } from '../core/shell-env.js';
67
74
  import { allocateProjectSlug, listProjects, normalizeProjectTags, probeProjectStatus, registerProject, removeProject, shouldRegisterProject, } from '../workspace/projects.js';
@@ -218,6 +225,46 @@ export function projectRouteManifest(app) {
218
225
  const FOLLOWUPS_OFF = 'the follow-up inbox is disabled — set XEZ_FOLLOWUPS=1 to enable it';
219
226
  /** 409 body for every automations route while GitHub automations are off (#801). */
220
227
  const AUTOMATIONS_OFF = 'GitHub automations are disabled — set XEZ_AUTOMATIONS=1 to enable them';
228
+ /**
229
+ * How long `DELETE /projects/:id` waits on either of its project-lifecycle promises — a context
230
+ * still opening, then the removed context's last background writes — before answering anyway
231
+ * (#200). One bound per wait, applied through `withinTeardownBound`.
232
+ *
233
+ * `RunManager.dispose()` deliberately carries NO deadline — an invented one there would only
234
+ * move an ENOTEMPTY a few hundred milliseconds later, which is the right call for a teardown
235
+ * helper whose caller is about to delete the directory. An HTTP request is the opposite case:
236
+ * it must answer. The realistic tail is short (the 409 above already refused the removal if any
237
+ * run is active, and a retention sweep stops after its current iteration), but that iteration
238
+ * is `git worktree remove --force` + `rm -rf` + `git worktree prune`, and `git()` in
239
+ * `git-worktree.ts` passes no `timeout` to `execFile` — so a git blocked on `index.lock` or a
240
+ * stalled network mount would hang the request with no response and no recovery.
241
+ *
242
+ * Deliberately bounded HERE and not by giving `git()` a timeout: the same helper runs
243
+ * `git worktree add` and `git fetch`, which are legitimately slow on a large repository, so a
244
+ * blanket timeout would turn a slow clone into a failed run. What the bound gives up is only
245
+ * the tail of the guarantee: the registry entry is already gone and the context is already out
246
+ * of the map, so the answer stays true — a sweep that outlives it can still stamp a record, the
247
+ * pre-#200 behaviour, and only for a project no route can reach.
248
+ */
249
+ const PROJECT_TEARDOWN_WAIT_MS = 5_000;
250
+ /**
251
+ * Wait for one of `DELETE /projects/:id`'s two project-lifecycle promises — the in-flight build
252
+ * before the running-tasks guard, the teardown after the removal — and answer anyway once
253
+ * `PROJECT_TEARDOWN_WAIT_MS` is up.
254
+ *
255
+ * One helper for both because the reason is the same one, and because two hand-rolled races in
256
+ * one handler drift: neither wait may turn a request into something that cannot answer. Both
257
+ * outcomes are swallowed deliberately — the route's answer does not depend on how either promise
258
+ * settled, and a build that lost its project rejects by design.
259
+ */
260
+ async function withinTeardownBound(work) {
261
+ await Promise.race([
262
+ work.then(() => undefined, () => undefined),
263
+ new Promise((resolve) => {
264
+ setTimeout(resolve, PROJECT_TEARDOWN_WAIT_MS).unref?.();
265
+ }),
266
+ ]);
267
+ }
221
268
  /**
222
269
  * The in-process bus for workspace-level SSE events. The registry-mutating
223
270
  * routes (`POST /api/projects` — step 4.2, emits `project-added` for a
@@ -307,9 +354,6 @@ const startRunSchema = z
307
354
  .refine((b) => Boolean(b.workflow) !== Boolean(b.steps), {
308
355
  message: 'provide either "workflow" or "steps", not both',
309
356
  });
310
- const pickSchema = z.object({
311
- runId: z.string().min(1),
312
- });
313
357
  const planSchema = z.object({
314
358
  // Same bound as `startRunSchema.task` — this flows into `planChain` (#429).
315
359
  task: z.string().trim().min(1).max(100_000, 'must be at most 100000 characters'),
@@ -439,16 +483,6 @@ const uiStateSchema = z
439
483
  dismissedSkillsBanner: z.boolean().optional(),
440
484
  })
441
485
  .passthrough();
442
- // Editable titles (#389), and the initial prompt while the run is still queued
443
- // (#472 — rejected with 409 on any other status by the handler).
444
- const patchRunSchema = z.object({
445
- title: z.string().trim().min(1).max(300).optional(),
446
- task: z.string().trim().min(1).max(100_000).optional(),
447
- });
448
- // Session commit (redesign R5 — §"Git/session API additions").
449
- const gitCommitSchema = z.object({
450
- message: z.string().trim().min(1, 'must not be empty').max(5_000),
451
- });
452
486
  // "Open in…" (#open-in / #365): `target` selects the app; `path` (optional, worktree-relative)
453
487
  // narrows the target's own worktree/repo-root default to one file — used by the diff pane's
454
488
  // "open in default app" action for images. Containment is re-checked server-side via
@@ -458,27 +492,6 @@ const openInSchema = z.object({
458
492
  target: z.string().trim().min(1, 'target required').max(200),
459
493
  path: z.string().max(1_000).optional(),
460
494
  });
461
- // Attachment-carrying bodies validate with the CONTRACT's `attachmentInputSchema` (#950) —
462
- // images plus the short PDF/TXT/MD allowlist, ~5 MB each once base64-decoded. Imported rather
463
- // than mirrored here, so the wire cannot drift from what the cockpit compiles against.
464
- const messageSchema = z
465
- .object({
466
- text: z.string().max(100_000).default(''),
467
- images: z.array(attachmentInputSchema).max(4).default([]),
468
- })
469
- .refine((m) => m.text.trim().length > 0 || m.images.length > 0, {
470
- message: 'message needs text or at least one attachment',
471
- });
472
- // PATCH semantics are load-bearing here: an omitted field keeps its current value.
473
- // In particular, the cockpit edits text without re-uploading existing attachments.
474
- const queuedMessagePatchSchema = z
475
- .object({
476
- text: z.string().max(100_000).optional(),
477
- images: z.array(attachmentInputSchema).max(4).optional(),
478
- })
479
- .refine((m) => m.text !== undefined || m.images !== undefined, {
480
- message: 'message edit needs text or attachments',
481
- });
482
495
  // Queued prompt stack bounds (#472). The per-message bounds mirror `messageSchema`
483
496
  // above; the one that actually matters is the FOLDED total, because 20 messages of
484
497
  // 100 000 chars each would otherwise compose a ~2 M-character {{task}}.
@@ -495,21 +508,6 @@ function foldedLength(task, stack) {
495
508
  .filter((part) => part.length > 0)
496
509
  .join('\n\n').length;
497
510
  }
498
- // "Continue"/"Send back" body (spec 003 / #401): every field optional, so an empty POST reopens
499
- // the last session on the run's current backend (backward compat). A runner/model/account override
500
- // lets the follow-up composer choose which engine handles the continuation. `text` stays bounded
501
- // like the live-session message `text` (#429), and `images` like a live-session message's — the
502
- // follow-up composer is a full composer, so a screenshot pasted into it must reach the reopened
503
- // session rather than being silently dropped.
504
- const continueSchema = z.object({
505
- text: z.string().max(100_000, 'must be at most 100000 characters').optional(),
506
- images: z.array(attachmentInputSchema).max(4).optional(),
507
- runner: z.enum(RUNNER_IDS).optional(),
508
- model: z.string().max(200).optional(),
509
- /** Agent account for the reopened session (spec 2026-07-29-agent-profiles). Bound mirrors
510
- * `POST /runs`' own `agentProfile`. Omitted = keep the account the run is already on. */
511
- agentProfile: z.string().max(64).optional(),
512
- });
513
511
  // Inbox "▶ Run" body (spec 007 / #401 / #413): every field optional, and the whole body is
514
512
  // optional too, so an empty POST — every client before the pills and the composer — starts on
515
513
  // the host's `defaultRunner` with no extra instructions, exactly as before. This is a START
@@ -529,17 +527,6 @@ const startTodoSchema = z
529
527
  .transform((s) => (s ? s : undefined)),
530
528
  })
531
529
  .optional();
532
- // `POST /api/runs/:id/archive` (#429) — no body archives; `{archived:false}`
533
- // un-archives. A tiny schema so the route follows the safeParse convention.
534
- const archiveSchema = z.object({
535
- archived: z.boolean().optional(),
536
- });
537
- // `POST /api/v1/runs/:id/pin` (#935) — no body pins; `{pinned:false}` unpins. The archive
538
- // route's shape, deliberately: it is the same kind of per-task flag, and a second spelling for
539
- // "absent means do the thing" would be one more rule for a client to remember.
540
- const pinSchema = z.object({
541
- pinned: z.boolean().optional(),
542
- });
543
530
  // Request-body size guards (#429). A generous global cap keeps a single
544
531
  // localhost request from being unbounded (the largest legit body is 4 pasted
545
532
  // images at ~7 MB base64 each); the ui-state PUT gets a much tighter cap since
@@ -1453,14 +1440,14 @@ export function createApp(deps) {
1453
1440
  * `AgentHomePaths` whose slot for this provider is the account's dir. That is what makes a second
1454
1441
  * login's `settings.json` the file you open rather than the default account's, and it keeps the
1455
1442
  * ids opaque and stable so the open route below never takes a path from the client.
1443
+ *
1444
+ * WHICH slot comes from `accountHomePatch`, shared with `defaultAgentProfile`'s read of the same
1445
+ * table. This was a run of conditional spreads that named three providers and silently did
1446
+ * nothing for the fourth, so a pi account would have resolved its files in the default pi home
1447
+ * (#329). It never surfaced only because `CONFIG_FILES` carries no pi entry yet.
1456
1448
  */
1457
1449
  const accountFiles = async (profile) => {
1458
- const home = {
1459
- ...agentHomePaths(),
1460
- ...(profile.provider === 'claude' ? { claude: profile.path } : {}),
1461
- ...(profile.provider === 'codex' ? { codex: profile.path } : {}),
1462
- ...(profile.provider === 'opencode' ? { opencodeConfig: profile.path } : {}),
1463
- };
1450
+ const home = { ...agentHomePaths(), ...accountHomePatch(profile.provider, profile.path) };
1464
1451
  const defs = listConfigFiles().filter((def) => def.scope === 'user' && def.runners.includes(profile.provider));
1465
1452
  return Promise.all(defs.map(async (def) => {
1466
1453
  const path = def.resolve(bootRoot, home);
@@ -1985,6 +1972,16 @@ export function createApp(deps) {
1985
1972
  error: `xezar is serving ${entry.name} right now — it re-registers itself at every start, so it cannot be removed from here`,
1986
1973
  }, 409);
1987
1974
  }
1975
+ // A project whose context is still BUILDING is not a project with no runs: its store opens
1976
+ // with `keepLive` and `manager.recover()` re-queues or resumes every live-looking row, so
1977
+ // the guard below — which reads the already-built context only — would count zero for a
1978
+ // project that is at this moment bringing agents back, and remove it out from under them.
1979
+ // Let the build finish, then count. Bounded for the same reason the teardown below is: a
1980
+ // build that outlives the bound leaves the pre-existing answer (nothing built, zero runs)
1981
+ // rather than a project that can never be removed.
1982
+ const opening = contexts.pending(id);
1983
+ if (opening)
1984
+ await withinTeardownBound(opening);
1988
1985
  const active = activeRunCount(id);
1989
1986
  if (active > 0) {
1990
1987
  return c.json({
@@ -2006,7 +2003,27 @@ export function createApp(deps) {
2006
2003
  return c.json({ error: `unknown project: ${id}` }, 404);
2007
2004
  // In-process handles for a project no route can reach any more: store
2008
2005
  // closed (index flushed), manager's timers and usage subscription dropped.
2009
- contexts.dispose(id);
2006
+ //
2007
+ // AWAITED (#200): the manager's promise is what settles the background writes it could not
2008
+ // stop synchronously — a worktree-retention sweep spawning git and stamping records. Not
2009
+ // awaiting it let the response go out while a sweep was still writing into the removed
2010
+ // project's `.local/xezar`, and a re-add inside that window could see the stale in-memory
2011
+ // index overwrite the fresh one. There is no run to wait for: the 409 above already refused
2012
+ // the removal if this process owns any.
2013
+ //
2014
+ // BOUNDED, because this is a request and not a teardown helper: `dispose()` has no deadline
2015
+ // by design and the git it waits on has no `execFile` timeout, so an unbounded await is a
2016
+ // request that can never answer. `dispose()` removed the context from the map
2017
+ // synchronously, so the floated remainder is invisible to every route; see
2018
+ // `PROJECT_TEARDOWN_WAIT_MS`. The answer is the same however it settles — the removal has
2019
+ // already happened, and this is only the wait for the last writes.
2020
+ //
2021
+ // Covers a context that was still BUILDING when the removal landed, too: `dispose()` ends
2022
+ // the registration first, so that build tears itself down instead of publishing, and the
2023
+ // promise awaited here is the one that settles its store and manager. The answer stays
2024
+ // `{ removed: true }` — the registry entry is gone, which is what the caller asked for, and
2025
+ // the racing build was never a context anyone could reach.
2026
+ await withinTeardownBound(contexts.dispose(id));
2010
2027
  workspaceEvents.emit('project-removed', { id });
2011
2028
  const body = { removed: true, id };
2012
2029
  return c.json(body);
@@ -2279,6 +2296,12 @@ export function createApp(deps) {
2279
2296
  * rejected for the same reason as the `rm` above: `RunStore.open` creates
2280
2297
  * directories, and a stale `running` row left by a crashed process would
2281
2298
  * become a 409 the user could never clear.
2299
+ *
2300
+ * "No context, no agent to strand" holds for a project nobody has touched. It does NOT hold for
2301
+ * a project whose context is being built RIGHT NOW — that build opened its store with
2302
+ * `keepLive` and its `manager.recover()` re-queues or resumes exactly the runs this guard
2303
+ * exists to protect — so the caller waits for an in-flight build (`contexts.pending`) before
2304
+ * asking, and only then is zero an answer about the project rather than about the map.
2282
2305
  */
2283
2306
  const activeRunCount = (projectId) => {
2284
2307
  const ctx = contexts.peek(projectId);
@@ -2726,6 +2749,8 @@ export function createApp(deps) {
2726
2749
  const message = err instanceof Error ? err.message : String(err);
2727
2750
  return c.json({ error: message }, 500);
2728
2751
  }
2752
+ // E-05 (#252): the project leader hears of it. No catalog for this project → no row.
2753
+ reportProjectChange(c.get('project').id, (catalog) => catalog.workflowChanged({ name: parsed.data.name, change: 'saved' }));
2729
2754
  return c.json({ path, name: parsed.data.name }, 201);
2730
2755
  })
2731
2756
  // Delete a saved workflow (spec 012 follow-up): file workflows only —
@@ -2751,6 +2776,7 @@ export function createApp(deps) {
2751
2776
  catch (err) {
2752
2777
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2753
2778
  }
2779
+ reportProjectChange(c.get('project').id, (catalog) => catalog.workflowChanged({ name, change: 'deleted' }));
2754
2780
  return c.json({ ok: true, path: target });
2755
2781
  })
2756
2782
  // Import support for the builder (spec 012): parse + validate a pasted
@@ -3072,6 +3098,17 @@ export function createApp(deps) {
3072
3098
  console.warn(`[xezar] could not mark inbox entry ${todoId} started: ${String(err)}`);
3073
3099
  }
3074
3100
  };
3101
+ // A run whose recorded worktree is this project's own (#316). `worktreePath` is data: a copied or
3102
+ // hand-edited `.local/xezar` can name another project's worktree, and the delete, remove-worktree,
3103
+ // commit, push, PR, diff and changes routes would `rm -rf`, commit, push or run `git add -N .`
3104
+ // there. They look the run up through the same rule the variant pick and reclaim use (#288). An
3105
+ // id this project does not hold is still the plain 404. A record it DOES hold whose worktree is
3106
+ // elsewhere gets `FOREIGN_WORKTREE` instead: the cockpit shows the server's words as they are,
3107
+ // and a bare "not found" on a row the user can see would leave them with no way forward. The
3108
+ // sentence names no path, id or project, so it says nothing of the folder it refuses to touch,
3109
+ // and it is short because a toast is on screen for five seconds (`TOAST_MS` in the cockpit).
3110
+ const ownedRun = (project, id) => ownRunAndWorktree(ownershipScope(project), id);
3111
+ const FOREIGN_WORKTREE = "This task's worktree is outside this project, so xezar will not touch it from here. Archive the task to hide it from your list.";
3075
3112
  // ---- chained family: runs lifecycle + artifacts (project-scoped) ----
3076
3113
  const runsRoutes = new Hono()
3077
3114
  .get('/runs', (c) => c.json(c.get('project').store.listRuns().map(withUsage)))
@@ -3081,7 +3118,7 @@ export function createApp(deps) {
3081
3118
  // The read-receipt sweep (#unread-done-items) — the mark-read twin of the archive
3082
3119
  // sweep above, and under the same registration-order guard.
3083
3120
  .post('/runs/read-all', (c) => c.json({ read: c.get('project').store.markAllRead() }))
3084
- .post('/runs/:id/archive', jsonZodValidator(archiveSchema, { absent: ({}) }), async (c) => {
3121
+ .post('/runs/:id/archive', jsonZodValidator(archiveRunInputSchema, { absent: ({}) }), async (c) => {
3085
3122
  const { store } = c.get('project');
3086
3123
  const id = c.req.param('id');
3087
3124
  // An empty/absent body archives (the common case); a malformed body degrades
@@ -3090,6 +3127,13 @@ export function createApp(deps) {
3090
3127
  // `setArchived` itself — the bulk sweep must obey it too (spec
3091
3128
  // 2026-08-03-auto-resume-after-usage-limit).
3092
3129
  const parsed = { data: c.req.valid('json') };
3130
+ // An unknown run is a 404 before any version is compared — the answer it always got.
3131
+ if (!store.getRun(id))
3132
+ return c.json({ error: 'not found' }, 404);
3133
+ // The stale-write guard (#250): checked in the same synchronous stretch as the write.
3134
+ const stale = staleRunWrite(store, id, parsed.data.expectedVersion);
3135
+ if (stale)
3136
+ return c.json(stale, 409);
3093
3137
  const run = store.setArchived(id, parsed.data.archived !== false);
3094
3138
  return run ? c.json(run) : c.json({ error: 'not found' }, 404);
3095
3139
  })
@@ -3097,19 +3141,32 @@ export function createApp(deps) {
3097
3141
  // twin in every respect: an absent body pins (the common case), the answer is the updated
3098
3142
  // record, and the change rides the existing `run` SSE because `setPinned` touches. No new
3099
3143
  // event and no new response shape.
3100
- .post('/runs/:id/pin', jsonZodValidator(pinSchema, { absent: ({}) }), (c) => {
3144
+ .post('/runs/:id/pin', jsonZodValidator(pinRunInputSchema, { absent: ({}) }), (c) => {
3101
3145
  const { store } = c.get('project');
3102
- const run = store.setPinned(c.req.param('id'), c.req.valid('json').pinned !== false);
3146
+ const id = c.req.param('id');
3147
+ const { pinned, expectedVersion } = c.req.valid('json');
3148
+ // An unknown run is a 404 before any version is compared — the answer it always got.
3149
+ if (!store.getRun(id))
3150
+ return c.json({ error: 'not found' }, 404);
3151
+ // The stale-write guard (#250): checked in the same synchronous stretch as the write.
3152
+ const stale = staleRunWrite(store, id, expectedVersion);
3153
+ if (stale)
3154
+ return c.json(stale, 409);
3155
+ const run = store.setPinned(id, pinned !== false);
3103
3156
  return run ? c.json(run) : c.json({ error: 'not found' }, 404);
3104
3157
  })
3105
3158
  // The per-task off switch for that resume (the workspace setting is Settings → Resources).
3106
3159
  // Idempotent: a run with nothing pending answers 200 too, because "this task will not
3107
3160
  // resume itself" is equally true either way.
3108
- .delete('/runs/:id/auto-resume', (c) => {
3161
+ .delete('/runs/:id/auto-resume', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), (c) => {
3109
3162
  const { store, manager } = c.get('project');
3110
3163
  const id = c.req.param('id');
3111
3164
  if (!store.getRun(id))
3112
3165
  return c.json({ error: 'not found' }, 404);
3166
+ // The stale-write guard (#250): checked in the same synchronous stretch as the write.
3167
+ const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
3168
+ if (stale)
3169
+ return c.json(stale, 409);
3113
3170
  manager.cancelAutoResume(id);
3114
3171
  return c.json({ cancelled: true });
3115
3172
  })
@@ -3207,6 +3264,15 @@ export function createApp(deps) {
3207
3264
  const { store } = c.get('project');
3208
3265
  const run = store.getRun(c.req.param('id'));
3209
3266
  return run ? c.json(withUsage(run)) : c.json({ error: 'not found' }, 404);
3267
+ })
3268
+ // The run's stale-write token (#250, D-06 § 4.2) — what a caller echoes back as
3269
+ // `expectedVersion` on a run mutation. Its own route rather than a field of the record above,
3270
+ // so the cockpit's reads neither change shape nor pay for it (it scans the run's event file).
3271
+ // A reader that wants both reads THIS first: an older token costs a spurious rejection at
3272
+ // worst, while a token newer than the record it read would let a stale write through.
3273
+ .get('/runs/:id/version', (c) => {
3274
+ const version = runVersion(c.get('project').store, c.req.param('id'));
3275
+ return version === undefined ? c.json({ error: 'not found' }, 404) : c.json({ version });
3210
3276
  })
3211
3277
  .get('/runs/:id/history', paramZodValidator(runIdParamSchema), queryZodValidator(runHistoryQuerySchema), async (c) => {
3212
3278
  const { store, dataDir } = c.get('project');
@@ -3244,12 +3310,17 @@ export function createApp(deps) {
3244
3310
  // actually displays). The auto-summarizer only ever fills an *unset*
3245
3311
  // titleSummary (RunManager.recordTurnEnd), so an edit wins over any past or
3246
3312
  // future auto-summary. Answers the updated record.
3247
- .patch('/runs/:id', jsonZodValidator(patchRunSchema), async (c) => {
3313
+ .patch('/runs/:id', jsonZodValidator(patchRunInputSchema), async (c) => {
3248
3314
  const { store, manager } = c.get('project');
3249
3315
  const id = c.req.param('id');
3250
3316
  if (!store.getRun(id))
3251
3317
  return c.json({ error: 'not found' }, 404);
3252
3318
  const parsed = { data: c.req.valid('json') };
3319
+ // The stale-write guard (#250). Everything from here to the writes below is synchronous, so
3320
+ // the check and both writes run in one stretch.
3321
+ const stale = staleRunWrite(store, id, parsed.data.expectedVersion);
3322
+ if (stale)
3323
+ return c.json(stale, 409);
3253
3324
  // The prompt is editable only while the run is still queued (#472). Checked
3254
3325
  // BEFORE the title write so a rejected PATCH is a no-op rather than a partial
3255
3326
  // one. `title` itself keeps working on any status — no regression to #389.
@@ -3275,17 +3346,21 @@ export function createApp(deps) {
3275
3346
  }
3276
3347
  return c.json(store.getRun(id));
3277
3348
  })
3278
- .post('/runs/:id/cancel', (c) => {
3349
+ .post('/runs/:id/cancel', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), (c) => {
3279
3350
  const { store, manager } = c.get('project');
3280
3351
  const id = c.req.param('id');
3281
3352
  if (!store.getRun(id))
3282
3353
  return c.json({ error: 'not found' }, 404);
3354
+ // The stale-write guard (#250): checked in the same synchronous stretch as the write.
3355
+ const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
3356
+ if (stale)
3357
+ return c.json(stale, 409);
3283
3358
  const cancelled = manager.cancel(id);
3284
3359
  return c.json({ cancelled });
3285
3360
  })
3286
3361
  // Live-session participation (spec 002): deliver a user message (text +
3287
3362
  // pasted screenshots) into the run's open claude session.
3288
- .post('/runs/:id/messages', jsonZodValidator(messageSchema), async (c) => {
3363
+ .post('/runs/:id/messages', jsonZodValidator(messageInputSchema), async (c) => {
3289
3364
  const { store, manager } = c.get('project');
3290
3365
  const id = c.req.param('id');
3291
3366
  const run = store.getRun(id);
@@ -3302,6 +3377,11 @@ export function createApp(deps) {
3302
3377
  if (blocked)
3303
3378
  return c.json({ error: blocked }, 409);
3304
3379
  }
3380
+ // The stale-write guard (#250), after the last `await`: from here through the delivery
3381
+ // ladder every step is synchronous, so the check and the delivery run in one stretch.
3382
+ const stale = staleRunWrite(store, id, parsed.data.expectedVersion);
3383
+ if (stale)
3384
+ return c.json(stale, 409);
3305
3385
  const content = [
3306
3386
  ...parsed.data.images.map(toPastedContent),
3307
3387
  ...(parsed.data.text.trim() ? [{ type: 'text', text: parsed.data.text }] : []),
@@ -3344,7 +3424,7 @@ export function createApp(deps) {
3344
3424
  })
3345
3425
  // Edit / remove a stacked message (#472). Registered before any conflicting
3346
3426
  // `/:id` route so `queued-messages` never matches as a run id.
3347
- .patch('/runs/:id/queued-messages/:msgId', jsonZodValidator(queuedMessagePatchSchema), async (c) => {
3427
+ .patch('/runs/:id/queued-messages/:msgId', jsonZodValidator(queuedMessagePatchInputSchema), async (c) => {
3348
3428
  const { store, manager } = c.get('project');
3349
3429
  const id = c.req.param('id');
3350
3430
  const run = store.getRun(id);
@@ -3372,6 +3452,10 @@ export function createApp(deps) {
3372
3452
  error: `prompt too long — ${MAX_FOLDED_TASK_CHARS} character limit across the task and its queued messages (would be ${prospective})`,
3373
3453
  }, 400);
3374
3454
  }
3455
+ // The stale-write guard (#250): checked in the same synchronous stretch as the write.
3456
+ const stale = staleRunWrite(store, id, parsed.data.expectedVersion);
3457
+ if (stale)
3458
+ return c.json(stale, 409);
3375
3459
  const images = parsed.data.images?.map(toPastedContent);
3376
3460
  const message = manager.editQueuedMessage(id, msgId, {
3377
3461
  ...(parsed.data.text !== undefined ? { text: parsed.data.text } : {}),
@@ -3381,7 +3465,7 @@ export function createApp(deps) {
3381
3465
  return c.json({ error: 'run already started' }, 409);
3382
3466
  return c.json({ message });
3383
3467
  })
3384
- .delete('/runs/:id/queued-messages/:msgId', (c) => {
3468
+ .delete('/runs/:id/queued-messages/:msgId', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), (c) => {
3385
3469
  const { store, manager } = c.get('project');
3386
3470
  const id = c.req.param('id');
3387
3471
  const run = store.getRun(id);
@@ -3391,23 +3475,31 @@ export function createApp(deps) {
3391
3475
  if (!(run.queuedMessages ?? []).some((m) => m.id === msgId)) {
3392
3476
  return c.json({ error: 'not found' }, 404);
3393
3477
  }
3478
+ // The stale-write guard (#250): checked in the same synchronous stretch as the write.
3479
+ const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
3480
+ if (stale)
3481
+ return c.json(stale, 409);
3394
3482
  if (!manager.removeQueuedMessage(id, msgId))
3395
3483
  return c.json({ error: 'run already started' }, 409);
3396
3484
  return c.json({ removed: true });
3397
3485
  })
3398
3486
  // "Finish": gracefully close a waiting session — the run completes as done.
3399
- .post('/runs/:id/finish', (c) => {
3487
+ .post('/runs/:id/finish', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), (c) => {
3400
3488
  const { store, manager } = c.get('project');
3401
3489
  const id = c.req.param('id');
3402
3490
  if (!store.getRun(id))
3403
3491
  return c.json({ error: 'not found' }, 404);
3492
+ // The stale-write guard (#250): checked in the same synchronous stretch as the write.
3493
+ const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
3494
+ if (stale)
3495
+ return c.json(stale, 409);
3404
3496
  const finished = manager.finish(id);
3405
3497
  if (!finished)
3406
3498
  return c.json({ error: 'no open session' }, 409);
3407
3499
  return c.json({ finished: true });
3408
3500
  })
3409
3501
  // "Continue" (spec 003): reopen a finished run's session in-process.
3410
- .post('/runs/:id/continue', jsonZodValidator(continueSchema, { absent: ({}) }), async (c) => {
3502
+ .post('/runs/:id/continue', jsonZodValidator(continueRunInputSchema, { absent: ({}) }), async (c) => {
3411
3503
  const { root: repoRoot, store, manager } = c.get('project');
3412
3504
  const id = c.req.param('id');
3413
3505
  const run = store.getRun(id);
@@ -3432,6 +3524,11 @@ export function createApp(deps) {
3432
3524
  if ('error' in account)
3433
3525
  return c.json({ error: account.error }, 400);
3434
3526
  }
3527
+ // The stale-write guard (#250), after the last `await`, so the check and the reopen run in
3528
+ // one synchronous stretch.
3529
+ const stale = staleRunWrite(store, id, parsed.data.expectedVersion);
3530
+ if (stale)
3531
+ return c.json(stale, 409);
3435
3532
  const result = manager.continueRun(id, {
3436
3533
  text: parsed.data.text,
3437
3534
  images: parsed.data.images?.map(toPastedContent),
@@ -3635,20 +3732,25 @@ export function createApp(deps) {
3635
3732
  })
3636
3733
  // Task diff (spec 006): what this run changed — its worktree vs its base.
3637
3734
  .get('/runs/:id/diff', async (c) => {
3638
- const { store } = c.get('project');
3639
- const run = store.getRun(c.req.param('id'));
3640
- if (!run)
3641
- return c.json({ error: 'not found' }, 404);
3735
+ const run = await ownedRun(c.get('project'), c.req.param('id'));
3736
+ if (!run) {
3737
+ return c.get('project').store.getRun(c.req.param('id'))
3738
+ ? c.json({ error: FOREIGN_WORKTREE }, 409)
3739
+ : c.json({ error: 'not found' }, 404);
3740
+ }
3642
3741
  if (!run.worktreePath || !existsSync(run.worktreePath)) {
3643
3742
  return c.text('(no worktree — this task ran directly in the repo working tree)');
3644
3743
  }
3645
3744
  return c.text(await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD'));
3646
3745
  })
3647
3746
  .get('/runs/:id/changes', async (c) => {
3648
- const { root: repoRoot, store } = c.get('project');
3649
- const run = store.getRun(c.req.param('id'));
3650
- if (!run)
3651
- return c.json({ error: 'not found' }, 404);
3747
+ const { root: repoRoot } = c.get('project');
3748
+ const run = await ownedRun(c.get('project'), c.req.param('id'));
3749
+ if (!run) {
3750
+ return c.get('project').store.getRun(c.req.param('id'))
3751
+ ? c.json({ error: FOREIGN_WORKTREE }, 409)
3752
+ : c.json({ error: 'not found' }, 404);
3753
+ }
3652
3754
  const workingDirectory = workingDirectoryOf(run, repoRoot);
3653
3755
  if (!workingDirectory)
3654
3756
  return c.json({ error: NO_WORKTREE }, 409);
@@ -3760,28 +3862,36 @@ export function createApp(deps) {
3760
3862
  ...(result.content !== undefined ? { content: result.content } : {}),
3761
3863
  });
3762
3864
  })
3763
- .post('/runs/:id/git/commit', jsonZodValidator(gitCommitSchema), async (c) => {
3865
+ .post('/runs/:id/git/commit', jsonZodValidator(gitCommitInputSchema), async (c) => {
3764
3866
  const { store } = c.get('project');
3765
- const run = store.getRun(c.req.param('id'));
3867
+ const run = await ownedRun(c.get('project'), c.req.param('id'));
3766
3868
  if (!run)
3767
- return c.json({ error: 'not found' }, 404);
3869
+ return store.getRun(c.req.param('id')) ? c.json({ error: FOREIGN_WORKTREE }, 409) : c.json({ error: 'not found' }, 404);
3768
3870
  const worktree = worktreeOf(run);
3769
3871
  if (!worktree)
3770
3872
  return c.json({ error: NO_WORKTREE }, 409);
3771
3873
  const parsed = { data: c.req.valid('json') };
3874
+ // The stale-write guard (#250), right before the (async) effect starts.
3875
+ const stale = staleRunWrite(store, run.id, parsed.data.expectedVersion);
3876
+ if (stale)
3877
+ return c.json(stale, 409);
3772
3878
  const result = await commitAll(worktree, parsed.data.message);
3773
3879
  if (!result.ok)
3774
3880
  return c.json({ error: result.error }, 409);
3775
3881
  return c.json({ committed: true, sha: result.sha });
3776
3882
  })
3777
- .post('/runs/:id/git/push', async (c) => {
3883
+ .post('/runs/:id/git/push', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), async (c) => {
3778
3884
  const { root: repoRoot, store } = c.get('project');
3779
- const run = store.getRun(c.req.param('id'));
3885
+ const run = await ownedRun(c.get('project'), c.req.param('id'));
3780
3886
  if (!run)
3781
- return c.json({ error: 'not found' }, 404);
3887
+ return store.getRun(c.req.param('id')) ? c.json({ error: FOREIGN_WORKTREE }, 409) : c.json({ error: 'not found' }, 404);
3782
3888
  const worktree = worktreeOf(run);
3783
3889
  if (!worktree)
3784
3890
  return c.json({ error: NO_WORKTREE }, 409);
3891
+ // The stale-write guard (#250), right before the (async) effect starts.
3892
+ const stale = staleRunWrite(store, run.id, c.req.valid('json').expectedVersion);
3893
+ if (stale)
3894
+ return c.json(stale, 409);
3785
3895
  const result = await pushCurrentBranch(worktree);
3786
3896
  if (!result.ok)
3787
3897
  return c.json({ error: result.error }, 409);
@@ -3803,12 +3913,12 @@ export function createApp(deps) {
3803
3913
  // `gh pr create --draft`; on success the run completes as done with the PR
3804
3914
  // badge. Failures come back as 409 with a `manual` merge command the GUI
3805
3915
  // shows next to the toast. XEZ_DRY_RUN=1 fakes the URL (no push, no gh).
3806
- .post('/runs/:id/pr', async (c) => {
3916
+ .post('/runs/:id/pr', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), async (c) => {
3807
3917
  const { root: repoRoot, dataDir, store, manager } = c.get('project');
3808
3918
  const id = c.req.param('id');
3809
- const run = store.getRun(id);
3919
+ const run = await ownedRun(c.get('project'), id);
3810
3920
  if (!run)
3811
- return c.json({ error: 'not found' }, 404);
3921
+ return store.getRun(id) ? c.json({ error: FOREIGN_WORKTREE }, 409) : c.json({ error: 'not found' }, 404);
3812
3922
  if (manager.isActive(id))
3813
3923
  return c.json({ error: 'run is still active — wait for the review gate' }, 409);
3814
3924
  if (!run.worktreePath || !existsSync(run.worktreePath) || !run.branch) {
@@ -3816,6 +3926,10 @@ export function createApp(deps) {
3816
3926
  error: 'no worktree/branch to publish — this task ran in the repo working tree',
3817
3927
  }, 400);
3818
3928
  }
3929
+ // The stale-write guard (#250), right before the (async) effect starts.
3930
+ const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
3931
+ if (stale)
3932
+ return c.json(stale, 409);
3819
3933
  const outcome = await createDraftPr({
3820
3934
  repoRoot,
3821
3935
  run,
@@ -3843,43 +3957,56 @@ export function createApp(deps) {
3843
3957
  })
3844
3958
  // Archived tasks keep their worktree for inspection; this is the explicit
3845
3959
  // "🧹 Remove worktree" cleanup (spec 006).
3846
- .post('/runs/:id/remove-worktree', async (c) => {
3960
+ .post('/runs/:id/remove-worktree', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), async (c) => {
3847
3961
  const { root: repoRoot, store, manager } = c.get('project');
3848
3962
  const id = c.req.param('id');
3849
- const run = store.getRun(id);
3963
+ const run = await ownedRun(c.get('project'), id);
3850
3964
  if (!run)
3851
- return c.json({ error: 'not found' }, 404);
3965
+ return store.getRun(id) ? c.json({ error: FOREIGN_WORKTREE }, 409) : c.json({ error: 'not found' }, 404);
3852
3966
  if (manager.isActive(id))
3853
3967
  return c.json({ error: 'run is active — cancel it first' }, 409);
3968
+ // The stale-write guard (#250), right before the (async) effect starts.
3969
+ const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
3970
+ if (stale)
3971
+ return c.json(stale, 409);
3854
3972
  if (run.worktreePath)
3855
3973
  await removeWorktree(repoRoot, run.worktreePath, run.branch);
3856
3974
  store.updateRun(id, { worktreePath: undefined, branch: undefined });
3857
3975
  return c.json({ removed: true });
3858
3976
  })
3859
- .delete('/runs/:id', async (c) => {
3977
+ .delete('/runs/:id', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), async (c) => {
3860
3978
  const { root: repoRoot, store, manager } = c.get('project');
3861
3979
  const id = c.req.param('id');
3862
3980
  if (manager.isActive(id))
3863
3981
  return c.json({ error: 'run is active — cancel it first' }, 409);
3864
- const run = store.getRun(id);
3982
+ const run = await ownedRun(c.get('project'), id);
3865
3983
  if (!run)
3866
- return c.json({ error: 'not found' }, 404);
3984
+ return store.getRun(id) ? c.json({ error: FOREIGN_WORKTREE }, 409) : c.json({ error: 'not found' }, 404);
3985
+ // The stale-write guard (#250), right before the (async) effect starts.
3986
+ const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
3987
+ if (stale)
3988
+ return c.json(stale, 409);
3867
3989
  // Delete cleans up after itself: worktree + branch go with the run (spec 006).
3868
3990
  if (run.worktreePath)
3869
3991
  await removeWorktree(repoRoot, run.worktreePath, run.branch);
3870
3992
  return store.deleteRun(id) ? c.json({ deleted: true }) : c.json({ error: 'not found' }, 404);
3871
3993
  });
3872
3994
  // ---- parallel variants (spec 010) -----------------------------------------
3873
- const groupRuns = (store, groupId) => store
3874
- .listRuns()
3875
- .filter((r) => r.groupId === groupId)
3876
- .sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
3995
+ // A group's members, in variant order — but only when EVERY member is this project's (#288).
3996
+ // A member's `worktreePath` is data: a copied or hand-edited `.local/xezar` can name another
3997
+ // project's worktree, and the read runs git inside each member's path while the pick deletes the
3998
+ // losers'. `ownGroup` refuses such a group whole, exactly as it does for MCP (M-07), and the
3999
+ // refusal is the same 404 an unknown group gets, so it names nothing of the other project.
4000
+ const ownedGroupRuns = async (project, groupId) => {
4001
+ const owned = await ownGroup(ownershipScope(project), groupId);
4002
+ return owned.ok ? owned.value.runs : null;
4003
+ };
3877
4004
  // ---- chained family: variant groups (project-scoped) ----
3878
4005
  const groupsRoutes = new Hono()
3879
4006
  .get('/groups/:groupId', async (c) => {
3880
- const { dataDir, store } = c.get('project');
3881
- const runs = groupRuns(store, c.req.param('groupId'));
3882
- if (runs.length === 0)
4007
+ const { dataDir } = c.get('project');
4008
+ const runs = await ownedGroupRuns(c.get('project'), c.req.param('groupId'));
4009
+ if (!runs)
3883
4010
  return c.json({ error: 'not found' }, 404);
3884
4011
  const detailed = await Promise.all(runs.map(async (r) => ({
3885
4012
  id: r.id,
@@ -3904,10 +4031,10 @@ export function createApp(deps) {
3904
4031
  // "Pick this one": the winner rests at `review` (spec 009 takes it from
3905
4032
  // there — send back / draft PR / finish); the losers are cancelled if
3906
4033
  // alive, archived, and their worktrees + branches removed.
3907
- .post('/groups/:groupId/pick', jsonZodValidator(pickSchema), async (c) => {
4034
+ .post('/groups/:groupId/pick', jsonZodValidator(pickVariantInputSchema), async (c) => {
3908
4035
  const { root: repoRoot, dataDir, store, manager } = c.get('project');
3909
- const runs = groupRuns(store, c.req.param('groupId'));
3910
- if (runs.length === 0)
4036
+ const runs = await ownedGroupRuns(c.get('project'), c.req.param('groupId'));
4037
+ if (!runs)
3911
4038
  return c.json({ error: 'not found' }, 404);
3912
4039
  const parsed = { data: c.req.valid('json') };
3913
4040
  const winner = runs.find((r) => r.id === parsed.data.runId);
@@ -3921,16 +4048,22 @@ export function createApp(deps) {
3921
4048
  // (#489): it is enabled (`reviewGateEnabled`, default off) AND the winner is
3922
4049
  // not autonomous. An autonomous / gate-off winner keeps its `done` state with
3923
4050
  // the diff left in the worktree; an empty diff (or no worktree) stays too.
4051
+ let toReview = false;
3924
4052
  if (winner.status !== 'review' &&
3925
4053
  winner.worktreePath &&
3926
4054
  existsSync(winner.worktreePath) &&
3927
4055
  winner.autonomous !== true &&
3928
4056
  reviewGateEnabled(await loadConfig(repoRoot))) {
3929
4057
  const diff = await worktreeDiff(winner.worktreePath, winner.baseBranch ?? 'HEAD');
3930
- if (diff.trim().length > 0 && !diff.startsWith('(diff failed')) {
3931
- store.updateRun(winner.id, { status: 'review' });
3932
- }
3933
- }
4058
+ toReview = diff.trim().length > 0 && !diff.startsWith('(diff failed');
4059
+ }
4060
+ // The stale-write guard (#250, #271) on the kept variant, after the reads above and right
4061
+ // before the first effect: the losers' worktrees and branches are deleted below, for good.
4062
+ const stale = staleRunWrite(store, winner.id, parsed.data.expectedVersion);
4063
+ if (stale)
4064
+ return c.json(stale, 409);
4065
+ if (toReview)
4066
+ store.updateRun(winner.id, { status: 'review' });
3934
4067
  const losers = runs.filter((r) => r.id !== winner.id);
3935
4068
  store.appendEvent(winner.id, {
3936
4069
  type: 'lifecycle',
@@ -4524,6 +4657,25 @@ export function createApp(deps) {
4524
4657
  ...(result.code ? { code: result.code } : {}),
4525
4658
  ...(result.current ? { current: result.current } : {}),
4526
4659
  }, result.status);
4660
+ })
4661
+ // Mark a draft pull request ready for review (#262). No cockpit control yet: the leader's
4662
+ // `handoff_git ready` is the caller. The forge re-reads the pull request and refuses a moved head.
4663
+ .post('/github/prs/:number/ready', paramZodValidator(mergeNumberParams, { message: 'invalid pull request number' }), jsonZodValidator(githubPrReadyInputSchema, { message: 'invalid ready request' }), async (c) => {
4664
+ const { root: repoRoot } = c.get('project');
4665
+ const { number } = c.req.valid('param');
4666
+ const forge = resolveForge(await getRepoInfo(repoRoot));
4667
+ if (!forge?.markReady)
4668
+ return c.json({ error: 'GitHub ready-for-review is unavailable' }, 409);
4669
+ const result = await forge.markReady(number, c.req.valid('json'));
4670
+ if (result.ready) {
4671
+ forgetRefStatus(repoRoot, number);
4672
+ return c.json({ ready: true, number: result.number, url: result.url });
4673
+ }
4674
+ return c.json({
4675
+ error: result.error,
4676
+ ...(result.code ? { code: result.code } : {}),
4677
+ ...(result.current ? { current: result.current } : {}),
4678
+ }, result.status);
4527
4679
  })
4528
4680
  .get('/github/prs/:number/changes',
4529
4681
  // Split out of one `safeParse` over both inputs, because a path param and the query string
@@ -4708,6 +4860,8 @@ export function createApp(deps) {
4708
4860
  catch {
4709
4861
  // missing or malformed — start fresh
4710
4862
  }
4863
+ // What the file held before, per key — so E-05 names only the keys this write really changed.
4864
+ const before = new Map(Object.entries(raw).map(([key, value]) => [key, JSON.stringify(value)]));
4711
4865
  if (parsed.data.baseBranch !== undefined) {
4712
4866
  if (parsed.data.baseBranch === null)
4713
4867
  delete raw.baseBranch;
@@ -4808,6 +4962,10 @@ export function createApp(deps) {
4808
4962
  // B2 exists to fix.
4809
4963
  if (parsed.data.memoryLimitMb !== undefined)
4810
4964
  await deps.semaphore?.refresh();
4965
+ // E-05 (#252): key NAMES only, never values. A write that changed nothing reports nothing.
4966
+ const changed = [...new Set([...before.keys(), ...Object.keys(raw)])].filter((key) => before.get(key) !== (key in raw ? JSON.stringify(raw[key]) : undefined));
4967
+ if (changed.length > 0)
4968
+ reportProjectChange(c.get('project').id, (catalog) => catalog.configChanged({ keys: changed }));
4811
4969
  // Pre-R6 answer shape ({baseBranch, defaultRunner}) + additive R6 fields.
4812
4970
  return c.json(await configAnswer(repoRoot, await loadConfig(repoRoot)));
4813
4971
  });
@@ -4902,8 +5060,53 @@ export function createApp(deps) {
4902
5060
  return c.json({ error: 'unknown config file' }, 404);
4903
5061
  if (!out.ok)
4904
5062
  return c.json({ error: out.error }, out.status);
5063
+ // E-05 (#252), after the hosted-mode 409 above and never before it: a refused write is no change.
5064
+ reportProjectChange(c.get('project').id, (catalog) => catalog.agentConfigChanged({ id: c.req.param('id') }));
4905
5065
  return c.json(out.read);
4906
5066
  });
5067
+ // ---- chained family: the MCP API reference (project-scoped, read-only) ----
5068
+ // #284, spec `mcp-api-reference-spec.md` § 11. The tool registry is loaded LAZILY, as
5069
+ // `startMcpSocket` loads the service, so the cockpit's static import graph never includes it
5070
+ // and a broken MCP module answers `{available: false, reason}` instead of breaking the app.
5071
+ // It does NOT need the MCP service: the listing is static code, and this page is most needed
5072
+ // on a machine where MCP did not start (N-07). The list is fixed for the life of the process
5073
+ // (`listChanged: false`), so the answer is built once. A GET with no side effect — and there
5074
+ // is deliberately no route that runs a tool from here (spec § 13).
5075
+ let mcpReference;
5076
+ const readMcpReference = () => (mcpReference ??= import('../mcp/api-reference.js')
5077
+ .then((m) => m.buildMcpApiReference(version))
5078
+ .catch((err) => {
5079
+ const message = err instanceof Error ? err.message : String(err);
5080
+ console.warn(`[xez] MCP API reference unavailable (${message}) — the cockpit works without it`);
5081
+ return { available: false, reason: `The MCP tool list could not be loaded: ${message}` };
5082
+ }));
5083
+ const mcpReferenceRoutes = new Hono().get('/mcp/reference', async (c) => c.json(await readMcpReference()));
5084
+ // ---- chained family: the MCP leader's push-delivery path (project-scoped) ----
5085
+ // #309. Delivery itself is on by default and needs no route: every MCP session that owns the
5086
+ // project gets an event controller when it opens. The route reads that state, and lets the person
5087
+ // point xezar at the leader they run — `attach` an OpenCode `serve` session — or detach it. xezar
5088
+ // starts no agent process (owner decision on #311); there is no `start` or `resume`. It runs no
5089
+ // tool, so the cockpit never becomes a second leader (spec `mcp-api-reference-spec.md` § 13).
5090
+ // Attaching makes this process talk to a local server by URL: a local-machine capability, so
5091
+ // hosted mode refuses it like every other local mutator.
5092
+ const mcpLeaderStatus = (projectId) => projectLeader(projectId)?.status() ?? {
5093
+ available: false,
5094
+ reason: 'The MCP service is not running for this project, so there is no event delivery to report.',
5095
+ };
5096
+ const mcpLeaderRoutes = new Hono()
5097
+ .get('/mcp/leader', (c) => c.json(mcpLeaderStatus(c.get('project').id)))
5098
+ .post('/mcp/leader', jsonZodValidator(() => mcpLeaderActionInputSchema), async (c) => {
5099
+ if (!capabilities().localHandoff) {
5100
+ return c.json({ error: 'a leader session is attached from the machine that owns the checkout (this cockpit runs in hosted mode)' }, 409);
5101
+ }
5102
+ const port = projectLeader(c.get('project').id);
5103
+ if (!port)
5104
+ return c.json({ error: 'the MCP service is not running for this project' }, 409);
5105
+ const out = await port.act(c.req.valid('json'));
5106
+ if (!out.ok)
5107
+ return c.json({ error: out.error }, 409);
5108
+ return c.json(out.status);
5109
+ });
4907
5110
  // Repo view branch actions: switch to an existing branch, or create one
4908
5111
  // (from `from` or HEAD) and switch. Predictable git failures — invalid
4909
5112
  // name, unknown `from`, dirty-tree checkout conflict — are 409 + reason.
@@ -4939,7 +5142,9 @@ export function createApp(deps) {
4939
5142
  .route('/', githubRoutes)
4940
5143
  .route('/', repoRoutes)
4941
5144
  .route('/', configRoutes)
4942
- .route('/', agentConfigRoutes);
5145
+ .route('/', agentConfigRoutes)
5146
+ .route('/', mcpReferenceRoutes)
5147
+ .route('/', mcpLeaderRoutes);
4943
5148
  // ---- chained family: the cross-project run index (workspace-level) -------
4944
5149
  /**
4945
5150
  * How many runs each project may contribute, newest first. The index is a FINDER, not a
@@ -5209,6 +5414,7 @@ export function startServer(deps, port) {
5209
5414
  socketHub,
5210
5415
  automationsChanged: () => rescheduleAutomations(),
5211
5416
  });
5417
+ deps.onApp?.(app);
5212
5418
  // SECURITY: default to loopback. This server executes agents locally and its endpoints are
5213
5419
  // same-origin-trusted (only /api/health is CORS-open); binding to a non-loopback host would
5214
5420
  // expose an agent-executing box to the network. `bindHost` exists only for a deliberate