catherd-cli 1.4.0 → 2.0.0-beta.20261022113309

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 (320) hide show
  1. package/README.md +188 -169
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -239
  5. package/package.json +6 -8
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/commands/catherd.md +2 -2
  10. package/plugin/skills/catherd/SKILL.md +97 -356
  11. package/plugin/skills/catherd-setup/SKILL.md +21 -20
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +19 -4
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/codex/events.ts +10 -0
  16. package/src/adapters/codex/index.ts +17 -5
  17. package/src/adapters/cursor/home.ts +1 -1
  18. package/src/adapters/cursor/index.ts +10 -4
  19. package/src/adapters/opencode/index.ts +38 -1
  20. package/src/cli.ts +27 -27
  21. package/src/domain/agents.ts +3 -1
  22. package/src/domain/bars.ts +33 -107
  23. package/src/domain/blocked.ts +72 -0
  24. package/src/domain/branch.ts +51 -0
  25. package/src/domain/brief.ts +106 -0
  26. package/src/domain/catalog.ts +69 -313
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +19 -29
  29. package/src/domain/errors.ts +6 -2
  30. package/src/domain/fail-lines.ts +26 -0
  31. package/src/domain/failover.ts +37 -75
  32. package/src/domain/findings.ts +47 -0
  33. package/src/domain/gate-brief.ts +53 -0
  34. package/src/domain/hints.ts +13 -2
  35. package/src/domain/ids.ts +13 -2
  36. package/src/domain/jev.ts +47 -2
  37. package/src/domain/judge.ts +124 -0
  38. package/src/domain/lane.ts +118 -3
  39. package/src/domain/milestone-memory.ts +53 -0
  40. package/src/domain/notice.ts +32 -8
  41. package/src/domain/notify.ts +32 -0
  42. package/src/domain/plan-doc.ts +198 -0
  43. package/src/domain/plan-lint.ts +141 -0
  44. package/src/domain/profile-rules.ts +175 -138
  45. package/src/domain/profile.ts +140 -63
  46. package/src/domain/record.ts +68 -3
  47. package/src/domain/reply.ts +29 -0
  48. package/src/domain/role-prompts.ts +182 -56
  49. package/src/domain/role-scope.ts +76 -0
  50. package/src/domain/role-tools.ts +34 -0
  51. package/src/domain/roles.ts +2 -0
  52. package/src/domain/route.ts +90 -0
  53. package/src/domain/rules.ts +33 -0
  54. package/src/domain/run-events.ts +298 -0
  55. package/src/domain/run-machine.ts +668 -0
  56. package/src/domain/run-state.ts +1310 -0
  57. package/src/domain/scorecard.ts +197 -0
  58. package/src/domain/select.ts +229 -72
  59. package/src/domain/slopalytics.ts +72 -0
  60. package/src/domain/state.ts +5 -0
  61. package/src/domain/util.ts +22 -0
  62. package/src/domain/workspace.ts +130 -0
  63. package/src/entry/answer-command.ts +39 -0
  64. package/src/entry/catalog-command.ts +24 -165
  65. package/src/entry/check-bin.ts +17 -0
  66. package/src/entry/cli-kit.ts +18 -2
  67. package/src/entry/daemon-bin.ts +22 -0
  68. package/src/entry/deps.ts +2 -0
  69. package/src/entry/doctor-command.ts +20 -2
  70. package/src/entry/glyphs.ts +15 -2
  71. package/src/entry/host-arg.ts +28 -0
  72. package/src/entry/init-command.ts +20 -69
  73. package/src/entry/knowledge-command.ts +121 -5
  74. package/src/entry/lock-command.ts +103 -8
  75. package/src/entry/mcp/handshake.ts +6 -2
  76. package/src/entry/mcp/role-bin.ts +20 -0
  77. package/src/entry/mcp/role-server.ts +134 -0
  78. package/src/entry/mcp/server.ts +85 -21
  79. package/src/entry/mcp/session-tools.ts +149 -0
  80. package/src/entry/panel/openapi.ts +189 -0
  81. package/src/entry/panel/server.ts +410 -0
  82. package/src/entry/panel-command.ts +68 -0
  83. package/src/entry/pause-command.ts +67 -0
  84. package/src/entry/profile-command.ts +33 -18
  85. package/src/entry/role-cli-command.ts +159 -0
  86. package/src/entry/runs-command.ts +81 -27
  87. package/src/entry/scorecard-command.ts +187 -0
  88. package/src/entry/supervise-bin.ts +1 -1
  89. package/src/entry/supervise-command.ts +30 -3
  90. package/src/entry/workspace-command.ts +44 -0
  91. package/src/infra/boot-lock.ts +25 -0
  92. package/src/infra/delivery.ts +3 -0
  93. package/src/infra/dispatch-dir.ts +2 -0
  94. package/src/infra/env.ts +11 -3
  95. package/src/infra/git.ts +70 -0
  96. package/src/infra/heavy-lock.ts +133 -6
  97. package/src/infra/host-probe.ts +16 -0
  98. package/src/infra/launch.ts +63 -7
  99. package/src/infra/lock-activity.ts +88 -0
  100. package/src/infra/login-env.ts +48 -0
  101. package/src/infra/memory.ts +45 -0
  102. package/src/infra/ntfy.ts +40 -0
  103. package/src/infra/open-url.ts +42 -0
  104. package/src/infra/paths.ts +37 -2
  105. package/src/infra/role-mcp.ts +69 -0
  106. package/src/infra/slopalytics.ts +30 -0
  107. package/src/infra/store.ts +6 -1
  108. package/src/infra/supervisor.ts +107 -16
  109. package/src/services/admission.ts +133 -31
  110. package/src/services/backends.ts +4 -6
  111. package/src/services/budget.ts +26 -6
  112. package/src/services/capture.ts +1 -22
  113. package/src/services/catalog-service.ts +155 -254
  114. package/src/services/credentials.ts +15 -26
  115. package/src/services/daemon.ts +181 -0
  116. package/src/services/dispatch-service.ts +209 -17
  117. package/src/services/dispatches.ts +30 -2
  118. package/src/services/doctor-access.ts +8 -3
  119. package/src/services/doctor-backends.ts +1 -1
  120. package/src/services/doctor-checks.ts +47 -0
  121. package/src/services/doctor-docker.ts +221 -0
  122. package/src/services/doctor-role-mcp.ts +65 -0
  123. package/src/services/doctor.ts +72 -15
  124. package/src/services/end-push.ts +157 -0
  125. package/src/services/failover-rungs.ts +109 -0
  126. package/src/services/finalize.ts +24 -2
  127. package/src/services/gate-env.ts +217 -0
  128. package/src/services/gate-service.ts +231 -45
  129. package/src/services/global-install.ts +46 -13
  130. package/src/services/jev-service.ts +2 -4
  131. package/src/services/lane-edit.ts +154 -0
  132. package/src/services/lane-service.ts +291 -55
  133. package/src/services/milestone-check.ts +433 -0
  134. package/src/services/milestone-tree.ts +170 -0
  135. package/src/services/milestones.ts +167 -29
  136. package/src/services/notifier.ts +124 -4
  137. package/src/services/notify-service.ts +26 -0
  138. package/src/services/orchestrator-wait.ts +82 -0
  139. package/src/services/panel-setup.ts +116 -0
  140. package/src/services/pause.ts +161 -0
  141. package/src/services/peek.ts +79 -17
  142. package/src/services/plan-input.ts +151 -0
  143. package/src/services/ports.ts +60 -5
  144. package/src/services/preflight.ts +232 -24
  145. package/src/services/profile-service.ts +31 -3
  146. package/src/services/profile-store.ts +18 -6
  147. package/src/services/protocol.ts +123 -25
  148. package/src/services/provenance.ts +34 -89
  149. package/src/services/questions.ts +24 -0
  150. package/src/services/reconcile.ts +33 -1
  151. package/src/services/reentry.ts +7 -6
  152. package/src/services/role-access.ts +27 -0
  153. package/src/services/routing-service.ts +161 -42
  154. package/src/services/run-briefs.ts +477 -0
  155. package/src/services/run-client.ts +217 -0
  156. package/src/services/run-effects.ts +837 -0
  157. package/src/services/run-events-store.ts +174 -0
  158. package/src/services/run-evidence.ts +1 -1
  159. package/src/services/run-pin.ts +125 -0
  160. package/src/services/run-read.ts +221 -0
  161. package/src/services/run-report.ts +176 -0
  162. package/src/services/run-service.ts +204 -23
  163. package/src/services/run-store.ts +159 -18
  164. package/src/services/scorecard-metrics.ts +86 -0
  165. package/src/services/scorecard.ts +382 -0
  166. package/src/services/scratch.ts +60 -0
  167. package/src/services/sessions.ts +3 -1
  168. package/src/services/state.ts +21 -0
  169. package/src/services/summary.ts +76 -12
  170. package/src/services/tree-path.ts +27 -0
  171. package/src/services/verifier-brief.ts +23 -0
  172. package/src/services/verifier-step.ts +48 -0
  173. package/src/services/workspace-admission.ts +259 -0
  174. package/src/services/workspace-service.ts +259 -0
  175. package/src/services/workspace-store.ts +95 -0
  176. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  177. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  178. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  179. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  180. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  181. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  182. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  183. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  184. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  185. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  186. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  187. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  188. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  189. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  190. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  191. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  192. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  193. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  194. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  195. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  196. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  197. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  198. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  199. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  200. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  201. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  202. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  203. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  204. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  205. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  206. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  207. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  208. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  209. package/web/dist/assets/index-C5IbylvM.css +2 -0
  210. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  211. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  212. package/web/dist/assets/label-DFSYBc62.js +1 -0
  213. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  214. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  215. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  216. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  217. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  218. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  219. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  220. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  221. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  222. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  223. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  224. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  225. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  226. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  227. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  228. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  229. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  230. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  231. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  232. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  233. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  234. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  235. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  236. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  237. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  238. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  239. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  240. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  241. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  242. package/web/dist/assets/time--9kSQbbp.js +1 -0
  243. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  244. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  245. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  246. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  247. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  248. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  249. package/web/dist/index.html +15 -0
  250. package/catalog/ATTRIBUTION.md +0 -36
  251. package/catalog/scores.json +0 -3202
  252. package/catalog/sources.json +0 -97
  253. package/src/adapters/antigravity/events.ts +0 -131
  254. package/src/adapters/antigravity/home.ts +0 -46
  255. package/src/adapters/antigravity/index.ts +0 -320
  256. package/src/adapters/antigravity/models.ts +0 -37
  257. package/src/adapters/grok/events.ts +0 -112
  258. package/src/adapters/grok/home.ts +0 -132
  259. package/src/adapters/grok/index.ts +0 -339
  260. package/src/adapters/grok/models.ts +0 -48
  261. package/src/domain/calibration.ts +0 -125
  262. package/src/domain/sources.ts +0 -144
  263. package/src/entry/mcp/dispatch-tools.ts +0 -52
  264. package/src/entry/mcp/lane-tools.ts +0 -84
  265. package/src/entry/mcp/protocol-tools.ts +0 -57
  266. package/src/entry/mcp/run-tools.ts +0 -148
  267. package/src/entry/mcp/setup-tools.ts +0 -108
  268. package/src/entry/tui/commands.ts +0 -563
  269. package/src/entry/tui/effects.ts +0 -381
  270. package/src/entry/tui/fixtures.ts +0 -637
  271. package/src/entry/tui/profile-edits.ts +0 -146
  272. package/src/entry/tui/profile-tree.ts +0 -429
  273. package/src/entry/tui/providers/app.tsx +0 -162
  274. package/src/entry/tui/providers/boundary.tsx +0 -57
  275. package/src/entry/tui/providers/data.tsx +0 -160
  276. package/src/entry/tui/providers/keymap.tsx +0 -166
  277. package/src/entry/tui/providers/root.tsx +0 -45
  278. package/src/entry/tui/providers/theme.tsx +0 -17
  279. package/src/entry/tui/providers/toast.tsx +0 -77
  280. package/src/entry/tui/run.tsx +0 -114
  281. package/src/entry/tui/state.ts +0 -373
  282. package/src/entry/tui/stories.ts +0 -114
  283. package/src/entry/tui/text.ts +0 -130
  284. package/src/entry/tui/theme.ts +0 -140
  285. package/src/entry/tui/views/app.tsx +0 -208
  286. package/src/entry/tui/views/dialogs.tsx +0 -41
  287. package/src/entry/tui/views/profile-actions.ts +0 -331
  288. package/src/entry/tui/views/profiles.tsx +0 -520
  289. package/src/entry/tui/views/runs.tsx +0 -573
  290. package/src/entry/tui/views/save-dialog.tsx +0 -340
  291. package/src/entry/tui/views/status.tsx +0 -209
  292. package/src/entry/tui/widgets/chrome.tsx +0 -114
  293. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  294. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  295. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  296. package/src/entry/tui/widgets/dialog.tsx +0 -68
  297. package/src/entry/tui/widgets/line.tsx +0 -92
  298. package/src/entry/tui/widgets/list.tsx +0 -197
  299. package/src/entry/tui/widgets/spinner.tsx +0 -49
  300. package/src/entry/tui/widgets/toast.tsx +0 -62
  301. package/src/infra/sources/arena.ts +0 -87
  302. package/src/infra/sources/artificial-analysis.ts +0 -112
  303. package/src/infra/sources/cache.ts +0 -124
  304. package/src/infra/sources/csv.ts +0 -38
  305. package/src/infra/sources/epoch.ts +0 -82
  306. package/src/infra/sources/http.ts +0 -75
  307. package/src/infra/sources/litellm.ts +0 -58
  308. package/src/infra/sources/models-dev.ts +0 -59
  309. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  310. package/src/infra/sources/openrouter-models.ts +0 -60
  311. package/src/infra/sources/rows.ts +0 -38
  312. package/src/infra/sources/vectara.ts +0 -69
  313. package/src/infra/sources/zip.ts +0 -48
  314. package/src/services/catalog-refresh.ts +0 -201
  315. package/src/services/doctor-sources.ts +0 -76
  316. package/src/services/runs-page.ts +0 -255
  317. package/src/services/source-derive.ts +0 -374
  318. package/src/services/source-sync.ts +0 -305
  319. package/src/services/standins.ts +0 -204
  320. package/src/services/treat-likes.ts +0 -97
