@gajae-code/coding-agent 0.14.2 → 0.15.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 (345) hide show
  1. package/CHANGELOG.md +120 -1
  2. package/dist/types/async/job-manager.d.ts +4 -1
  3. package/dist/types/autoresearch/dashboard.d.ts +29 -4
  4. package/dist/types/autoresearch/data-context.d.ts +25 -0
  5. package/dist/types/autoresearch/git.d.ts +65 -7
  6. package/dist/types/autoresearch/harness.d.ts +58 -0
  7. package/dist/types/autoresearch/prompts.d.ts +51 -0
  8. package/dist/types/autoresearch/runs.d.ts +173 -0
  9. package/dist/types/autoresearch/types.d.ts +9 -139
  10. package/dist/types/capability/types.d.ts +20 -0
  11. package/dist/types/cli/accounts-cli.d.ts +1 -0
  12. package/dist/types/commands/{rlm.d.ts → autoresearch.d.ts} +2 -4
  13. package/dist/types/config/autorouting-contract.d.ts +146 -0
  14. package/dist/types/config/autorouting-generator.d.ts +40 -0
  15. package/dist/types/config/autorouting-tier-map.d.ts +16844 -0
  16. package/dist/types/config/autorouting.d.ts +35 -0
  17. package/dist/types/config/model-discovery-manager.d.ts +8 -1
  18. package/dist/types/config/model-registry.d.ts +30 -1
  19. package/dist/types/config/provider-selection-policy.d.ts +23 -0
  20. package/dist/types/config/settings-schema.d.ts +61 -3
  21. package/dist/types/config/settings.d.ts +3 -0
  22. package/dist/types/coordinator/contract.d.ts +1 -1
  23. package/dist/types/coordinator-mcp/durability.d.ts +29 -0
  24. package/dist/types/coordinator-mcp/event-webhook.d.ts +50 -0
  25. package/dist/types/coordinator-mcp/question-state.d.ts +1 -1
  26. package/dist/types/coordinator-mcp/server.d.ts +14 -0
  27. package/dist/types/crash/index-store.d.ts +3 -0
  28. package/dist/types/crash/record-loader.d.ts +2 -0
  29. package/dist/types/crash/upstream/relay.d.ts +5 -3
  30. package/dist/types/cursor.d.ts +12 -0
  31. package/dist/types/defaults/gjc-defaults.d.ts +35 -1
  32. package/dist/types/defaults/gjc-skills.generated.d.ts +1 -1
  33. package/dist/types/discovery/helpers.d.ts +1 -1
  34. package/dist/types/edit/index.d.ts +2 -1
  35. package/dist/types/eval/py/executor.d.ts +2 -0
  36. package/dist/types/extensibility/extensions/loader.d.ts +32 -0
  37. package/dist/types/extensibility/gjc-plugins/types.d.ts +1 -1
  38. package/dist/types/extensibility/skills.d.ts +2 -0
  39. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +194 -0
  40. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  41. package/dist/types/gjc-runtime/psmux-detect.d.ts +1 -1
  42. package/dist/types/gjc-runtime/python-transcript.d.ts +19 -0
  43. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +2 -8
  44. package/dist/types/gjc-runtime/session-layout.d.ts +6 -3
  45. package/dist/types/gjc-runtime/state-renderer.d.ts +0 -22
  46. package/dist/types/gjc-runtime/state-schema.d.ts +7 -7
  47. package/dist/types/gjc-runtime/tmux-common.d.ts +2 -2
  48. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +38 -1
  49. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -24
  50. package/dist/types/gjc-runtime/ultragoal-validation-policy.d.ts +73 -0
  51. package/dist/types/gjc-runtime/workflow-recovery-projection.d.ts +100 -0
  52. package/dist/types/hooks/skill-keywords.d.ts +1 -1
  53. package/dist/types/main.d.ts +0 -10
  54. package/dist/types/modes/acp/acp-agent.d.ts +13 -6
  55. package/dist/types/modes/components/model-selector.d.ts +14 -0
  56. package/dist/types/modes/components/skill-hud/render.d.ts +6 -1
  57. package/dist/types/modes/components/smart-routing-panel.d.ts +82 -0
  58. package/dist/types/modes/controllers/selector-controller.d.ts +14 -0
  59. package/dist/types/modes/interactive-mode.d.ts +3 -0
  60. package/dist/types/modes/types.d.ts +9 -0
  61. package/dist/types/rlm/artifacts.d.ts +4 -4
  62. package/dist/types/runtime/optional-runtime-services.d.ts +2 -1
  63. package/dist/types/runtime/workspace-tree-service.d.ts +5 -1
  64. package/dist/types/runtime-mcp/config.d.ts +6 -0
  65. package/dist/types/runtime-mcp/manager.d.ts +10 -0
  66. package/dist/types/sdk/acp/adapter.d.ts +0 -4
  67. package/dist/types/sdk/broker/broker.d.ts +1 -1
  68. package/dist/types/sdk/broker/lifecycle.d.ts +57 -2
  69. package/dist/types/sdk/broker/session-index.d.ts +15 -0
  70. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -0
  71. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
  72. package/dist/types/sdk/bus/index.d.ts +8 -0
  73. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +2 -0
  74. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -2
  75. package/dist/types/sdk/client/client.d.ts +39 -4
  76. package/dist/types/sdk/host/host.d.ts +1 -0
  77. package/dist/types/sdk/host/internal-autorouting-state.d.ts +3 -0
  78. package/dist/types/sdk/host/model-pin.d.ts +29 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +2 -1
  80. package/dist/types/sdk/lifecycle-session.d.ts +13 -0
  81. package/dist/types/sdk/router/session-router.d.ts +27 -1
  82. package/dist/types/sdk/session.d.ts +11 -1
  83. package/dist/types/session/agent-session.d.ts +36 -6
  84. package/dist/types/session/artifacts.d.ts +16 -1
  85. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  86. package/dist/types/session/session-manager.d.ts +84 -2
  87. package/dist/types/session/session-staging-paths.d.ts +3 -0
  88. package/dist/types/skill-state/canonical-skills.d.ts +1 -1
  89. package/dist/types/skill-state/workflow-hud.d.ts +12 -21
  90. package/dist/types/skill-state/workflow-mutation-guard.d.ts +1 -0
  91. package/dist/types/slash-commands/helpers/autorouting-status.d.ts +8 -0
  92. package/dist/types/system-prompt.d.ts +23 -0
  93. package/dist/types/task/executor.d.ts +35 -4
  94. package/dist/types/task/index.d.ts +17 -3
  95. package/dist/types/task/receipt.d.ts +3 -1
  96. package/dist/types/task/types.d.ts +144 -0
  97. package/dist/types/tools/atomic-file-write.d.ts +72 -0
  98. package/dist/types/tools/descriptors.d.ts +1 -0
  99. package/dist/types/tools/index.d.ts +36 -1
  100. package/dist/types/tools/move-session.d.ts +40 -0
  101. package/dist/types/tools/python.d.ts +12 -0
  102. package/dist/types/tools/resource-gc.d.ts +0 -3
  103. package/dist/types/tools/search.d.ts +8 -1
  104. package/dist/types/tools/skill.d.ts +9 -4
  105. package/dist/types/tools/subagent.d.ts +33 -3
  106. package/dist/types/utils/xml-escape.d.ts +2 -0
  107. package/dist/types/workflow/workflow-intent-diff.d.ts +2 -2
  108. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +1 -1
  109. package/package.json +11 -12
  110. package/scripts/build-sdk-package-smoke.ts +7 -5
  111. package/scripts/check-autorouting-tier-map.ts +183 -0
  112. package/scripts/generate-sdk-operation-inventory.ts +12 -0
  113. package/scripts/generate-tool-catalog.ts +40 -5
  114. package/src/async/job-manager.ts +5 -2
  115. package/src/autoresearch/dashboard.ts +134 -314
  116. package/src/autoresearch/data-context.ts +35 -0
  117. package/src/autoresearch/git.ts +186 -26
  118. package/src/autoresearch/{helpers.ts → harness.ts} +65 -40
  119. package/src/autoresearch/{prompt-setup.md → prompts/prompt-setup.md} +7 -7
  120. package/src/autoresearch/prompts/prompt.md +103 -0
  121. package/src/autoresearch/prompts.ts +126 -0
  122. package/src/autoresearch/runs.ts +708 -0
  123. package/src/autoresearch/types.ts +9 -150
  124. package/src/capability/index.ts +4 -4
  125. package/src/capability/types.ts +20 -0
  126. package/src/cli/accounts-cli.ts +21 -5
  127. package/src/cli/config-cli.ts +25 -1
  128. package/src/cli/fast-help.ts +1 -2
  129. package/src/cli/list-models.ts +5 -8
  130. package/src/cli/setup-cli.ts +8 -0
  131. package/src/cli.ts +2 -21
  132. package/src/commands/autoresearch.ts +33 -0
  133. package/src/commands/coordinator.ts +15 -0
  134. package/src/commands/launch.ts +2 -4
  135. package/src/commands/quick-lane.ts +1 -1
  136. package/src/commands/sdk.ts +24 -0
  137. package/src/commands/state.ts +1 -1
  138. package/src/config/autorouting-contract.ts +474 -0
  139. package/src/config/autorouting-generator.ts +238 -0
  140. package/src/config/autorouting-tier-map.ts +6108 -0
  141. package/src/config/autorouting.ts +120 -0
  142. package/src/config/model-discovery-manager.ts +42 -11
  143. package/src/config/model-registry.ts +348 -68
  144. package/src/config/provider-selection-policy.ts +60 -9
  145. package/src/config/settings-schema.ts +195 -6
  146. package/src/config/settings.ts +62 -1
  147. package/src/config.ts +2 -3
  148. package/src/coordinator/contract.ts +0 -1
  149. package/src/coordinator-mcp/codex-handoff.ts +68 -42
  150. package/src/coordinator-mcp/durability.ts +200 -0
  151. package/src/coordinator-mcp/event-webhook.ts +278 -0
  152. package/src/coordinator-mcp/question-state.ts +109 -46
  153. package/src/coordinator-mcp/server.ts +932 -504
  154. package/src/crash/index-store.ts +126 -22
  155. package/src/crash/record-loader.ts +7 -0
  156. package/src/crash/upstream/relay.ts +82 -34
  157. package/src/cursor.ts +84 -1
  158. package/src/debug/index.ts +26 -0
  159. package/src/defaults/gjc/skills/autoresearch/SKILL.md +116 -0
  160. package/src/defaults/gjc/skills/autoresearch/auto-critic.md +46 -0
  161. package/src/defaults/gjc/skills/autoresearch/auto-iterate.md +42 -0
  162. package/src/defaults/gjc/skills/deep-interview/SKILL.md +17 -37
  163. package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -18
  164. package/src/defaults/gjc/skills/ultragoal/SKILL.md +6 -17
  165. package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +2 -2
  166. package/src/defaults/gjc-defaults.ts +89 -2
  167. package/src/defaults/gjc-skills.generated.ts +24 -17
  168. package/src/discovery/builtin.ts +18 -5
  169. package/src/discovery/helpers.ts +6 -6
  170. package/src/edit/index.ts +5 -2
  171. package/src/eval/py/executor.ts +8 -0
  172. package/src/extensibility/extensions/loader.ts +135 -13
  173. package/src/extensibility/extensions/wrapper.ts +1 -2
  174. package/src/extensibility/gjc-plugins/schema.ts +1 -1
  175. package/src/extensibility/runtime-skill-discovery.ts +2 -2
  176. package/src/extensibility/skill-management.ts +2 -3
  177. package/src/extensibility/skills.ts +16 -8
  178. package/src/gjc-runtime/autoresearch-runtime.ts +1509 -0
  179. package/src/gjc-runtime/gc-render.ts +0 -1
  180. package/src/gjc-runtime/gc-runtime.ts +1 -11
  181. package/src/gjc-runtime/launch-tmux.ts +3 -3
  182. package/src/gjc-runtime/psmux-detect.ts +2 -2
  183. package/src/gjc-runtime/python-transcript.ts +58 -0
  184. package/src/gjc-runtime/ralplan-runtime.ts +6 -18
  185. package/src/gjc-runtime/session-layout.ts +18 -7
  186. package/src/gjc-runtime/session-state-sidecar.ts +58 -9
  187. package/src/gjc-runtime/state-renderer.ts +0 -38
  188. package/src/gjc-runtime/state-runtime.ts +43 -22
  189. package/src/gjc-runtime/state-schema.ts +1 -1
  190. package/src/gjc-runtime/tmux-common.ts +7 -7
  191. package/src/gjc-runtime/ultragoal-change-set.ts +232 -11
  192. package/src/gjc-runtime/ultragoal-runtime.ts +309 -114
  193. package/src/gjc-runtime/ultragoal-validation-policy.ts +247 -0
  194. package/src/gjc-runtime/workflow-command-ref.ts +15 -11
  195. package/src/gjc-runtime/workflow-manifest.generated.json +413 -532
  196. package/src/gjc-runtime/workflow-manifest.ts +43 -83
  197. package/src/gjc-runtime/workflow-recovery-projection.ts +616 -0
  198. package/src/hooks/skill-keywords.ts +3 -3
  199. package/src/hooks/skill-state.ts +2 -2
  200. package/src/internal-urls/docs-index.generated.ts +26 -24
  201. package/src/lsp/index.ts +25 -5
  202. package/src/main.ts +2 -27
  203. package/src/modes/acp/acp-agent.ts +200 -75
  204. package/src/modes/components/model-selector.ts +216 -13
  205. package/src/modes/components/oauth-selector.ts +5 -5
  206. package/src/modes/components/skill-hud/render.ts +173 -35
  207. package/src/modes/components/smart-routing-panel.ts +531 -0
  208. package/src/modes/components/tool-status-header.ts +6 -3
  209. package/src/modes/controllers/command-controller.ts +17 -7
  210. package/src/modes/controllers/input-controller.ts +44 -9
  211. package/src/modes/controllers/runtime-mcp-command-controller.ts +4 -0
  212. package/src/modes/controllers/selector-controller.ts +298 -65
  213. package/src/modes/interactive-mode.ts +8 -1
  214. package/src/modes/shared/agent-wire/workflow-gate-types.ts +1 -1
  215. package/src/modes/types.ts +9 -1
  216. package/src/prompts/agents/planner.md +1 -1
  217. package/src/prompts/system/system-prompt.md +1 -1
  218. package/src/prompts/system/volatile-project-context.md +2 -1
  219. package/src/prompts/tools/move-session.md +15 -0
  220. package/src/prompts/tools/python.md +12 -0
  221. package/src/prompts/tools/skill-discovery.md +1 -1
  222. package/src/prompts/tools/skill.md +2 -2
  223. package/src/prompts/tools/task-summary.md +1 -0
  224. package/src/prompts/tools/task.md +6 -0
  225. package/src/rlm/artifacts.ts +12 -10
  226. package/src/runtime/optional-runtime-services.ts +3 -2
  227. package/src/runtime/workspace-tree-service.ts +11 -3
  228. package/src/runtime-mcp/config.ts +8 -1
  229. package/src/runtime-mcp/manager.ts +33 -0
  230. package/src/sdk/acp/adapter.ts +48 -1
  231. package/src/sdk/broker/broker.ts +30 -2
  232. package/src/sdk/broker/discovery.ts +217 -2
  233. package/src/sdk/broker/lifecycle.ts +385 -59
  234. package/src/sdk/broker/session-index.ts +243 -48
  235. package/src/sdk/broker/transport.ts +1 -0
  236. package/src/sdk/bus/chat-daemon-control.ts +8 -2
  237. package/src/sdk/bus/chat-daemon-runtime.ts +5 -0
  238. package/src/sdk/bus/index.ts +208 -11
  239. package/src/sdk/bus/kind-aware-reconciliation.ts +16 -0
  240. package/src/sdk/bus/reconciliation-store.ts +27 -5
  241. package/src/sdk/bus/telegram-daemon-contract.ts +12 -2
  242. package/src/sdk/client/client.ts +204 -22
  243. package/src/sdk/host/host.ts +16 -0
  244. package/src/sdk/host/internal-autorouting-state.ts +13 -0
  245. package/src/sdk/host/model-pin.ts +101 -0
  246. package/src/sdk/host/reverse-leases.ts +2 -1
  247. package/src/sdk/host/session-runtime.test.ts +224 -1
  248. package/src/sdk/host/session-runtime.ts +125 -37
  249. package/src/sdk/lifecycle/service.ts +3 -1
  250. package/src/sdk/lifecycle-session.ts +42 -1
  251. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  252. package/src/sdk/router/session-router.ts +375 -36
  253. package/src/sdk/session.ts +547 -47
  254. package/src/session/account-inventory.ts +1 -1
  255. package/src/session/agent-session.ts +765 -189
  256. package/src/session/artifacts.ts +234 -4
  257. package/src/session/internal/managed-session-storage.ts +91 -4
  258. package/src/session/provider-safety-stop-hint.ts +3 -2
  259. package/src/session/session-manager.ts +775 -16
  260. package/src/session/session-staging-paths.ts +8 -0
  261. package/src/setup/hermes/templates/operator-instructions.v1.md +0 -1
  262. package/src/setup/host-plugin-setup.ts +2 -2
  263. package/src/skill-state/active-state.ts +2 -2
  264. package/src/skill-state/canonical-skills.ts +1 -1
  265. package/src/skill-state/initial-phase.ts +1 -1
  266. package/src/skill-state/workflow-hud.ts +41 -41
  267. package/src/skill-state/workflow-mutation-guard.ts +26 -7
  268. package/src/slash-commands/builtin-registry.ts +77 -0
  269. package/src/slash-commands/helpers/autorouting-status.ts +55 -0
  270. package/src/system-prompt.ts +102 -5
  271. package/src/task/discovery.ts +2 -3
  272. package/src/task/executor.ts +617 -43
  273. package/src/task/index.ts +262 -15
  274. package/src/task/receipt.ts +30 -0
  275. package/src/task/render.ts +11 -1
  276. package/src/task/types.ts +316 -0
  277. package/src/tools/atomic-file-write.ts +594 -0
  278. package/src/tools/bisect.ts +3 -2
  279. package/src/tools/descriptors.test.ts +3 -0
  280. package/src/tools/descriptors.ts +24 -0
  281. package/src/tools/image-gen.ts +5 -1
  282. package/src/tools/index.ts +62 -5
  283. package/src/tools/move-session.ts +107 -0
  284. package/src/tools/python.ts +205 -0
  285. package/src/tools/read.ts +88 -15
  286. package/src/tools/renderers.ts +2 -0
  287. package/src/tools/resource-gc.ts +8 -177
  288. package/src/tools/search.ts +30 -6
  289. package/src/tools/skill-discovery.ts +1 -0
  290. package/src/tools/skill.ts +37 -7
  291. package/src/tools/subagent-render.ts +119 -13
  292. package/src/tools/subagent.ts +91 -74
  293. package/src/tools/tool-catalog.generated.ts +64 -2
  294. package/src/tools/write.ts +16 -12
  295. package/src/utils/xml-escape.ts +14 -0
  296. package/src/web/search/providers/codex.ts +2 -2
  297. package/src/workflow/workflow-intent-diff.ts +13 -12
  298. package/src/workflow/workflow-intent-report-consumer.ts +6 -6
  299. package/dist/types/autoresearch/helpers.d.ts +0 -24
  300. package/dist/types/autoresearch/index.d.ts +0 -2
  301. package/dist/types/autoresearch/state.d.ts +0 -17
  302. package/dist/types/autoresearch/storage.d.ts +0 -142
  303. package/dist/types/autoresearch/tools/init-experiment.d.ts +0 -31
  304. package/dist/types/autoresearch/tools/log-experiment.d.ts +0 -23
  305. package/dist/types/autoresearch/tools/run-experiment.d.ts +0 -8
  306. package/dist/types/autoresearch/tools/update-notes.d.ts +0 -12
  307. package/dist/types/commands/team.d.ts +0 -24
  308. package/dist/types/gjc-runtime/team-gc.d.ts +0 -7
  309. package/dist/types/gjc-runtime/team-launch.d.ts +0 -61
  310. package/dist/types/gjc-runtime/team-notify.d.ts +0 -45
  311. package/dist/types/gjc-runtime/team-runtime.d.ts +0 -336
  312. package/dist/types/gjc-runtime/team-store.d.ts +0 -168
  313. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +0 -43
  314. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +0 -92
  315. package/dist/types/gjc-runtime/team-workers.d.ts +0 -83
  316. package/dist/types/rlm/index.d.ts +0 -47
  317. package/dist/types/rlm/preset.d.ts +0 -23
  318. package/dist/types/rlm/python-tool.d.ts +0 -16
  319. package/src/autoresearch/command-resume.md +0 -14
  320. package/src/autoresearch/index.ts +0 -536
  321. package/src/autoresearch/prompt.md +0 -103
  322. package/src/autoresearch/resume-message.md +0 -10
  323. package/src/autoresearch/state.ts +0 -273
  324. package/src/autoresearch/storage.ts +0 -699
  325. package/src/autoresearch/tools/init-experiment.ts +0 -272
  326. package/src/autoresearch/tools/log-experiment.ts +0 -524
  327. package/src/autoresearch/tools/run-experiment.ts +0 -475
  328. package/src/autoresearch/tools/update-notes.ts +0 -109
  329. package/src/commands/rlm.ts +0 -19
  330. package/src/commands/team.ts +0 -238
  331. package/src/defaults/gjc/skills/deep-interview/auto-research-greenfield.md +0 -42
  332. package/src/defaults/gjc/skills/team/SKILL.md +0 -467
  333. package/src/gjc-runtime/team-gc.ts +0 -49
  334. package/src/gjc-runtime/team-launch.ts +0 -404
  335. package/src/gjc-runtime/team-notify.ts +0 -326
  336. package/src/gjc-runtime/team-runtime.ts +0 -5923
  337. package/src/gjc-runtime/team-store.ts +0 -878
  338. package/src/gjc-runtime/team-worker-heartbeat.ts +0 -149
  339. package/src/gjc-runtime/team-worker-memory-guard.ts +0 -363
  340. package/src/gjc-runtime/team-workers.ts +0 -1123
  341. package/src/prompts/system/rlm-report-command.md +0 -1
  342. package/src/prompts/system/rlm-research.md +0 -23
  343. package/src/rlm/index.ts +0 -365
  344. package/src/rlm/preset.ts +0 -76
  345. package/src/rlm/python-tool.ts +0 -68
