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.
- package/README.md +141 -33
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +282 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +333 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +60 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +49 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +160 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +197 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CatherdError } from "../../domain/errors.ts";
|
|
4
|
+
import type { Access, RunStatus } from "../../domain/record.ts";
|
|
5
|
+
import {
|
|
6
|
+
type AccessShell,
|
|
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 { scratchShell, writableRoots } from "../access.ts";
|
|
19
|
+
import { CODEX_LIMIT, CODEX_TOO_OLD, foldCodexEvents, parseCodexLine } from "./events.ts";
|
|
20
|
+
|
|
21
|
+
/** The item types that are a tool call running, as the idle watchdog counts them. */
|
|
22
|
+
const CODEX_TOOL_ITEMS = new Set(["command_execution", "mcp_tool_call", "web_search", "file_change"]);
|
|
23
|
+
import { generatedImages, isolatedCodexHome, isolatedCodexHomePath, userCodexHome } from "./home.ts";
|
|
24
|
+
|
|
25
|
+
const CODEX_MIN_VERSION = "0.157.0";
|
|
26
|
+
const THREAD = /^[A-Za-z0-9][A-Za-z0-9-]{3,127}$/;
|
|
27
|
+
|
|
28
|
+
const SANDBOX: Record<Access, string> = {
|
|
29
|
+
"read-only": "read-only",
|
|
30
|
+
"workspace-write": "workspace-write",
|
|
31
|
+
full: "danger-full-access",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The `writable_roots` the user's Codex config lists under its top-level `[sandbox_workspace_write]`
|
|
36
|
+
* (`$CODEX_HOME/config.toml`). A `-c` override replaces that array, so catherd passes the union. A
|
|
37
|
+
* `--profile`'s or a managed requirements file's roots are not read. Unreadable or absent: none.
|
|
38
|
+
*/
|
|
39
|
+
export function userWritableRoots(): string[] {
|
|
40
|
+
const file = join(userCodexHome(), "config.toml");
|
|
41
|
+
if (!existsSync(file)) return [];
|
|
42
|
+
try {
|
|
43
|
+
const roots = (Bun.TOML.parse(readFileSync(file, "utf8")) as Record<string, unknown>)
|
|
44
|
+
.sandbox_workspace_write as { writable_roots?: unknown } | undefined;
|
|
45
|
+
const list = roots?.writable_roots;
|
|
46
|
+
return Array.isArray(list) ? list.filter((r): r is string => typeof r === "string" && r !== "") : [];
|
|
47
|
+
} catch {
|
|
48
|
+
// a config Codex cannot parse either: Codex reports it on the run itself
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Spec §5: a workspace-write worker also writes the lock and temp dirs, and (unless the role says
|
|
55
|
+
* `network: false`) reaches the network and binds loopback. `network_access` is always set, to false for
|
|
56
|
+
* `network: false`, since leaving it out would keep a `network_access = true` from the user's config.toml.
|
|
57
|
+
* The same `-c` overrides go to `codex sandbox` in doctor's probes, so doctor tests exactly what a worker gets. `-c …writable_roots=` replaces the
|
|
58
|
+
* user's own roots, so theirs go in too; an isolated run ignores the user's config, so it has none.
|
|
59
|
+
*/
|
|
60
|
+
export function codexGrants(access: Access, network = true, isolated = false): string[] {
|
|
61
|
+
if (access !== "workspace-write") return [];
|
|
62
|
+
const roots = [...new Set([...(isolated ? [] : userWritableRoots()), ...writableRoots()])];
|
|
63
|
+
return [
|
|
64
|
+
"-c",
|
|
65
|
+
`sandbox_workspace_write.network_access=${network}`,
|
|
66
|
+
"-c",
|
|
67
|
+
`sandbox_workspace_write.writable_roots=${JSON.stringify(roots)}`,
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** How long a `codex` query (version, login, models) may take before it counts as failed. */
|
|
72
|
+
export const codexShell = { timeoutMs: 15_000 };
|
|
73
|
+
|
|
74
|
+
/** Runs `codex <args>` without catherd's secrets; a run past the timeout is killed and counts as failed. */
|
|
75
|
+
const sh = (args: string[], cwd?: string): Promise<CliResult | null> =>
|
|
76
|
+
runCli("codex", args, { timeoutMs: codexShell.timeoutMs, cwd });
|
|
77
|
+
|
|
78
|
+
function plan(r: RunRequest): SpawnPlan {
|
|
79
|
+
if (r.thread !== null && !THREAD.test(r.thread))
|
|
80
|
+
throw new CatherdError("E_ADMIT_THREAD", `"${r.thread}" is not a Codex thread id`, {
|
|
81
|
+
fix: "pass the thread from the earlier RunRecord",
|
|
82
|
+
});
|
|
83
|
+
const common = [
|
|
84
|
+
...(r.isolated ? ["--ignore-user-config"] : []),
|
|
85
|
+
"-m",
|
|
86
|
+
r.rung.model,
|
|
87
|
+
// "default" leaves the effort to Codex's own config (spec §5.1)
|
|
88
|
+
...(r.rung.effort === "default" ? [] : ["-c", `model_reasoning_effort=${r.rung.effort}`]),
|
|
89
|
+
"--json",
|
|
90
|
+
"-o",
|
|
91
|
+
r.replyPath,
|
|
92
|
+
...codexGrants(r.access, r.network, r.isolated),
|
|
93
|
+
];
|
|
94
|
+
const sandbox = SANDBOX[r.access];
|
|
95
|
+
const args =
|
|
96
|
+
r.thread === null
|
|
97
|
+
? ["exec", ...common, "-s", sandbox, "--", "-"]
|
|
98
|
+
: ["exec", "resume", ...common, "-c", `sandbox_mode=${sandbox}`, "--", r.thread, "-"];
|
|
99
|
+
return {
|
|
100
|
+
cmd: "codex",
|
|
101
|
+
args,
|
|
102
|
+
env: r.isolated ? { CODEX_HOME: isolatedCodexHome() } : {},
|
|
103
|
+
cwd: r.repo,
|
|
104
|
+
stdinPath: r.briefPath,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function finalize(run: FinishedRun): Outcome {
|
|
109
|
+
const f = foldCodexEvents(run.eventLines);
|
|
110
|
+
const tooOld = f.tooOld || CODEX_TOO_OLD.some((re) => re.test(run.stderr));
|
|
111
|
+
const stopped = run.exit.reason;
|
|
112
|
+
const status: RunStatus =
|
|
113
|
+
stopped === "cancelled"
|
|
114
|
+
? "cancelled"
|
|
115
|
+
: stopped === "idle-timeout" || stopped === "wall-timeout"
|
|
116
|
+
? "timeout"
|
|
117
|
+
: tooOld
|
|
118
|
+
? "cli-too-old"
|
|
119
|
+
: f.limit
|
|
120
|
+
? "limit"
|
|
121
|
+
: f.turnFailed
|
|
122
|
+
? "failed"
|
|
123
|
+
: run.exit.code !== 0 && !run.reply.trim()
|
|
124
|
+
? "failed"
|
|
125
|
+
: "ok";
|
|
126
|
+
const lastErr = run.stderr.trim().split("\n").at(-1) ?? "";
|
|
127
|
+
const home = run.request.isolated ? isolatedCodexHomePath() : userCodexHome();
|
|
128
|
+
return {
|
|
129
|
+
status,
|
|
130
|
+
thread: f.thread ?? run.request.thread,
|
|
131
|
+
tokens: f.tokens,
|
|
132
|
+
costUsd: null,
|
|
133
|
+
images: generatedImages(home, f.thread ?? run.request.thread, run.startedAtMs),
|
|
134
|
+
error:
|
|
135
|
+
status === "ok"
|
|
136
|
+
? null
|
|
137
|
+
: {
|
|
138
|
+
code: status,
|
|
139
|
+
message: f.failure ?? (lastErr || `${stopped}, exit ${run.exit.code ?? run.exit.signal}`),
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function probe(): Promise<Probe> {
|
|
145
|
+
const v = await sh(["--version"]);
|
|
146
|
+
if (!v)
|
|
147
|
+
return {
|
|
148
|
+
installed: false,
|
|
149
|
+
version: null,
|
|
150
|
+
versionOk: false,
|
|
151
|
+
loggedIn: null,
|
|
152
|
+
problems: [
|
|
153
|
+
{ code: "E_BACKEND_MISSING", message: "codex is not on PATH", fix: "npm i -g @openai/codex" },
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
const version = extractVersion(v.out);
|
|
157
|
+
const versionOk = version !== null && compareVersions(version, CODEX_MIN_VERSION) >= 0;
|
|
158
|
+
const status = await sh(["login", "status"]);
|
|
159
|
+
const loggedIn = status?.ok ?? false;
|
|
160
|
+
// how: `Logged in using ChatGPT`, or `… using an API key - <masked key>` (read, never kept); on stdout
|
|
161
|
+
// or stderr, depending on the version
|
|
162
|
+
const how = `${status?.out ?? ""}\n${status?.err ?? ""}`;
|
|
163
|
+
const login = !loggedIn
|
|
164
|
+
? null
|
|
165
|
+
: /using ChatGPT/i.test(how)
|
|
166
|
+
? "ChatGPT"
|
|
167
|
+
: /API key/i.test(how)
|
|
168
|
+
? "API key"
|
|
169
|
+
: null;
|
|
170
|
+
const problems: Probe["problems"] = [];
|
|
171
|
+
if (!versionOk)
|
|
172
|
+
problems.push({
|
|
173
|
+
code: "E_BACKEND_TOO_OLD",
|
|
174
|
+
message: `codex ${version ?? "?"} is older than ${CODEX_MIN_VERSION}`,
|
|
175
|
+
fix: "npm i -g @openai/codex@latest",
|
|
176
|
+
});
|
|
177
|
+
if (!loggedIn)
|
|
178
|
+
problems.push({ code: "E_BACKEND_NOT_LOGGED_IN", message: "codex is not logged in", fix: "codex login" });
|
|
179
|
+
return {
|
|
180
|
+
installed: true,
|
|
181
|
+
version,
|
|
182
|
+
versionOk,
|
|
183
|
+
loggedIn,
|
|
184
|
+
...(login ? { login, billing: login === "ChatGPT" ? "chatgpt-plan" : "metered" } : {}),
|
|
185
|
+
problems,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function listModels(): Promise<DiscoveredModel[]> {
|
|
190
|
+
const r = await sh(["debug", "models"]);
|
|
191
|
+
const raw = r?.ok ? r.out : ((await sh(["debug", "models", "--bundled"]))?.out ?? "");
|
|
192
|
+
let data: { models?: Record<string, any>[] };
|
|
193
|
+
try {
|
|
194
|
+
data = JSON.parse(raw);
|
|
195
|
+
} catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
return (data.models ?? [])
|
|
199
|
+
.filter((m) => m.visibility === "list" && typeof m.slug === "string")
|
|
200
|
+
.map((m) => ({
|
|
201
|
+
id: m.slug as string,
|
|
202
|
+
efforts: ((m.supported_reasoning_levels ?? []) as { effort?: string }[])
|
|
203
|
+
.map((l) => l.effort)
|
|
204
|
+
.filter((e): e is string => typeof e === "string"),
|
|
205
|
+
context: typeof m.context_window === "number" ? m.context_window : null,
|
|
206
|
+
imageIn: Array.isArray(m.input_modalities) && m.input_modalities.includes("image"),
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Spec §12: `codex sandbox [-c …] -- <cmd>` in the workspace-write sandbox with the grants a worker gets
|
|
212
|
+
* (codexGrants), else the old `codex sandbox <os> --full-auto` form. Each form is tried with `true` first;
|
|
213
|
+
* a string says why neither runs here.
|
|
214
|
+
*/
|
|
215
|
+
async function accessShell(o: { network: boolean }): Promise<AccessShell | string> {
|
|
216
|
+
const grants = codexGrants("workspace-write", o.network);
|
|
217
|
+
const os = process.platform === "darwin" ? "macos" : process.platform === "linux" ? "linux" : null;
|
|
218
|
+
const forms: [string, string[]][] = [
|
|
219
|
+
["codex sandbox", ["codex", "sandbox", "-c", "sandbox_mode=workspace-write", ...grants, "--"]],
|
|
220
|
+
...(os
|
|
221
|
+
? [
|
|
222
|
+
[
|
|
223
|
+
`codex sandbox ${os} --full-auto (the old form)`,
|
|
224
|
+
["codex", "sandbox", os, "--full-auto", ...grants, "--"],
|
|
225
|
+
],
|
|
226
|
+
]
|
|
227
|
+
: []),
|
|
228
|
+
] as [string, string[]][];
|
|
229
|
+
for (const [how, prefix] of forms) {
|
|
230
|
+
const shell = scratchShell(how, prefix);
|
|
231
|
+
if ((await shell.run("true", []))?.ok) return shell;
|
|
232
|
+
shell.close();
|
|
233
|
+
}
|
|
234
|
+
return "no codex sandbox to test with on this machine (codex sandbox did not run `true`)";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Spec §3.7: a command Codex runs, the files it changes, or its message. */
|
|
238
|
+
function codexActivity(e: Record<string, any>): string | undefined {
|
|
239
|
+
const it = e.item;
|
|
240
|
+
if (!it || (e.type !== "item.started" && e.type !== "item.completed")) return undefined;
|
|
241
|
+
if (it.type === "command_execution" && typeof it.command === "string") return `$ ${it.command}`;
|
|
242
|
+
if (it.type === "file_change" && Array.isArray(it.changes))
|
|
243
|
+
return `edit ${it.changes
|
|
244
|
+
.map((c: { path?: unknown }) => c.path)
|
|
245
|
+
.filter((p: unknown) => typeof p === "string")
|
|
246
|
+
.join(", ")}`;
|
|
247
|
+
if (it.type === "agent_message" && typeof it.text === "string") return it.text;
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const codexAdapter: BackendAdapter = {
|
|
252
|
+
id: "codex",
|
|
253
|
+
minVersion: CODEX_MIN_VERSION,
|
|
254
|
+
probe,
|
|
255
|
+
listModels,
|
|
256
|
+
plan,
|
|
257
|
+
parse(line) {
|
|
258
|
+
const e = parseCodexLine(line);
|
|
259
|
+
if (!e) return {};
|
|
260
|
+
const f = foldCodexEvents([line]);
|
|
261
|
+
// a tool call runs between item.started and item.completed, often printing nothing: the run is busy.
|
|
262
|
+
// Not a todo_list: update_plan opens one that only completes with the turn, and would mute the watchdog
|
|
263
|
+
const id = typeof e.item?.id === "string" && CODEX_TOOL_ITEMS.has(e.item.type) ? e.item.id : null;
|
|
264
|
+
const open = e.type === "item.started" ? true : e.type === "item.completed" ? false : null;
|
|
265
|
+
return {
|
|
266
|
+
...(f.thread ? { thread: f.thread } : {}),
|
|
267
|
+
...(id !== null && open !== null ? { item: { id, open } } : {}),
|
|
268
|
+
...(e.type === "turn.completed" ? { tokens: f.tokens } : {}),
|
|
269
|
+
lastEvent: f.lastEvent ?? undefined,
|
|
270
|
+
...(codexActivity(e) ? { activity: codexActivity(e) } : {}),
|
|
271
|
+
...(f.turnFailed ? { failure: f.failure ?? "turn failed" } : {}),
|
|
272
|
+
...(f.limit ? { limit: true } : {}),
|
|
273
|
+
...(f.tooOld ? { tooOld: true } : {}),
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
finalize,
|
|
277
|
+
enforcement: { "read-only": "enforced", "workspace-write": "enforced", full: "enforced" },
|
|
278
|
+
errors: { limit: CODEX_LIMIT, tooOld: CODEX_TOO_OLD },
|
|
279
|
+
resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
|
|
280
|
+
graceAfterFinalMs: null,
|
|
281
|
+
accessShell,
|
|
282
|
+
};
|
|
@@ -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
|
+
}
|