@@ -7,6 +7,7 @@ import { claudeHome, locksDir } from "../infra/paths.ts";
7
7
  import { ensurePrivateDir, PRIVATE_FILE } from "../infra/store.ts";
8
8
  import { agentLinkState } from "./agent-links.ts";
9
9
  import type { Handshake } from "./doctor.ts";
10
+ import { type IndexStatus, indexStatus } from "./catalog-service.ts";
10
11
  import { activeName } from "./profile-store.ts";
11
12
 
12
13
  // The rows of `catherd doctor` and the checks that stand alone; doctor.ts assembles the report.
@@ -40,6 +41,52 @@ export function guarded(id: string, label: string, fallbackFix: string, check: (
40
41
  }
41
42
  }
42
43
 
44
+ const HOUR = 3_600_000;
45
+ const DAY = 24 * HOUR;
46
+
47
+ /** An age as doctor prints it: minutes under an hour, hours under two days, else days. */
48
+ export function ageText(ms: number): string {
49
+ if (ms < HOUR) return `${Math.max(1, Math.round(ms / 60_000))} min`;
50
+ if (ms < 2 * DAY) return `${Math.round(ms / HOUR)} h`;
51
+ return `${Math.round(ms / DAY)} d`;
52
+ }
53
+
54
+ /**
55
+ * The `catalog` row: the Slopalytics index's age and the last fetch's error. `info` before the first fetch;
56
+ * a warning when the last fetch failed, or the index has not been checked for a day, since the hourly refresh
57
+ * would have. It reads the cache file only: no request.
58
+ */
59
+ export function indexCheck(now = Date.now(), s: IndexStatus = indexStatus()): Check {
60
+ const base = { id: "catalog", label: "catalog" };
61
+ if (s.checkedAt === null || (s.fetchedAt === null && s.error === null))
62
+ return {
63
+ ...base,
64
+ state: "info",
65
+ word: "not fetched",
66
+ detail: "no model is indexed yet",
67
+ fix: "catherd catalog sync",
68
+ };
69
+ const age = ageText(now - Date.parse(s.fetchedAt ?? s.checkedAt));
70
+ if (s.error !== null)
71
+ return {
72
+ ...base,
73
+ state: "warn",
74
+ word: "failing",
75
+ detail:
76
+ s.fetchedAt === null ? `${s.error}; no index` : `${s.error}; keeps the index fetched ${age} ago`,
77
+ fix: "catherd catalog sync --force",
78
+ };
79
+ if (now - Date.parse(s.checkedAt) > DAY)
80
+ return {
81
+ ...base,
82
+ state: "warn",
83
+ word: "stale",
84
+ detail: `${s.pairs} pairs, fetched ${age} ago`,
85
+ fix: "catherd catalog sync --force",
86
+ };
87
+ return { ...base, state: "ok", word: "fresh", detail: `${s.pairs} pairs, fetched ${age} ago` };
88
+ }
89
+
43
90
  export function pluginCheck(version: string): Check {
44
91
  const base = { id: "plugin", label: "Claude Code plugin" };
45
92
  const file = join(claudeHome(), "plugins", "installed_plugins.json");
@@ -0,0 +1,221 @@
1
+ import {
2
+ accessSync,
3
+ constants,
4
+ existsSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ statfsSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { realTmpdir, toolchainCaches } from "../adapters/access.ts";
14
+ import { runCli } from "../adapters/cli.ts";
15
+ import { probeTwice } from "../infra/host-probe.ts";
16
+ import { probeTargets } from "./doctor-access.ts";
17
+ import type { Check } from "./doctor-checks.ts";
18
+
19
+ // Plan 23: the rows that catch what cost the identity and payment runs their verifier rounds: a Docker client
20
+ // that injects proxies into every container, a compose network whose services cannot reach each other by
21
+ // name, a Docker disk about to fill, and the toolchain caches a sandboxed worker writes.
22
+
23
+ /** Under this much free space in Docker's data root, doctor warns: image builds start failing near it (tests raise it). */
24
+ export const dockerDisk = { minFreeBytes: 10 * 1024 ** 3 };
25
+
26
+ /** How long the compose probe may take, an image pull included. */
27
+ export const composeProbe = { timeoutMs: 120_000 };
28
+
29
+ /** The Docker client's config file: $DOCKER_CONFIG/config.json, else ~/.docker/config.json. */
30
+ export const dockerConfigFile = (env = process.env): string =>
31
+ join(env.DOCKER_CONFIG || join(env.HOME || homedir(), ".docker"), "config.json");
32
+
33
+ /** The keys of a proxies entry the Docker client turns into container environment variables. */
34
+ const PROXY_KEYS = ["httpProxy", "httpsProxy", "noProxy", "ftpProxy", "allProxy"];
35
+
36
+ /** The Docker client's default daemon host, which its proxies block may key an entry by. */
37
+ const DEFAULT_DOCKER_HOST = "unix:///var/run/docker.sock";
38
+
39
+ /**
40
+ * A `proxies` block in the Docker client's config that sets a value: every container then gets HTTP_PROXY and its
41
+ * siblings. The Docker client applies the current daemon host's entry, else `default`; an entry with no value
42
+ * (or an empty block) injects nothing.
43
+ */
44
+ export function proxiesCheck(
45
+ file = dockerConfigFile(),
46
+ env: Record<string, string | undefined> = process.env,
47
+ ): Check | null {
48
+ let config: { proxies?: unknown };
49
+ try {
50
+ config = JSON.parse(readFileSync(file, "utf8")) as { proxies?: unknown };
51
+ } catch {
52
+ return null;
53
+ }
54
+ const proxies = config.proxies;
55
+ if (!proxies || typeof proxies !== "object") return null;
56
+ const entries = proxies as Record<string, unknown>;
57
+ const host = env.DOCKER_HOST || DEFAULT_DOCKER_HOST;
58
+ const entry = Object.hasOwn(entries, host) ? entries[host] : entries.default;
59
+ const sets =
60
+ !!entry &&
61
+ typeof entry === "object" &&
62
+ PROXY_KEYS.some((k) => {
63
+ const v = (entry as Record<string, unknown>)[k];
64
+ return typeof v === "string" && v.trim() !== "";
65
+ });
66
+ if (!sets) return null;
67
+ return {
68
+ id: "docker-proxies",
69
+ label: "Docker client proxies",
70
+ state: "warn",
71
+ word: "injects proxies",
72
+ detail: `${file} has a proxies block: every container gets HTTP_PROXY, so compose services cannot reach each other by name and a loopback health check goes to the proxy`,
73
+ fix: `remove the proxies block from ${file}, or add the compose service names and 127.0.0.1 to its noProxy`,
74
+ };
75
+ }
76
+
77
+ /** Two busybox services: `web` serves on 8080 with a loopback health check, `probe` fetches it by name. */
78
+ const COMPOSE = `services:
79
+ web:
80
+ image: busybox:1.36
81
+ command: ["httpd", "-f", "-p", "8080", "-h", "/etc"]
82
+ healthcheck:
83
+ test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/hostname"]
84
+ interval: 1s
85
+ retries: 30
86
+ probe:
87
+ image: busybox:1.36
88
+ depends_on:
89
+ web:
90
+ condition: service_healthy
91
+ command: ["wget", "-q", "-O", "/dev/null", "http://web:8080/hostname"]
92
+ `;
93
+
94
+ /** `docker compose up` on the two-service file, then `down`; the result of the up. */
95
+ async function composeUp(docker: string): Promise<{ ok: boolean; why: string }> {
96
+ const dir = mkdtempSync(join(realTmpdir(), "catherd-compose-"));
97
+ const file = join(dir, "compose.yaml");
98
+ writeFileSync(file, COMPOSE);
99
+ const project = ["compose", "-p", `catherd-doctor-${process.pid}`, "-f", file];
100
+ try {
101
+ const up = await runCli(
102
+ docker,
103
+ [...project, "up", "--abort-on-container-exit", "--exit-code-from", "probe"],
104
+ {
105
+ timeoutMs: composeProbe.timeoutMs,
106
+ cwd: dir,
107
+ },
108
+ );
109
+ const lines = `${up?.err ?? ""}\n${up?.out ?? ""}`
110
+ .split("\n")
111
+ .map((l) => l.trim())
112
+ .filter(Boolean);
113
+ return { ok: up?.ok === true, why: lines.at(-1) ?? "no output" };
114
+ } finally {
115
+ await runCli(docker, [...project, "down", "-v", "--remove-orphans"], { timeoutMs: 30_000, cwd: dir });
116
+ rmSync(dir, { recursive: true, force: true });
117
+ }
118
+ }
119
+
120
+ /** Free bytes in Docker's data root, when it is on this machine's disk; null when Docker runs in a VM. */
121
+ async function dockerFree(docker: string): Promise<{ root: string; free: number } | null> {
122
+ const r = await runCli(docker, ["info", "--format", "{{.DockerRootDir}}"], { timeoutMs: 15_000 });
123
+ const root = r?.ok ? r.out.trim() : "";
124
+ if (!root || !existsSync(root)) return null;
125
+ try {
126
+ const s = statfsSync(root);
127
+ return { root, free: Number(s.bavail) * Number(s.bsize) };
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ const gb = (bytes: number): string => `${(bytes / 1024 ** 3).toFixed(1)} GB`;
134
+
135
+ /**
136
+ * The Docker rows: the client's proxies, read from its config file; with `probe` (`catherd doctor --docker`), when
137
+ * a daemon answers, the compose network probe (retried once after 5 s before it calls the host blocked) and the
138
+ * free space in Docker's data root. The probes pull busybox and start containers, so a plain doctor skips them.
139
+ */
140
+ export async function dockerChecks(o: { probe: boolean }): Promise<Check[]> {
141
+ const docker = probeTargets.docker;
142
+ const checks: Check[] = [];
143
+ const proxies = proxiesCheck();
144
+ if (proxies) checks.push(proxies);
145
+ if (!o.probe) return checks;
146
+ const version = await runCli(docker, ["version"], { timeoutMs: 15_000 });
147
+ if (!version?.ok) return checks;
148
+ const compose = await runCli(docker, ["compose", "version"], { timeoutMs: 15_000 });
149
+ if (compose?.ok) {
150
+ const { result, attempts } = await probeTwice(
151
+ () => composeUp(docker),
152
+ (r) => r.ok,
153
+ );
154
+ checks.push(
155
+ result.ok
156
+ ? {
157
+ id: "docker-network",
158
+ label: "compose network",
159
+ state: "ok",
160
+ word: "ready",
161
+ detail: "two services reach each other by name, and a loopback health check passes",
162
+ }
163
+ : {
164
+ id: "docker-network",
165
+ label: "compose network",
166
+ state: "warn",
167
+ word: "blocked",
168
+ detail: `a compose service could not reach another by name, or its loopback health check failed (${attempts} tries, 5 s apart): ${result.why}`,
169
+ fix: proxies
170
+ ? (proxies.fix as string)
171
+ : "check the Docker client's proxies, a VPN or socket filter on the Docker bridge, and that busybox can be pulled",
172
+ },
173
+ );
174
+ }
175
+ const free = await dockerFree(docker);
176
+ if (free && free.free < dockerDisk.minFreeBytes)
177
+ checks.push({
178
+ id: "docker-disk",
179
+ label: "Docker disk",
180
+ state: "warn",
181
+ word: "low",
182
+ detail: `${gb(free.free)} free in Docker's data root ${free.root}; builds fail with no space left on device`,
183
+ fix: "docker image prune -f, then docker builder prune -f",
184
+ });
185
+ return checks;
186
+ }
187
+
188
+ /** Whether this user may write `dir`. */
189
+ const writable = (dir: string): boolean => {
190
+ try {
191
+ accessSync(dir, constants.W_OK);
192
+ return true;
193
+ } catch {
194
+ return false;
195
+ }
196
+ };
197
+
198
+ /**
199
+ * The toolchain caches present on the machine (Go's build and module caches, the pnpm store, Bun's and npm's
200
+ * caches), which join every workspace-write worker's writable roots; a warning for one this user cannot write.
201
+ */
202
+ export function cachesCheck(caches = toolchainCaches()): Check {
203
+ const base = { id: "caches", label: "toolchain caches" };
204
+ if (caches.length === 0)
205
+ return { ...base, state: "skip", word: "none", detail: "no Go, pnpm, Bun or npm cache on this machine" };
206
+ const blocked = caches.filter((c) => !writable(c));
207
+ return blocked.length
208
+ ? {
209
+ ...base,
210
+ state: "warn",
211
+ word: "not writable",
212
+ detail: `${blocked.join(", ")}: a worker's checks cannot write it, even with it in their writable roots`,
213
+ fix: `chown -R "$(id -un)" ${blocked.join(" ")}`,
214
+ }
215
+ : {
216
+ ...base,
217
+ state: "ok",
218
+ word: "ready",
219
+ detail: `in every workspace-write worker's writable roots: ${caches.join(", ")}`,
220
+ };
221
+ }
@@ -0,0 +1,65 @@
1
+ import { tryParseRung } from "../domain/ids.ts";
2
+ import type { Profile } from "../domain/profile.ts";
3
+ import { roleMcpTools } from "../domain/role-tools.ts";
4
+ import { ROLES } from "../domain/roles.ts";
5
+ import { ROLE_MCP_STARTUP_SEC, type RoleServerStart } from "../infra/role-mcp.ts";
6
+ import { standInFor } from "./backends.ts";
7
+ import type { Check } from "./doctor-checks.ts";
8
+
9
+ /**
10
+ * The active profile's role server, per backend that runs one (codex, claude-code), isolated or not (spec 1.5
11
+ * plan 21, #42 finding 6): the roles that get it and their tools, and, when doctor probed it, how long a cold
12
+ * start took against Codex's startup timeout. A start past half that timeout warns: on a loaded machine Codex,
13
+ * which requires the server, would abort the role.
14
+ */
15
+ export function roleMcpChecks(profile: Profile | null, start?: RoleServerStart): Check[] {
16
+ if (!profile) return [];
17
+ const checks: Check[] = [];
18
+ for (const backend of ["codex", "claude-code"] as const) {
19
+ const uses = (rung: string) => tryParseRung(rung)?.backend === backend;
20
+ const roles = ROLES.filter((role) => {
21
+ const configured = profile.roles[role];
22
+ return (
23
+ configured.enabled &&
24
+ configured.rungs.some((rung) => {
25
+ const failover = standInFor(profile.failover, rung);
26
+ return uses(rung) || (failover !== null && uses(failover));
27
+ })
28
+ );
29
+ });
30
+ if (!roles.length) continue;
31
+ const base = { id: `role-mcp:${profile.name}:${backend}`, label: `${profile.name} ${backend} role MCP` };
32
+ const tools = [...new Set(roles.flatMap(roleMcpTools))];
33
+ const configured = `${roles.join(", ")}: scoped catherd_role exposes ${tools.join(", ")}; ${backend === "codex" ? "per-tool approval_mode=approve" : "exact MCP tool allowlist"}. Model invocation not tested; managed policy may still deny tools.`;
34
+ const limit = ROLE_MCP_STARTUP_SEC * 1000;
35
+ if (start && !start.ok) {
36
+ checks.push({
37
+ ...base,
38
+ state: "fail",
39
+ word: "no start",
40
+ detail: `the role server did not start: ${start.error}`,
41
+ fix: "reinstall catherd at this version, then run catherd doctor again",
42
+ });
43
+ continue;
44
+ }
45
+ if (start?.ok && start.ms > limit / 2) {
46
+ checks.push({
47
+ ...base,
48
+ state: "warn",
49
+ word: "slow start",
50
+ detail: `the role server took ${Math.round(start.ms)} ms to start; Codex gives it ${ROLE_MCP_STARTUP_SEC} s and stops the role when it misses`,
51
+ fix: "install catherd globally (catherd init) so roles start it without resolving a package, and check the machine's load",
52
+ });
53
+ continue;
54
+ }
55
+ checks.push({
56
+ ...base,
57
+ state: "info",
58
+ word: "configured",
59
+ detail: start?.ok
60
+ ? `${configured} Starts in ${Math.round(start.ms)} ms (limit ${ROLE_MCP_STARTUP_SEC} s).`
61
+ : configured,
62
+ });
63
+ }
64
+ return checks;
65
+ }
@@ -7,14 +7,17 @@ import { BUILTIN_ROLES, type Profile } from "../domain/profile.ts";
7
7
  import { DEFAULT_ACCESS, ROLES } from "../domain/roles.ts";
8
8
  import { bunTooOld, MIN_BUN } from "../domain/runtime.ts";
9
9
  import type { JevTransport } from "../infra/jev-client.ts";
10
+ import type { RoleServerStart } from "../infra/role-mcp.ts";
10
11
  import { linkedProfiles } from "./agent-links.ts";
11
12
  import { accessChecks } from "./doctor-access.ts";
12
13
  import { backendChecks, usedBackends } from "./doctor-backends.ts";
13
14
  import { type PushProbe, pushCheck, probePush } from "./doctor-push.ts";
14
- import { sourcesCheck, standInsToConfirmIn } from "./doctor-sources.ts";
15
+ import { cachesCheck, dockerChecks } from "./doctor-docker.ts";
16
+ import { roleMcpChecks } from "./doctor-role-mcp.ts";
15
17
  import {
16
18
  agentsCheck,
17
19
  hostFlag,
20
+ indexCheck,
18
21
  type Check,
19
22
  errText,
20
23
  fixOf,
@@ -66,14 +69,33 @@ export interface DoctorDeps {
66
69
  /** Explicit smoke test injection; default doctor never invokes it. */
67
70
  push?: () => Promise<PushProbe>;
68
71
  testPush?: boolean;
72
+ /** plan 23: `--docker`: run the Docker host probes (a compose network by service name, the free disk) */
73
+ docker?: boolean;
69
74
  env?: Record<string, string | undefined>;
70
75
  jev?: JevTransport;
76
+ /** spec 1.5 plan 21: a cold start of the role server, timed; absent, doctor does not probe it */
77
+ roleServerStart?: () => Promise<RoleServerStart>;
71
78
  }
72
79
 
73
80
  /**
74
81
  * Spec §10.3: the readiness report. Reads and probes; it writes discovery and a lock probe itself, and the
75
82
  * handshake starts `catherd mcp`, which reconciles runs as it starts. A check that throws becomes a `fail` row.
76
83
  */
84
+ /**
85
+ * Where the access probes look for a per-repo sandbox: the current repo and each repo bound to a checked
86
+ * profile. A repo bound to another profile never runs these roles, so its settings must not decide the probes.
87
+ */
88
+ export function accessRepos(
89
+ repo: string | null,
90
+ bindings: Record<string, string>,
91
+ checked: string[],
92
+ ): string[] {
93
+ const bound = Object.entries(bindings)
94
+ .filter(([, name]) => checked.includes(name))
95
+ .map(([r]) => r);
96
+ return [...new Set([...(repo ? [repo] : []), ...bound])];
97
+ }
98
+
77
99
  export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
78
100
  const checks: Check[] = [];
79
101
  checks.push(
@@ -86,7 +108,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
86
108
  detail: `${d.bunVersion}, needs ${MIN_BUN}`,
87
109
  fix: "bun upgrade",
88
110
  }
89
- : { id: "bun", label: "Bun", state: "ok", word: "ready", detail: d.bunVersion },
111
+ : {
112
+ id: "bun",
113
+ label: "Bun",
114
+ state: "ok",
115
+ word: "ready",
116
+ detail: d.bunVersion,
117
+ },
90
118
  );
91
119
 
92
120
  // every fix names the host doctor judged with: run elsewhere (a plain terminal), it must judge the same way
@@ -148,7 +176,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
148
176
  const named = (fix: string) =>
149
177
  fix.replaceAll("catherd profile set ", `catherd profile set --profile ${name}${onHost} `);
150
178
  const row = guarded(id, `profile ${name}`, validate, () => {
151
- const v = validateNamed(name, d.repo, d.host.host);
179
+ const v = validateNamed(name, d.repo, d.host.host, { unindexed: "warning" });
152
180
  const first = v.errors[0] ?? v.warnings[0];
153
181
  return {
154
182
  id,
@@ -170,6 +198,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
170
198
  const used = usedBackends(profiles);
171
199
  const installed = new Set<string>();
172
200
  checks.push(...(await backendChecks(used, profiles, installed, d.host.host)));
201
+ checks.push(...roleMcpChecks(active, await d.roleServerStart?.()));
173
202
 
174
203
  if (active && [active, ...profiles].every((p) => p.jev.use === "off"))
175
204
  checks.push({
@@ -194,7 +223,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
194
223
  const ok = await testJevKey(key, d.jev).catch(() => false);
195
224
  checks.push(
196
225
  ok
197
- ? { id: "jev", label: "Jev", state: "ok", word: "ready", detail: "the key answers" }
226
+ ? {
227
+ id: "jev",
228
+ label: "Jev",
229
+ state: "ok",
230
+ word: "ready",
231
+ detail: "the key answers",
232
+ }
198
233
  : {
199
234
  id: "jev",
200
235
  label: "Jev",
@@ -207,14 +242,9 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
207
242
  }
208
243
  }
209
244
 
210
- // spec 1.2 §9: the public sources' ages and errors, the Artificial Analysis key, and the active and linked
211
- // profiles' stand-ins to confirm
212
245
  const mine = [...(active ? [active] : []), ...diagnostics.filter((p) => p.name !== active?.name)];
213
- checks.push(
214
- guarded("sources", "sources", "catherd catalog sync --force", () =>
215
- sourcesCheck(Date.now(), standInsToConfirmIn(mine)),
216
- ),
217
- );
246
+ // the Slopalytics index: its age and the last fetch's error
247
+ checks.push(guarded("catalog", "catalog", "catherd catalog sync", () => indexCheck()));
218
248
 
219
249
  const native = used.get("claude");
220
250
  const plugin = pluginCheck(d.version);
@@ -275,7 +305,9 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
275
305
  word: !queue.cli ? "unavailable" : queue.server,
276
306
  detail: queue.reason ?? "Native server advertises queue support",
277
307
  ...(!queue.cli || queue.server === "unsupported"
278
- ? { fix: "Update native Codex/server; use peek/result while push is unavailable." }
308
+ ? {
309
+ fix: "Update native Codex/server; use peek/result while push is unavailable.",
310
+ }
279
311
  : {}),
280
312
  });
281
313
  let push: PushProbe | null = null;
@@ -291,8 +323,21 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
291
323
  }
292
324
 
293
325
  checks.push(locksCheck());
326
+ // plan 23: the toolchain caches workers write, and Docker as a gate sees it (before the access probes)
327
+ checks.push(cachesCheck());
328
+ checks.push(...(await dockerChecks({ probe: d.docker === true })));
294
329
  // spec §5 and §12: which codex sandbox form runs, and the five access probes per workspace-write backend
295
- checks.push(...(await accessChecks(profiles, installed)));
330
+ let repos: string[] = d.repo ? [d.repo] : [];
331
+ try {
332
+ repos = accessRepos(
333
+ d.repo,
334
+ readProjects().bindings,
335
+ profiles.map((p) => p.name),
336
+ );
337
+ } catch {
338
+ // the config row above already reports an unreadable projects.json
339
+ }
340
+ checks.push(...(await accessChecks(profiles, installed, repos)));
296
341
 
297
342
  // spec 1.1 §13: what the shipped defaults do is info; a warning only for what a profile changed
298
343
  const full = { changed: [] as string[], shipped: [] as string[] };
@@ -329,7 +374,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
329
374
  word: "warning",
330
375
  detail: `${what}: ${rows.changed.join(", ")}${shipped ? `; as shipped: ${shipped}` : ""}`,
331
376
  }
332
- : { id, label, state: "info", word: "default", detail: `${what}: ${shipped}` };
377
+ : {
378
+ id,
379
+ label,
380
+ state: "info",
381
+ word: "default",
382
+ detail: `${what}: ${shipped}`,
383
+ };
333
384
  };
334
385
  for (const row of [
335
386
  accessRow("access:full", "full access", full, "no sandbox for"),
@@ -372,7 +423,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
372
423
  detail: unreadable.message,
373
424
  fix: unreadable.fix ?? `fix or delete ${creds}, then catherd init`,
374
425
  }
375
- : { id: "credentials", label: "credentials.json", state: "ok", word: "ready", detail: "mode 600" },
426
+ : {
427
+ id: "credentials",
428
+ label: "credentials.json",
429
+ state: "ok",
430
+ word: "ready",
431
+ detail: "mode 600",
432
+ },
376
433
  );
377
434
  }
378
435
 
@@ -0,0 +1,157 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { errorMessage } from "../domain/errors.ts";
3
+ import type { HostSessionRef } from "../domain/host.ts";
4
+ import { envelope, formatNotices } from "../domain/notice.ts";
5
+ import { type QueueSendResult, sendToCodexQueue } from "../infra/codex-queue.ts";
6
+ import {
7
+ type DeliveryAttempt,
8
+ deliveryState,
9
+ ROLE_THREAD_REFUSAL,
10
+ writeDeliveryAttempt,
11
+ } from "../infra/delivery.ts";
12
+ import { awaitsCollect, dispatchPaths } from "../infra/dispatch-dir.ts";
13
+ import { tryLock } from "../infra/filelock.ts";
14
+ import { log } from "../infra/log.ts";
15
+ import { admitPath, listDispatches, roleThreadOf } from "./dispatches.ts";
16
+ import { finalizeDispatch } from "./finalize.ts";
17
+ import { finishedNotice, recoverSubmission } from "./notifier.ts";
18
+ import { findRun } from "./run-store.ts";
19
+ import { runOwner } from "./sessions.ts";
20
+
21
+ /**
22
+ * How long the supervisor leaves the owner's MCP server to push first (the fast path: its watcher finalizes,
23
+ * settles and sends within the notifier's coalescing window). Either path sends under the same per-event receipt,
24
+ * so a late server or a late supervisor finds the event accepted and sends nothing.
25
+ */
26
+ export const END_PUSH_GRACE_MS = 20_000;
27
+
28
+ /** `CATHERD_END_PUSH_GRACE_MS` (a test of the spawned supervisor shortens it), else END_PUSH_GRACE_MS. */
29
+ function graceFromEnv(): number {
30
+ const ms = Number(process.env.CATHERD_END_PUSH_GRACE_MS);
31
+ return process.env.CATHERD_END_PUSH_GRACE_MS !== undefined && Number.isFinite(ms) && ms >= 0
32
+ ? ms
33
+ : END_PUSH_GRACE_MS;
34
+ }
35
+
36
+ export interface EndPushOptions {
37
+ graceMs?: number;
38
+ /** the sender; tests replace it */
39
+ sendCodex?: (target: HostSessionRef, content: string) => Promise<QueueSendResult>;
40
+ now?: () => number;
41
+ }
42
+
43
+ /** Why the supervisor pushed nothing, or the receipt's status when it did. */
44
+ export type EndPushOutcome =
45
+ | "off"
46
+ | "not-a-run"
47
+ | "not-codex"
48
+ | "limit"
49
+ | "read"
50
+ | "busy"
51
+ | "delivered"
52
+ | DeliveryAttempt["status"];
53
+
54
+ /** The run's owner, when it is a Codex thread: the supervisor's push targets it. */
55
+ function codexOwner(runId: string): HostSessionRef | null {
56
+ const owner = runOwner(findRun(runId));
57
+ return owner?.host === "codex"
58
+ ? { host: "codex", sessionId: owner.sessionId, hostSessionId: null, name: null }
59
+ : null;
60
+ }
61
+
62
+ /**
63
+ * Plan 22, push from where a role ends: the detached supervisor of a dispatch whose run a Codex thread owns, once
64
+ * its worker exited, queues the role's notice to that thread with `codex queue --remote unix:// --thread <owner>`.
65
+ * Codex's app-server keeps queued input for an unloaded thread, so a result reaches a coordinator whose MCP
66
+ * server the daemon stopped (no client attached) at its next attach. The notice is the server's own text, and it
67
+ * goes under the same per-event receipt (`delivery.json`, the dispatch's notify lock), so the two paths never
68
+ * double-deliver. A usage limit is left to the owner's server, which fails it over before it announces it.
69
+ * `CATHERD_NO_END_PUSH=1` turns it off (the test preload sets it). Never throws.
70
+ */
71
+ export async function pushFromEnd(dispatchDir: string, o: EndPushOptions = {}): Promise<EndPushOutcome> {
72
+ if (process.env.CATHERD_NO_END_PUSH === "1") return "off";
73
+ try {
74
+ const runId = (JSON.parse(readFileSync(admitPath(dispatchDir), "utf8")) as { runId?: unknown }).runId;
75
+ if (typeof runId !== "string") return "not-a-run";
76
+ // read before the grace: a run no Codex thread owns costs the supervisor nothing
77
+ if (!codexOwner(runId)) return "not-codex";
78
+ await Bun.sleep(o.graceMs ?? graceFromEnv());
79
+ const run = findRun(runId);
80
+ const d = listDispatches(run).find((x) => x.dir === dispatchDir);
81
+ if (!d) return "not-a-run";
82
+ const record = await finalizeDispatch(run, d);
83
+ if (record.status === "limit") return "limit";
84
+ // the owner of record now, which a claim may have changed during the grace
85
+ const target = codexOwner(runId);
86
+ if (!target) return "not-codex";
87
+ if (!awaitsCollect(d.dir)) return "read";
88
+ const notice = finishedNotice(run, d, record);
89
+ const release = tryLock(dispatchPaths(d.dir).notified);
90
+ if (!release) return "busy";
91
+ try {
92
+ recoverSubmission(d.dir, notice.eventId);
93
+ if (deliveryState(d.dir, target, notice.eventId) !== "pending") return "delivered";
94
+ // spec 1.5 plan 21: never to a role's own thread (a `codex exec` thread exits after its turn, so what is
95
+ // queued there is lost); it fails loudly, with the notifier's reason, which `status` shows
96
+ const role = roleThreadOf(run, target.sessionId);
97
+ if (role !== null) {
98
+ const reason = `${ROLE_THREAD_REFUSAL} (${role}, ${target.host} ${target.sessionId}), not the orchestrator's; peek(run) from the orchestrator's session takes the run back`;
99
+ log("error", "notify", { from: "supervisor", run: run.id, dispatch: notice.dispatchId, reason });
100
+ writeDeliveryAttempt(d.dir, {
101
+ attemptId: crypto.randomUUID(),
102
+ target,
103
+ eventIds: [notice.eventId],
104
+ at: new Date((o.now ?? Date.now)()).toISOString(),
105
+ status: "failed",
106
+ msgId: null,
107
+ reason,
108
+ });
109
+ return "failed";
110
+ }
111
+ const attempt: DeliveryAttempt = {
112
+ attemptId: crypto.randomUUID(),
113
+ target,
114
+ eventIds: [notice.eventId],
115
+ at: new Date((o.now ?? Date.now)()).toISOString(),
116
+ status: "submitting",
117
+ msgId: null,
118
+ reason: null,
119
+ };
120
+ writeDeliveryAttempt(d.dir, attempt);
121
+ let sent: QueueSendResult;
122
+ try {
123
+ sent = await (o.sendCodex ?? ((t, c) => sendToCodexQueue(t, c, process.env)))(
124
+ target,
125
+ envelope(formatNotices([notice])),
126
+ );
127
+ } catch {
128
+ sent = { outcome: "ambiguous", reason: "Sender ended without a verified receipt; use peek/result." };
129
+ }
130
+ const receipt: DeliveryAttempt = {
131
+ ...attempt,
132
+ status:
133
+ sent.outcome === "accepted"
134
+ ? "accepted"
135
+ : sent.outcome === "not-submitted"
136
+ ? "failed"
137
+ : "ambiguous",
138
+ msgId: sent.outcome === "accepted" ? sent.msgId : null,
139
+ reason: sent.outcome === "accepted" ? null : sent.reason,
140
+ };
141
+ writeDeliveryAttempt(d.dir, receipt);
142
+ log(receipt.status === "accepted" ? "info" : "warn", "notify", {
143
+ from: "supervisor",
144
+ outcome: receipt.status,
145
+ msgId: receipt.msgId,
146
+ reason: receipt.reason,
147
+ dispatches: [notice.dispatchId],
148
+ });
149
+ return receipt.status;
150
+ } finally {
151
+ release();
152
+ }
153
+ } catch (e) {
154
+ log("warn", "notify", { from: "supervisor", dispatch: dispatchDir, error: errorMessage(e) });
155
+ return "not-a-run";
156
+ }
157
+ }