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.
- package/README.md +114 -30
- 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 +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -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 +227 -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 +316 -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 +58 -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 +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -26
- 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 +33 -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 +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -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 +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -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 +133 -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 +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -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 +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -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 +196 -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/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -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/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -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 +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -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
- /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
|
+
}
|