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,47 @@
1
+ import { existsSync, lstatSync, readdirSync, readlinkSync, statSync, symlinkSync, unlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { dataDir } from "../../infra/paths.ts";
5
+ import { ensurePrivateDir } from "../../infra/store.ts";
6
+
7
+ export const userCodexHome = (): string => process.env.CODEX_HOME || join(homedir(), ".codex");
8
+
9
+ /** Where isolated runs keep their Codex home; computing it creates nothing. */
10
+ export const isolatedCodexHomePath = (): string => join(dataDir(), "codex-home");
11
+
12
+ /** Isolated runs only: `--ignore-user-config` alone still loads the global AGENTS.md, so the home goes too. */
13
+ export function isolatedCodexHome(): string {
14
+ const home = isolatedCodexHomePath();
15
+ ensurePrivateDir(home);
16
+ const auth = join(userCodexHome(), "auth.json");
17
+ const link = join(home, "auth.json");
18
+ if (!existsSync(auth)) return home;
19
+ // lstat, not existsSync: a broken link "does not exist" yet still blocks symlinkSync with EEXIST.
20
+ const current = lstatSync(link, { throwIfNoEntry: false });
21
+ if (current?.isSymbolicLink() && readlinkSync(link) !== auth) unlinkSync(link);
22
+ else if (current) return home;
23
+ symlinkSync(auth, link);
24
+ return home;
25
+ }
26
+
27
+ function mtimeMs(file: string): number | null {
28
+ try {
29
+ return statSync(file).mtimeMs;
30
+ } catch {
31
+ return null; // vanished between readdir and stat
32
+ }
33
+ }
34
+
35
+ export function generatedImages(home: string, thread: string | null, sinceMs: number): string[] {
36
+ if (!thread) return [];
37
+ const dir = join(home, "generated_images", thread);
38
+ if (!existsSync(dir)) return [];
39
+ return readdirSync(dir)
40
+ .filter((f) => /\.(png|webp|jpe?g)$/i.test(f))
41
+ .map((f) => join(dir, f))
42
+ .filter((f) => {
43
+ const m = mtimeMs(f);
44
+ return m !== null && m >= sinceMs - 1000;
45
+ })
46
+ .sort();
47
+ }
@@ -0,0 +1,227 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { CatherdError } from "../../domain/errors.ts";
5
+ import type { Access, RunStatus } from "../../domain/record.ts";
6
+ import {
7
+ type BackendAdapter,
8
+ compareVersions,
9
+ type DiscoveredModel,
10
+ extractVersion,
11
+ type FinishedRun,
12
+ type Outcome,
13
+ type Probe,
14
+ type RunRequest,
15
+ type SpawnPlan,
16
+ } from "../backend.ts";
17
+ import { type CliResult, runCli } from "../cli.ts";
18
+ import { CODEX_LIMIT, CODEX_TOO_OLD, foldCodexEvents, parseCodexLine } from "./events.ts";
19
+
20
+ /** The item types that are a tool call running, as the idle watchdog counts them. */
21
+ const CODEX_TOOL_ITEMS = new Set(["command_execution", "mcp_tool_call", "web_search", "file_change"]);
22
+ import { generatedImages, isolatedCodexHome, isolatedCodexHomePath, userCodexHome } from "./home.ts";
23
+
24
+ const CODEX_MIN_VERSION = "0.157.0";
25
+ const THREAD = /^[A-Za-z0-9][A-Za-z0-9-]{3,127}$/;
26
+
27
+ const SANDBOX: Record<Access, string> = {
28
+ "read-only": "read-only",
29
+ "workspace-write": "workspace-write",
30
+ full: "danger-full-access",
31
+ };
32
+
33
+ /** How long a `codex` query (version, login, models) may take before it counts as failed. */
34
+ export const codexShell = { timeoutMs: 15_000 };
35
+
36
+ /** Runs `codex <args>` without catherd's secrets; a run past the timeout is killed and counts as failed. */
37
+ const sh = (args: string[], cwd?: string): Promise<CliResult | null> =>
38
+ runCli("codex", args, { timeoutMs: codexShell.timeoutMs, cwd });
39
+
40
+ function plan(r: RunRequest): SpawnPlan {
41
+ if (r.thread !== null && !THREAD.test(r.thread))
42
+ throw new CatherdError("E_ADMIT_THREAD", `"${r.thread}" is not a Codex thread id`, {
43
+ fix: "pass the thread from the earlier RunRecord",
44
+ });
45
+ const common = [
46
+ ...(r.isolated ? ["--ignore-user-config"] : []),
47
+ "-m",
48
+ r.rung.model,
49
+ // "default" leaves the effort to Codex's own config (spec §5.1)
50
+ ...(r.rung.effort === "default" ? [] : ["-c", `model_reasoning_effort=${r.rung.effort}`]),
51
+ "--json",
52
+ "-o",
53
+ r.replyPath,
54
+ ];
55
+ const sandbox = SANDBOX[r.access];
56
+ const args =
57
+ r.thread === null
58
+ ? ["exec", ...common, "-s", sandbox, "--", "-"]
59
+ : ["exec", "resume", ...common, "-c", `sandbox_mode=${sandbox}`, "--", r.thread, "-"];
60
+ return {
61
+ cmd: "codex",
62
+ args,
63
+ env: r.isolated ? { CODEX_HOME: isolatedCodexHome() } : {},
64
+ cwd: r.repo,
65
+ stdinPath: r.briefPath,
66
+ };
67
+ }
68
+
69
+ function finalize(run: FinishedRun): Outcome {
70
+ const f = foldCodexEvents(run.eventLines);
71
+ const tooOld = f.tooOld || CODEX_TOO_OLD.some((re) => re.test(run.stderr));
72
+ const stopped = run.exit.reason;
73
+ const status: RunStatus =
74
+ stopped === "cancelled"
75
+ ? "cancelled"
76
+ : stopped === "idle-timeout" || stopped === "wall-timeout"
77
+ ? "timeout"
78
+ : tooOld
79
+ ? "cli-too-old"
80
+ : f.limit
81
+ ? "limit"
82
+ : f.turnFailed
83
+ ? "failed"
84
+ : run.exit.code !== 0 && !run.reply.trim()
85
+ ? "failed"
86
+ : "ok";
87
+ const lastErr = run.stderr.trim().split("\n").at(-1) ?? "";
88
+ const home = run.request.isolated ? isolatedCodexHomePath() : userCodexHome();
89
+ return {
90
+ status,
91
+ thread: f.thread ?? run.request.thread,
92
+ tokens: f.tokens,
93
+ costUsd: null,
94
+ images: generatedImages(home, f.thread ?? run.request.thread, run.startedAtMs),
95
+ error:
96
+ status === "ok"
97
+ ? null
98
+ : {
99
+ code: status,
100
+ message: f.failure ?? (lastErr || `${stopped}, exit ${run.exit.code ?? run.exit.signal}`),
101
+ },
102
+ };
103
+ }
104
+
105
+ async function probe(): Promise<Probe> {
106
+ const v = await sh(["--version"]);
107
+ if (!v)
108
+ return {
109
+ installed: false,
110
+ version: null,
111
+ versionOk: false,
112
+ loggedIn: null,
113
+ problems: [
114
+ { code: "E_BACKEND_MISSING", message: "codex is not on PATH", fix: "npm i -g @openai/codex" },
115
+ ],
116
+ };
117
+ const version = extractVersion(v.out);
118
+ const versionOk = version !== null && compareVersions(version, CODEX_MIN_VERSION) >= 0;
119
+ const status = await sh(["login", "status"]);
120
+ const loggedIn = status?.ok ?? false;
121
+ // how: `Logged in using ChatGPT`, or `… using an API key - <masked key>` (read, never kept); on stdout
122
+ // or stderr, depending on the version
123
+ const how = `${status?.out ?? ""}\n${status?.err ?? ""}`;
124
+ const login = !loggedIn
125
+ ? null
126
+ : /using ChatGPT/i.test(how)
127
+ ? "ChatGPT"
128
+ : /API key/i.test(how)
129
+ ? "API key"
130
+ : null;
131
+ const problems: Probe["problems"] = [];
132
+ if (!versionOk)
133
+ problems.push({
134
+ code: "E_BACKEND_TOO_OLD",
135
+ message: `codex ${version ?? "?"} is older than ${CODEX_MIN_VERSION}`,
136
+ fix: "npm i -g @openai/codex@latest",
137
+ });
138
+ if (!loggedIn)
139
+ problems.push({ code: "E_BACKEND_NOT_LOGGED_IN", message: "codex is not logged in", fix: "codex login" });
140
+ return {
141
+ installed: true,
142
+ version,
143
+ versionOk,
144
+ loggedIn,
145
+ ...(login ? { login, billing: login === "ChatGPT" ? "chatgpt-plan" : "metered" } : {}),
146
+ problems,
147
+ };
148
+ }
149
+
150
+ async function listModels(): Promise<DiscoveredModel[]> {
151
+ const r = await sh(["debug", "models"]);
152
+ const raw = r?.ok ? r.out : ((await sh(["debug", "models", "--bundled"]))?.out ?? "");
153
+ let data: { models?: Record<string, any>[] };
154
+ try {
155
+ data = JSON.parse(raw);
156
+ } catch {
157
+ return [];
158
+ }
159
+ return (data.models ?? [])
160
+ .filter((m) => m.visibility === "list" && typeof m.slug === "string")
161
+ .map((m) => ({
162
+ id: m.slug as string,
163
+ efforts: ((m.supported_reasoning_levels ?? []) as { effort?: string }[])
164
+ .map((l) => l.effort)
165
+ .filter((e): e is string => typeof e === "string"),
166
+ context: typeof m.context_window === "number" ? m.context_window : null,
167
+ imageIn: Array.isArray(m.input_modalities) && m.input_modalities.includes("image"),
168
+ }));
169
+ }
170
+
171
+ /**
172
+ * Spec §10.3: runs a write into `dir` under `codex sandbox <os> --full-auto`, the workspace-write sandbox,
173
+ * from a scratch folder. null when this machine has no Codex sandbox to test with (the control fails).
174
+ */
175
+ async function canWrite(dir: string): Promise<{ ok: boolean; fix?: string } | null> {
176
+ const os = process.platform === "darwin" ? "macos" : process.platform === "linux" ? "linux" : null;
177
+ if (!os) return null;
178
+ const cwd = mkdtempSync(join(tmpdir(), "catherd-sandbox-"));
179
+ try {
180
+ const run = (...script: string[]) => sh(["sandbox", os, "--full-auto", "--", "sh", "-c", ...script], cwd);
181
+ if (!(await run("true"))?.ok) return null;
182
+ const probe = join(dir, `.doctor-${process.pid}`);
183
+ // the path goes in as $1, never into the script text
184
+ const r = await run('touch "$1" && rm -f "$1"', "_", probe);
185
+ if (!r) return null;
186
+ return r.ok
187
+ ? { ok: true }
188
+ : {
189
+ ok: false,
190
+ fix: `add "${dir}" to writable_roots under [sandbox_workspace_write] in ~/.codex/config.toml`,
191
+ };
192
+ } finally {
193
+ rmSync(cwd, { recursive: true, force: true });
194
+ }
195
+ }
196
+
197
+ export const codexAdapter: BackendAdapter = {
198
+ id: "codex",
199
+ minVersion: CODEX_MIN_VERSION,
200
+ probe,
201
+ listModels,
202
+ plan,
203
+ parse(line) {
204
+ const e = parseCodexLine(line);
205
+ if (!e) return {};
206
+ const f = foldCodexEvents([line]);
207
+ // a tool call runs between item.started and item.completed, often printing nothing: the run is busy.
208
+ // Not a todo_list: update_plan opens one that only completes with the turn, and would mute the watchdog
209
+ const id = typeof e.item?.id === "string" && CODEX_TOOL_ITEMS.has(e.item.type) ? e.item.id : null;
210
+ const open = e.type === "item.started" ? true : e.type === "item.completed" ? false : null;
211
+ return {
212
+ ...(f.thread ? { thread: f.thread } : {}),
213
+ ...(id !== null && open !== null ? { item: { id, open } } : {}),
214
+ ...(e.type === "turn.completed" ? { tokens: f.tokens } : {}),
215
+ lastEvent: f.lastEvent ?? undefined,
216
+ ...(f.turnFailed ? { failure: f.failure ?? "turn failed" } : {}),
217
+ ...(f.limit ? { limit: true } : {}),
218
+ ...(f.tooOld ? { tooOld: true } : {}),
219
+ };
220
+ },
221
+ finalize,
222
+ enforcement: { "read-only": "enforced", "workspace-write": "enforced", full: "enforced" },
223
+ errors: { limit: CODEX_LIMIT, tooOld: CODEX_TOO_OLD },
224
+ resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
225
+ graceAfterFinalMs: null,
226
+ canWrite,
227
+ };
@@ -0,0 +1,87 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { discoveryDir } from "../infra/paths.ts";
5
+ import { readVersioned, writeJsonAtomic } from "../infra/store.ts";
6
+ import type { DiscoveredModel } from "./backend.ts";
7
+
8
+ const DiscoverySchema = z.looseObject({
9
+ schema: z.literal(1),
10
+ backend: z.string(),
11
+ fetchedAt: z.string(),
12
+ models: z.array(
13
+ z.object({
14
+ id: z.string(),
15
+ efforts: z.array(z.string()),
16
+ context: z.number().nullable(),
17
+ imageIn: z.boolean(),
18
+ }),
19
+ ),
20
+ });
21
+ export type DiscoveryFile = z.infer<typeof DiscoverySchema>;
22
+
23
+ /**
24
+ * Spec §3.5: `<data>/discovery/<backend>.json`, the last listModels() with fetchedAt. A listing made for
25
+ * one repository (a backend whose models depend on where it runs) is kept apart, under `repos/`.
26
+ */
27
+ export const discoveryPath = (backend: string, repo?: string): string =>
28
+ repo === undefined
29
+ ? join(discoveryDir(), `${backend}.json`)
30
+ : join(
31
+ discoveryDir(),
32
+ "repos",
33
+ `${backend}-${createHash("sha256").update(repo).digest("hex").slice(0, 16)}.json`,
34
+ );
35
+
36
+ /** Backends whose listing depends on the repository they run in (opencode's project config enables models). */
37
+ const PER_REPO: ReadonlySet<string> = new Set(["opencode"]);
38
+
39
+ /** The repository `backend`'s listing is kept for: `repo` for a backend listed per repository, else none. */
40
+ export const listingRepo = (backend: string, repo?: string): string | undefined =>
41
+ PER_REPO.has(backend) ? repo : undefined;
42
+
43
+ /** The cached listing (for `repo` when given), or null when there is none or it cannot be read. */
44
+ export function readDiscovery(backend: string, repo?: string): DiscoveryFile | null {
45
+ try {
46
+ return readVersioned(discoveryPath(backend, repo), DiscoverySchema, 1);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export function writeDiscovery(
53
+ backend: string,
54
+ models: DiscoveredModel[],
55
+ now = Date.now(),
56
+ repo?: string,
57
+ ): DiscoveryFile {
58
+ const file: DiscoveryFile = {
59
+ schema: 1,
60
+ backend,
61
+ ...(repo === undefined ? {} : { repo }),
62
+ fetchedAt: new Date(now).toISOString(),
63
+ models,
64
+ };
65
+ writeJsonAtomic(discoveryPath(backend, repo), file);
66
+ return file;
67
+ }
68
+
69
+ /**
70
+ * The cached models (for `repo` when given) while younger than `maxAgeMs` and listing `need` (when
71
+ * given); otherwise a fresh `list()`, cached when it is not empty. An empty fresh list falls back to the
72
+ * stale cache.
73
+ */
74
+ export async function discovered(
75
+ backend: string,
76
+ list: () => Promise<DiscoveredModel[]>,
77
+ o: { maxAgeMs: number; need?: string; now?: number; repo?: string },
78
+ ): Promise<DiscoveredModel[]> {
79
+ const now = o.now ?? Date.now();
80
+ const cached = readDiscovery(backend, o.repo);
81
+ const fresh = cached !== null && now - Date.parse(cached.fetchedAt) < o.maxAgeMs;
82
+ if (cached && fresh && (o.need === undefined || cached.models.some((m) => m.id === o.need)))
83
+ return cached.models;
84
+ const models = await list();
85
+ if (models.length === 0) return cached?.models ?? [];
86
+ return writeDiscovery(backend, models, now, o.repo).models;
87
+ }
@@ -0,0 +1,84 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { Access } from "../../domain/record.ts";
5
+ import { dataDir } from "../../infra/paths.ts";
6
+
7
+ /** Spec §6.3: one catherd agent per access mode. */
8
+ export const OPENCODE_AGENT: Record<Access, "catherd-ro" | "catherd-worker" | "catherd-full"> = {
9
+ "read-only": "catherd-ro",
10
+ "workspace-write": "catherd-worker",
11
+ full: "catherd-full",
12
+ };
13
+
14
+ const rule = (action: string, resource: string, effect: "allow" | "deny") =>
15
+ ` - { action: ${JSON.stringify(action)}, resource: ${JSON.stringify(resource)}, effect: ${effect} }`;
16
+
17
+ /**
18
+ * Why catherd-ro has no shell (opencode v2 at 6585bb7, packages/core/src/…): the last matching rule wins
19
+ * (permission.ts:87-96) over a whole-resource wildcard (util/wildcard.ts:3-13), and the shell tool asks with
20
+ * one resource per parsed command (tool/plugin/shell.ts:126-140, shell/parse.ts:173-205). A redirect after a
21
+ * list or pipeline reaches no resource (`ls && cat a > b` asks for "ls" and "cat a"), so no allowlist of
22
+ * read commands is read-only. The role reads with read, glob and grep; its brief names the files.
23
+ */
24
+ /**
25
+ * opencode v2 agent files (research 2026-09-25-opencode.md §4.5). Later rules win; every run passes
26
+ * `--auto`, so an "ask" left by opencode's defaults never aborts the run, and an explicit deny still holds.
27
+ * No body: the agent keeps opencode's own system prompt.
28
+ */
29
+ export const OPENCODE_AGENT_FILES: Record<string, string> = {
30
+ "catherd-ro": [
31
+ "---",
32
+ "description: catherd read-only roles (architect, reviewer, researcher). Written by catherd.",
33
+ "mode: primary",
34
+ "permissions:",
35
+ rule("*", "*", "deny"),
36
+ ...["read", "glob", "grep", "webfetch", "websearch"].map((a) => rule(a, "*", "allow")),
37
+ // edit, write and patch all ask for `edit` (tool/plugin/edit.ts:181, write.ts:79, patch.ts:197)
38
+ rule("edit", "*", "deny"),
39
+ rule("shell", "*", "deny"),
40
+ "---",
41
+ "",
42
+ ].join("\n"),
43
+ "catherd-worker": [
44
+ "---",
45
+ "description: catherd workspace-write roles (worker, writer, artist). Written by catherd.",
46
+ "mode: primary",
47
+ "permissions:",
48
+ rule("*", "*", "allow"),
49
+ ...["git commit*", "git push*", "git reset --hard*"].map((c) => rule("shell", c, "deny")),
50
+ rule("question", "*", "deny"),
51
+ "---",
52
+ "",
53
+ ].join("\n"),
54
+ "catherd-full": [
55
+ "---",
56
+ "description: catherd full-access roles (verifier, ui-reviewer). Written by catherd.",
57
+ "mode: primary",
58
+ "permissions:",
59
+ rule("*", "*", "allow"),
60
+ rule("question", "*", "deny"),
61
+ "---",
62
+ "",
63
+ ].join("\n"),
64
+ };
65
+
66
+ /** The XDG config root opencode reads: the user's, or catherd's own for isolated runs. */
67
+ export const userConfigRoot = (): string => process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
68
+ export const isolatedConfigRoot = (): string => join(dataDir(), "opencode-home", "config");
69
+
70
+ export const agentsDir = (root: string): string => join(root, "opencode", "agents");
71
+
72
+ /** Writes any catherd agent file that is missing or differs; true when one changed. Never touches other agents. */
73
+ export function installAgents(root: string): boolean {
74
+ const dir = agentsDir(root);
75
+ mkdirSync(dir, { recursive: true });
76
+ let changed = false;
77
+ for (const [name, text] of Object.entries(OPENCODE_AGENT_FILES)) {
78
+ const file = join(dir, `${name}.md`);
79
+ if (existsSync(file) && readFileSync(file, "utf8") === text) continue;
80
+ writeFileSync(file, text);
81
+ changed = true;
82
+ }
83
+ return changed;
84
+ }
@@ -0,0 +1,109 @@
1
+ import { type Tokens, ZERO_TOKENS } from "../../domain/record.ts";
2
+
3
+ /** Spec §6.3: these error types are a usage limit, so the rung fails over. */
4
+ export const OPENCODE_LIMIT_TYPES = ["provider.quota", "provider.rate-limit"];
5
+ export const OPENCODE_LIMIT = [/provider\.quota/, /provider\.rate-limit/];
6
+ /** opencode v1 errors: v2's `-m provider/model#variant` is a model v1 cannot find. */
7
+ export const OPENCODE_TOO_OLD = [/ProviderModelNotFoundError/];
8
+
9
+ export interface OpencodeFold {
10
+ thread: string | null;
11
+ tokens: Tokens;
12
+ costUsd: number;
13
+ lastEvent: string | null;
14
+ error: { type: string | null; message: string } | null;
15
+ limit: boolean;
16
+ tooOld: boolean;
17
+ /** the tool call a permission rule declined, e.g. `read <scratch>/outside.txt` */
18
+ declined: string | null;
19
+ /** the text of the last assistant message: earlier narration is not the reply */
20
+ reply: string;
21
+ }
22
+
23
+ type Event = Record<string, any>;
24
+
25
+ export function parseOpencodeLine(line: string): Event | null {
26
+ if (!line.trim().startsWith("{")) return null;
27
+ try {
28
+ const e = JSON.parse(line) as unknown;
29
+ return e && typeof e === "object" && !Array.isArray(e) ? (e as Event) : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /** opencode counts cache reads and writes apart from input; catherd's input includes them (spec §4.3). */
36
+ export function opencodeTokens(t: Event | undefined): Tokens {
37
+ if (!t) return { ...ZERO_TOKENS };
38
+ const n = (v: unknown) => (typeof v === "number" ? v : 0);
39
+ const cached = n(t.cache?.read);
40
+ return {
41
+ input: n(t.input) + cached + n(t.cache?.write),
42
+ cached,
43
+ output: n(t.output) + n(t.reasoning),
44
+ };
45
+ }
46
+
47
+ export const eventName = (e: Event): string =>
48
+ e.type === "tool_use" ? `tool_use/${e.part?.tool ?? "?"}` : String(e.type ?? "?");
49
+
50
+ /** v1 errors are `{name, data}`; v2 errors are `{type, message}`. */
51
+ export const isV1Error = (e: Event): boolean =>
52
+ e.type === "error" && typeof e.error?.name === "string" && e.error?.type === undefined;
53
+
54
+ function firstString(input: unknown): string {
55
+ if (!input || typeof input !== "object") return "";
56
+ const v = Object.values(input as Record<string, unknown>).find((x) => typeof x === "string");
57
+ return typeof v === "string" ? v : "";
58
+ }
59
+
60
+ /**
61
+ * v2 often drops the last `step_finish`, so these stream tokens undercount; `settle` replaces them with
62
+ * the session's totals from the API.
63
+ */
64
+ export function foldOpencodeEvents(lines: string[]): OpencodeFold {
65
+ const f: OpencodeFold = {
66
+ thread: null,
67
+ tokens: { ...ZERO_TOKENS },
68
+ costUsd: 0,
69
+ lastEvent: null,
70
+ error: null,
71
+ limit: false,
72
+ tooOld: false,
73
+ declined: null,
74
+ reply: "",
75
+ };
76
+ const texts = new Map<string, string[]>();
77
+ let lastMessage: string | null = null;
78
+ for (const line of lines) {
79
+ const e = parseOpencodeLine(line);
80
+ if (!e) continue;
81
+ f.lastEvent = eventName(e);
82
+ if (typeof e.sessionID === "string") f.thread ??= e.sessionID;
83
+ if (e.type === "text" && typeof e.part?.text === "string") {
84
+ const id = String(e.part.messageID ?? "");
85
+ texts.set(id, [...(texts.get(id) ?? []), e.part.text]);
86
+ lastMessage = id;
87
+ } else if (e.type === "step_finish") {
88
+ const t = opencodeTokens(e.part?.tokens);
89
+ f.tokens.input += t.input;
90
+ f.tokens.cached += t.cached;
91
+ f.tokens.output += t.output;
92
+ if (typeof e.part?.cost === "number") f.costUsd += e.part.cost;
93
+ } else if (e.type === "tool_use" && e.part?.state?.status === "error") {
94
+ if (/declined/i.test(String(e.part.state.error ?? "")))
95
+ f.declined = `${e.part.tool} ${firstString(e.part.state.input)}`.trim();
96
+ } else if (e.type === "error") {
97
+ if (isV1Error(e)) {
98
+ f.tooOld = true;
99
+ f.error = { type: null, message: String(e.error.data?.message ?? e.error.name) };
100
+ } else {
101
+ const type = typeof e.error?.type === "string" ? e.error.type : null;
102
+ f.error = { type, message: String(e.error?.message ?? type ?? "error") };
103
+ if (type && OPENCODE_LIMIT_TYPES.includes(type)) f.limit = true;
104
+ }
105
+ }
106
+ }
107
+ if (lastMessage !== null) f.reply = (texts.get(lastMessage) ?? []).join("\n");
108
+ return f;
109
+ }