catherd-cli 0.2.1 → 1.1.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 (207) hide show
  1. package/README.md +141 -33
  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 +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -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
+ }
@@ -0,0 +1,44 @@
1
+ import type { BackendAdapter, Probe } from "../adapters/backend.ts";
2
+ import { adapterFor } from "../adapters/registry.ts";
3
+ import "../adapters/all.ts";
4
+ import { CatherdError } from "../domain/errors.ts";
5
+ import { formatRung, tryParseRung } from "../domain/ids.ts";
6
+
7
+ const READY_TTL_MS = 10 * 60_000;
8
+ const ready = new Map<string, { at: number; probe: Probe }>();
9
+
10
+ /** Forget every cached probe (tests, and after the user fixes a backend). */
11
+ export const resetReadiness = (): void => ready.clear();
12
+
13
+ /**
14
+ * Spec §4.4: the adapter for `backend` once its last probe says it is ready. A ready probe is kept
15
+ * for 10 minutes; a failing one is never kept, so a fixed backend works on the next dispatch.
16
+ */
17
+ export async function readyAdapter(backend: string): Promise<{ adapter: BackendAdapter; probe: Probe }> {
18
+ const now = Date.now();
19
+ const adapter = adapterFor(backend);
20
+ if (!adapter)
21
+ throw new CatherdError("E_BACKEND_MISSING", `catherd has no ${backend} adapter yet`, {
22
+ fix: "use a rung on a backend catherd supports; catalog_query lists them",
23
+ });
24
+ const hit = ready.get(backend);
25
+ if (hit && now - hit.at < READY_TTL_MS) return { adapter, probe: hit.probe };
26
+ const probe = await adapter.probe();
27
+ const problem = probe.problems[0];
28
+ if (problem) throw new CatherdError(problem.code, problem.message, { fix: problem.fix });
29
+ ready.set(backend, { at: now, probe });
30
+ return { adapter, probe };
31
+ }
32
+
33
+ /**
34
+ * Spec §4.5: a rung's stand-in on a usage limit: the profile's, else its backend's default (for `repo`,
35
+ * when given), else none.
36
+ */
37
+ export function standInFor(failover: Record<string, string>, rung: string, repo?: string): string | null {
38
+ const own = failover[rung];
39
+ if (own) return own;
40
+ const r = tryParseRung(rung);
41
+ if (!r) return null;
42
+ const byAdapter = adapterFor(r.backend)?.failoverFor?.(r, repo) ?? null;
43
+ return byAdapter && formatRung(byAdapter);
44
+ }
@@ -0,0 +1,47 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { adapterFor } from "../adapters/registry.ts";
3
+ import "../adapters/all.ts";
4
+ import { type Budget, type BudgetStatus, budgetStatus, type Spend } from "../domain/budget.ts";
5
+ import type { RunRecord } from "../domain/record.ts";
6
+ import { dispatchPaths } from "../infra/dispatch-dir.ts";
7
+ import { type Dispatch, liveDispatches } from "./dispatches.ts";
8
+ import { readAgentRuns, readRecords, type Run } from "./run-store.ts";
9
+
10
+ /** Tokens a running dispatch has used so far, summed from the events its CLI has written. */
11
+ export function liveTokens(d: Dispatch): number {
12
+ const a = adapterFor(d.admit.backend);
13
+ const file = dispatchPaths(d.dir).events;
14
+ if (!a || !existsSync(file)) return 0;
15
+ let n = 0;
16
+ for (const line of readFileSync(file, "utf8").split("\n")) {
17
+ if (!line.trim()) continue;
18
+ try {
19
+ const t = a.parse(line).tokens;
20
+ if (t) n += t.input + t.output;
21
+ } catch {
22
+ // a line the adapter cannot read adds nothing
23
+ }
24
+ }
25
+ return n;
26
+ }
27
+
28
+ /**
29
+ * Spec §4.6: minutes are wall clock since run_start; tokens and dollars sum every record (every
30
+ * backend), every native subagent run the orchestrator reported, and what live roles used so far.
31
+ */
32
+ export function spendOf(run: Run, records: RunRecord[], live: Dispatch[], now: number): Spend {
33
+ const agents = readAgentRuns(run);
34
+ const sum = <T>(xs: T[], f: (x: T) => number) => xs.reduce((n, x) => n + f(x), 0);
35
+ return {
36
+ minutes: Math.max(0, (now - Date.parse(run.meta.createdAt)) / 60_000),
37
+ tokens:
38
+ sum(records, (r) => r.tokens.input + r.tokens.output) +
39
+ sum(agents, (a) => a.totalTokens) +
40
+ sum(live, liveTokens),
41
+ usd: sum(records, (r) => r.costUsd ?? 0) + sum(agents, (a) => a.costUsd ?? 0),
42
+ };
43
+ }
44
+
45
+ export function budgetOf(run: Run, budget: Budget, now: number): BudgetStatus | null {
46
+ return budgetStatus(spendOf(run, readRecords(run).records, liveDispatches(run, now), now), budget);
47
+ }
@@ -0,0 +1,233 @@
1
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { BackendAdapter, FinishedRun } from "../adapters/backend.ts";
5
+ import { isCatherdError } from "../domain/errors.ts";
6
+ import { parseRung } from "../domain/ids.ts";
7
+ import type { Access } from "../domain/record.ts";
8
+ import { sanitize, type Scrub } from "../domain/sanitize.ts";
9
+ import { secretEnvValues } from "../domain/secrets.ts";
10
+ import { workerEnv } from "../infra/env.ts";
11
+ import { git } from "../infra/git.ts";
12
+ import { killGroup } from "../infra/proc.ts";
13
+ import { writeJsonAtomic } from "../infra/store.ts";
14
+ import { readyAdapter } from "./backends.ts";
15
+ import { settled } from "./finalize.ts";
16
+ import { collect, DRAIN_MS } from "./preflight.ts";
17
+
18
+ interface CaptureCase {
19
+ backend: string;
20
+ name: string;
21
+ rung: string;
22
+ access: Access;
23
+ brief: string;
24
+ }
25
+
26
+ const SAY_HELLO =
27
+ "Reply with the single word hello. The last line of your reply is exactly: STATUS: complete — said hello";
28
+ const TRY_WRITE =
29
+ "Create a file named out.txt containing the word hi. If you cannot, say why. The last line of your reply is: STATUS: complete|blocked — <one line why>";
30
+ const HAIKU = "claude-code:claude-haiku-4-5-20251001#default";
31
+ const BUNNY = "opencode:opencode/space-bunny-free#default";
32
+
33
+ /** Spec §11.7–8: one cheap run per backend, plus a read-only role trying to write where enforcement is advisory. */
34
+ const CAPTURE_CASES: CaptureCase[] = [
35
+ { backend: "codex", name: "ok", rung: "codex:gpt-6-luna#low", access: "read-only", brief: SAY_HELLO },
36
+ { backend: "claude-code", name: "ok", rung: HAIKU, access: "read-only", brief: SAY_HELLO },
37
+ { backend: "claude-code", name: "read-only-write", rung: HAIKU, access: "read-only", brief: TRY_WRITE },
38
+ { backend: "opencode", name: "ok", rung: BUNNY, access: "read-only", brief: SAY_HELLO },
39
+ { backend: "opencode", name: "read-only-write", rung: BUNNY, access: "read-only", brief: TRY_WRITE },
40
+ ];
41
+ export const CAPTURE_BACKENDS = [...new Set(CAPTURE_CASES.map((c) => c.backend))];
42
+
43
+ export type Captured =
44
+ | {
45
+ backend: string;
46
+ name: string;
47
+ status: "captured";
48
+ dir: string;
49
+ cliVersion: string;
50
+ exitCode: number | null;
51
+ }
52
+ | { backend: string; name: string; status: "skipped"; reason: string };
53
+
54
+ async function scratchRepo(): Promise<{ work: string; repo: string }> {
55
+ const work = realpathSync(mkdtempSync(join(tmpdir(), "catherd-capture-")));
56
+ const repo = join(work, "repo");
57
+ mkdirSync(repo);
58
+ await git(repo, ["init", "-q"]);
59
+ await git(repo, [
60
+ "-c",
61
+ "user.name=catherd",
62
+ "-c",
63
+ "user.email=capture@catherd.invalid",
64
+ "commit",
65
+ "-q",
66
+ "--allow-empty",
67
+ "-m",
68
+ "init",
69
+ ]);
70
+ return { work, repo };
71
+ }
72
+
73
+ /** Every string in `v`, cleaned: meta values are sanitized before JSON escapes them, not after. */
74
+ function cleanStrings(v: unknown, clean: (s: string) => string): unknown {
75
+ if (typeof v === "string") return clean(v);
76
+ if (Array.isArray(v)) return v.map((x) => cleanStrings(x, clean));
77
+ if (v && typeof v === "object")
78
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cleanStrings(x, clean)]));
79
+ return v;
80
+ }
81
+
82
+ /**
83
+ * Runs one case the way an isolated dispatch would (prepare, plan, the worker env), without the
84
+ * supervisor. Isolated, so the stream names none of the owner's own hooks, plugins, MCP servers or config:
85
+ * the fixtures are committed.
86
+ */
87
+ async function captureOne(
88
+ adapter: BackendAdapter,
89
+ version: string,
90
+ c: CaptureCase,
91
+ outDir: string,
92
+ timeoutMs: number,
93
+ ): Promise<Captured> {
94
+ const { work, repo } = await scratchRepo();
95
+ try {
96
+ const briefPath = join(work, "brief.md");
97
+ writeFileSync(briefPath, c.brief);
98
+ const rung = parseRung(c.rung);
99
+ await adapter.prepare?.({ rung, access: c.access, isolated: true, repo });
100
+ const request = {
101
+ rung,
102
+ access: c.access,
103
+ thread: null,
104
+ isolated: true,
105
+ repo,
106
+ briefPath,
107
+ replyPath: join(work, "reply.md"),
108
+ dispatchDir: work,
109
+ };
110
+ const plan = adapter.plan(request);
111
+ const startedAtMs = Date.now();
112
+ const p = Bun.spawn([plan.cmd, ...plan.args], {
113
+ cwd: plan.cwd,
114
+ env: workerEnv(process.env, plan.env, plan.cwd),
115
+ stdin: Bun.file(briefPath),
116
+ stdout: "pipe",
117
+ stderr: "pipe",
118
+ // its own group, so a timeout stops whatever the CLI started too, as the supervisor does
119
+ detached: true,
120
+ });
121
+ let timedOut = false;
122
+ const timer = setTimeout(() => {
123
+ timedOut = true;
124
+ killGroup(p.pid, "SIGKILL");
125
+ }, timeoutMs);
126
+ // as preflight does: a process the CLI left behind (in its own session) may hold the pipes open
127
+ const out = collect(p.stdout);
128
+ const err = collect(p.stderr);
129
+ const code = await p.exited;
130
+ clearTimeout(timer);
131
+ const drained = await Promise.race([
132
+ Promise.all([out.done, err.done]).then(() => true),
133
+ Bun.sleep(DRAIN_MS).then(() => false),
134
+ ]);
135
+ if (!drained) killGroup(p.pid, "SIGKILL"); // leftovers still in the CLI's group
136
+ out.stop();
137
+ err.stop();
138
+ const events = out.text();
139
+ const stderr = err.text();
140
+ const run: FinishedRun = {
141
+ request,
142
+ eventLines: events.split("\n").filter((l) => l.trim()),
143
+ reply: "",
144
+ stderr,
145
+ exit: {
146
+ code: p.signalCode ? null : code,
147
+ signal: p.signalCode ?? null,
148
+ reason: timedOut ? "wall-timeout" : "exited",
149
+ endedAt: new Date().toISOString(),
150
+ },
151
+ startedAtMs,
152
+ };
153
+ // as a dispatch records it: the backend's own totals and limits, on a fresh thread (nothing prior)
154
+ const o = await settled(adapter, adapter.finalize(run), run, () => ({
155
+ tokens: { input: 0, cached: 0, output: 0 },
156
+ costUsd: 0,
157
+ }));
158
+ const scrub: Scrub = {
159
+ secrets: secretEnvValues(process.env),
160
+ paths: [
161
+ { from: repo, to: "<repo>" },
162
+ { from: work, to: "<tmp>" },
163
+ { from: realpathSync(tmpdir()), to: "<tmp>" },
164
+ { from: homedir(), to: "~" },
165
+ ],
166
+ };
167
+ const clean = (t: string) => sanitize(t, scrub);
168
+ const dir = join(outDir, c.backend, version);
169
+ mkdirSync(dir, { recursive: true });
170
+ writeFileSync(join(dir, `${c.name}.jsonl`), clean(events));
171
+ writeFileSync(join(dir, `${c.name}.stderr`), clean(stderr));
172
+ writeJsonAtomic(
173
+ join(dir, `${c.name}.json`),
174
+ cleanStrings(
175
+ {
176
+ schema: 1,
177
+ backend: c.backend,
178
+ cliVersion: version,
179
+ case: c.name,
180
+ rung: c.rung,
181
+ access: c.access,
182
+ isolated: true,
183
+ brief: c.brief,
184
+ exitCode: run.exit.code,
185
+ reason: run.exit.reason,
186
+ capturedAt: new Date().toISOString(),
187
+ outcome: {
188
+ status: o.status,
189
+ thread: o.thread,
190
+ tokens: o.tokens,
191
+ costUsd: o.costUsd,
192
+ error: o.error,
193
+ },
194
+ },
195
+ clean,
196
+ ),
197
+ { mode: 0o644 },
198
+ );
199
+ return {
200
+ backend: c.backend,
201
+ name: c.name,
202
+ status: "captured",
203
+ dir,
204
+ cliVersion: version,
205
+ exitCode: run.exit.code,
206
+ };
207
+ } finally {
208
+ rmSync(work, { recursive: true, force: true });
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Spec §11.8: records the capture cases of every ready backend (or only `backends`) under
214
+ * `<outDir>/<backend>/<cli-version>/`, with secrets and home paths stripped. A backend that is not
215
+ * ready is skipped with the reason.
216
+ */
217
+ export async function captureFixtures(o: {
218
+ outDir: string;
219
+ backends?: string[];
220
+ timeoutMs?: number;
221
+ }): Promise<Captured[]> {
222
+ const out: Captured[] = [];
223
+ for (const c of CAPTURE_CASES.filter((x) => !o.backends || o.backends.includes(x.backend))) {
224
+ try {
225
+ const { adapter, probe } = await readyAdapter(c.backend);
226
+ out.push(await captureOne(adapter, probe.version ?? "unknown", c, o.outDir, o.timeoutMs ?? 300_000));
227
+ } catch (e) {
228
+ if (!isCatherdError(e)) throw e;
229
+ out.push({ backend: c.backend, name: c.name, status: "skipped", reason: `${e.code}: ${e.message}` });
230
+ }
231
+ }
232
+ return out;
233
+ }