@@ -2,8 +2,9 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import * as nodeFs from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
- import { getAgentDir, isKnownSinkPeerClosedError } from "@gajae-code/utils";
5
+ import { getAgentDir, isKnownSinkPeerClosedError, logger } from "@gajae-code/utils";
6
6
  import { normalizePathForComparison, VERSION } from "@gajae-code/utils/dirs";
7
+ import { withFileLock } from "../config/file-lock";
7
8
  import {
8
9
  COORDINATOR_MCP_PROTOCOL_VERSION,
9
10
  COORDINATOR_MCP_SERVER_NAME,
@@ -52,6 +53,20 @@ import {
52
53
  createDefaultCodexTransportFactory,
53
54
  publishCodexWake,
54
55
  } from "./codex-wake-publisher";
56
+ import {
57
+ appendCoordinatorFile,
58
+ CoordinatorPublicationUncertainError,
59
+ ensureCoordinatorDirectory,
60
+ syncCoordinatorDirectory,
61
+ writeCoordinatorAtomic,
62
+ } from "./durability";
63
+ import {
64
+ createDefaultEventWebhookDelivery,
65
+ deliverEventWebhook,
66
+ type EventWebhookConfig,
67
+ parseEventWebhookConfig,
68
+ type WebhookDelivery,
69
+ } from "./event-webhook";
55
70
  import {
56
71
  type CoordinatorModelProfileLoader,
57
72
  loadCoordinatorModelProfiles,
@@ -190,10 +205,16 @@ interface CoordinatorServices {
190
205
  readSdkBrokerDiscovery?: (agentDir: string) => Promise<BrokerDiscovery | null>;
191
206
  getAgentDir?: () => string;
192
207
  resolveModelProfiles?: CoordinatorModelProfileLoader;
208
+ resolveModelPin?: (raw: unknown, cwd?: string) => Promise<CoordinatorModelResolution>;
193
209
  canonicalizePath?: (value: string) => Promise<string>;
194
210
  codexTransportFactory?: CodexTransportFactory;
211
+ eventWebhookDelivery?: WebhookDelivery;
195
212
  }
196
213
 
214
+ type CoordinatorModelResolution =
215
+ | { ok: true; model: string | null }
216
+ | { ok: false; reason: "unknown_model"; model: string; error: string };
217
+
197
218
  interface CoordinatorMcpServerOptions {
198
219
  env?: NodeJS.ProcessEnv;
199
220
  services?: CoordinatorServices;
@@ -308,6 +329,28 @@ type CoordinatorEventKind =
308
329
  | "tmux.delivery_failed"
309
330
  | "delegation.started";
310
331
 
332
+ const COORDINATOR_EVENT_KINDS: ReadonlySet<string> = new Set<CoordinatorEventKind>([
333
+ "session.registered",
334
+ "session.started",
335
+ "session.reaped",
336
+ "session.state_changed",
337
+ "turn.queued",
338
+ "turn.delivering",
339
+ "turn.active",
340
+ "turn.acknowledged",
341
+ "turn.waiting_for_answer",
342
+ "turn.completed",
343
+ "turn.failed",
344
+ "turn.cancelled",
345
+ "turn.superseded",
346
+ "question.opened",
347
+ "question.answered",
348
+ "report.written",
349
+ "tmux.delivery_succeeded",
350
+ "tmux.delivery_failed",
351
+ "delegation.started",
352
+ ]);
353
+
311
354
  interface CoordinatorEvent {
312
355
  schema_version: 1;
313
356
  seq: number;
@@ -385,6 +428,11 @@ function toolSchema(name: CoordinatorToolName): {
385
428
  description:
386
429
  "Optional GJC model profile (`gjc --mpreset <profile>`). Unknown names are rejected with the available-profile listing.",
387
430
  };
431
+ const modelPin = {
432
+ type: "string",
433
+ description:
434
+ "Optional explicit model pin (`gjc --model <provider/model>`, e.g. cursor/claude-fable-5-xhigh). Unknown ids are rejected with the same not-found error as the CLI; when both model and mpreset are given, the explicit model wins exactly like `gjc --mpreset <p> --model <m>`.",
435
+ };
388
436
 
389
437
  const common = { type: "object", properties: {} as Record<string, unknown> };
390
438
  const idempotencyKey = {
@@ -431,6 +479,7 @@ function toolSchema(name: CoordinatorToolName): {
431
479
  "Create the session prepared instead of ready: no readiness is published and no initial prompt is accepted until gjc_coordinator_activate_session proves activation.",
432
480
  },
433
481
  mpreset,
482
+ model: modelPin,
434
483
  idempotency_key: idempotencyKey,
435
484
  allow_mutation: allowMutation,
436
485
  },
@@ -714,11 +763,7 @@ function toolSchema(name: CoordinatorToolName): {
714
763
  description: "When reusing a session with an active turn, supersede it before sending.",
715
764
  },
716
765
  mpreset,
717
-
718
- model: {
719
- type: "string",
720
- description: "Optional model hint passed in prompt metadata; no provider default is implied.",
721
- },
766
+ model: modelPin,
722
767
  await_completion: { type: "boolean", description: "If true, poll the turn until terminal or timeout." },
723
768
  timeout_ms: {
724
769
  type: "number",
@@ -734,7 +779,7 @@ function toolSchema(name: CoordinatorToolName): {
734
779
  return { name, description: "List known scoped GJC coordinator bridge sessions.", inputSchema: common };
735
780
  }
736
781
 
737
- type DelegateWorkflow = "plan" | "execute" | "team";
782
+ type DelegateWorkflow = "plan" | "execute";
738
783
 
739
784
  function workflowForDelegateTool(name: string): DelegateWorkflow | null {
740
785
  switch (name) {
@@ -742,21 +787,17 @@ function workflowForDelegateTool(name: string): DelegateWorkflow | null {
742
787
  return "plan";
743
788
  case "gjc_delegate_execute":
744
789
  return "execute";
745
- case "gjc_delegate_team":
746
- return "team";
747
790
  default:
748
791
  return null;
749
792
  }
750
793
  }
751
794
 
752
- function workflowSkill(workflow: DelegateWorkflow): "ralplan" | "ultragoal" | "team" {
795
+ function workflowSkill(workflow: DelegateWorkflow): "ralplan" | "ultragoal" {
753
796
  switch (workflow) {
754
797
  case "plan":
755
798
  return "ralplan";
756
799
  case "execute":
757
800
  return "ultragoal";
758
- case "team":
759
- return "team";
760
801
  }
761
802
  }
762
803
 
@@ -766,8 +807,6 @@ function delegateToolDescription(workflow: DelegateWorkflow): string {
766
807
  return "Delegate consensus planning to GJC: start a session and run /skill:ralplan to completion, returning durable turn status and artifact references.";
767
808
  case "execute":
768
809
  return "Delegate execution to GJC: start a session and run /skill:ultragoal to completion, returning durable turn status and artifact references.";
769
- case "team":
770
- return "Delegate parallel team execution to GJC: start a session and run /skill:team to completion, returning durable turn status and artifact references.";
771
810
  }
772
811
  }
773
812
 
@@ -853,20 +892,46 @@ function coordinatorLifecycleTarget(sessionCommand: string | null, cwd: string):
853
892
  }
854
893
 
855
894
  async function ensureDir(dir: string): Promise<void> {
856
- await fs.mkdir(dir, { recursive: true });
895
+ await ensureCoordinatorDirectory(dir);
857
896
  }
858
897
 
859
898
  async function readJsonFile(file: string): Promise<unknown | null> {
899
+ let source: string;
860
900
  try {
861
- return JSON.parse(await fs.readFile(file, "utf8"));
862
- } catch {
901
+ source = await fs.readFile(file, "utf8");
902
+ } catch (error) {
903
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
863
904
  return null;
864
905
  }
906
+ try {
907
+ return JSON.parse(source);
908
+ } catch (error) {
909
+ throw new Error(
910
+ `invalid coordinator projection ${file}: ${error instanceof Error ? error.message : String(error)}`,
911
+ );
912
+ }
913
+ }
914
+
915
+ async function removeCoordinatorFile(file: string): Promise<void> {
916
+ try {
917
+ await fs.lstat(file);
918
+ } catch (error) {
919
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
920
+ try {
921
+ await fs.stat(path.dirname(file));
922
+ } catch (parentError) {
923
+ if ((parentError as NodeJS.ErrnoException).code === "ENOENT") return;
924
+ throw parentError;
925
+ }
926
+ await syncCoordinatorDirectory(path.dirname(file));
927
+ return;
928
+ }
929
+ await fs.rm(file, { force: true });
930
+ await syncCoordinatorDirectory(path.dirname(file));
865
931
  }
866
932
 
867
933
  async function writeJsonFile(file: string, value: unknown): Promise<void> {
868
- await ensureDir(path.dirname(file));
869
- await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
934
+ await writeCoordinatorAtomic(file, `${JSON.stringify(value, null, 2)}\n`);
870
935
  }
871
936
 
872
937
  const COORDINATOR_IDEMPOTENCY_RESPONSE_BYTE_CAP = 64 * 1024;
@@ -904,27 +969,7 @@ async function readCoordinatorIdempotencyFile(file: string): Promise<Coordinator
904
969
  }
905
970
 
906
971
  async function writeCoordinatorIdempotencyFile(file: string, value: CoordinatorToolIdempotencyRecord): Promise<void> {
907
- await ensureDir(path.dirname(file));
908
- const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
909
- try {
910
- const handle = await fs.open(temporary, "wx", 0o600);
911
- try {
912
- await handle.writeFile(`${JSON.stringify(value)}\n`);
913
- await handle.sync();
914
- } finally {
915
- await handle.close();
916
- }
917
- await fs.rename(temporary, file);
918
- const directory = await fs.open(path.dirname(file), "r");
919
- try {
920
- await directory.sync();
921
- } finally {
922
- await directory.close();
923
- }
924
- } catch (error) {
925
- await fs.rm(temporary, { force: true }).catch(() => undefined);
926
- throw error;
927
- }
972
+ await writeCoordinatorAtomic(file, `${JSON.stringify(value)}\n`);
928
973
  }
929
974
 
930
975
  function canonicalJson(value: unknown): string {
@@ -1139,11 +1184,22 @@ async function listJsonFiles(dir: string): Promise<unknown[]> {
1139
1184
  try {
1140
1185
  const entries = await fs.readdir(dir);
1141
1186
  const values = await Promise.all(
1142
- entries.filter(entry => entry.endsWith(".json")).map(entry => readJsonFile(path.join(dir, entry))),
1187
+ entries
1188
+ .filter(entry => entry.endsWith(".json") && !entry.startsWith(".gjc-"))
1189
+ .map(async entry => {
1190
+ try {
1191
+ return await readJsonFile(path.join(dir, entry));
1192
+ } catch (error) {
1193
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
1194
+ logger.warn("Coordinator projection read failed", { path: entry, error: String(error) });
1195
+ throw error;
1196
+ }
1197
+ }),
1143
1198
  );
1144
1199
  return values.filter(value => value !== null);
1145
- } catch {
1146
- return [];
1200
+ } catch (error) {
1201
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
1202
+ throw error;
1147
1203
  }
1148
1204
  }
1149
1205
 
@@ -1207,7 +1263,7 @@ function publicCoordinatorSession(session: Record<string, unknown>): Record<stri
1207
1263
  const result: Record<string, unknown> = {
1208
1264
  session_id: firstString(session, ["session_id", "sessionId"]) ?? "unknown",
1209
1265
  };
1210
- for (const key of ["cwd", "created_at", "mpreset"]) {
1266
+ for (const key of ["cwd", "created_at", "mpreset", "model"]) {
1211
1267
  const value = session[key];
1212
1268
  if (typeof value === "string") result[key] = value;
1213
1269
  }
@@ -1328,6 +1384,10 @@ function eventSequenceFile(namespaceDir: string): string {
1328
1384
  return path.join(eventsDir(namespaceDir), "latest-seq.json");
1329
1385
  }
1330
1386
 
1387
+ function eventJournalLockFile(namespaceDir: string): string {
1388
+ return path.join(eventsDir(namespaceDir), "event-journal.lock");
1389
+ }
1390
+
1331
1391
  function boundSummary(value: string): string {
1332
1392
  const normalized = value
1333
1393
  .replace(/[\r\n\t]+/g, " ")
@@ -1337,17 +1397,64 @@ function boundSummary(value: string): string {
1337
1397
  }
1338
1398
 
1339
1399
  async function readLatestEventSeq(namespaceDir: string): Promise<number> {
1340
- const sequence = asRecord(await readJsonFile(eventSequenceFile(namespaceDir)));
1400
+ let sequence: Record<string, unknown> | null = null;
1401
+ try {
1402
+ sequence = asRecord(await readJsonFile(eventSequenceFile(namespaceDir)));
1403
+ } catch (error) {
1404
+ logger.warn("Coordinator sequence cache unreadable; recovering from journal", { error: String(error) });
1405
+ }
1406
+ const events = await readCoordinatorEvents(namespaceDir);
1407
+ const journalSeq = events.at(-1)?.seq ?? 0;
1341
1408
  const seq = sequence?.seq;
1342
- if (typeof seq === "number" && Number.isInteger(seq) && seq >= 0) return seq;
1343
- let latestSeq = 0;
1344
- for (const event of await readCoordinatorEvents(namespaceDir)) latestSeq = Math.max(latestSeq, event.seq);
1345
- return latestSeq;
1409
+ if (typeof seq === "number" && Number.isSafeInteger(seq) && seq >= 0 && seq > journalSeq)
1410
+ throw new Error("state_corrupt");
1411
+ return journalSeq;
1346
1412
  }
1347
1413
 
1348
1414
  const eventAppendQueues = new Map<string, Promise<unknown>>();
1349
1415
  const codexWakeTransportFactories = new Map<string, CodexTransportFactory>();
1350
1416
  const codexWakePublishTails = new Map<string, Promise<void>>();
1417
+ const eventWebhookConfigs = new Map<string, EventWebhookConfig | null>();
1418
+ const eventWebhookDeliveries = new Map<string, WebhookDelivery>();
1419
+ const eventWebhookTails = new Map<string, Promise<void>>();
1420
+ const EVENT_WEBHOOK_ERROR_CAP = 240;
1421
+
1422
+ async function appendEventWebhookDiagnostic(namespaceDir: string, eventId: string, error: unknown): Promise<void> {
1423
+ const code =
1424
+ error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
1425
+ ? error.message.slice(0, EVENT_WEBHOOK_ERROR_CAP)
1426
+ : "event_webhook_delivery_failed";
1427
+ const line = `${new Date().toISOString()} event=${eventId} error=${code}\n`;
1428
+ try {
1429
+ await fs.appendFile(path.join(namespaceDir, "event-webhook-errors.log"), line.slice(0, 512), { mode: 0o600 });
1430
+ } catch {
1431
+ // Diagnostics are best-effort; sink problems must never propagate upward.
1432
+ }
1433
+ }
1434
+
1435
+ function enqueueEventWebhook(namespaceDir: string, event: CoordinatorEvent): void {
1436
+ const config = eventWebhookConfigs.get(namespaceDir);
1437
+ if (config === null || config === undefined) return;
1438
+ const delivery = eventWebhookDeliveries.get(namespaceDir);
1439
+ if (!delivery) return;
1440
+ const previous = eventWebhookTails.get(namespaceDir) ?? Promise.resolve();
1441
+ const next = previous
1442
+ .then(async () => {
1443
+ await deliverEventWebhook(namespaceDir, config, event, () => JSON.stringify(event), delivery);
1444
+ })
1445
+ .catch(async error => {
1446
+ await appendEventWebhookDiagnostic(namespaceDir, event.id, error);
1447
+ });
1448
+ eventWebhookTails.set(namespaceDir, next);
1449
+ void next.finally(() => {
1450
+ if (eventWebhookTails.get(namespaceDir) === next) eventWebhookTails.delete(namespaceDir);
1451
+ });
1452
+ }
1453
+
1454
+ /** Test-only helper that waits for queued event webhook deliveries in a namespace. */
1455
+ export async function awaitEventWebhookDeliveriesForTest(namespaceDir: string): Promise<void> {
1456
+ await Promise.all([...eventWebhookTails.entries()].filter(([key]) => key === namespaceDir).map(([, tail]) => tail));
1457
+ }
1351
1458
 
1352
1459
  const CODEX_WAKE_ERROR_CAP = 240;
1353
1460
  const CODEX_WAKE_DIAGNOSTIC_CAP = 512;
@@ -1366,13 +1473,15 @@ async function appendCodexWakeDiagnostic(
1366
1473
  ): Promise<void> {
1367
1474
  const line = `${new Date().toISOString()} event=${event.id} error=${codexWakeErrorCode(error)}\n`;
1368
1475
  try {
1369
- await fs.appendFile(path.join(namespaceDir, "codex-wake-errors.log"), line.slice(0, CODEX_WAKE_DIAGNOSTIC_CAP), {
1370
- mode: 0o600,
1476
+ await appendCoordinatorFile(
1477
+ path.join(namespaceDir, "codex-wake-errors.log"),
1478
+ line.slice(0, CODEX_WAKE_DIAGNOSTIC_CAP),
1479
+ );
1480
+ } catch (diagnosticError) {
1481
+ logger.warn("Coordinator Codex wake diagnostic persistence failed", {
1482
+ eventId: event.id,
1483
+ error: String(diagnosticError),
1371
1484
  });
1372
- } catch {
1373
- try {
1374
- process.stderr.write("codex-wake-diagnostic-unwritable\n");
1375
- } catch {}
1376
1485
  }
1377
1486
  }
1378
1487
 
@@ -1380,17 +1489,21 @@ async function appendCodexWakePublishDiagnostic(
1380
1489
  namespaceDir: string,
1381
1490
  event: CodexWakeEventV1,
1382
1491
  error: unknown,
1383
- ): Promise<void> {
1492
+ ): Promise<unknown | null> {
1384
1493
  const line = `${new Date().toISOString()} wake=${event.key} error=${codexWakeErrorCode(error)}\n`;
1385
1494
  try {
1386
- await fs.appendFile(path.join(namespaceDir, "codex-wake-errors.log"), line.slice(0, CODEX_WAKE_DIAGNOSTIC_CAP), {
1387
- mode: 0o600,
1495
+ await appendCoordinatorFile(
1496
+ path.join(namespaceDir, "codex-wake-errors.log"),
1497
+ line.slice(0, CODEX_WAKE_DIAGNOSTIC_CAP),
1498
+ );
1499
+ } catch (diagnosticError) {
1500
+ logger.warn("Coordinator Codex wake publish diagnostic persistence failed", {
1501
+ wakeKey: event.key,
1502
+ error: String(diagnosticError),
1388
1503
  });
1389
- } catch {
1390
- try {
1391
- process.stderr.write("codex-wake-diagnostic-unwritable\n");
1392
- } catch {}
1504
+ return diagnosticError;
1393
1505
  }
1506
+ return null;
1394
1507
  }
1395
1508
 
1396
1509
  async function autoBindDelegateCodexHandoff(
@@ -1414,13 +1527,13 @@ async function autoBindDelegateCodexHandoff(
1414
1527
  let source: CodexHandoffRegistrationV1 | null;
1415
1528
  try {
1416
1529
  source = await readCodexHandoff(namespaceDir, explicitHostWorkUnit);
1417
- } catch {
1530
+ } catch (error) {
1418
1531
  await appendCodexWakeDiagnostic(
1419
1532
  namespaceDir,
1420
1533
  diagnosticEvent,
1421
1534
  new Error("codex_handoff_explicit_source_missing"),
1422
1535
  );
1423
- return { auto_bound: false };
1536
+ throw error;
1424
1537
  }
1425
1538
  if (!source) {
1426
1539
  await appendCodexWakeDiagnostic(
@@ -1448,7 +1561,7 @@ async function autoBindDelegateCodexHandoff(
1448
1561
  return { auto_bound: true, thread_id: binding.handoff.thread_id };
1449
1562
  } catch (error) {
1450
1563
  await appendCodexWakeDiagnostic(namespaceDir, diagnosticEvent, error);
1451
- return { auto_bound: false };
1564
+ throw error;
1452
1565
  }
1453
1566
  }
1454
1567
  try {
@@ -1505,7 +1618,7 @@ async function autoBindDelegateCodexHandoff(
1505
1618
  return { auto_bound: true, thread_id: binding.handoff.thread_id };
1506
1619
  } catch (error) {
1507
1620
  await appendCodexWakeDiagnostic(namespaceDir, diagnosticEvent, error);
1508
- return { auto_bound: false };
1621
+ throw error;
1509
1622
  }
1510
1623
  }
1511
1624
 
@@ -1540,27 +1653,31 @@ async function publishRecordedCodexWake(
1540
1653
  if (event.status !== "pending" && event.status !== "failed") return "skipped";
1541
1654
  const transportFactory = codexWakeTransportFactories.get(namespaceDir);
1542
1655
  if (!transportFactory) return "skipped";
1656
+ let published: { published: boolean };
1543
1657
  try {
1544
- const published = await publishCodexWake({ handoff, event, transportFactory });
1545
- await updateCodexWakeEvent(namespaceDir, event.key, {
1546
- ...(published.published ? { status: "published" as const } : {}),
1547
- attempts_delta: 1,
1548
- last_error: null,
1549
- });
1550
- return published.published ? "published" : "thread_active_pending";
1658
+ published = await publishCodexWake({ handoff, event, transportFactory });
1551
1659
  } catch (error) {
1552
- await appendCodexWakePublishDiagnostic(namespaceDir, event, error);
1660
+ const diagnosticError = await appendCodexWakePublishDiagnostic(namespaceDir, event, error);
1553
1661
  try {
1554
1662
  await updateCodexWakeEvent(namespaceDir, event.key, {
1555
1663
  status: "failed",
1556
1664
  attempts_delta: 1,
1557
1665
  last_error: codexWakeErrorCode(error),
1558
1666
  });
1559
- } catch (updateError) {
1560
- await appendCodexWakePublishDiagnostic(namespaceDir, event, updateError);
1667
+ } catch (stateError) {
1668
+ throw new AggregateError(
1669
+ [error, stateError, ...(diagnosticError ? [diagnosticError] : [])],
1670
+ "Codex wake publication and failure-state persistence failed",
1671
+ );
1561
1672
  }
1562
1673
  return "failed";
1563
1674
  }
1675
+ await updateCodexWakeEvent(namespaceDir, event.key, {
1676
+ ...(published.published ? { status: "published" as const } : {}),
1677
+ attempts_delta: 1,
1678
+ last_error: null,
1679
+ });
1680
+ return published.published ? "published" : "thread_active_pending";
1564
1681
  }
1565
1682
 
1566
1683
  async function publishPendingCodexWakes(namespaceDir: string, threadId: string): Promise<void> {
@@ -1586,18 +1703,20 @@ function enqueueCodexWakePublish(namespaceDir: string, handoff: CodexHandoffRegi
1586
1703
  const tailKey = codexWakeTailKey(namespaceDir, handoff.thread_id);
1587
1704
  const previous = codexWakePublishTails.get(tailKey) ?? Promise.resolve();
1588
1705
  const next = previous
1589
- .then(() => publishPendingCodexWakes(namespaceDir, handoff.thread_id))
1590
1706
  .catch(async error => {
1591
1707
  await appendCodexWakeDiagnostic(
1592
1708
  namespaceDir,
1593
1709
  { id: `wake-queue:${handoff.thread_id}` } as CoordinatorEvent,
1594
1710
  error,
1595
1711
  );
1596
- });
1712
+ })
1713
+ .then(() => publishPendingCodexWakes(namespaceDir, handoff.thread_id));
1597
1714
  codexWakePublishTails.set(tailKey, next);
1598
- void next.finally(() => {
1599
- if (codexWakePublishTails.get(tailKey) === next) codexWakePublishTails.delete(tailKey);
1600
- });
1715
+ void next
1716
+ .catch(() => undefined)
1717
+ .finally(() => {
1718
+ if (codexWakePublishTails.get(tailKey) === next) codexWakePublishTails.delete(tailKey);
1719
+ });
1601
1720
  }
1602
1721
 
1603
1722
  /** Test-only helper that waits for queued Codex wake publishes in a namespace. */
@@ -1623,31 +1742,48 @@ async function appendCoordinatorEvent(namespaceDir: string, input: CoordinatorEv
1623
1742
 
1624
1743
  await previous.catch(() => undefined);
1625
1744
  try {
1626
- const latestSeq = await readLatestEventSeq(namespaceDir);
1627
- const seq = latestSeq + 1;
1628
- const timestamp = new Date().toISOString();
1629
- const event: CoordinatorEvent = {
1630
- schema_version: 1,
1631
- seq,
1632
- id: `event-${seq.toString().padStart(12, "0")}`,
1633
- timestamp,
1634
- kind: input.kind,
1635
- summary: boundSummary(input.summary),
1636
- ...(input.sessionId ? { session_id: input.sessionId } : {}),
1637
- ...(input.turnId ? { turn_id: input.turnId } : {}),
1638
- ...(input.questionId ? { question_id: input.questionId } : {}),
1639
- ...(input.reportId ? { report_id: input.reportId } : {}),
1640
- ...(input.payloadRef ? { payload_ref: input.payloadRef } : {}),
1641
- ...(input.metadata ? { metadata: input.metadata } : {}),
1642
- };
1643
- await ensureDir(eventsDir(namespaceDir));
1644
- await fs.appendFile(eventJournalFile(namespaceDir), `${JSON.stringify(event)}\n`);
1645
- await writeJsonFile(eventSequenceFile(namespaceDir), { seq, updated_at: timestamp });
1646
- const codexWake = await maybeRecordCodexWake(namespaceDir, event).catch(async error => {
1647
- await appendCodexWakeDiagnostic(namespaceDir, event, error);
1648
- return null;
1745
+ await ensureCoordinatorDirectory(eventsDir(namespaceDir));
1746
+ const event = await withFileLock(eventJournalLockFile(namespaceDir), async () => {
1747
+ const latestSeq = await readLatestEventSeq(namespaceDir);
1748
+ if (!Number.isSafeInteger(latestSeq) || latestSeq >= Number.MAX_SAFE_INTEGER) throw new Error("state_corrupt");
1749
+ const seq = latestSeq + 1;
1750
+ const timestamp = new Date().toISOString();
1751
+ const event: CoordinatorEvent = {
1752
+ schema_version: 1,
1753
+ seq,
1754
+ id: `event-${seq.toString().padStart(12, "0")}`,
1755
+ timestamp,
1756
+ kind: input.kind,
1757
+ summary: boundSummary(input.summary),
1758
+ ...(input.sessionId ? { session_id: input.sessionId } : {}),
1759
+ ...(input.turnId ? { turn_id: input.turnId } : {}),
1760
+ ...(input.questionId ? { question_id: input.questionId } : {}),
1761
+ ...(input.reportId ? { report_id: input.reportId } : {}),
1762
+ ...(input.payloadRef ? { payload_ref: input.payloadRef } : {}),
1763
+ ...(input.metadata ? { metadata: input.metadata } : {}),
1764
+ };
1765
+ await appendCoordinatorFile(eventJournalFile(namespaceDir), `${JSON.stringify(event)}\n`);
1766
+ await writeJsonFile(eventSequenceFile(namespaceDir), { seq, updated_at: timestamp });
1767
+ return event;
1649
1768
  });
1769
+ let codexWake: { handoff: CodexHandoffRegistrationV1; event: CodexWakeEventV1 | null } | null;
1770
+ try {
1771
+ codexWake = await maybeRecordCodexWake(namespaceDir, event);
1772
+ } catch (error) {
1773
+ try {
1774
+ await appendCodexWakeDiagnostic(namespaceDir, event, error);
1775
+ } catch (diagnosticError) {
1776
+ // The canonical event is already durable. Preserve that committed
1777
+ // result even when the optional diagnostic cannot be persisted.
1778
+ logger.warn("Coordinator Codex wake diagnostic persistence failed", {
1779
+ eventId: event.id,
1780
+ error: String(diagnosticError),
1781
+ });
1782
+ }
1783
+ codexWake = null;
1784
+ }
1650
1785
  if (codexWake) enqueueCodexWakePublish(namespaceDir, codexWake.handoff);
1786
+ if (eventWebhookConfigs.has(namespaceDir)) enqueueEventWebhook(namespaceDir, event);
1651
1787
  return event;
1652
1788
  } finally {
1653
1789
  release();
@@ -1664,9 +1800,22 @@ export async function appendCoordinatorEventForTest(
1664
1800
 
1665
1801
  function parseCoordinatorEvent(line: string): CoordinatorEvent | null {
1666
1802
  try {
1667
- const event = JSON.parse(line) as CoordinatorEvent;
1668
- if (typeof event.seq !== "number" || typeof event.kind !== "string") return null;
1669
- return event;
1803
+ const event = JSON.parse(line) as Partial<CoordinatorEvent>;
1804
+ if (
1805
+ event.schema_version !== 1 ||
1806
+ typeof event.seq !== "number" ||
1807
+ !Number.isSafeInteger(event.seq) ||
1808
+ event.seq < 0 ||
1809
+ typeof event.id !== "string" ||
1810
+ event.id.length === 0 ||
1811
+ typeof event.timestamp !== "string" ||
1812
+ typeof event.summary !== "string" ||
1813
+ typeof event.kind !== "string" ||
1814
+ !COORDINATOR_EVENT_KINDS.has(event.kind) ||
1815
+ event.id !== `event-${event.seq.toString().padStart(12, "0")}`
1816
+ )
1817
+ return null;
1818
+ return event as CoordinatorEvent;
1670
1819
  } catch {
1671
1820
  return null;
1672
1821
  }
@@ -1675,13 +1824,23 @@ function parseCoordinatorEvent(line: string): CoordinatorEvent | null {
1675
1824
  async function readCoordinatorEvents(namespaceDir: string): Promise<CoordinatorEvent[]> {
1676
1825
  try {
1677
1826
  const content = await fs.readFile(eventJournalFile(namespaceDir), "utf8");
1678
- return content
1679
- .split("\n")
1680
- .map(line => line.trim())
1681
- .filter(Boolean)
1682
- .map(parseCoordinatorEvent)
1683
- .filter((event): event is CoordinatorEvent => event !== null)
1684
- .sort((left, right) => left.seq - right.seq);
1827
+ const events: CoordinatorEvent[] = [];
1828
+ const seenIds = new Set<string>();
1829
+ const seenSeqs = new Set<number>();
1830
+ for (const rawLine of content.split("\n")) {
1831
+ const line = rawLine.trim();
1832
+ if (!line) continue;
1833
+ const event = parseCoordinatorEvent(line);
1834
+ if (!event) throw new Error("state_corrupt");
1835
+ if (seenIds.has(event.id) || seenSeqs.has(event.seq)) throw new Error("state_corrupt");
1836
+ seenIds.add(event.id);
1837
+ seenSeqs.add(event.seq);
1838
+ events.push(event);
1839
+ }
1840
+ events.sort((left, right) => left.seq - right.seq);
1841
+ for (let index = 0; index < events.length; index += 1)
1842
+ if (events[index]!.seq !== index + 1) throw new Error("state_corrupt");
1843
+ return events;
1685
1844
  } catch (error) {
1686
1845
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
1687
1846
  throw error;
@@ -1837,7 +1996,7 @@ async function writeActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<
1837
1996
 
1838
1997
  async function clearActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<void> {
1839
1998
  const active = asRecord(await readJsonFile(activeTurnFile(namespaceDir, turn.session_id)));
1840
- if (active?.turn_id === turn.turn_id) await fs.rm(activeTurnFile(namespaceDir, turn.session_id), { force: true });
1999
+ if (active?.turn_id === turn.turn_id) await removeCoordinatorFile(activeTurnFile(namespaceDir, turn.session_id));
1841
2000
  }
1842
2001
 
1843
2002
  async function readSessionState(namespaceDir: string, sessionId: string): Promise<CoordinatorSessionState | null> {
@@ -2386,13 +2545,59 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2386
2545
  namespaceDir,
2387
2546
  services.codexTransportFactory ?? createDefaultCodexTransportFactory(),
2388
2547
  );
2389
- void (async () => {
2548
+ let eventWebhookConfig: EventWebhookConfig | null;
2549
+ try {
2550
+ // `env` here may be the merged process environment, which Bun has
2551
+ // already loaded the cwd `.env` overlay into; that is not a trusted
2552
+ // source for an egress destination. Only an explicitly provided
2553
+ // operator env (rendered hermes block, tests) may select it.
2554
+ eventWebhookConfig = parseEventWebhookConfig(options.env);
2555
+ } catch (error) {
2556
+ // Fail closed: an invalid webhook config disables delivery rather than
2557
+ // crashing the whole coordinator bridge. `gjc coordinator doctor`
2558
+ // surfaces the same error as a failing check.
2559
+ eventWebhookConfig = null;
2560
+ const reason = error instanceof Error ? error.message : "coordinator_event_webhook_invalid";
2561
+ void (async () => {
2562
+ try {
2563
+ await fs.mkdir(namespaceDir, { recursive: true });
2564
+ await fs.appendFile(
2565
+ path.join(namespaceDir, "event-webhook-errors.log"),
2566
+ `${new Date().toISOString()} config_error=${reason}\n`,
2567
+ { mode: 0o600 },
2568
+ );
2569
+ } catch {
2570
+ // Diagnostics are best-effort; delivery stays disabled either way.
2571
+ }
2572
+ })();
2573
+ }
2574
+ eventWebhookConfigs.set(namespaceDir, eventWebhookConfig);
2575
+ eventWebhookDeliveries.set(namespaceDir, services.eventWebhookDelivery ?? createDefaultEventWebhookDelivery());
2576
+ const startupEventWebhookReplay = (async () => {
2577
+ if (eventWebhookConfig === null) return;
2390
2578
  try {
2391
- for (const handoff of await listCodexHandoffs(namespaceDir)) enqueueCodexWakePublish(namespaceDir, handoff);
2579
+ for (const event of await readCoordinatorEvents(namespaceDir)) enqueueEventWebhook(namespaceDir, event);
2580
+ await awaitEventWebhookDeliveriesForTest(namespaceDir);
2581
+ } catch (error) {
2582
+ await appendEventWebhookDiagnostic(namespaceDir, "startup-replay", error);
2583
+ }
2584
+ })();
2585
+ void startupEventWebhookReplay;
2586
+ const startupCodexWakeReplay = (async () => {
2587
+ try {
2588
+ for (const handoff of await listCodexHandoffs(namespaceDir)) {
2589
+ enqueueCodexWakePublish(namespaceDir, handoff);
2590
+ await awaitCodexWakePublishesForTest(namespaceDir);
2591
+ }
2392
2592
  } catch (error) {
2393
2593
  await appendCodexWakeDiagnostic(namespaceDir, { id: "startup-drain" }, error);
2594
+ throw error;
2394
2595
  }
2395
2596
  })();
2597
+ // Startup replay is optional. Keep its rejection observable to callers that
2598
+ // explicitly await the replay, but never let an unhandled replay rejection
2599
+ // gate canonical question-state initialization.
2600
+ void startupCodexWakeReplay.catch(() => undefined);
2396
2601
  let questionStateReady: Promise<void> | null = null;
2397
2602
 
2398
2603
  function ensureQuestionStateReady(): Promise<void> {
@@ -3096,6 +3301,8 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
3096
3301
 
3097
3302
  function sdkError(error: unknown): Record<string, unknown> {
3098
3303
  if (error instanceof SdkClientError) return { ok: false, error: { code: error.code, message: error.message } };
3304
+ if (error instanceof CoordinatorPublicationUncertainError)
3305
+ return { ok: false, error: { code: "ambiguous", message: error.message } };
3099
3306
  return {
3100
3307
  ok: false,
3101
3308
  error: { code: "unavailable", message: error instanceof Error ? error.message : String(error) },
@@ -3289,6 +3496,57 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
3289
3496
  return result;
3290
3497
  }
3291
3498
 
3499
+ const resolveModelPin =
3500
+ services.resolveModelPin ??
3501
+ (async (raw: unknown, cwd?: string): Promise<CoordinatorModelResolution> => {
3502
+ if (raw === undefined || raw === null) return { ok: true, model: null };
3503
+ const result = brokerResult(
3504
+ await brokerSession("", "model.resolve", {
3505
+ model: raw,
3506
+ cwd,
3507
+ target: coordinatorLifecycleTarget(config.sessionCommand, cwd ?? ""),
3508
+ }),
3509
+ );
3510
+ if (result) {
3511
+ if (result.ok === true && (result.model === null || typeof result.model === "string"))
3512
+ return { ok: true, model: result.model };
3513
+ if (
3514
+ result.ok === false &&
3515
+ result.reason === "unknown_model" &&
3516
+ typeof result.model === "string" &&
3517
+ typeof result.error === "string"
3518
+ )
3519
+ return { ok: false, reason: "unknown_model", model: result.model, error: result.error };
3520
+ }
3521
+ throw new SdkClientError("unavailable", "SDK host returned an invalid model resolution.");
3522
+ });
3523
+
3524
+ async function withRemoteSessionCompensation<T>(
3525
+ cwd: string,
3526
+ remoteSession: { value: string | null },
3527
+ operation: () => Promise<T>,
3528
+ ): Promise<T> {
3529
+ try {
3530
+ return await operation();
3531
+ } catch (error) {
3532
+ const sessionId = remoteSession.value;
3533
+ if (!sessionId) throw error;
3534
+ try {
3535
+ strictBrokerSessionClose(
3536
+ await brokerSession(cwd, "session.close", { sessionId }, `${sessionId}:compensate-create`),
3537
+ sessionId,
3538
+ );
3539
+ } catch (closeError) {
3540
+ throw new SdkClientError(
3541
+ UNOBSERVED_COMPENSATION_CODE,
3542
+ `Coordinator creation failed after remote session ${sessionId} was created, and compensation failed.`,
3543
+ { primary: error, compensation: closeError, session_id: sessionId },
3544
+ );
3545
+ }
3546
+ throw error;
3547
+ }
3548
+ }
3549
+
3292
3550
  function brokerResult(value: unknown): Record<string, unknown> {
3293
3551
  const response = asRecord(value);
3294
3552
  if (response?.ok === false) {
@@ -3301,6 +3559,37 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
3301
3559
  return asRecord(response?.result) ?? response ?? {};
3302
3560
  }
3303
3561
 
3562
+ function strictBrokerResult(value: unknown, operation: string): Record<string, unknown> {
3563
+ const response = asRecord(value);
3564
+ if (response?.ok === false) brokerResult(response);
3565
+ if (response?.ok !== true || !Object.hasOwn(response, "result"))
3566
+ throw new SdkClientError(
3567
+ UNOBSERVED_COMPENSATION_CODE,
3568
+ `SDK broker returned a malformed ${operation} response; the lifecycle outcome is unobserved.`,
3569
+ { response },
3570
+ );
3571
+ const result = asRecord(response.result);
3572
+ if (!result)
3573
+ throw new SdkClientError(
3574
+ UNOBSERVED_COMPENSATION_CODE,
3575
+ `SDK broker returned a malformed ${operation} result; the lifecycle outcome is unobserved.`,
3576
+ { response },
3577
+ );
3578
+ return result;
3579
+ }
3580
+
3581
+ function strictBrokerSessionClose(value: unknown, expectedSessionId: string): Record<string, unknown> {
3582
+ const result = strictBrokerResult(value, "session.close");
3583
+ const sessionId = optionalString(result.sessionId ?? result.session_id);
3584
+ if (sessionId !== expectedSessionId)
3585
+ throw new SdkClientError(
3586
+ UNOBSERVED_COMPENSATION_CODE,
3587
+ "SDK broker returned no proof that the expected session was closed.",
3588
+ { expected_session_id: expectedSessionId, result },
3589
+ );
3590
+ return result;
3591
+ }
3592
+
3304
3593
  async function paginatedBrokerSessionList(
3305
3594
  cwd: string,
3306
3595
  input: Record<string, unknown>,
@@ -3333,8 +3622,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
3333
3622
  async function canonicalBrokerWorkspace(cwd: string): Promise<string> {
3334
3623
  try {
3335
3624
  return await (services.canonicalizePath ?? (value => fs.realpath(value)))(cwd);
3336
- } catch {
3337
- throw new SdkClientError("not_found", "Coordinator workspace cannot be resolved.");
3625
+ } catch (error) {
3626
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
3627
+ throw new SdkClientError("not_found", "Coordinator workspace cannot be resolved.");
3628
+ throw error;
3338
3629
  }
3339
3630
  }
3340
3631
 
@@ -3452,14 +3743,44 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
3452
3743
  const session = asRecord(await readJsonFile(sessionFile(id)));
3453
3744
  if (!session) {
3454
3745
  await ensureQuestionStateReady();
3455
- const deletion = await withNamespaceRegistry(
3456
- questionPaths,
3457
- async registry =>
3458
- Object.values(registry.deletions).find(
3459
- entry => entry.session_id === id && entry.phase === "completed",
3460
- ) ?? null,
3461
- );
3462
- if (deletion?.safe_response) return deletion.safe_response as { ok: boolean; closed: boolean };
3746
+ const deletion = await withNamespaceRegistry(questionPaths, async registry => {
3747
+ const matching = Object.values(registry.deletions).filter(entry => entry.session_id === id);
3748
+ return (
3749
+ matching.find(entry => entry.phase !== "completed") ??
3750
+ matching.find(entry => entry.safe_response !== undefined) ??
3751
+ null
3752
+ );
3753
+ });
3754
+ if (deletion?.phase === "broker_closed" || deletion?.phase === "cleanup_pending") {
3755
+ await removeCoordinatorFile(sessionFile(id));
3756
+ await removeCoordinatorFile(sessionStateFile(namespaceDir, id));
3757
+ await removeCoordinatorFile(activeTurnFile(namespaceDir, id));
3758
+ await appendCoordinatorEvent(namespaceDir, {
3759
+ kind: "session.reaped",
3760
+ sessionId: id,
3761
+ summary: `Session ${id} cleanup resumed`,
3762
+ metadata: { recovered: true, closed: true },
3763
+ });
3764
+ await advanceDeletion(
3765
+ questionPaths,
3766
+ deletion.deletion_id,
3767
+ "completed",
3768
+ { wal: true, turns: true, reports: false, session: true, events: true },
3769
+ { ok: true, closed: true },
3770
+ );
3771
+ return { ok: true, closed: true };
3772
+ }
3773
+ if (deletion?.safe_response) {
3774
+ const safeResponse = asRecord(deletion.safe_response);
3775
+ const safeSessionId = optionalString(safeResponse?.session_id ?? safeResponse?.sessionId);
3776
+ if (
3777
+ safeResponse?.ok === true &&
3778
+ safeResponse.closed === true &&
3779
+ (safeSessionId === null || safeSessionId === id)
3780
+ )
3781
+ return deletion.safe_response as { ok: boolean; closed: boolean };
3782
+ throw new SdkClientError("state_corrupt", "Persisted reap response does not prove completed cleanup.");
3783
+ }
3463
3784
  return { ok: false, reason: "unknown_session", closed: false };
3464
3785
  }
3465
3786
 
@@ -3481,58 +3802,75 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
3481
3802
  const deletionId = `delete:${id}:${persistedIncarnation}`;
3482
3803
  const deletionKey = createHash("sha256").update(deletionId).digest("hex");
3483
3804
  await ensureQuestionStateReady();
3484
- await ensureQuestionTransaction(id);
3485
- await withSessionTransaction(questionPaths, id, async transaction => {
3486
- const now = new Date().toISOString();
3487
- transaction.requests.operations[deletionId] = {
3488
- operation_id: deletionId,
3489
- tool: "gjc_coordinator_stop_session",
3490
- key_digest: deletionKey,
3491
- request_digest: deletionKey,
3492
- local_id: id,
3493
- phase: "remote_started",
3494
- intent: { kind: "reap", endpoint_incarnation: persistedIncarnation },
3495
- created_at: now,
3496
- updated_at: now,
3497
- };
3498
- });
3499
- await recordDeletionIntent(questionPaths, {
3500
- deletion_id: deletionId,
3501
- session_id: id,
3502
- endpoint_incarnation: persistedIncarnation,
3503
- operation_id: deletionId,
3504
- key_digest: deletionKey,
3505
- request_digest: deletionKey,
3506
- close_key: deletionId,
3507
- phase: "intent",
3508
- cleanup: { wal: false, turns: false, reports: false, session: false, events: false },
3509
- authority_digest: deletionKey,
3510
- created_at: new Date().toISOString(),
3511
- updated_at: new Date().toISOString(),
3512
- });
3513
-
3805
+ const deletionPhase = await withNamespaceRegistry(
3806
+ questionPaths,
3807
+ async registry => registry.deletions[deletionId]?.phase,
3808
+ );
3809
+ const closeAlreadyProven = deletionPhase === "broker_closed" || deletionPhase === "cleanup_pending";
3514
3810
  let workspace = "";
3515
3811
  try {
3516
3812
  workspace = await canonicalBrokerWorkspace(cwd);
3517
- const authority = await exactBrokerSessionAuthority(id, workspace);
3518
- if (
3519
- !sameCanonicalPath(authority.workspace, persistedWorkspace, platform) ||
3520
- authority.endpointGeneration !== persistedGeneration ||
3521
- authority.endpointIncarnation !== persistedIncarnation
3522
- )
3523
- return { ok: false, reason: "endpoint_stale", closed: false };
3524
- brokerResult(
3525
- await brokerSession(
3526
- cwd,
3527
- "session.close",
3528
- {
3529
- sessionId: id,
3530
- endpointGeneration: authority.endpointGeneration,
3531
- endpointIncarnation: authority.endpointIncarnation,
3532
- },
3533
- `coordinator-reap:${id}:${authority.endpointIncarnation}`,
3534
- ),
3535
- );
3813
+ let authority: BrokerSessionAuthority | null = null;
3814
+ if (!closeAlreadyProven) {
3815
+ authority = await exactBrokerSessionAuthority(id, workspace);
3816
+ if (
3817
+ !sameCanonicalPath(authority.workspace, persistedWorkspace, platform) ||
3818
+ authority.endpointGeneration !== persistedGeneration ||
3819
+ authority.endpointIncarnation !== persistedIncarnation
3820
+ )
3821
+ return { ok: false, reason: "endpoint_stale", closed: false };
3822
+ }
3823
+ await ensureQuestionStateReady();
3824
+ await ensureQuestionTransaction(id);
3825
+ await withSessionTransaction(questionPaths, id, async transaction => {
3826
+ const now = new Date().toISOString();
3827
+ transaction.requests.operations[deletionId] = {
3828
+ operation_id: deletionId,
3829
+ tool: "gjc_coordinator_stop_session",
3830
+ key_digest: deletionKey,
3831
+ request_digest: deletionKey,
3832
+ local_id: id,
3833
+ phase: "remote_started",
3834
+ intent: { kind: "reap", endpoint_incarnation: persistedIncarnation },
3835
+ created_at: now,
3836
+ updated_at: now,
3837
+ };
3838
+ });
3839
+ await recordDeletionIntent(questionPaths, {
3840
+ deletion_id: deletionId,
3841
+ session_id: id,
3842
+ endpoint_incarnation: persistedIncarnation,
3843
+ operation_id: deletionId,
3844
+ key_digest: deletionKey,
3845
+ request_digest: deletionKey,
3846
+ close_key: deletionId,
3847
+ phase: "intent",
3848
+ cleanup: { wal: false, turns: false, reports: false, session: false, events: false },
3849
+ authority_digest: deletionKey,
3850
+ created_at: new Date().toISOString(),
3851
+ updated_at: new Date().toISOString(),
3852
+ });
3853
+ if (!closeAlreadyProven) {
3854
+ if (!authority) throw new Error("state_corrupt");
3855
+ strictBrokerSessionClose(
3856
+ await brokerSession(
3857
+ cwd,
3858
+ "session.close",
3859
+ {
3860
+ sessionId: id,
3861
+ endpointGeneration: authority.endpointGeneration,
3862
+ endpointIncarnation: authority.endpointIncarnation,
3863
+ },
3864
+ `coordinator-reap:${id}:${authority.endpointIncarnation}`,
3865
+ ),
3866
+ id,
3867
+ );
3868
+ await advanceDeletion(questionPaths, deletionId, "broker_closed", undefined, {
3869
+ ok: true,
3870
+ closed: true,
3871
+ session_id: id,
3872
+ });
3873
+ }
3536
3874
  } catch (error) {
3537
3875
  return {
3538
3876
  ok: false,
@@ -3541,8 +3879,6 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
3541
3879
  closed: false,
3542
3880
  };
3543
3881
  }
3544
- await advanceDeletion(questionPaths, deletionId, "broker_closed");
3545
-
3546
3882
  try {
3547
3883
  const listing = await paginatedBrokerSessionList(workspace, { cwd: workspace });
3548
3884
  const rows = jsonRecords(
@@ -3588,9 +3924,9 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
3588
3924
  return { ok: false, reason: "close_failed", detail: error.code, closed: false };
3589
3925
  return { ok: false, reason: "close_failed", detail: "unavailable", closed: false };
3590
3926
  }
3591
- await fs.rm(sessionFile(id), { force: true });
3592
- await fs.rm(sessionStateFile(namespaceDir, id), { force: true });
3593
- await fs.rm(activeTurnFile(namespaceDir, id), { force: true });
3927
+ await removeCoordinatorFile(sessionFile(id));
3928
+ await removeCoordinatorFile(sessionStateFile(namespaceDir, id));
3929
+ await removeCoordinatorFile(activeTurnFile(namespaceDir, id));
3594
3930
  await appendCoordinatorEvent(namespaceDir, {
3595
3931
  kind: "session.reaped",
3596
3932
  sessionId: id,
@@ -3988,22 +4324,30 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
3988
4324
  for (const report of Object.values(canonical.reports))
3989
4325
  await writeJsonFile(path.join(namespaceDir, "reports", `${report.report_id}.json`), report);
3990
4326
  const canonicalTurnIds = new Set(Object.keys(canonical.turns));
3991
- for (const entry of await fs.readdir(turnsDir(namespaceDir)).catch(() => [])) {
4327
+ const turnEntries = await fs.readdir(turnsDir(namespaceDir)).catch(error => {
4328
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
4329
+ throw error;
4330
+ });
4331
+ for (const entry of turnEntries) {
3992
4332
  if (!entry.endsWith(".json")) continue;
3993
4333
  const turnId = entry.slice(0, -5);
3994
4334
  if (canonicalTurnIds.has(turnId)) continue;
3995
4335
  const projected = asRecord(await readJsonFile(path.join(turnsDir(namespaceDir), entry)));
3996
4336
  if (projected?.session_id === sessionId)
3997
- await fs.rm(path.join(turnsDir(namespaceDir), entry), { force: true });
4337
+ await removeCoordinatorFile(path.join(turnsDir(namespaceDir), entry));
3998
4338
  }
3999
4339
  const reportsDirectory = path.join(namespaceDir, "reports");
4000
4340
  const canonicalReportIds = new Set(Object.keys(canonical.reports));
4001
- for (const entry of await fs.readdir(reportsDirectory).catch(() => [])) {
4341
+ const reportEntries = await fs.readdir(reportsDirectory).catch(error => {
4342
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
4343
+ throw error;
4344
+ });
4345
+ for (const entry of reportEntries) {
4002
4346
  if (!entry.endsWith(".json")) continue;
4003
4347
  const reportId = entry.slice(0, -5);
4004
4348
  if (canonicalReportIds.has(reportId)) continue;
4005
4349
  const projected = asRecord(await readJsonFile(path.join(reportsDirectory, entry)));
4006
- if (projected?.session_id === sessionId) await fs.rm(path.join(reportsDirectory, entry), { force: true });
4350
+ if (projected?.session_id === sessionId) await removeCoordinatorFile(path.join(reportsDirectory, entry));
4007
4351
  }
4008
4352
  const activeId = canonical.queue.selected_promotion?.to_turn_id ?? canonical.queue.active_turn_id;
4009
4353
  const active = activeId ? canonical.turns[activeId] : null;
@@ -4535,13 +4879,25 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
4535
4879
  available_profiles: mpresetResolution.available_profiles,
4536
4880
  };
4537
4881
  }
4882
+ // Explicit model pin (#4707) is resolved by the SDK host. The
4883
+ // coordinator remains a transport/controller boundary and never loads
4884
+ // session, settings, discovery, or MCP-manager authority.
4885
+ const modelResolution = await resolveModelPin(args.model, canonicalCwd);
4886
+ if (!modelResolution.ok) {
4887
+ return {
4888
+ ok: false,
4889
+ reason: modelResolution.reason,
4890
+ model: modelResolution.model,
4891
+ error: { code: "unknown_model", message: modelResolution.error },
4892
+ };
4893
+ }
4538
4894
  const hasTask = typeof args.task === "string" && args.task.trim().length > 0;
4539
4895
  const hasPrompt = typeof args.prompt === "string" && args.prompt.trim().length > 0;
4540
4896
  const task = hasTask ? String(args.task) : hasPrompt ? String(args.prompt) : null;
4541
4897
  if (!task) return { ok: false, reason: "task_required" };
4542
4898
  const taggedPrompt = workflowPrompt(delegateWorkflow, name, canonicalCwd, task, {
4543
4899
  mutationRequested: args.allow_mutation === true,
4544
- model: typeof args.model === "string" ? args.model : null,
4900
+ model: modelResolution.model,
4545
4901
  });
4546
4902
  const reusedSessionId = args.session_id == null ? undefined : safeExternalId("session", args.session_id);
4547
4903
  const explicitHostWorkUnit =
@@ -4558,7 +4914,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
4558
4914
  queue: args.queue === true,
4559
4915
  force: args.force === true,
4560
4916
  mpreset: mpresetResolution.mpreset,
4561
- model: typeof args.model === "string" ? args.model : null,
4917
+ model: modelResolution.model,
4562
4918
  await_completion: args.await_completion === true,
4563
4919
  ...(args.await_completion === true
4564
4920
  ? { timeout_ms: args.timeout_ms, poll_interval_ms: args.poll_interval_ms }
@@ -4572,215 +4928,244 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
4572
4928
  idempotencyKey,
4573
4929
  canonicalArgs,
4574
4930
  async () => {
4575
- const delegate = async () => {
4576
- let sessionId: string;
4577
- let session: Record<string, unknown>;
4578
- let reusedSession = false;
4579
- let creationKey: string | null = null;
4580
- if (reusedSessionId) {
4581
- sessionId = reusedSessionId;
4582
- const existing = asRecord(await readJsonFile(sessionFile(sessionId)));
4583
- if (!existing)
4584
- return {
4585
- ok: false,
4586
- error: { code: "not_found", message: `Coordinator session not found: ${sessionId}` },
4587
- };
4588
- const sessionMpreset = optionalString(existing.mpreset);
4589
- if (mpresetResolution.mpreset !== null && sessionMpreset !== mpresetResolution.mpreset) {
4590
- return {
4591
- ok: false,
4592
- reason: "mpreset_conflict",
4593
- session_id: sessionId,
4594
- session_mpreset: sessionMpreset,
4595
- requested_mpreset: mpresetResolution.mpreset,
4596
- };
4597
- }
4598
- const existingCwd = optionalString(existing.cwd);
4599
- if (
4600
- !existingCwd ||
4601
- !sameCanonicalPath(await canonicalBrokerWorkspace(existingCwd), canonicalCwd, platform)
4602
- )
4603
- return {
4604
- ok: false,
4605
- error: {
4606
- code: "workspace_mismatch",
4607
- message: "Coordinator session is bound to another workspace.",
4608
- },
4609
- };
4610
- const binding = await exactBrokerSessionBinding(sessionId, canonicalCwd);
4611
- if (
4612
- !sameCanonicalPath(
4613
- optionalString(existing.broker_workspace) ?? "",
4614
- canonicalCwd,
4615
- platform,
4616
- ) ||
4617
- existing.endpoint_generation !== binding.endpointGeneration ||
4618
- optionalString(existing.endpoint_incarnation) !== binding.endpointIncarnation
4619
- )
4620
- return {
4621
- ok: false,
4622
- error: {
4623
- code: "endpoint_stale",
4624
- message: "Coordinator session endpoint incarnation binding is stale.",
4625
- },
4626
- };
4627
- session = normalizeSession({
4628
- ...existing,
4629
- session_id: sessionId,
4630
- cwd: canonicalCwd,
4631
- broker_workspace: binding.workspace,
4632
- endpoint_generation: binding.endpointGeneration,
4633
- endpoint_incarnation: binding.endpointIncarnation,
4634
- });
4635
- reusedSession = true;
4636
- } else {
4637
- const creation = await claimProductionCreation(name, idempotencyKey, canonicalArgs);
4638
- if (creation.request.phase === "completed" && creation.request.safe_response)
4639
- return creation.request.safe_response;
4640
- creationKey = creation.keyDigest;
4641
- if (creation.request.canonical_create_intent) {
4642
- session = sessionFromCreationSnapshot(creation.request.canonical_create_intent.session);
4643
- await bindCreationRequest(
4644
- questionPaths,
4645
- creation.keyDigest,
4646
- creation.request.canonical_create_intent,
4647
- );
4648
- await commitCreationWal(
4649
- questionPaths,
4650
- creation.keyDigest,
4651
- creation.request.canonical_create_intent,
4652
- );
4653
- sessionId = creation.request.canonical_create_intent.session.session_id;
4654
- } else {
4655
- const created = brokerResult(
4656
- await brokerSession(
4931
+ const remoteSession = { value: null as string | null };
4932
+ const delegate = async () =>
4933
+ withRemoteSessionCompensation(canonicalCwd, remoteSession, async () => {
4934
+ let sessionId: string;
4935
+ let session: Record<string, unknown>;
4936
+ let reusedSession = false;
4937
+ let creationKey: string | null = null;
4938
+ if (reusedSessionId) {
4939
+ sessionId = reusedSessionId;
4940
+ const existing = asRecord(await readJsonFile(sessionFile(sessionId)));
4941
+ if (!existing)
4942
+ return {
4943
+ ok: false,
4944
+ error: { code: "not_found", message: `Coordinator session not found: ${sessionId}` },
4945
+ };
4946
+ const sessionMpreset = optionalString(existing.mpreset);
4947
+ if (mpresetResolution.mpreset !== null && sessionMpreset !== mpresetResolution.mpreset) {
4948
+ return {
4949
+ ok: false,
4950
+ reason: "mpreset_conflict",
4951
+ session_id: sessionId,
4952
+ session_mpreset: sessionMpreset,
4953
+ requested_mpreset: mpresetResolution.mpreset,
4954
+ };
4955
+ }
4956
+ const sessionModel = optionalString(existing.model);
4957
+ if (modelResolution.model !== null && sessionModel !== modelResolution.model) {
4958
+ return {
4959
+ ok: false,
4960
+ reason: "model_conflict",
4961
+ session_id: sessionId,
4962
+ session_model: sessionModel,
4963
+ requested_model: modelResolution.model,
4964
+ };
4965
+ }
4966
+ const existingCwd = optionalString(existing.cwd);
4967
+ if (
4968
+ !existingCwd ||
4969
+ !sameCanonicalPath(await canonicalBrokerWorkspace(existingCwd), canonicalCwd, platform)
4970
+ )
4971
+ return {
4972
+ ok: false,
4973
+ error: {
4974
+ code: "workspace_mismatch",
4975
+ message: "Coordinator session is bound to another workspace.",
4976
+ },
4977
+ };
4978
+ const binding = await exactBrokerSessionBinding(sessionId, canonicalCwd);
4979
+ if (
4980
+ !sameCanonicalPath(
4981
+ optionalString(existing.broker_workspace) ?? "",
4657
4982
  canonicalCwd,
4658
- "session.create",
4659
- {
4660
- cwd: canonicalCwd,
4661
- target: coordinatorLifecycleTarget(config.sessionCommand, canonicalCwd),
4662
- ...(mpresetResolution.mpreset ? { modelPreset: mpresetResolution.mpreset } : {}),
4663
- // Thread the coordinator state dir so the broker-spawned runtime
4664
- // writes terminal state to the coordinator-shared file (#2549).
4665
- coordinatorStateDir: namespaceDir,
4983
+ platform,
4984
+ ) ||
4985
+ existing.endpoint_generation !== binding.endpointGeneration ||
4986
+ optionalString(existing.endpoint_incarnation) !== binding.endpointIncarnation
4987
+ )
4988
+ return {
4989
+ ok: false,
4990
+ error: {
4991
+ code: "endpoint_stale",
4992
+ message: "Coordinator session endpoint incarnation binding is stale.",
4666
4993
  },
4667
- idempotencyKey,
4668
- ),
4669
- );
4670
- sessionId = safeExternalId("session", created.sessionId ?? created.session_id);
4671
- const createdCwd = await canonicalBrokerWorkspace(
4672
- optionalString(created.cwd) ?? canonicalCwd,
4673
- );
4674
- const binding = await exactBrokerSessionBinding(sessionId, createdCwd);
4994
+ };
4675
4995
  session = normalizeSession({
4996
+ ...existing,
4676
4997
  session_id: sessionId,
4677
- cwd: createdCwd,
4678
- ephemeral: true,
4679
- created_at: new Date().toISOString(),
4680
- ...(mpresetResolution.mpreset ? { mpreset: mpresetResolution.mpreset } : {}),
4998
+ cwd: canonicalCwd,
4681
4999
  broker_workspace: binding.workspace,
4682
5000
  endpoint_generation: binding.endpointGeneration,
4683
5001
  endpoint_incarnation: binding.endpointIncarnation,
4684
5002
  });
4685
- const intent: CanonicalCreateIntentV1 = {
4686
- kind: "delegate",
4687
- workflow: delegateWorkflow,
4688
- session: canonicalCreationSnapshot(session),
4689
- remote_create_key: creation.request.remote_create_key,
4690
- initial_state: "running",
4691
- initial_prompt: {
4692
- text: taggedPrompt,
4693
- caller_key_digest: createHash("sha256").update(idempotencyKey).digest("hex"),
5003
+ reusedSession = true;
5004
+ } else {
5005
+ const creation = await claimProductionCreation(name, idempotencyKey, canonicalArgs);
5006
+ if (creation.request.phase === "completed" && creation.request.safe_response)
5007
+ return creation.request.safe_response;
5008
+ creationKey = creation.keyDigest;
5009
+ if (creation.request.canonical_create_intent) {
5010
+ session = sessionFromCreationSnapshot(creation.request.canonical_create_intent.session);
5011
+ await bindCreationRequest(
5012
+ questionPaths,
5013
+ creation.keyDigest,
5014
+ creation.request.canonical_create_intent,
5015
+ );
5016
+ await commitCreationWal(
5017
+ questionPaths,
5018
+ creation.keyDigest,
5019
+ creation.request.canonical_create_intent,
5020
+ );
5021
+ sessionId = creation.request.canonical_create_intent.session.session_id;
5022
+ } else {
5023
+ const created = brokerResult(
5024
+ await brokerSession(
5025
+ canonicalCwd,
5026
+ "session.create",
5027
+ {
5028
+ cwd: canonicalCwd,
5029
+ target: coordinatorLifecycleTarget(config.sessionCommand, canonicalCwd),
5030
+ ...(mpresetResolution.mpreset ? { modelPreset: mpresetResolution.mpreset } : {}),
5031
+ ...(modelResolution.model ? { modelId: modelResolution.model } : {}),
5032
+ // Thread the coordinator state dir so the broker-spawned runtime
5033
+ // writes terminal state to the coordinator-shared file (#2549).
5034
+ coordinatorStateDir: namespaceDir,
5035
+ },
5036
+ idempotencyKey,
5037
+ ),
5038
+ );
5039
+ const createdSessionId = optionalString(created.sessionId ?? created.session_id);
5040
+ if (!createdSessionId)
5041
+ throw new SdkClientError(
5042
+ UNOBSERVED_COMPENSATION_CODE,
5043
+ "SDK broker created a session but returned no usable session identity; the outcome is unobserved.",
5044
+ { creation_response: created },
5045
+ );
5046
+ remoteSession.value = createdSessionId;
5047
+ sessionId = safeExternalId("session", createdSessionId);
5048
+ const createdCwd = await canonicalBrokerWorkspace(
5049
+ optionalString(created.cwd) ?? canonicalCwd,
5050
+ );
5051
+ const binding = await exactBrokerSessionBinding(sessionId, createdCwd);
5052
+ session = normalizeSession({
5053
+ session_id: sessionId,
5054
+ cwd: createdCwd,
5055
+ ephemeral: true,
5056
+ created_at: new Date().toISOString(),
5057
+ ...(mpresetResolution.mpreset ? { mpreset: mpresetResolution.mpreset } : {}),
5058
+ ...(modelResolution.model ? { model: modelResolution.model } : {}),
5059
+ broker_workspace: binding.workspace,
5060
+ endpoint_generation: binding.endpointGeneration,
5061
+ endpoint_incarnation: binding.endpointIncarnation,
5062
+ });
5063
+ const intent: CanonicalCreateIntentV1 = {
5064
+ kind: "delegate",
5065
+ workflow: delegateWorkflow,
5066
+ session: canonicalCreationSnapshot(session),
5067
+ remote_create_key: creation.request.remote_create_key,
5068
+ initial_state: "running",
5069
+ initial_prompt: {
5070
+ text: taggedPrompt,
5071
+ caller_key_digest: createHash("sha256").update(idempotencyKey).digest("hex"),
5072
+ },
5073
+ initial_events: [],
5074
+ };
5075
+ await bindCreationRequest(questionPaths, creation.keyDigest, intent);
5076
+ await commitCreationWal(questionPaths, creation.keyDigest, intent);
5077
+ }
5078
+ }
5079
+ await writeJsonFile(sessionFile(sessionId), session);
5080
+ const previousActiveTurn = await readActiveTurn(namespaceDir, sessionId);
5081
+ if (previousActiveTurn && args.queue !== true && args.force !== true) {
5082
+ return {
5083
+ ok: false,
5084
+ error: {
5085
+ code: "active_turn_exists",
5086
+ message: `Session ${sessionId} already has active turn ${previousActiveTurn.turn_id}.`,
4694
5087
  },
4695
- initial_events: [],
5088
+ turn_id: previousActiveTurn.turn_id,
4696
5089
  };
4697
- await bindCreationRequest(questionPaths, creation.keyDigest, intent);
4698
- await commitCreationWal(questionPaths, creation.keyDigest, intent);
4699
5090
  }
4700
- }
4701
- await writeJsonFile(sessionFile(sessionId), session);
4702
- const previousActiveTurn = await readActiveTurn(namespaceDir, sessionId);
4703
- if (previousActiveTurn && args.queue !== true && args.force !== true) {
4704
- return {
4705
- ok: false,
4706
- error: {
4707
- code: "active_turn_exists",
4708
- message: `Session ${sessionId} already has active turn ${previousActiveTurn.turn_id}.`,
5091
+ const operation =
5092
+ args.force === true
5093
+ ? "turn.abort_and_prompt"
5094
+ : args.queue === true
5095
+ ? "turn.follow_up"
5096
+ : "turn.prompt";
5097
+ const promptKey = await claimCanonicalPrompt(
5098
+ sessionId,
5099
+ taggedPrompt,
5100
+ operation,
5101
+ idempotencyKey,
5102
+ );
5103
+ const result = await controlSession(session, operation, { text: taggedPrompt }, idempotencyKey);
5104
+ const acknowledgement = requirePromptAcknowledgement(result);
5105
+ const turn = await recordAcceptedPrompt(
5106
+ sessionId,
5107
+ taggedPrompt,
5108
+ operation,
5109
+ previousActiveTurn,
5110
+ acknowledgement,
5111
+ promptKey,
5112
+ );
5113
+ const codexHandoff = await autoBindDelegateCodexHandoff(
5114
+ namespaceDir,
5115
+ canonicalCwd,
5116
+ sessionId,
5117
+ turn.turn_id,
5118
+ delegateWorkflow,
5119
+ explicitHostWorkUnit,
5120
+ );
5121
+ await appendCoordinatorEvent(namespaceDir, {
5122
+ kind: "delegation.started",
5123
+ sessionId,
5124
+ turnId: turn.turn_id,
5125
+ summary: `Delegated ${delegateWorkflow} via ${name} on session ${sessionId}`,
5126
+ metadata: {
5127
+ workflow: delegateWorkflow,
5128
+ tool_name: name,
5129
+ reused_session: reusedSession,
5130
+ sdk_operation: operation,
4709
5131
  },
4710
- turn_id: previousActiveTurn.turn_id,
4711
- };
4712
- }
4713
- const operation =
4714
- args.force === true
4715
- ? "turn.abort_and_prompt"
4716
- : args.queue === true
4717
- ? "turn.follow_up"
4718
- : "turn.prompt";
4719
- const promptKey = await claimCanonicalPrompt(sessionId, taggedPrompt, operation, idempotencyKey);
4720
- const result = await controlSession(session, operation, { text: taggedPrompt }, idempotencyKey);
4721
- const acknowledgement = requirePromptAcknowledgement(result);
4722
- const turn = await recordAcceptedPrompt(
4723
- sessionId,
4724
- taggedPrompt,
4725
- operation,
4726
- previousActiveTurn,
4727
- acknowledgement,
4728
- promptKey,
4729
- );
4730
- const codexHandoff = await autoBindDelegateCodexHandoff(
4731
- namespaceDir,
4732
- canonicalCwd,
4733
- sessionId,
4734
- turn.turn_id,
4735
- delegateWorkflow,
4736
- explicitHostWorkUnit,
4737
- );
4738
- await appendCoordinatorEvent(namespaceDir, {
4739
- kind: "delegation.started",
4740
- sessionId,
4741
- turnId: turn.turn_id,
4742
- summary: `Delegated ${delegateWorkflow} via ${name} on session ${sessionId}`,
4743
- metadata: {
5132
+ });
5133
+ const response = {
5134
+ ok: true,
4744
5135
  workflow: delegateWorkflow,
4745
5136
  tool_name: name,
4746
- reused_session: reusedSession,
4747
- sdk_operation: operation,
4748
- },
5137
+ session_id: sessionId,
5138
+ turn_id: turn.turn_id,
5139
+ active_turn_id: turn.delivery.queued ? (previousActiveTurn?.turn_id ?? null) : turn.turn_id,
5140
+ status: turn.status,
5141
+ queued: turn.delivery.queued,
5142
+ delivered: turn.delivery.delivered,
5143
+ delivery: turn.delivery,
5144
+ session: publicCoordinatorSession(session),
5145
+ session_state: publicCoordinatorSessionState(
5146
+ await readSessionState(namespaceDir, sessionId),
5147
+ ),
5148
+ turn: boundedPublicValue(turn, { remaining: COORDINATOR_IDEMPOTENCY_RESPONSE_BYTE_CAP }),
5149
+ result: publicSdkAcknowledgement(acknowledgement),
5150
+ codex_handoff: codexHandoff,
5151
+ ...(hasTask && hasPrompt ? { prompt_alias_ignored: true } : {}),
5152
+ };
5153
+ if (creationKey) {
5154
+ await advanceCreationReceipt(questionPaths, creationKey, "projected", response);
5155
+ await advanceCreationReceipt(questionPaths, creationKey, "completed", response);
5156
+ }
5157
+ return args.await_completion === true
5158
+ ? {
5159
+ ...response,
5160
+ completion: await awaitTurnPayload(
5161
+ turn.turn_id,
5162
+ sessionId,
5163
+ args.timeout_ms,
5164
+ args.poll_interval_ms,
5165
+ ),
5166
+ }
5167
+ : response;
4749
5168
  });
4750
- const response = {
4751
- ok: true,
4752
- workflow: delegateWorkflow,
4753
- tool_name: name,
4754
- session_id: sessionId,
4755
- turn_id: turn.turn_id,
4756
- active_turn_id: turn.delivery.queued ? (previousActiveTurn?.turn_id ?? null) : turn.turn_id,
4757
- status: turn.status,
4758
- queued: turn.delivery.queued,
4759
- delivered: turn.delivery.delivered,
4760
- delivery: turn.delivery,
4761
- session: publicCoordinatorSession(session),
4762
- session_state: publicCoordinatorSessionState(await readSessionState(namespaceDir, sessionId)),
4763
- turn: boundedPublicValue(turn, { remaining: COORDINATOR_IDEMPOTENCY_RESPONSE_BYTE_CAP }),
4764
- result: publicSdkAcknowledgement(acknowledgement),
4765
- codex_handoff: codexHandoff,
4766
- ...(hasTask && hasPrompt ? { prompt_alias_ignored: true } : {}),
4767
- };
4768
- if (creationKey) {
4769
- await advanceCreationReceipt(questionPaths, creationKey, "projected", response);
4770
- await advanceCreationReceipt(questionPaths, creationKey, "completed", response);
4771
- }
4772
- return args.await_completion === true
4773
- ? {
4774
- ...response,
4775
- completion: await awaitTurnPayload(
4776
- turn.turn_id,
4777
- sessionId,
4778
- args.timeout_ms,
4779
- args.poll_interval_ms,
4780
- ),
4781
- }
4782
- : response;
4783
- };
4784
5169
  return reusedSessionId ? await withSessionTransition(reusedSessionId, delegate) : await delegate();
4785
5170
  },
4786
5171
  true,
@@ -4821,6 +5206,17 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
4821
5206
  available_profiles: mpresetResolution.available_profiles,
4822
5207
  };
4823
5208
  }
5209
+ // Explicit model pin (#4707): resolve with CLI grammar at the SDK host
5210
+ // boundary before any coordinator mutation or lifecycle request.
5211
+ const modelResolution = await resolveModelPin(args.model, cwd);
5212
+ if (!modelResolution.ok) {
5213
+ return {
5214
+ ok: false,
5215
+ reason: modelResolution.reason,
5216
+ model: modelResolution.model,
5217
+ error: { code: "unknown_model", message: modelResolution.error },
5218
+ };
5219
+ }
4824
5220
  const prompt = typeof args.prompt === "string" && args.prompt.length > 0 ? args.prompt : null;
4825
5221
  /**
4826
5222
  * A prepared session is deliberately not ready for input: its readiness
@@ -4855,6 +5251,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
4855
5251
  const canonicalArgs = {
4856
5252
  cwd,
4857
5253
  mpreset: mpresetResolution.mpreset,
5254
+ model: modelResolution.model,
4858
5255
  ...(prompt ? { prompt } : {}),
4859
5256
  ...(preparesExistingThread ? { prepare_existing_thread: true } : {}),
4860
5257
  allow_mutation: true,
@@ -4864,148 +5261,178 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
4864
5261
  idempotencyKey,
4865
5262
  canonicalArgs,
4866
5263
  async () => {
4867
- const creation = await claimProductionCreation(name, idempotencyKey, canonicalArgs);
4868
- if (creation.request.phase === "completed" && creation.request.safe_response)
4869
- return creation.request.safe_response;
4870
- let created: Record<string, unknown>;
4871
- let session: Record<string, unknown>;
4872
- let sessionId: string;
4873
- if (creation.request.canonical_create_intent) {
4874
- session = sessionFromCreationSnapshot(creation.request.canonical_create_intent.session);
4875
- sessionId = creation.request.canonical_create_intent.session.session_id;
4876
- created = { session_id: sessionId };
4877
- } else {
4878
- created = brokerResult(
4879
- await brokerSession(
4880
- cwd,
4881
- "session.create",
4882
- {
4883
- cwd,
4884
- target: coordinatorLifecycleTarget(config.sessionCommand, cwd),
4885
- ...(mpresetResolution.mpreset ? { modelPreset: mpresetResolution.mpreset } : {}),
4886
- ...(preparesExistingThread ? { readiness: "deferred" } : {}),
4887
- // Thread the coordinator state dir so the broker-spawned runtime
4888
- // writes terminal state to the coordinator-shared file (#2549).
4889
- coordinatorStateDir: namespaceDir,
4890
- },
4891
- idempotencyKey,
4892
- ),
4893
- );
4894
- /**
4895
- * Preparation is only real when the broker proves it. A create that
4896
- * silently published readiness would leave a live session whose root
4897
- * is already claimed, so the session is closed rather than reported
4898
- * as prepared.
4899
- */
4900
- if (preparesExistingThread && created.readiness !== "prepared") {
4901
- const unpreparedId = optionalString(created.sessionId ?? created.session_id);
4902
- let compensated = true;
4903
- if (unpreparedId) {
4904
- compensated = await brokerSession(
5264
+ const remoteSession = { value: null as string | null };
5265
+ return await withRemoteSessionCompensation(cwd, remoteSession, async () => {
5266
+ const creation = await claimProductionCreation(name, idempotencyKey, canonicalArgs);
5267
+ if (creation.request.phase === "completed" && creation.request.safe_response)
5268
+ return creation.request.safe_response;
5269
+ let created: Record<string, unknown>;
5270
+ let session: Record<string, unknown>;
5271
+ let sessionId: string;
5272
+ if (creation.request.canonical_create_intent) {
5273
+ session = sessionFromCreationSnapshot(creation.request.canonical_create_intent.session);
5274
+ sessionId = creation.request.canonical_create_intent.session.session_id;
5275
+ created = { session_id: sessionId };
5276
+ } else {
5277
+ created = brokerResult(
5278
+ await brokerSession(
4905
5279
  cwd,
4906
- "session.close",
4907
- { sessionId: unpreparedId },
4908
- `${idempotencyKey}:unprepared-close`,
4909
- ).then(
4910
- () => true,
4911
- () => false,
5280
+ "session.create",
5281
+ {
5282
+ cwd,
5283
+ target: coordinatorLifecycleTarget(config.sessionCommand, cwd),
5284
+ ...(mpresetResolution.mpreset ? { modelPreset: mpresetResolution.mpreset } : {}),
5285
+ ...(modelResolution.model ? { modelId: modelResolution.model } : {}),
5286
+ ...(preparesExistingThread ? { readiness: "deferred" } : {}),
5287
+ // Thread the coordinator state dir so the broker-spawned runtime
5288
+ // writes terminal state to the coordinator-shared file (#2549).
5289
+ coordinatorStateDir: namespaceDir,
5290
+ },
5291
+ idempotencyKey,
5292
+ ),
5293
+ );
5294
+ const createdSessionId = optionalString(created.sessionId ?? created.session_id);
5295
+ /**
5296
+ * Preparation is only real when the broker proves it. A create that
5297
+ * silently published readiness would leave a live session whose root
5298
+ * is already claimed, so the session is closed rather than reported
5299
+ * what the schema promises. Reject a non-boolean before any mutation.
5300
+ */
5301
+ if (preparesExistingThread && created.readiness !== "prepared") {
5302
+ if (!createdSessionId)
5303
+ throw new SdkClientError(
5304
+ UNOBSERVED_COMPENSATION_CODE,
5305
+ "SDK broker did not prepare the requested session and returned no usable session identity.",
5306
+ { creation_response: created },
5307
+ );
5308
+ remoteSession.value = createdSessionId;
5309
+ const unpreparedId = safeExternalId("session", createdSessionId);
5310
+ let compensated = true;
5311
+ if (unpreparedId) {
5312
+ try {
5313
+ strictBrokerSessionClose(
5314
+ await brokerSession(
5315
+ cwd,
5316
+ "session.close",
5317
+ { sessionId: unpreparedId },
5318
+ `${idempotencyKey}:unprepared-close`,
5319
+ ),
5320
+ unpreparedId,
5321
+ );
5322
+ } catch {
5323
+ compensated = false;
5324
+ }
5325
+ }
5326
+ // A swallowed compensation leaves a live, untracked session while
5327
+ // idempotency seals the failure, so exact retries only replay the
5328
+ // cached error and never reach the session again. Name the session
5329
+ // and mark the outcome unobserved so the key is not sealed.
5330
+ if (!compensated)
5331
+ throw new SdkClientError(
5332
+ UNOBSERVED_COMPENSATION_CODE,
5333
+ `SDK broker did not prepare the requested session, and closing the unprepared session ${unpreparedId} failed; it may still be running.`,
5334
+ );
5335
+ remoteSession.value = null;
5336
+ throw new SdkClientError(
5337
+ "broker_request_unavailable",
5338
+ "SDK broker did not prepare the requested session.",
4912
5339
  );
4913
5340
  }
4914
- // A swallowed compensation leaves a live, untracked session while
4915
- // idempotency seals the failure, so exact retries only replay the
4916
- // cached error and never reach the session again. Name the session
4917
- // and mark the outcome unobserved so the key is not sealed.
4918
- if (!compensated)
5341
+ if (!createdSessionId)
4919
5342
  throw new SdkClientError(
4920
5343
  UNOBSERVED_COMPENSATION_CODE,
4921
- `SDK broker did not prepare the requested session, and closing the unprepared session ${unpreparedId} failed; it may still be running.`,
5344
+ "SDK broker created a session but returned no usable session identity; the outcome is unobserved.",
5345
+ { creation_response: created },
4922
5346
  );
4923
- throw new SdkClientError(
4924
- "broker_request_unavailable",
4925
- "SDK broker did not prepare the requested session.",
5347
+ remoteSession.value = createdSessionId;
5348
+ sessionId = safeExternalId("session", createdSessionId);
5349
+ const sessionCwd = await canonicalBrokerWorkspace(optionalString(created.cwd) ?? cwd);
5350
+ const binding = await exactBrokerSessionBinding(sessionId, sessionCwd);
5351
+ session = normalizeSession({
5352
+ session_id: sessionId,
5353
+ cwd: sessionCwd,
5354
+ ...(mpresetResolution.mpreset ? { mpreset: mpresetResolution.mpreset } : {}),
5355
+ ...(modelResolution.model ? { model: modelResolution.model } : {}),
5356
+ broker_workspace: binding.workspace,
5357
+ endpoint_generation: binding.endpointGeneration,
5358
+ endpoint_incarnation: binding.endpointIncarnation,
5359
+ });
5360
+ }
5361
+ const intent: CanonicalCreateIntentV1 = {
5362
+ kind: "start",
5363
+ session: canonicalCreationSnapshot(session),
5364
+ remote_create_key: creation.request.remote_create_key,
5365
+ initial_state: prompt ? "running" : preparesExistingThread ? "prepared" : "ready_for_input",
5366
+ initial_prompt: prompt
5367
+ ? {
5368
+ text: prompt,
5369
+ caller_key_digest: createHash("sha256").update(idempotencyKey).digest("hex"),
5370
+ }
5371
+ : null,
5372
+ initial_events: [],
5373
+ };
5374
+ await bindCreationRequest(questionPaths, creation.keyDigest, intent);
5375
+ await commitCreationWal(questionPaths, creation.keyDigest, intent);
5376
+ await writeJsonFile(sessionFile(sessionId), session);
5377
+ const lifecycle = publicLifecycleReceipt(created, sessionId);
5378
+ if (prompt) {
5379
+ const promptKey = await claimCanonicalPrompt(sessionId, prompt, "turn.prompt", idempotencyKey);
5380
+ const result = await controlSession(session, "turn.prompt", { text: prompt }, idempotencyKey);
5381
+ const acknowledgement = requirePromptAcknowledgement(result);
5382
+ const turn = await recordAcceptedPrompt(
5383
+ sessionId,
5384
+ prompt,
5385
+ "turn.prompt",
5386
+ null,
5387
+ acknowledgement,
5388
+ promptKey,
4926
5389
  );
5390
+ const response = {
5391
+ ok: true,
5392
+ session: publicCoordinatorSession(session),
5393
+ session_id: sessionId,
5394
+ lifecycle,
5395
+ turn_id: turn.turn_id,
5396
+ active_turn_id: turn.turn_id,
5397
+ status: turn.status,
5398
+ queued: turn.delivery.queued,
5399
+ delivered: turn.delivery.delivered,
5400
+ operation: "turn.prompt",
5401
+ turn: boundedPublicValue(turn, { remaining: COORDINATOR_IDEMPOTENCY_RESPONSE_BYTE_CAP }),
5402
+ result: publicSdkAcknowledgement(acknowledgement),
5403
+ session_state: publicCoordinatorSessionState(
5404
+ await readSessionState(namespaceDir, sessionId),
5405
+ ),
5406
+ };
5407
+ await advanceCreationReceipt(questionPaths, creation.keyDigest, "projected", response);
5408
+ await advanceCreationReceipt(questionPaths, creation.keyDigest, "completed", response);
5409
+ return response;
4927
5410
  }
4928
- sessionId = safeExternalId("session", created.sessionId ?? created.session_id);
4929
- const sessionCwd = await canonicalBrokerWorkspace(optionalString(created.cwd) ?? cwd);
4930
- const binding = await exactBrokerSessionBinding(sessionId, sessionCwd);
4931
- session = normalizeSession({
4932
- session_id: sessionId,
4933
- cwd: sessionCwd,
4934
- ...(mpresetResolution.mpreset ? { mpreset: mpresetResolution.mpreset } : {}),
4935
- broker_workspace: binding.workspace,
4936
- endpoint_generation: binding.endpointGeneration,
4937
- endpoint_incarnation: binding.endpointIncarnation,
4938
- });
4939
- }
4940
- const intent: CanonicalCreateIntentV1 = {
4941
- kind: "start",
4942
- session: canonicalCreationSnapshot(session),
4943
- remote_create_key: creation.request.remote_create_key,
4944
- initial_state: prompt ? "running" : preparesExistingThread ? "prepared" : "ready_for_input",
4945
- initial_prompt: prompt
4946
- ? { text: prompt, caller_key_digest: createHash("sha256").update(idempotencyKey).digest("hex") }
4947
- : null,
4948
- initial_events: [],
4949
- };
4950
- await bindCreationRequest(questionPaths, creation.keyDigest, intent);
4951
- await commitCreationWal(questionPaths, creation.keyDigest, intent);
4952
- await writeJsonFile(sessionFile(sessionId), session);
4953
- const lifecycle = publicLifecycleReceipt(created, sessionId);
4954
- if (prompt) {
4955
- const promptKey = await claimCanonicalPrompt(sessionId, prompt, "turn.prompt", idempotencyKey);
4956
- const result = await controlSession(session, "turn.prompt", { text: prompt }, idempotencyKey);
4957
- const acknowledgement = requirePromptAcknowledgement(result);
4958
- const turn = await recordAcceptedPrompt(
5411
+ const sessionState = await writeSessionState(
5412
+ namespaceDir,
4959
5413
  sessionId,
4960
- prompt,
4961
- "turn.prompt",
4962
- null,
4963
- acknowledgement,
4964
- promptKey,
5414
+ preparesExistingThread ? "prepared" : "ready_for_input",
5415
+ { live: null, reason: null },
4965
5416
  );
5417
+ await appendCoordinatorEvent(namespaceDir, {
5418
+ kind: "session.started",
5419
+ sessionId,
5420
+ summary: preparesExistingThread
5421
+ ? `Session ${sessionId} prepared through SDK lifecycle control`
5422
+ : `Session ${sessionId} started through SDK lifecycle control`,
5423
+ payloadRef: path.relative(namespaceDir, sessionFile(sessionId)),
5424
+ });
4966
5425
  const response = {
4967
5426
  ok: true,
4968
5427
  session: publicCoordinatorSession(session),
4969
- session_id: sessionId,
5428
+ session_state: publicCoordinatorSessionState(sessionState),
4970
5429
  lifecycle,
4971
- turn_id: turn.turn_id,
4972
- active_turn_id: turn.turn_id,
4973
- status: turn.status,
4974
- queued: turn.delivery.queued,
4975
- delivered: turn.delivery.delivered,
4976
- operation: "turn.prompt",
4977
- turn: boundedPublicValue(turn, { remaining: COORDINATOR_IDEMPOTENCY_RESPONSE_BYTE_CAP }),
4978
- result: publicSdkAcknowledgement(acknowledgement),
4979
- session_state: publicCoordinatorSessionState(await readSessionState(namespaceDir, sessionId)),
5430
+ ...(preparesExistingThread ? { session_id: sessionId, state: "prepared" as const } : {}),
4980
5431
  };
4981
5432
  await advanceCreationReceipt(questionPaths, creation.keyDigest, "projected", response);
4982
5433
  await advanceCreationReceipt(questionPaths, creation.keyDigest, "completed", response);
4983
5434
  return response;
4984
- }
4985
- const sessionState = await writeSessionState(
4986
- namespaceDir,
4987
- sessionId,
4988
- preparesExistingThread ? "prepared" : "ready_for_input",
4989
- { live: null, reason: null },
4990
- );
4991
- await appendCoordinatorEvent(namespaceDir, {
4992
- kind: "session.started",
4993
- sessionId,
4994
- summary: preparesExistingThread
4995
- ? `Session ${sessionId} prepared through SDK lifecycle control`
4996
- : `Session ${sessionId} started through SDK lifecycle control`,
4997
- payloadRef: path.relative(namespaceDir, sessionFile(sessionId)),
4998
5435
  });
4999
- const response = {
5000
- ok: true,
5001
- session: publicCoordinatorSession(session),
5002
- session_state: publicCoordinatorSessionState(sessionState),
5003
- lifecycle,
5004
- ...(preparesExistingThread ? { session_id: sessionId, state: "prepared" as const } : {}),
5005
- };
5006
- await advanceCreationReceipt(questionPaths, creation.keyDigest, "projected", response);
5007
- await advanceCreationReceipt(questionPaths, creation.keyDigest, "completed", response);
5008
- return response;
5009
5436
  },
5010
5437
  true,
5011
5438
  response => isUnobservedCompensation(response) || isRouterRequestAmbiguous(response),
@@ -5700,6 +6127,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
5700
6127
  const reportPath = path.join(namespaceDir, "reports", `${reportId}.json`);
5701
6128
  await writeJsonFile(reportPath, report);
5702
6129
  await appendCoordinatorEvent(namespaceDir, {
6130
+ ...(sessionId == null ? { stableId: `report-written:${reportId}` } : {}),
5703
6131
  kind: "report.written",
5704
6132
  sessionId,
5705
6133
  turnId: typeof args.turn_id === "string" ? args.turn_id : null,