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,333 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { CatherdError } from "../../domain/errors.ts";
5
+ import type { Rung } from "../../domain/ids.ts";
6
+ import type { Access, RunStatus, Tokens } from "../../domain/record.ts";
7
+ import {
8
+ type BackendAdapter,
9
+ compareVersions,
10
+ type DiscoveredModel,
11
+ type EventDelta,
12
+ extractVersion,
13
+ type FinishedRun,
14
+ type Outcome,
15
+ type Probe,
16
+ type RunRequest,
17
+ type SpawnPlan,
18
+ type Spent,
19
+ } from "../backend.ts";
20
+ import { scratchShell } from "../access.ts";
21
+ import { jsonOf, runCli } from "../cli.ts";
22
+ import { configDir } from "../../infra/paths.ts";
23
+ import { discovered, readDiscovery } from "../discovery.ts";
24
+ import { installAgents, isolatedConfigRoot, OPENCODE_AGENT, userConfigRoot } from "./agents.ts";
25
+ import {
26
+ eventName,
27
+ foldOpencodeEvents,
28
+ isV1Error,
29
+ OPENCODE_LIMIT,
30
+ OPENCODE_LIMIT_TYPES,
31
+ OPENCODE_TOO_OLD,
32
+ opencodeTokens,
33
+ parseOpencodeLine,
34
+ } from "./events.ts";
35
+
36
+ /** Spec §6.3: v2 only (`@opencode/cli` 2.0.16 or newer). */
37
+ const OPENCODE_MIN_VERSION = "2.0.16";
38
+ export const OPENCODE_INSTALL = "curl -fsSL https://opencode.ai/v2/install | bash";
39
+ /** Spec §6.3: discovery is limited to OpenCode Zen and Go. */
40
+ const OPENCODE_PROVIDERS = ["opencode", "opencode-go"];
41
+ const THREAD = /^ses_[A-Za-z0-9]{20,40}$/;
42
+ const DAY_MS = 24 * 60 * 60_000;
43
+
44
+ /** Limits for `opencode` queries; tests shorten them. */
45
+ export const opencodeShell = { timeoutMs: 15_000, retryDelayMs: 1_500 };
46
+
47
+ /** `opencode api <method> <path>`: the JSON the service answered, or null when it failed or said nothing. */
48
+ async function opencodeApi(method: "GET" | "POST", path: string): Promise<Record<string, any> | null> {
49
+ const r = await runCli("opencode", ["api", method, path], opencodeShell);
50
+ const j = r?.ok ? jsonOf(r.out) : null;
51
+ return j && typeof j === "object" ? (j as Record<string, any>) : null;
52
+ }
53
+
54
+ /**
55
+ * An isolated worker's XDG_CONFIG_HOME holds only catherd's opencode config, and every process the worker
56
+ * starts inherits it: point catherd (`catherd lock`), gh and git back at the user's own config.
57
+ */
58
+ function isolatedEnv(): Record<string, string> {
59
+ const user = userConfigRoot();
60
+ const env: Record<string, string> = {
61
+ XDG_CONFIG_HOME: isolatedConfigRoot(),
62
+ CATHERD_CONFIG_DIR: configDir(),
63
+ };
64
+ if (!process.env.GH_CONFIG_DIR) env.GH_CONFIG_DIR = join(user, "gh");
65
+ const gitXdg = join(user, "git", "config");
66
+ if (!process.env.GIT_CONFIG_GLOBAL && !existsSync(join(homedir(), ".gitconfig")) && existsSync(gitXdg))
67
+ env.GIT_CONFIG_GLOBAL = gitXdg;
68
+ return env;
69
+ }
70
+
71
+ function plan(r: RunRequest): SpawnPlan {
72
+ if (r.thread !== null && !THREAD.test(r.thread))
73
+ throw new CatherdError("E_ADMIT_THREAD", `"${r.thread}" is not an opencode session id`, {
74
+ fix: "pass the thread from the earlier RunRecord",
75
+ });
76
+ return {
77
+ cmd: "opencode",
78
+ args: [
79
+ "run",
80
+ "--format",
81
+ "json",
82
+ "--auto",
83
+ "--agent",
84
+ OPENCODE_AGENT[r.access],
85
+ "-m",
86
+ // "default" means the model's own default variant: no `#` (spec §5.1)
87
+ r.rung.effort === "default" ? r.rung.model : `${r.rung.model}#${r.rung.effort}`,
88
+ // v2's background service ignores the client's config env; a standalone server reads it
89
+ ...(r.isolated ? ["--standalone"] : []),
90
+ ...(r.thread === null ? [] : ["-s", r.thread]),
91
+ ],
92
+ env: r.isolated ? isolatedEnv() : {},
93
+ cwd: r.repo,
94
+ stdinPath: r.briefPath,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * The Zen and Go models enabled in `repo`; without one, in the home directory, where v2 resolves a
100
+ * request that names no location (research §2.4).
101
+ */
102
+ async function listModels(repo?: string): Promise<DiscoveredModel[]> {
103
+ const where = repo === undefined ? "" : `?${new URLSearchParams({ "location[directory]": repo })}`;
104
+ let data: Record<string, any>[] = [];
105
+ for (let attempt = 0; attempt < 2 && data.length === 0; attempt++) {
106
+ // the first listing after the service (re)starts can be empty (research §1.4)
107
+ if (attempt > 0) await Bun.sleep(opencodeShell.retryDelayMs);
108
+ const r = await opencodeApi("GET", `/api/model${where}`);
109
+ data = Array.isArray(r?.data) ? r.data : [];
110
+ }
111
+ return data
112
+ .filter(
113
+ (m) => OPENCODE_PROVIDERS.includes(m.providerID) && m.enabled !== false && typeof m.id === "string",
114
+ )
115
+ .map((m) => ({
116
+ id: `${m.providerID}/${m.id}`,
117
+ efforts: ((m.variants ?? []) as { id?: unknown }[])
118
+ .map((v) => v.id)
119
+ .filter((v): v is string => typeof v === "string"),
120
+ context: typeof m.limit?.context === "number" ? m.limit.context : null,
121
+ imageIn: Array.isArray(m.capabilities?.input) && m.capabilities.input.includes("image"),
122
+ }));
123
+ }
124
+
125
+ /**
126
+ * Spec §6.3: install the catherd agents, and refuse a Zen or Go model or variant opencode does not list
127
+ * in the repository (its config can enable or disable models), from a listing cached per repository.
128
+ */
129
+ async function prepare(req: { rung: Rung; access: Access; isolated: boolean; repo: string }): Promise<void> {
130
+ const root = req.isolated ? isolatedConfigRoot() : userConfigRoot();
131
+ // the background service only sees a new agent file after a reload (verified on 2.0.16)
132
+ if (installAgents(root) && !req.isolated) await runCli("opencode", ["reload"], opencodeShell);
133
+ const { model, effort } = req.rung;
134
+ if (!OPENCODE_PROVIDERS.includes(model.split("/")[0] ?? "")) return;
135
+ const models = await discovered("opencode", () => listModels(req.repo), {
136
+ maxAgeMs: DAY_MS,
137
+ need: model,
138
+ repo: req.repo,
139
+ });
140
+ if (models.length === 0) return; // opencode listed nothing: let the run itself say what is wrong
141
+ const m = models.find((x) => x.id === model);
142
+ if (!m)
143
+ throw new CatherdError("E_BACKEND_MODEL_UNKNOWN", `opencode does not list ${model}`, {
144
+ fix: model.startsWith("opencode-go/")
145
+ ? "subscribe to OpenCode Go and run: opencode auth login opencode-go --method key"
146
+ : "run: opencode auth login opencode --method key, or pick a model catalog_query lists",
147
+ });
148
+ if (effort !== "default" && !m.efforts.includes(effort))
149
+ throw new CatherdError("E_BACKEND_MODEL_UNKNOWN", `${model} has no variant "${effort}"`, {
150
+ fix: `use one of: ${["default", ...m.efforts].join(", ")}`,
151
+ });
152
+ }
153
+
154
+ function finalize(run: FinishedRun): Outcome {
155
+ const f = foldOpencodeEvents(run.eventLines);
156
+ const stopped = run.exit.reason;
157
+ const tooOld = f.tooOld || OPENCODE_TOO_OLD.some((r) => r.test(run.stderr));
158
+ const status: RunStatus =
159
+ stopped === "cancelled"
160
+ ? "cancelled"
161
+ : stopped === "idle-timeout" || stopped === "wall-timeout"
162
+ ? "timeout"
163
+ : tooOld
164
+ ? "cli-too-old"
165
+ : f.limit
166
+ ? "limit"
167
+ : f.error !== null || run.exit.code !== 0
168
+ ? "failed"
169
+ : "ok";
170
+ const lastErr = run.stderr.trim().split("\n").at(-1) ?? "";
171
+ const message = f.declined
172
+ ? `permission rejected: ${f.declined}`
173
+ : (f.error?.message ?? (lastErr || `${stopped}, exit ${run.exit.code ?? run.exit.signal}`));
174
+ return {
175
+ status,
176
+ thread: f.thread ?? run.request.thread,
177
+ tokens: f.tokens,
178
+ costUsd: f.costUsd,
179
+ images: [],
180
+ error: status === "ok" ? null : { code: status, message },
181
+ reply: f.reply,
182
+ };
183
+ }
184
+
185
+ /** Spec §3.7: the tool opencode ran (a command, a file edit, another tool with its first argument) or its text. */
186
+ function opencodeActivity(e: Record<string, any>): string | undefined {
187
+ if (e.type === "text" && typeof e.part?.text === "string") return e.part.text;
188
+ if (e.type !== "tool_use" || typeof e.part?.tool !== "string") return undefined;
189
+ const input = e.part.state?.input ?? {};
190
+ if (typeof input.command === "string") return `$ ${input.command}`;
191
+ const path = input.filePath ?? input.path;
192
+ if (/^(edit|write|patch)$/.test(e.part.tool) && typeof path === "string") return `edit ${path}`;
193
+ const first = Object.values(input).find((v) => typeof v === "string");
194
+ return `${e.part.tool}${typeof first === "string" ? ` ${first}` : ""}`;
195
+ }
196
+
197
+ function parse(line: string): EventDelta {
198
+ const e = parseOpencodeLine(line);
199
+ if (!e) return {};
200
+ const d: EventDelta = { lastEvent: eventName(e) };
201
+ const activity = opencodeActivity(e);
202
+ if (activity) d.activity = activity;
203
+ if (typeof e.sessionID === "string") d.thread = e.sessionID;
204
+ if (e.type === "step_finish") {
205
+ d.tokens = opencodeTokens(e.part?.tokens);
206
+ d.requestInput = d.tokens.input;
207
+ if (typeof e.part?.cost === "number") d.costUsd = e.part.cost;
208
+ }
209
+ if (e.type === "error") {
210
+ if (isV1Error(e)) d.tooOld = true;
211
+ else if (OPENCODE_LIMIT_TYPES.includes(e.error?.type)) d.limit = true;
212
+ d.failure = String(e.error?.message ?? e.error?.data?.message ?? "error");
213
+ }
214
+ return d;
215
+ }
216
+
217
+ /**
218
+ * A usage limit the latest assistant message is retrying on or failed with (v2 keeps `retry: {attempt, at,
219
+ * error}` on it), or null. Messages come newest first, after an `idle` marker once the session stopped;
220
+ * older messages keep their errors, so only the newest assistant message counts, and with `sinceMs` only
221
+ * if this run wrote it.
222
+ */
223
+ function limitRetry(messages: unknown, sinceMs?: number): string | null {
224
+ if (!Array.isArray(messages)) return null;
225
+ const m = messages.find((x) => x?.type === "assistant") as Record<string, any> | undefined;
226
+ const err = m?.retry?.error ?? m?.error;
227
+ if (!OPENCODE_LIMIT_TYPES.includes(err?.type)) return null;
228
+ if (sinceMs !== undefined && !(m?.time?.created >= sinceMs)) return null;
229
+ return String(err.message ?? err.type);
230
+ }
231
+
232
+ const minus = (a: Tokens, b: Tokens): Tokens => ({
233
+ input: Math.max(0, a.input - b.input),
234
+ cached: Math.max(0, a.cached - b.cached),
235
+ output: Math.max(0, a.output - b.output),
236
+ });
237
+
238
+ /**
239
+ * Spec §6.3: totals from `GET /api/session/<id>` (the stream often drops its last step_finish), minus
240
+ * what earlier records on the session counted; a run stopped while retrying on a usage limit is a limit.
241
+ */
242
+ async function settle(o: Outcome, run: FinishedRun, prior: Spent): Promise<Outcome> {
243
+ if (o.thread === null) return o;
244
+ let out = o;
245
+ const s = (await opencodeApi("GET", `/api/session/${o.thread}`))?.data;
246
+ if (s?.tokens)
247
+ out = {
248
+ ...out,
249
+ tokens: minus(opencodeTokens(s.tokens), prior.tokens),
250
+ costUsd: typeof s.cost === "number" ? Math.max(0, s.cost - prior.costUsd) : out.costUsd,
251
+ };
252
+ if (out.status === "timeout" || out.status === "failed") {
253
+ const messages = (await opencodeApi("GET", `/api/session/${o.thread}/message`))?.data;
254
+ const why = limitRetry(messages, run.startedAtMs);
255
+ if (why) out = { ...out, status: "limit", error: { code: "limit", message: why } };
256
+ }
257
+ return out;
258
+ }
259
+
260
+ /**
261
+ * Busy while the service lists the session as running, unless it is only waiting out a usage limit.
262
+ * Empty or failed API output counts as not busy (spec §6.3), so a hung run still times out.
263
+ */
264
+ async function isBusy(thread: string, sinceMs?: number): Promise<boolean> {
265
+ const active = (await opencodeApi("GET", "/api/session/active"))?.data;
266
+ if (!active || typeof active !== "object" || !(thread in active)) return false;
267
+ const messages = (await opencodeApi("GET", `/api/session/${thread}/message`))?.data;
268
+ return Array.isArray(messages) && limitRetry(messages, sinceMs) === null;
269
+ }
270
+
271
+ /** Killing the v2 client does not stop its session; the service must be told (research §2.4). */
272
+ async function interrupt(thread: string): Promise<void> {
273
+ await opencodeApi("POST", `/api/session/${thread}/interrupt`);
274
+ }
275
+
276
+ /** Spec §4.5: Go `X` → Zen `X` when Zen lists `X` with the same variant (in `repo`, when given). */
277
+ function failoverFor(rung: Rung, repo?: string): Rung | null {
278
+ if (!rung.model.startsWith("opencode-go/")) return null;
279
+ const zen = `opencode/${rung.model.slice("opencode-go/".length)}`;
280
+ const m = readDiscovery("opencode", repo)?.models.find((x) => x.id === zen);
281
+ if (!m || (rung.effort !== "default" && !m.efforts.includes(rung.effort))) return null;
282
+ return { backend: "opencode", model: zen, effort: rung.effort };
283
+ }
284
+
285
+ async function probe(): Promise<Probe> {
286
+ const v = await runCli("opencode", ["--version"], opencodeShell);
287
+ if (!v)
288
+ return {
289
+ installed: false,
290
+ version: null,
291
+ versionOk: false,
292
+ loggedIn: null,
293
+ problems: [{ code: "E_BACKEND_MISSING", message: "opencode is not on PATH", fix: OPENCODE_INSTALL }],
294
+ };
295
+ const version = extractVersion(v.out);
296
+ const versionOk = version !== null && compareVersions(version, OPENCODE_MIN_VERSION) >= 0;
297
+ const auth = jsonOf((await runCli("opencode", ["auth", "list", "--format", "json"], opencodeShell))?.out);
298
+ // Not logged in is no problem: the free Zen models run without a key.
299
+ const loggedIn = Array.isArray(auth)
300
+ ? auth.some((p) => OPENCODE_PROVIDERS.includes(p?.id) && (p?.connections?.length ?? 0) > 0)
301
+ : null;
302
+ const problems: Probe["problems"] = [];
303
+ if (!versionOk)
304
+ problems.push({
305
+ code: "E_BACKEND_TOO_OLD",
306
+ message: version?.startsWith("1.")
307
+ ? `opencode ${version} is v1; catherd needs opencode v2 (${OPENCODE_MIN_VERSION} or newer)`
308
+ : `opencode ${version ?? "?"} is older than ${OPENCODE_MIN_VERSION}`,
309
+ fix: OPENCODE_INSTALL,
310
+ });
311
+ return { installed: true, version, versionOk, loggedIn, problems };
312
+ }
313
+
314
+ export const opencodeAdapter: BackendAdapter = {
315
+ id: "opencode",
316
+ minVersion: OPENCODE_MIN_VERSION,
317
+ probe,
318
+ listModels,
319
+ prepare,
320
+ plan,
321
+ parse,
322
+ finalize,
323
+ settle,
324
+ enforcement: { "read-only": "advisory", "workspace-write": "advisory", full: "advisory" },
325
+ errors: { limit: OPENCODE_LIMIT, tooOld: OPENCODE_TOO_OLD },
326
+ resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
327
+ interrupt: (thread) => interrupt(thread),
328
+ isBusy: (thread, _cwd, sinceMs) => isBusy(thread, sinceMs),
329
+ failoverFor,
330
+ graceAfterFinalMs: null,
331
+ // research 2026-09-25 §2.6: opencode has no OS sandbox; its shell runs with the user's own authority
332
+ accessShell: async () => scratchShell("an unsandboxed shell (opencode has no sandbox)", []),
333
+ };
@@ -0,0 +1,14 @@
1
+ import type { BackendAdapter } from "./backend.ts";
2
+
3
+ const adapters = new Map<string, BackendAdapter>();
4
+
5
+ export function registerAdapter(a: BackendAdapter): void {
6
+ adapters.set(a.id, a);
7
+ }
8
+
9
+ export const adapterFor = (id: string): BackendAdapter | null => adapters.get(id) ?? null;
10
+
11
+ /** Tests only: forget an adapter registered for the test. */
12
+ export function unregisterAdapter(id: string): void {
13
+ adapters.delete(id);
14
+ }
package/src/cli.ts CHANGED
@@ -1,28 +1,168 @@
1
1
  #!/usr/bin/env bun
2
- import { readFileSync, realpathSync } from "node:fs";
3
- import { fileURLToPath } from "node:url";
4
- import { defineCommand, runMain } from "citty";
5
- import { lockCommand } from "./core/lock.ts";
6
- import { mcpCommand } from "./mcp/commands.ts";
7
- import { catalogCommand } from "./routing/commands.ts";
8
- import { editorRun, initCommand, watchCommand } from "./tui/commands.ts";
9
-
10
- const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
11
- version: string;
2
+ import type { ArgsDef } from "citty";
3
+ import { errorMessage } from "./domain/errors.ts";
4
+ import { runtimeRefusal } from "./domain/runtime.ts";
5
+
6
+ // Spec §3.1: refuse an old Bun before anything else loads.
7
+ const refusal = runtimeRefusal(typeof Bun === "undefined" ? undefined : Bun.version);
8
+ if (refusal) {
9
+ for (const line of refusal) console.error(line);
10
+ process.exit(1);
11
+ }
12
+
13
+ const { realpathSync } = await import("node:fs");
14
+ const { fileURLToPath } = await import("node:url");
15
+ const { defineCommand, renderUsage, runCommand } = await import("citty");
16
+ const { CatherdError, isCatherdError } = await import("./domain/errors.ts");
17
+ const { EXIT, exitCodeOf, printError, stripAnsi } = await import("./entry/cli-kit.ts");
18
+ const { VERSION } = await import("./infra/version.ts");
19
+
20
+ type Command = ReturnType<typeof defineCommand>;
21
+
22
+ /** The dashboard's flags, typed as any command's args so `main` stays a plain `Command`. */
23
+ const dashboardArgs: ArgsDef = {
24
+ plain: {
25
+ type: "boolean",
26
+ description: "the dashboard in ASCII without colour (NO_COLOR also drops colour)",
27
+ },
28
+ "reduced-motion": { type: "boolean", description: "the dashboard without animation" },
12
29
  };
13
30
 
14
- export const main = defineCommand({
15
- meta: { name: "catherd", version: pkg.version, description: "Herds coding agents." },
31
+ /**
32
+ * Spec §8. Every subcommand loads lazily, so `mcp`, `lock` and `_supervise` never load OpenTUI or React
33
+ * (spec §3.1); a bare `catherd` opens the TUI.
34
+ */
35
+ export const main: Command = defineCommand({
36
+ meta: {
37
+ name: "catherd",
38
+ version: VERSION,
39
+ description: "Herds coding agents",
40
+ },
41
+ args: dashboardArgs,
16
42
  subCommands: {
17
- lock: lockCommand,
18
- catalog: catalogCommand,
19
- mcp: mcpCommand,
20
- init: initCommand,
21
- watch: watchCommand,
43
+ init: () => import("./entry/init-command.ts").then((m) => m.initCommand),
44
+ status: () => import("./entry/runs-command.ts").then((m) => m.statusCommand),
45
+ watch: () => import("./entry/runs-command.ts").then((m) => m.watchCommand),
46
+ runs: () => import("./entry/runs-command.ts").then((m) => m.runsCommand),
47
+ profile: () => import("./entry/profile-command.ts").then((m) => m.profileCommand),
48
+ doctor: () => import("./entry/doctor-command.ts").then((m) => m.doctorCommand),
49
+ catalog: () => import("./entry/catalog-command.ts").then((m) => m.catalogCommand),
50
+ lock: () => import("./entry/lock-command.ts").then((m) => m.lockCommand),
51
+ "capture-fixtures": () =>
52
+ import("./entry/capture-fixtures-command.ts").then((m) => m.captureFixturesCommand),
53
+ mcp: () => import("./entry/mcp/command.ts").then((m) => m.mcpCommand),
54
+ _supervise: () => import("./entry/supervise-command.ts").then((m) => m.superviseCommand),
55
+ },
56
+ // citty runs this after any subcommand too; only a bare `catherd` loads and opens the dashboard
57
+ async run(ctx) {
58
+ if (!ctx.rawArgs.every((a) => a.startsWith("-"))) return;
59
+ await (await import("./entry/tui/run.tsx")).tuiRun(ctx);
22
60
  },
23
- run: editorRun,
24
61
  });
25
62
 
63
+ const resolve = async <T>(v: T | Promise<T> | (() => T | Promise<T>)): Promise<T> =>
64
+ typeof v === "function" ? await (v as () => T | Promise<T>)() : await v;
65
+
66
+ /** The deepest command `argv` names and its parent: what `--help` and a usage error describe. */
67
+ async function commandFor(argv: string[]): Promise<[Command, Command | undefined, string[]]> {
68
+ let cmd = main;
69
+ let parent: Command | undefined;
70
+ const path: string[] = [];
71
+ for (const a of argv) {
72
+ if (a.startsWith("-")) continue;
73
+ const subs = cmd.subCommands ? await resolve(cmd.subCommands) : undefined;
74
+ const next = subs?.[a];
75
+ if (!next) break;
76
+ parent = cmd;
77
+ cmd = (await resolve(next)) as Command;
78
+ path.push(a);
79
+ }
80
+ return [cmd, parent, path];
81
+ }
82
+
83
+ /** runCli takes `--verbose` before any command, so every command's help lists it. */
84
+ const VERBOSE_ARG: ArgsDef = {
85
+ verbose: { type: "boolean", description: "log at debug level (CATHERD_LOG=debug)" },
86
+ };
87
+
88
+ /**
89
+ * `--help` for the command `path` names (spec §8): `catherd <path>` with the version at every depth, the
90
+ * usage line a command spells out itself, and no colour codes when piped or under NO_COLOR.
91
+ */
92
+ async function helpText(cmd: Command, path: string[]): Promise<string> {
93
+ const shown = { ...cmd, args: { ...(await resolve(cmd.args ?? {})), ...VERBOSE_ARG } };
94
+ const parent = path.length
95
+ ? defineCommand({ meta: { name: ["catherd", ...path.slice(0, -1)].join(" "), version: VERSION } })
96
+ : undefined;
97
+ let text = await renderUsage(shown, parent);
98
+ const usage = path[0] === "lock" ? (await import("./entry/lock-command.ts")).LOCK_USAGE : undefined;
99
+ if (usage) text = text.replace(/^(\S*USAGE\S*) .*$/m, (_, head: string) => `${head} ${usage}`);
100
+ return process.stdout.isTTY && !process.env.NO_COLOR ? text : stripAnsi(text);
101
+ }
102
+
103
+ /** citty's "Unknown command x." worded as catherd's own messages: lower case, no full stop (audit N5). */
104
+ const cittyWording = (m: string): string => `${m.charAt(0).toLowerCase()}${m.slice(1)}`.replace(/\.$/, "");
105
+
106
+ const isCliError = (e: unknown): e is Error & { code: string } => e instanceof Error && e.name === "CLIError";
107
+
108
+ /** Runs `catherd <argv>` and returns its exit code (spec §8). */
109
+ export async function runCli(argv: string[]): Promise<number> {
110
+ // catherd's own flags are those before `--`; everything after belongs to the command `lock` runs
111
+ const dashdash = argv.indexOf("--");
112
+ const head = dashdash < 0 ? argv : argv.slice(0, dashdash);
113
+ if (head.includes("--verbose")) process.env.CATHERD_LOG = "debug";
114
+ const own = head.filter((a) => a !== "--verbose");
115
+ const rawArgs = [...own, ...(dashdash < 0 ? [] : argv.slice(dashdash))];
116
+ if (own.length === 1 && (own[0] === "--version" || own[0] === "-v")) {
117
+ console.log(VERSION);
118
+ return EXIT.ok;
119
+ }
120
+ if (own.includes("--help") || own.includes("-h")) {
121
+ const [cmd, , path] = await commandFor(own);
122
+ console.log(`${await helpText(cmd, path)}\n`);
123
+ return EXIT.ok;
124
+ }
125
+ const command = own.find((a) => !a.startsWith("-"));
126
+ // a bare `catherd` opens the dashboard, which takes only its own flags: a typo is not a terminal problem
127
+ const flag = (a: string) => (a.startsWith("--") ? (a.slice(2).split("=")[0] ?? "") : "");
128
+ // citty negates a boolean as `--no-<name>`
129
+ const known = (name: string) =>
130
+ Object.hasOwn(dashboardArgs, name) ||
131
+ (name.startsWith("no-") && dashboardArgs[name.slice(3)]?.type === "boolean");
132
+ const unknown = command === undefined ? own.find((a) => !known(flag(a))) : undefined;
133
+ if (unknown !== undefined) {
134
+ printError(new CatherdError("E_INPUT_INVALID", `unknown option ${unknown}`, { fix: "catherd --help" }));
135
+ return EXIT.usage;
136
+ }
137
+ // `lock` forwards signals to its command itself; everything else stops at once on Ctrl-C. The command is
138
+ // the first word that is not a flag: `catherd --plain lock -- …` is `lock` too.
139
+ if (command !== "lock") process.once("SIGINT", () => process.exit(EXIT.interrupted));
140
+ try {
141
+ await runCommand(main, { rawArgs });
142
+ return Number(process.exitCode ?? EXIT.ok);
143
+ } catch (e) {
144
+ if (isCliError(e)) {
145
+ const [, , path] = await commandFor(own);
146
+ printError(
147
+ new CatherdError("E_INPUT_INVALID", cittyWording(stripAnsi(e.message)), {
148
+ fix: `catherd ${[...path, "--help"].join(" ")}`,
149
+ }),
150
+ );
151
+ return EXIT.usage;
152
+ }
153
+ if (isCatherdError(e)) {
154
+ printError(e);
155
+ return exitCodeOf(e);
156
+ }
157
+ printError({
158
+ code: "E_IO_UNEXPECTED",
159
+ message: errorMessage(e),
160
+ fix: "this is a catherd bug: report it with this message and the output of the same command with --verbose",
161
+ });
162
+ return EXIT.error;
163
+ }
164
+ }
165
+
26
166
  if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
27
- void runMain(main);
167
+ process.exitCode = await runCli(process.argv.slice(2));
28
168
  }
@@ -0,0 +1,60 @@
1
+ import { parseRung, tryParseRung } from "./ids.ts";
2
+ import { agentName, type Profile } from "./profile.ts";
3
+ import type { Access } from "./record.ts";
4
+ import { nativeDisallowedTools, rolePrompt } from "./role-prompts.ts";
5
+ import { ROLES, type Role } from "./roles.ts";
6
+
7
+ export interface AgentFile {
8
+ /** the agent's name, also its file name without `.md` */
9
+ name: string;
10
+ role: Role;
11
+ rung: string;
12
+ text: string;
13
+ }
14
+
15
+ /** One native subagent's file: Claude Code reads `name`, `model`, `effort` and the tool list. */
16
+ export function renderAgent(o: {
17
+ profile: string;
18
+ role: Role;
19
+ rung: string;
20
+ access: Access;
21
+ version: string;
22
+ }): string {
23
+ const { model, effort } = parseRung(o.rung);
24
+ return [
25
+ "---",
26
+ `name: ${agentName(o.profile, o.role, o.rung)}`,
27
+ `description: Internal ${o.role} role of the catherd orchestrator (profile ${o.profile}), on ${model}${effort === "default" ? "" : ` at ${effort} effort`}. Dispatched only by the catherd skill while a run is in flight. Never for a plain request, even one that names this role.`,
28
+ `model: ${model}`,
29
+ ...(effort === "default" ? [] : [`effort: ${effort}`]),
30
+ `disallowedTools: ${nativeDisallowedTools(o.access).join(", ")}`,
31
+ "---",
32
+ "",
33
+ rolePrompt(o.role, o.version),
34
+ "",
35
+ ].join("\n");
36
+ }
37
+
38
+ /**
39
+ * Spec §7.3: an agent file per enabled role and native `claude:` rung, counting a native stand-in for one
40
+ * of the role's rungs, since dispatch hands those to the orchestrator as an Agent to start.
41
+ */
42
+ export function agentFiles(p: Profile, version: string): AgentFile[] {
43
+ const out = new Map<string, AgentFile>();
44
+ for (const role of ROLES) {
45
+ const rc = p.roles[role];
46
+ if (!rc.enabled) continue;
47
+ const rungs = [...rc.rungs, ...rc.rungs.flatMap((r) => p.failover[r] ?? [])];
48
+ for (const rung of rungs) {
49
+ if (tryParseRung(rung)?.backend !== "claude") continue;
50
+ const name = agentName(p.name, role, rung);
51
+ out.set(name, {
52
+ name,
53
+ role,
54
+ rung,
55
+ text: renderAgent({ profile: p.name, role, rung, access: rc.access, version }),
56
+ });
57
+ }
58
+ }
59
+ return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
60
+ }
@@ -0,0 +1,45 @@
1
+ export interface Budget {
2
+ minutes?: number;
3
+ tokens?: number;
4
+ usd?: number;
5
+ }
6
+
7
+ export interface Spend {
8
+ minutes: number;
9
+ tokens: number;
10
+ usd: number;
11
+ }
12
+
13
+ export interface BudgetStatus {
14
+ /** the highest spent/cap ratio over the caps the profile sets; ≥ 1 means spent */
15
+ fraction: number;
16
+ minutes?: { spent: number; cap: number };
17
+ tokens?: { spent: number; cap: number };
18
+ usd?: { spent: number; cap: number };
19
+ }
20
+
21
+ /** Spec §4.6: from this fraction on, `route` starts at the cheapest rung that clears the bar. */
22
+ export const BUDGET_CHEAP_AT = 0.8;
23
+
24
+ /** null when the profile sets no cap. */
25
+ export function budgetStatus(spend: Spend, budget: Budget): BudgetStatus | null {
26
+ const b: BudgetStatus = { fraction: 0 };
27
+ let capped = false;
28
+ for (const key of ["minutes", "tokens", "usd"] as const) {
29
+ const cap = budget[key];
30
+ if (cap === undefined) continue;
31
+ capped = true;
32
+ const spent = spend[key];
33
+ b[key] = { spent, cap };
34
+ b.fraction = Math.max(b.fraction, cap > 0 ? spent / cap : spent > 0 ? 1 : 0);
35
+ }
36
+ return capped ? b : null;
37
+ }
38
+
39
+ export function formatBudget(b: BudgetStatus): string {
40
+ const parts: string[] = [];
41
+ if (b.minutes) parts.push(`${Math.round(b.minutes.spent)}/${b.minutes.cap} min`);
42
+ if (b.tokens) parts.push(`${b.tokens.spent}/${b.tokens.cap} tokens`);
43
+ if (b.usd) parts.push(`$${b.usd.spent.toFixed(2)}/$${b.usd.cap.toFixed(2)}`);
44
+ return `${parts.join(" · ")} (${Math.round(b.fraction * 100)}%)`;
45
+ }