catherd-cli 0.2.1 → 1.0.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 (187) hide show
  1. package/README.md +114 -30
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -26
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -84
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
@@ -0,0 +1,257 @@
1
+ import type { Subprocess } from "bun";
2
+ import { appendFileSync, closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
3
+ import { z } from "zod";
4
+ import { errorMessage } from "../domain/errors.ts";
5
+ import type { ExitInfo, ExitReason } from "../domain/record.ts";
6
+ import { dispatchPaths, supervisorLockTarget } from "./dispatch-dir.ts";
7
+ import { tryLock } from "./filelock.ts";
8
+ import { log } from "./log.ts";
9
+ import { killGroup, processStartTime } from "./proc.ts";
10
+ import { PRIVATE_FILE, writeJsonAtomic } from "./store.ts";
11
+
12
+ export const SuperviseSpecSchema = z.looseObject({
13
+ schema: z.literal(1),
14
+ backend: z.string(),
15
+ dispatchDir: z.string(),
16
+ cmd: z.string(),
17
+ args: z.array(z.string()),
18
+ env: z.record(z.string(), z.string()),
19
+ cwd: z.string(),
20
+ stdinPath: z.string().nullable(),
21
+ idleMs: z.number().positive(),
22
+ wallMs: z.number().positive(),
23
+ killGraceMs: z.number().nonnegative(),
24
+ graceAfterFinalMs: z.number().nonnegative().nullable(),
25
+ pollMs: z.number().positive(),
26
+ });
27
+ export type SuperviseSpec = z.infer<typeof SuperviseSpecSchema>;
28
+
29
+ /** What one stream line tells the supervisor: the terminal event, the thread, a tool call opening or closing. */
30
+ interface LineInfo {
31
+ final?: boolean;
32
+ thread?: string;
33
+ /** a tool call the CLI started (`open`) or finished: while one is open the run is busy, however quiet */
34
+ item?: { id: string; open: boolean };
35
+ }
36
+
37
+ export interface SuperviseHooks {
38
+ onLine?(line: string): LineInfo;
39
+ /** `sinceMs`: when this run started, so a backend can ignore what an earlier run on the thread left */
40
+ isBusy?(thread: string | null, sinceMs: number): Promise<boolean>;
41
+ interrupt?(thread: string | null): Promise<void>;
42
+ }
43
+
44
+ /** Reads whatever the child appended to `file` since `offset`, as complete lines. */
45
+ function readNew(file: string, state: { offset: number; rest: string; decoder: TextDecoder }): string[] {
46
+ if (!existsSync(file)) return [];
47
+ const size = statSync(file).size;
48
+ if (size <= state.offset) return [];
49
+ const fd = openSync(file, "r");
50
+ try {
51
+ const buf = Buffer.alloc(size - state.offset);
52
+ readSync(fd, buf, 0, buf.length, state.offset);
53
+ state.offset = size;
54
+ // stream: a multi-byte character split across two polls waits in the decoder for its tail.
55
+ const text = state.rest + state.decoder.decode(buf, { stream: true });
56
+ const lines = text.split("\n");
57
+ state.rest = lines.pop() ?? "";
58
+ return lines.filter((l) => l.trim());
59
+ } finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+
64
+ /** A hook's answer, or `fallback` when it throws, rejects or takes longer than `ms`. */
65
+ async function bounded<T>(call: () => Promise<T> | undefined, ms: number, fallback: T): Promise<T> {
66
+ let timer: ReturnType<typeof setTimeout> | undefined;
67
+ try {
68
+ const answer = call();
69
+ if (!answer) return fallback;
70
+ const late = new Promise<T>((resolve) => {
71
+ timer = setTimeout(() => resolve(fallback), ms);
72
+ });
73
+ return await Promise.race([answer, late]);
74
+ } catch {
75
+ return fallback;
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
80
+
81
+ function groupAlive(pgid: number): boolean {
82
+ try {
83
+ process.kill(-pgid, 0);
84
+ return true;
85
+ } catch (e) {
86
+ return (e as NodeJS.ErrnoException).code === "EPERM";
87
+ }
88
+ }
89
+
90
+ /** SIGTERM the group, give it `graceMs` to leave, then SIGKILL whatever is left of it. */
91
+ async function stopGroup(pgid: number, graceMs: number, pollMs: number): Promise<void> {
92
+ killGroup(pgid, "SIGTERM");
93
+ const end = Date.now() + graceMs;
94
+ while (Date.now() < end && groupAlive(pgid))
95
+ await Bun.sleep(Math.max(1, Math.min(pollMs, end - Date.now())));
96
+ // Always, not only when the leader lingers: a member that ignores SIGTERM outlives its leader.
97
+ // The group only, never the bare pid: once the leader is gone its pid may belong to someone else.
98
+ try {
99
+ process.kill(-pgid, "SIGKILL");
100
+ } catch {
101
+ // the group is gone
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Supervises one dispatch's worker, as the dispatch's only supervisor: it first takes the dispatch's
107
+ * supervisor lock (a dead holder's is reclaimed) and holds it for its whole life. A supervisor that finds
108
+ * a live one holding it returns null at once, before opening events or stderr (opened with "w") and
109
+ * without exit.json, so a relaunch (recovery after a collector died between spawn and launch.json) never
110
+ * starts a second worker.
111
+ */
112
+ export async function supervise(spec: SuperviseSpec, hooks: SuperviseHooks = {}): Promise<ExitInfo | null> {
113
+ const release = tryLock(supervisorLockTarget(spec.dispatchDir));
114
+ if (!release) {
115
+ log("info", "supervise", { dispatch: spec.dispatchDir, skipped: "another supervisor holds it" });
116
+ return null;
117
+ }
118
+ try {
119
+ return await superviseHeld(spec, hooks);
120
+ } finally {
121
+ release();
122
+ }
123
+ }
124
+
125
+ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promise<ExitInfo> {
126
+ const p = dispatchPaths(spec.dispatchDir);
127
+ const finish = (info: ExitInfo): ExitInfo => {
128
+ writeJsonAtomic(p.exit, { schema: 1, ...info });
129
+ return info;
130
+ };
131
+ const fds: number[] = [];
132
+ let child: Subprocess;
133
+ try {
134
+ const stdin = spec.stdinPath ? openSync(spec.stdinPath, "r") : "ignore";
135
+ if (typeof stdin === "number") fds.push(stdin);
136
+ const stdout = openSync(p.events, "w", PRIVATE_FILE);
137
+ fds.push(stdout);
138
+ const stderr = openSync(p.stderr, "w", PRIVATE_FILE);
139
+ fds.push(stderr);
140
+ child = Bun.spawn([spec.cmd, ...spec.args], {
141
+ cwd: spec.cwd,
142
+ env: spec.env,
143
+ stdin,
144
+ stdout,
145
+ stderr,
146
+ detached: true,
147
+ });
148
+ log("info", "spawn", {
149
+ backend: spec.backend,
150
+ dispatch: spec.dispatchDir,
151
+ argv: [spec.cmd, ...spec.args],
152
+ env: spec.env,
153
+ cwd: spec.cwd,
154
+ pid: child.pid,
155
+ });
156
+ } catch (e) {
157
+ try {
158
+ appendFileSync(p.stderr, `catherd: could not start ${spec.cmd}: ${errorMessage(e)}\n`, {
159
+ mode: PRIVATE_FILE,
160
+ });
161
+ } catch {
162
+ // exit.json below still records that the worker never ran
163
+ }
164
+ return finish({ code: null, signal: null, reason: "lost", endedAt: new Date().toISOString() });
165
+ } finally {
166
+ for (const fd of fds) closeSync(fd);
167
+ }
168
+
169
+ const hookMs = Math.min(10_000, spec.idleMs);
170
+ let reason: ExitReason | null = null;
171
+ let thread: string | null = null;
172
+ let failure: { error: unknown } | null = null;
173
+ let done = false;
174
+ void child.exited.then(() => {
175
+ done = true;
176
+ });
177
+
178
+ try {
179
+ writeJsonAtomic(p.proc, {
180
+ schema: 1,
181
+ pid: child.pid,
182
+ pgid: child.pid, // the detached child leads its own group
183
+ startTime: processStartTime(child.pid),
184
+ supervisorPid: process.pid,
185
+ supervisorStartTime: processStartTime(process.pid),
186
+ startedAt: new Date().toISOString(),
187
+ });
188
+
189
+ const started = Date.now();
190
+ let lastActivity = started;
191
+ let finalAt: number | null = null;
192
+ const open = new Set<string>();
193
+ const stream = { offset: 0, rest: "", decoder: new TextDecoder("utf-8") };
194
+
195
+ while (!done && reason === null) {
196
+ await Bun.sleep(spec.pollMs);
197
+ const lines = readNew(p.events, stream);
198
+ if (lines.length) lastActivity = Date.now();
199
+ for (const line of lines) {
200
+ let d: LineInfo | undefined;
201
+ try {
202
+ d = hooks.onLine?.(line);
203
+ } catch {
204
+ continue; // one line the hook cannot read must not end supervision
205
+ }
206
+ if (d?.thread) thread = d.thread;
207
+ if (d?.final) finalAt ??= Date.now();
208
+ if (d?.item) {
209
+ if (d.item.open) open.add(d.item.id);
210
+ else open.delete(d.item.id);
211
+ }
212
+ }
213
+ // a worker that ended on its own is recorded as it ended, even if a cancel or a limit arrived meanwhile
214
+ if (done) break;
215
+ const now = Date.now();
216
+ if (existsSync(p.cancel)) reason = "cancelled";
217
+ else if (now - started >= spec.wallMs) reason = "wall-timeout";
218
+ else if (finalAt !== null && spec.graceAfterFinalMs !== null && now - finalAt >= spec.graceAfterFinalMs)
219
+ reason = "after-final";
220
+ else if (now - lastActivity >= spec.idleMs) {
221
+ const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
222
+ // the busy check can take up to hookMs: a worker that ended meanwhile is recorded as it ended, read
223
+ // from the child itself too, since `done` is set by a callback that may not have run yet
224
+ if (done || child.exitCode !== null || child.signalCode !== null) break;
225
+ if (busy) lastActivity = Date.now();
226
+ else reason = "idle-timeout";
227
+ }
228
+ }
229
+ } catch (e) {
230
+ failure = { error: e };
231
+ reason = "lost";
232
+ }
233
+
234
+ let stopped = false;
235
+ if (reason !== null && !done) {
236
+ if (reason !== "after-final" && failure === null && hooks.interrupt) {
237
+ await bounded(() => hooks.interrupt?.(thread), hookMs, undefined);
238
+ // a server-side stop lets the CLI end on its own: it gets the kill grace before any signal
239
+ await Promise.race([child.exited, Bun.sleep(spec.killGraceMs)]);
240
+ }
241
+ if (!done) {
242
+ await stopGroup(child.pid, spec.killGraceMs, spec.pollMs);
243
+ stopped = true;
244
+ }
245
+ }
246
+ const code = await child.exited;
247
+ // On every path: a worker that ends on its own may leave members of its group behind.
248
+ if (!stopped && groupAlive(child.pid)) await stopGroup(child.pid, spec.killGraceMs, spec.pollMs);
249
+ const info = finish({
250
+ code: child.signalCode ? null : code,
251
+ signal: child.signalCode ?? null,
252
+ reason: reason ?? "exited",
253
+ endedAt: new Date().toISOString(),
254
+ });
255
+ if (failure) throw failure.error;
256
+ return info;
257
+ }
@@ -0,0 +1,6 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ /** The package version; the MCP server reports it and every run folder records it. */
4
+ export const VERSION: string = (
5
+ JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }
6
+ ).version;
@@ -0,0 +1,277 @@
1
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { BackendAdapter } from "../adapters/backend.ts";
4
+ import { budgetStatus, formatBudget } from "../domain/budget.ts";
5
+ import { CatherdError } from "../domain/errors.ts";
6
+ import { assertId, formatRung, newDispatchId, parseRung } from "../domain/ids.ts";
7
+ import { overlaps, parseLaneHeader } from "../domain/lane.ts";
8
+ import type { Role } from "../domain/roles.ts";
9
+ import { dispatchPaths, markForCollect } from "../infra/dispatch-dir.ts";
10
+ import { withFileLock } from "../infra/filelock.ts";
11
+ import { statusSnapshot } from "../infra/git.ts";
12
+ import { launchSupervisor } from "../infra/launch.ts";
13
+ import { processStartTime } from "../infra/proc.ts";
14
+ import { ensurePrivateDir, PRIVATE_FILE, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
15
+ import { readyAdapter, standInFor } from "./backends.ts";
16
+ import { spendOf } from "./budget.ts";
17
+ import {
18
+ type Admit,
19
+ admitPath,
20
+ type Dispatch,
21
+ launchPath,
22
+ type LiveDispatch,
23
+ listDispatches,
24
+ pendingDispatches,
25
+ roleDir,
26
+ setLatest,
27
+ } from "./dispatches.ts";
28
+ import { finalizeDispatch } from "./finalize.ts";
29
+ import type { Deps } from "./ports.ts";
30
+ import { readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
31
+
32
+ export interface AdmitInput {
33
+ role: Role;
34
+ name: string;
35
+ brief: string;
36
+ rung: string;
37
+ thread: string | null;
38
+ lane: string | null;
39
+ failoverFrom: string | null;
40
+ /** the dispatch id of the limited dispatch this stand-in replaces */
41
+ failoverOf?: string;
42
+ }
43
+
44
+ /** Spec §3.3: SIGTERM, then SIGKILL this long after. */
45
+ export const KILL_GRACE_MS = 10_000;
46
+
47
+ export const laneFile = (run: Run, lane: string): string => join(runPaths(run.dir).lanes, `${lane}.md`);
48
+
49
+ function laneOwns(run: Run, lane: string): string[] {
50
+ const file = laneFile(run, lane);
51
+ if (!existsSync(file))
52
+ throw new CatherdError("E_LANE_INVALID", `no lane file lanes/${lane}.md`, {
53
+ fix: "write it with write_run_file first",
54
+ });
55
+ const owns = parseLaneHeader(readFileSync(file, "utf8")).owns;
56
+ if (owns.length === 0)
57
+ throw new CatherdError("E_LANE_INVALID", `lanes/${lane}.md has no Owns: line`, {
58
+ fix: "add `Owns: <paths>` below the lane's title",
59
+ });
60
+ return owns;
61
+ }
62
+
63
+ /**
64
+ * Records each finished dispatch of the run that has none yet, as reconcile does, so one whose waiter died
65
+ * never blocks its name or lane. Before the admission lock: a finalize may wait on another's claim. One that
66
+ * throws is skipped and stays pending, so the refusal still names it.
67
+ */
68
+ async function finalizeFinished(run: Run, now: number): Promise<void> {
69
+ for (const d of pendingDispatches(run, now)) {
70
+ if (d.state !== "finished") continue;
71
+ try {
72
+ await finalizeDispatch(run, d);
73
+ } catch {
74
+ // stays pending: admission refuses its name and lane, and names it
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The cap on an adapter's prepare. Each CLI call in it is bounded already (15 s); this covers the slowest
81
+ * bounded path (opencode: a reload, then two listings) so only a prepare that is truly stuck is refused.
82
+ */
83
+ export const prepareLimits = { timeoutMs: 60_000 };
84
+
85
+ /** The adapter's prepare, refused with E_IO_UNEXPECTED when it has not settled within the limit. */
86
+ async function prepared(adapter: BackendAdapter, req: Parameters<NonNullable<BackendAdapter["prepare"]>>[0]) {
87
+ if (!adapter.prepare) return;
88
+ const ms = prepareLimits.timeoutMs;
89
+ let timer: ReturnType<typeof setTimeout> | undefined;
90
+ const late = new Promise<never>((_, reject) => {
91
+ timer = setTimeout(
92
+ () =>
93
+ reject(
94
+ new CatherdError("E_IO_UNEXPECTED", `${adapter.id} did not get ready within ${ms} ms`, {
95
+ fix: `check that the ${adapter.id} CLI starts and answers, then dispatch again`,
96
+ }),
97
+ ),
98
+ ms,
99
+ );
100
+ });
101
+ try {
102
+ await Promise.race([adapter.prepare(req), late]);
103
+ } finally {
104
+ clearTimeout(timer);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Spec §4.4 step 1. The checks that read shared state and the write that makes the dispatch live
110
+ * happen under the run's admission lock, so parallel dispatches always see each other (audit C1).
111
+ */
112
+ export async function admit(deps: Deps, run: Run, i: AdmitInput): Promise<{ d: Dispatch; specPath: string }> {
113
+ assertId("role name", i.name);
114
+ if (i.lane !== null) assertId("lane", i.lane);
115
+ const rung = parseRung(i.rung);
116
+ const profile = deps.profiles.forRepo(run.meta.repo);
117
+ const rc = profile.roles[i.role];
118
+ if (!rc?.enabled)
119
+ throw new CatherdError("E_ADMIT_RUNG", `the ${i.role} role is off in profile ${profile.name}`, {
120
+ fix: "skip the role, or turn it on with profile_set",
121
+ });
122
+ if (rung.backend === "claude")
123
+ throw new CatherdError("E_ADMIT_RUNG", `${i.rung} is a native Claude rung`, {
124
+ fix: `run it as Agent(subagent_type: "${deps.profiles.agentFor(run.meta.repo, i.role, i.rung)}"), then record_agent_run`,
125
+ });
126
+ const allowed = new Set([
127
+ ...rc.rungs,
128
+ ...rc.rungs.flatMap((r) => standInFor(profile.failover, r, run.meta.repo) ?? []),
129
+ ]);
130
+ if (!allowed.has(formatRung(rung)))
131
+ throw new CatherdError(
132
+ "E_ADMIT_RUNG",
133
+ `${i.rung} is not on the ${i.role} ladder of profile ${profile.name}`,
134
+ {
135
+ fix: `use the rung route returned; the ladder is ${rc.rungs.join(", ") || "empty"}`,
136
+ },
137
+ );
138
+ const { adapter, probe } = await readyAdapter(rung.backend);
139
+ if (i.thread !== null && !(adapter.resume.supported && adapter.resume.threadPattern.test(i.thread)))
140
+ throw new CatherdError("E_ADMIT_THREAD", `"${i.thread}" is not a ${adapter.id} thread id`, {
141
+ fix: "pass the thread from the role's earlier record, or none for a fresh thread",
142
+ });
143
+ const owns = i.lane === null ? [] : laneOwns(run, i.lane);
144
+ const id = newDispatchId();
145
+ const dir = join(roleDir(run, i.name), id);
146
+ const p = dispatchPaths(dir);
147
+ // a resumed thread lives in the home it started in (CODEX_HOME), whatever the profile says now
148
+ const started = i.thread === null ? undefined : recordsOnThread(run, rung.backend, i.thread).at(-1);
149
+ const isolated = started?.isolated ?? profile.isolated[rung.backend] ?? false;
150
+ await prepared(adapter, { rung, access: rc.access, isolated, repo: run.meta.repo });
151
+ const plan = adapter.plan({
152
+ rung,
153
+ access: rc.access,
154
+ thread: i.thread,
155
+ isolated,
156
+ repo: run.meta.repo,
157
+ briefPath: p.brief,
158
+ replyPath: p.reply,
159
+ dispatchDir: dir,
160
+ });
161
+
162
+ await finalizeFinished(run, deps.now());
163
+ return withFileLock(runPaths(run.dir).admission, async () => {
164
+ // A dispatch blocks until its record is written, not only while it runs: its finalizer diffs the
165
+ // tree after the exit, so a later dispatch's writes must not land in between. A finished one here
166
+ // could not be recorded just now. The records and the pending dispatches are one snapshot, taken
167
+ // under the lock appendRecord writes under, so no record lands between the two reads.
168
+ const { records, pending } = await withFileLock(runPaths(run.dir).runs, () => {
169
+ const records = readRecords(run).records;
170
+ return { records, pending: pendingDispatches(run, deps.now(), records) };
171
+ });
172
+ const doing = (d: LiveDispatch): string => (d.state === "finished" ? "finished, unrecorded," : "running");
173
+ const same = pending.find((d) => d.admit.name === i.name);
174
+ if (same)
175
+ throw new CatherdError(
176
+ "E_ADMIT_DUPLICATE",
177
+ `${i.name} is already ${doing(same)} on ${same.admit.rung}`,
178
+ {
179
+ fix:
180
+ same.state === "finished"
181
+ ? `its record could not be written; see ${same.dir}, and retry`
182
+ : `wait for it, or cancel(run, "${i.name}")`,
183
+ },
184
+ );
185
+ for (const d of pending) {
186
+ const shared = overlaps(owns, d.admit.owns);
187
+ if (shared.length)
188
+ throw new CatherdError(
189
+ "E_ADMIT_OVERLAP",
190
+ `${i.name} owns ${shared.join(", ")}, which ${d.admit.name} (${doing(d)}) owns`,
191
+ {
192
+ fix: `dispatch it after ${d.admit.name} finishes`,
193
+ },
194
+ );
195
+ }
196
+ // a finished dispatch not yet recorded still spent its tokens: every pending one counts
197
+ const budget = budgetStatus(spendOf(run, records, pending, deps.now()), profile.budget);
198
+ if (budget && budget.fraction >= 1)
199
+ throw new CatherdError("E_RUN_BUDGET", `the run budget is spent: ${formatBudget(budget)}`, {
200
+ fix: "ask the user to raise profile.budget, or finish the run with what landed",
201
+ });
202
+ const admitted: Admit = {
203
+ schema: 1,
204
+ runId: run.id,
205
+ dispatchId: id,
206
+ name: i.name,
207
+ role: i.role,
208
+ lane: i.lane,
209
+ owns,
210
+ rung: formatRung(rung),
211
+ backend: rung.backend,
212
+ thread: i.thread,
213
+ attempt: listDispatches(run).filter((d) => d.admit.name === i.name).length + 1,
214
+ failoverFrom: i.failoverFrom,
215
+ ...(i.failoverOf ? { failoverOf: i.failoverOf } : {}),
216
+ access: rc.access,
217
+ isolated,
218
+ cliVersion: probe.version,
219
+ admittedAt: new Date(deps.now()).toISOString(),
220
+ repo: run.meta.repo,
221
+ before: await statusSnapshot(run.meta.repo),
222
+ };
223
+ ensurePrivateDir(dir);
224
+ writeTextAtomic(p.brief, i.brief);
225
+ // Spec §10.4: the adapter's overrides only; the supervisor adds its own inherited env at spawn
226
+ // time (src/entry/supervise-command.ts), so no credential is ever written to disk. 0600 all the same.
227
+ writeJsonAtomic(
228
+ p.spec,
229
+ {
230
+ schema: 1,
231
+ backend: rung.backend,
232
+ dispatchDir: dir,
233
+ cmd: plan.cmd,
234
+ args: plan.args,
235
+ env: { ...plan.env, PWD: plan.cwd },
236
+ cwd: plan.cwd,
237
+ stdinPath: plan.stdinPath,
238
+ idleMs: profile.timeouts.idleMin * 60_000,
239
+ wallMs: profile.timeouts.wallMin * 60_000,
240
+ killGraceMs: KILL_GRACE_MS,
241
+ graceAfterFinalMs: adapter.graceAfterFinalMs,
242
+ pollMs: deps.pollMs,
243
+ },
244
+ { mode: 0o600 },
245
+ );
246
+ // the collect mark before admit.json: no admitted dispatch ever exists without it, so a process that
247
+ // dies before launching it still leaves a record (lost, once its start grace passes) for a `wait`
248
+ markForCollect(dir);
249
+ writeJsonAtomic(admitPath(dir), admitted);
250
+ setLatest(run, i.name, id);
251
+ return { d: { dir, admit: admitted }, specPath: p.spec };
252
+ });
253
+ }
254
+
255
+ /** Starts the dispatch's supervisor and records who it is; a supervisor that cannot start ends the dispatch as lost. */
256
+ export function launch(d: Dispatch, specPath: string): void {
257
+ try {
258
+ const pid = launchSupervisor(specPath);
259
+ writeJsonAtomic(launchPath(d.dir), {
260
+ schema: 1,
261
+ supervisorPid: pid,
262
+ supervisorStartTime: processStartTime(pid),
263
+ });
264
+ } catch (e) {
265
+ const p = dispatchPaths(d.dir);
266
+ appendFileSync(p.stderr, `catherd: could not start the supervisor: ${(e as Error).message}\n`, {
267
+ mode: PRIVATE_FILE,
268
+ });
269
+ writeJsonAtomic(p.exit, {
270
+ schema: 1,
271
+ code: null,
272
+ signal: null,
273
+ reason: "lost",
274
+ endedAt: new Date().toISOString(),
275
+ });
276
+ }
277
+ }
@@ -0,0 +1,139 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ readlinkSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ } from "node:fs";
11
+ import { basename, join, sep } from "node:path";
12
+ import { type AgentFile, agentFiles } from "../domain/agents.ts";
13
+ import { CatherdError } from "../domain/errors.ts";
14
+ import { claudeAgentsDir } from "../infra/paths.ts";
15
+ import { ensurePrivateDir, writeTextAtomic } from "../infra/store.ts";
16
+ import { VERSION } from "../infra/version.ts";
17
+ import { activeName, agentsRoot, getProfile, profileExists, readProjects } from "./profile-store.ts";
18
+
19
+ // Spec §7.3: the agent files and their links in Claude Code's agents dir. Only profile-service.ts writes
20
+ // them, under its profiles lock.
21
+
22
+ const lstatOrNull = (p: string) => {
23
+ try {
24
+ return lstatSync(p);
25
+ } catch {
26
+ return null;
27
+ }
28
+ };
29
+ const ours = (): string => `${agentsRoot()}${sep}`;
30
+ /** A link catherd made: a symlink into `<config>/agents/`. Nothing else in the agents dir is ever touched. */
31
+ const isOurLink = (path: string): boolean => {
32
+ const st = lstatOrNull(path);
33
+ return st !== null && st.isSymbolicLink() && readlinkSync(path).startsWith(ours());
34
+ };
35
+
36
+ /** Spec §7.3: the active profile and every repo-bound one are linked at once. */
37
+ export function linkedProfiles(): string[] {
38
+ const names = new Set([activeName(), ...Object.values(readProjects().bindings)]);
39
+ return [...names].filter(profileExists).sort();
40
+ }
41
+
42
+ export interface Planned {
43
+ files: Map<string, AgentFile[]>;
44
+ links: Map<string, string>;
45
+ }
46
+
47
+ export function plan(extra: string[] = []): Planned {
48
+ const files = new Map<string, AgentFile[]>();
49
+ for (const name of new Set([...linkedProfiles(), ...extra.filter(profileExists)]))
50
+ files.set(name, agentFiles(getProfile(name), VERSION));
51
+ const links = new Map<string, string>();
52
+ for (const name of linkedProfiles())
53
+ for (const f of files.get(name) ?? [])
54
+ links.set(join(claudeAgentsDir(), `${f.name}.md`), join(agentsRoot(), name, `${f.name}.md`));
55
+ return { files, links };
56
+ }
57
+
58
+ /** Refuses before anything is written when a planned link would replace a file catherd does not own. */
59
+ function assertNoConflict(p: Planned): void {
60
+ for (const link of p.links.keys())
61
+ if (lstatOrNull(link) && !isOurLink(link))
62
+ throw new CatherdError("E_CONFIG_INVALID", `${link} exists and is not catherd's`, {
63
+ fix: `move ${link} away, then run the command again`,
64
+ });
65
+ }
66
+
67
+ export interface Synced {
68
+ linked: string[];
69
+ pruned: string[];
70
+ /** agents whose link is new or whose file changed: Claude Code reads them only at a session's start */
71
+ newSessionNeededFor: string[];
72
+ }
73
+
74
+ /**
75
+ * Writes the planned agent files and links, prunes catherd's stale ones, and says what changed. The
76
+ * ownership check runs before the first write, so no caller can replace a file catherd does not own.
77
+ */
78
+ export function apply(p: Planned, removed: string[] = []): Synced {
79
+ assertNoConflict(p);
80
+ const changed = new Set<string>();
81
+ for (const [name, files] of p.files) {
82
+ const dir = join(agentsRoot(), name);
83
+ ensurePrivateDir(dir);
84
+ const keep = new Set(files.map((f) => `${f.name}.md`));
85
+ for (const f of files) {
86
+ const path = join(dir, `${f.name}.md`);
87
+ if (!existsSync(path) || readFileSync(path, "utf8") !== f.text) {
88
+ writeTextAtomic(path, f.text);
89
+ changed.add(path);
90
+ }
91
+ }
92
+ for (const f of readdirSync(dir)) if (!keep.has(f)) rmSync(join(dir, f), { force: true });
93
+ }
94
+ for (const name of removed) rmSync(join(agentsRoot(), name), { recursive: true, force: true });
95
+
96
+ const target = claudeAgentsDir();
97
+ const newSession: string[] = [];
98
+ if (p.links.size > 0) mkdirSync(target, { recursive: true });
99
+ for (const [link, file] of p.links) {
100
+ const fresh = !isOurLink(link) || readlinkSync(link) !== file;
101
+ if (fresh) {
102
+ rmSync(link, { force: true });
103
+ symlinkSync(file, link);
104
+ }
105
+ if (fresh || changed.has(file)) newSession.push(basename(link, ".md"));
106
+ }
107
+ const pruned: string[] = [];
108
+ if (existsSync(target))
109
+ for (const entry of readdirSync(target)) {
110
+ const link = join(target, entry);
111
+ if (isOurLink(link) && !p.links.has(link)) {
112
+ rmSync(link, { force: true });
113
+ pruned.push(entry);
114
+ }
115
+ }
116
+ return {
117
+ linked: [...p.links.keys()].map((l) => basename(l, ".md")).sort(),
118
+ pruned: pruned.sort(),
119
+ // a removed agent is still loaded in the running Claude Code session until a new one starts
120
+ newSessionNeededFor: [...newSession, ...pruned.map((e) => basename(e, ".md"))].sort(),
121
+ };
122
+ }
123
+
124
+ /** What `doctor` compares: each link that should exist, and whether it and its file are current. */
125
+ export function agentLinkState(): { missing: string[]; stale: string[]; ok: string[] } {
126
+ const p = plan();
127
+ const out = { missing: [] as string[], stale: [] as string[], ok: [] as string[] };
128
+ for (const [name, files] of p.files)
129
+ for (const f of files) {
130
+ const link = join(claudeAgentsDir(), `${f.name}.md`);
131
+ if (!p.links.has(link)) continue;
132
+ const file = join(agentsRoot(), name, `${f.name}.md`);
133
+ if (!isOurLink(link)) out.missing.push(f.name);
134
+ else if (readlinkSync(link) !== file || !existsSync(file) || readFileSync(file, "utf8") !== f.text)
135
+ out.stale.push(f.name);
136
+ else out.ok.push(f.name);
137
+ }
138
+ return out;
139
+ }