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,317 @@
|
|
|
1
|
+
import { CatherdError } from "../../domain/errors.ts";
|
|
2
|
+
import type { Rung } from "../../domain/ids.ts";
|
|
3
|
+
import type { Access, RunStatus } from "../../domain/record.ts";
|
|
4
|
+
import {
|
|
5
|
+
type AccessShell,
|
|
6
|
+
type BackendAdapter,
|
|
7
|
+
compareVersions,
|
|
8
|
+
type EventDelta,
|
|
9
|
+
extractVersion,
|
|
10
|
+
type FinishedRun,
|
|
11
|
+
type Outcome,
|
|
12
|
+
type Probe,
|
|
13
|
+
type RunRequest,
|
|
14
|
+
type SpawnPlan,
|
|
15
|
+
} from "../backend.ts";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { claudeHome } from "../../infra/paths.ts";
|
|
19
|
+
import { dockerSocket, scratchShell, writableRoots } from "../access.ts";
|
|
20
|
+
import { jsonOf, runCli } from "../cli.ts";
|
|
21
|
+
import {
|
|
22
|
+
CLAUDE_LIMIT,
|
|
23
|
+
CLAUDE_TOO_OLD,
|
|
24
|
+
claudeTokens,
|
|
25
|
+
eventName,
|
|
26
|
+
foldClaudeEvents,
|
|
27
|
+
parseClaudeLine,
|
|
28
|
+
} from "./events.ts";
|
|
29
|
+
import { CLAUDE_ALIASES, CLAUDE_EFFORTS, CLAUDE_MODELS } from "./models.ts";
|
|
30
|
+
import { listClaudeModels } from "./models-api.ts";
|
|
31
|
+
|
|
32
|
+
/** The version whose flags catherd was verified against (`claude --help`, 2026-09-25). */
|
|
33
|
+
const CLAUDE_MIN_VERSION = "2.1.282";
|
|
34
|
+
const THREAD = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
35
|
+
|
|
36
|
+
/** How long a `claude` query (version, login) may take before it counts as failed. */
|
|
37
|
+
export const claudeShell = { timeoutMs: 15_000 };
|
|
38
|
+
|
|
39
|
+
const READ_TOOLS = ["Read", "Glob", "Grep", "WebFetch", "WebSearch"];
|
|
40
|
+
const NEVER = ["git commit", "git push", "git reset --hard"].map((c) => `Bash(${c} *)`);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Spec §6.2 access → Claude Code permission flags (advisory: Bash can still write where it likes).
|
|
44
|
+
* `dontAsk` denies anything not allowed; `--permission-prompts none` (always passed) denies any prompt.
|
|
45
|
+
* Read-only gets no Bash at all, as catherd-ro on opencode: `rg --pre=<cmd>` executes and
|
|
46
|
+
* `git diff --output=<file>` writes, so no Bash pattern is read-only. It reads with Read, Glob and Grep.
|
|
47
|
+
*/
|
|
48
|
+
export const CLAUDE_ACCESS: Record<Access, string[]> = {
|
|
49
|
+
"read-only": [
|
|
50
|
+
"--permission-mode",
|
|
51
|
+
"dontAsk",
|
|
52
|
+
"--allowedTools",
|
|
53
|
+
READ_TOOLS.join(","),
|
|
54
|
+
"--disallowedTools",
|
|
55
|
+
"Edit,Write,NotebookEdit,Bash",
|
|
56
|
+
],
|
|
57
|
+
"workspace-write": [
|
|
58
|
+
"--permission-mode",
|
|
59
|
+
"acceptEdits",
|
|
60
|
+
"--allowedTools",
|
|
61
|
+
[...READ_TOOLS, "Edit", "Write", "NotebookEdit", "Bash"].join(","),
|
|
62
|
+
"--disallowedTools",
|
|
63
|
+
NEVER.join(","),
|
|
64
|
+
],
|
|
65
|
+
full: ["--permission-mode", "bypassPermissions"],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Spec §5 for headless Claude Code. Its Bash runs unsandboxed unless the user turned Claude Code's own
|
|
70
|
+
* sandbox on (`sandbox.enabled`); then these settings, merged over the user's, add the lock and temp dirs,
|
|
71
|
+
* loopback binds, the Docker socket and `docker` itself (which cannot run inside that sandbox). Outbound
|
|
72
|
+
* domains stay the user's `sandbox.network.allowedDomains`: doctor's `access:claude-code` row says when
|
|
73
|
+
* the registry is not among them. `network: false` drops the network grants and the web tools (and with
|
|
74
|
+
* them Docker: the socket and `docker *` are network grants) and sets `allowLocalBinding: false`, so the
|
|
75
|
+
* user's own `true` does not carry over (their allowedDomains and other array grants merge in and stay;
|
|
76
|
+
* doctor says `network: false` is not enforced by claude-code's shell). With the user's sandbox on, `enabled: true`
|
|
77
|
+
* goes in too, so a merge that replaces the whole `sandbox` object cannot turn it off.
|
|
78
|
+
*/
|
|
79
|
+
export function claudeAccessArgs(access: Access, network = true, repo?: string): string[] {
|
|
80
|
+
const base = CLAUDE_ACCESS[access];
|
|
81
|
+
if (access !== "workspace-write") return base;
|
|
82
|
+
const sock = dockerSocket();
|
|
83
|
+
const sandbox = {
|
|
84
|
+
...(claudeSandboxOn(repo) ? { enabled: true } : {}),
|
|
85
|
+
filesystem: { allowWrite: writableRoots() },
|
|
86
|
+
...(network
|
|
87
|
+
? {
|
|
88
|
+
network: { allowLocalBinding: true, ...(sock ? { allowUnixSockets: [sock] } : {}) },
|
|
89
|
+
excludedCommands: ["docker *"],
|
|
90
|
+
}
|
|
91
|
+
: // a boolean overrides the user's own `allowLocalBinding: true`; their array grants cannot be revoked here
|
|
92
|
+
{ network: { allowLocalBinding: false } }),
|
|
93
|
+
};
|
|
94
|
+
const args = [...base, "--settings", JSON.stringify({ sandbox })];
|
|
95
|
+
if (!network) {
|
|
96
|
+
const i = args.indexOf("--disallowedTools") + 1;
|
|
97
|
+
args[i] = [args[i], "WebFetch", "WebSearch"].join(",");
|
|
98
|
+
}
|
|
99
|
+
return args;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `sandbox.enabled` in one settings file, when it says true or false; undefined when absent or unreadable. */
|
|
103
|
+
function sandboxEnabledIn(file: string): boolean | undefined {
|
|
104
|
+
try {
|
|
105
|
+
const on = JSON.parse(readFileSync(file, "utf8"))?.sandbox?.enabled;
|
|
106
|
+
return typeof on === "boolean" ? on : undefined;
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether Claude Code's Bash sandbox is on for a worker in `repo` (`sandbox.enabled`): the most specific
|
|
114
|
+
* settings file that says wins, as Claude Code layers them: the user's settings.json, then the project's
|
|
115
|
+
* .claude/settings.json and .claude/settings.local.json (Claude Code has no user-level settings.local.json).
|
|
116
|
+
*/
|
|
117
|
+
export function claudeSandboxOn(repo: string = process.cwd()): boolean {
|
|
118
|
+
const files = [
|
|
119
|
+
join(claudeHome(), "settings.json"),
|
|
120
|
+
join(repo, ".claude", "settings.json"),
|
|
121
|
+
join(repo, ".claude", "settings.local.json"),
|
|
122
|
+
];
|
|
123
|
+
return files.map(sandboxEnabledIn).findLast((on) => on !== undefined) ?? false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Spec §5: with Claude Code's own sandbox off (its default) a headless worker's Bash is an unsandboxed
|
|
128
|
+
* shell, which doctor probes as is. With it on, only a model turn runs inside it, so doctor says what to
|
|
129
|
+
* check instead of spending one.
|
|
130
|
+
*/
|
|
131
|
+
async function accessShell(): Promise<AccessShell | string> {
|
|
132
|
+
if (claudeSandboxOn())
|
|
133
|
+
return "Claude Code's own sandbox is on (sandbox.enabled): catherd passes the lock, temp, loopback and Docker grants in --settings; outbound HTTPS reaches only sandbox.network.allowedDomains, so add registry.npmjs.org and the hosts your checks need there";
|
|
134
|
+
return scratchShell("an unsandboxed shell (Claude Code's sandbox is off)", []);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function plan(r: RunRequest): SpawnPlan {
|
|
138
|
+
if (r.thread !== null && !THREAD.test(r.thread))
|
|
139
|
+
throw new CatherdError("E_ADMIT_THREAD", `"${r.thread}" is not a Claude Code session id`, {
|
|
140
|
+
fix: "pass the thread from the earlier RunRecord",
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
cmd: "claude",
|
|
144
|
+
args: [
|
|
145
|
+
"-p",
|
|
146
|
+
"--output-format",
|
|
147
|
+
"stream-json",
|
|
148
|
+
"--verbose",
|
|
149
|
+
"--model",
|
|
150
|
+
r.rung.model,
|
|
151
|
+
// "default" leaves the effort to Claude Code (spec §5.1); Haiku has none
|
|
152
|
+
...(r.rung.effort === "default" ? [] : ["--effort", r.rung.effort]),
|
|
153
|
+
// a fresh session gets its id from catherd, so the thread is known before the first event
|
|
154
|
+
...(r.thread === null ? ["--session-id", crypto.randomUUID()] : ["--resume", r.thread]),
|
|
155
|
+
"--permission-prompts",
|
|
156
|
+
"none",
|
|
157
|
+
...claudeAccessArgs(r.access, r.network, r.repo),
|
|
158
|
+
// --bare would also drop OAuth, so a Claude plan could not log in; --safe-mode keeps auth
|
|
159
|
+
...(r.isolated ? ["--safe-mode"] : []),
|
|
160
|
+
],
|
|
161
|
+
env: {},
|
|
162
|
+
cwd: r.repo,
|
|
163
|
+
stdinPath: r.briefPath,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Spec §6.2: full model ids only, and an effort the model has. */
|
|
168
|
+
async function prepare(req: { rung: Rung; access: Access; isolated: boolean }): Promise<void> {
|
|
169
|
+
const { model, effort } = req.rung;
|
|
170
|
+
const full = CLAUDE_ALIASES[model];
|
|
171
|
+
if (full || !model.startsWith("claude-"))
|
|
172
|
+
throw new CatherdError("E_BACKEND_MODEL_UNKNOWN", `"${model}" is not a full Claude model id`, {
|
|
173
|
+
fix: `use the full id${full ? `, ${full}` : ", e.g. claude-opus-5-5"}`,
|
|
174
|
+
});
|
|
175
|
+
const known = CLAUDE_MODELS.find((m) => m.id === model);
|
|
176
|
+
const efforts = known ? known.efforts : CLAUDE_EFFORTS;
|
|
177
|
+
if (effort !== "default" && !efforts.includes(effort))
|
|
178
|
+
throw new CatherdError("E_BACKEND_MODEL_UNKNOWN", `${model} has no effort "${effort}"`, {
|
|
179
|
+
fix: efforts.length ? `use one of: default, ${efforts.join(", ")}` : `use ${model}#default`,
|
|
180
|
+
});
|
|
181
|
+
if (req.access === "full" && process.getuid?.() === 0 && !process.env.IS_SANDBOX)
|
|
182
|
+
throw new CatherdError(
|
|
183
|
+
"E_ADMIT_RUNG",
|
|
184
|
+
"claude refuses full access (bypassPermissions) when run as root",
|
|
185
|
+
{
|
|
186
|
+
fix: "run catherd as a normal user, or give this role workspace-write access",
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function finalize(run: FinishedRun): Outcome {
|
|
192
|
+
const f = foldClaudeEvents(run.eventLines);
|
|
193
|
+
const res = f.result;
|
|
194
|
+
const stopped = run.exit.reason;
|
|
195
|
+
const failed = res === null || res.isError;
|
|
196
|
+
const status: RunStatus =
|
|
197
|
+
stopped === "cancelled"
|
|
198
|
+
? "cancelled"
|
|
199
|
+
: stopped === "idle-timeout" || stopped === "wall-timeout"
|
|
200
|
+
? "timeout"
|
|
201
|
+
: !failed
|
|
202
|
+
? "ok"
|
|
203
|
+
: res === null && CLAUDE_TOO_OLD.some((r) => r.test(run.stderr))
|
|
204
|
+
? "cli-too-old"
|
|
205
|
+
: f.limit
|
|
206
|
+
? "limit"
|
|
207
|
+
: "failed";
|
|
208
|
+
const lastErr = run.stderr.trim().split("\n").at(-1) ?? "";
|
|
209
|
+
const message =
|
|
210
|
+
res?.isError && res.text
|
|
211
|
+
? res.text
|
|
212
|
+
: lastErr || (res ? "" : `no result event (${stopped}, exit ${run.exit.code ?? run.exit.signal})`);
|
|
213
|
+
return {
|
|
214
|
+
status,
|
|
215
|
+
thread: f.thread ?? run.request.thread,
|
|
216
|
+
tokens: res?.tokens ?? claudeTokens(undefined),
|
|
217
|
+
costUsd: res?.costUsd ?? null,
|
|
218
|
+
images: [],
|
|
219
|
+
error: status === "ok" ? null : { code: status, message: message || status },
|
|
220
|
+
...(res && !res.isError ? { reply: res.text } : {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
225
|
+
|
|
226
|
+
/** Spec §3.7: the main thread's last tool call (a command, a file edit, another tool) or its text. */
|
|
227
|
+
function claudeActivity(e: Record<string, any>): string | undefined {
|
|
228
|
+
if (e.type !== "assistant" || e.parent_tool_use_id) return undefined;
|
|
229
|
+
const c = (e.message?.content ?? []).at(-1);
|
|
230
|
+
if (c?.type === "tool_use") {
|
|
231
|
+
const input = c.input ?? {};
|
|
232
|
+
if (typeof input.command === "string") return `$ ${input.command}`;
|
|
233
|
+
if (typeof input.file_path === "string")
|
|
234
|
+
return `${EDIT_TOOLS.has(c.name) ? "edit" : String(c.name)} ${input.file_path}`;
|
|
235
|
+
return String(c.name ?? "tool");
|
|
236
|
+
}
|
|
237
|
+
if (c?.type === "text" && typeof c.text === "string") return c.text;
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function parse(line: string): EventDelta {
|
|
242
|
+
const e = parseClaudeLine(line);
|
|
243
|
+
if (!e) return {};
|
|
244
|
+
const d: EventDelta = { lastEvent: eventName(e) };
|
|
245
|
+
const activity = claudeActivity(e);
|
|
246
|
+
if (activity) d.activity = activity;
|
|
247
|
+
if (typeof e.session_id === "string" && e.session_id) d.thread = e.session_id;
|
|
248
|
+
if (e.type === "system" && e.subtype === "api_retry") d.retrying = true;
|
|
249
|
+
if (e.type === "rate_limit_event" && e.rate_limit_info?.status === "rejected") d.limit = true;
|
|
250
|
+
// each assistant message of the main thread carries its own request's usage; `result` sums the session
|
|
251
|
+
if (e.type === "assistant" && !e.parent_tool_use_id && e.message?.usage)
|
|
252
|
+
d.requestInput = claudeTokens(e.message.usage).input;
|
|
253
|
+
if (e.type === "result") {
|
|
254
|
+
const f = foldClaudeEvents([line]);
|
|
255
|
+
d.final = true;
|
|
256
|
+
d.tokens = f.result?.tokens;
|
|
257
|
+
if (typeof e.total_cost_usd === "number") d.costUsd = e.total_cost_usd;
|
|
258
|
+
if (e.is_error === true) d.failure = String(e.result ?? "error");
|
|
259
|
+
if (f.limit) d.limit = true;
|
|
260
|
+
}
|
|
261
|
+
return d;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function probe(): Promise<Probe> {
|
|
265
|
+
const v = await runCli("claude", ["--version"], claudeShell);
|
|
266
|
+
if (!v)
|
|
267
|
+
return {
|
|
268
|
+
installed: false,
|
|
269
|
+
version: null,
|
|
270
|
+
versionOk: false,
|
|
271
|
+
loggedIn: null,
|
|
272
|
+
problems: [
|
|
273
|
+
{
|
|
274
|
+
code: "E_BACKEND_MISSING",
|
|
275
|
+
message: "claude is not on PATH",
|
|
276
|
+
fix: "npm i -g @anthropic-ai/claude-code",
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
};
|
|
280
|
+
const version = extractVersion(v.out);
|
|
281
|
+
const versionOk = version !== null && compareVersions(version, CLAUDE_MIN_VERSION) >= 0;
|
|
282
|
+
const status = jsonOf((await runCli("claude", ["auth", "status", "--json"], claudeShell))?.out) as {
|
|
283
|
+
loggedIn?: unknown;
|
|
284
|
+
} | null;
|
|
285
|
+
const loggedIn = status?.loggedIn === true;
|
|
286
|
+
const problems: Probe["problems"] = [];
|
|
287
|
+
if (!versionOk)
|
|
288
|
+
problems.push({
|
|
289
|
+
code: "E_BACKEND_TOO_OLD",
|
|
290
|
+
message: `claude ${version ?? "?"} is older than ${CLAUDE_MIN_VERSION}`,
|
|
291
|
+
fix: "claude update",
|
|
292
|
+
});
|
|
293
|
+
if (!loggedIn)
|
|
294
|
+
problems.push({
|
|
295
|
+
code: "E_BACKEND_NOT_LOGGED_IN",
|
|
296
|
+
message: "claude is not logged in",
|
|
297
|
+
fix: "claude auth login",
|
|
298
|
+
});
|
|
299
|
+
return { installed: true, version, versionOk, loggedIn, problems };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export const claudeCodeAdapter: BackendAdapter = {
|
|
303
|
+
id: "claude-code",
|
|
304
|
+
minVersion: CLAUDE_MIN_VERSION,
|
|
305
|
+
probe,
|
|
306
|
+
listModels: () => listClaudeModels(),
|
|
307
|
+
prepare,
|
|
308
|
+
plan,
|
|
309
|
+
parse,
|
|
310
|
+
finalize,
|
|
311
|
+
enforcement: { "read-only": "advisory", "workspace-write": "advisory", full: "advisory" },
|
|
312
|
+
errors: { limit: CLAUDE_LIMIT, tooOld: CLAUDE_TOO_OLD },
|
|
313
|
+
resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
|
|
314
|
+
accessShell,
|
|
315
|
+
// the `result` event is the last thing claude prints; a CLI still running 30 s later is stuck
|
|
316
|
+
graceAfterFinalMs: 30_000,
|
|
317
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DiscoveredModel } from "../backend.ts";
|
|
2
|
+
import { CLAUDE_ALIASES, CLAUDE_EFFORTS, CLAUDE_MODELS } from "./models.ts";
|
|
3
|
+
|
|
4
|
+
export const ANTHROPIC_MODELS_URL = "https://api.anthropic.com/v1/models";
|
|
5
|
+
|
|
6
|
+
interface ApiModel {
|
|
7
|
+
id?: unknown;
|
|
8
|
+
max_input_tokens?: unknown;
|
|
9
|
+
capabilities?: {
|
|
10
|
+
image_input?: { supported?: unknown };
|
|
11
|
+
effort?: Record<string, { supported?: unknown } | unknown>;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const supported = (v: unknown) => (v as { supported?: unknown } | undefined)?.supported === true;
|
|
16
|
+
|
|
17
|
+
function fromApi(m: ApiModel): DiscoveredModel | null {
|
|
18
|
+
if (typeof m.id !== "string" || !m.id.startsWith("claude-") || m.id in CLAUDE_ALIASES) return null;
|
|
19
|
+
const known = CLAUDE_MODELS.find((k) => k.id === m.id);
|
|
20
|
+
const effort = m.capabilities?.effort;
|
|
21
|
+
return {
|
|
22
|
+
id: m.id,
|
|
23
|
+
// a model without effort support lists no effort, so its only rung is #default (Haiku 4.5)
|
|
24
|
+
efforts: effort ? CLAUDE_EFFORTS.filter((e) => supported(effort[e])) : [...(known?.efforts ?? [])],
|
|
25
|
+
context: typeof m.max_input_tokens === "number" ? m.max_input_tokens : (known?.context ?? null),
|
|
26
|
+
imageIn: m.capabilities?.image_input ? supported(m.capabilities.image_input) : (known?.imageIn ?? false),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Spec §5.2: Claude's models are the shipped list, refreshed from the Models API when an Anthropic API
|
|
32
|
+
* key is present. The API's entries win; shipped models it does not list stay (a Claude plan login may
|
|
33
|
+
* reach models an API key does not). Any failure keeps the shipped list.
|
|
34
|
+
*/
|
|
35
|
+
export async function listClaudeModels(
|
|
36
|
+
o: { key?: string | null; fetchImpl?: typeof fetch; timeoutMs?: number } = {},
|
|
37
|
+
): Promise<DiscoveredModel[]> {
|
|
38
|
+
const shipped = CLAUDE_MODELS.map((m) => ({ ...m, efforts: [...m.efforts] }));
|
|
39
|
+
const key = o.key === undefined ? process.env.ANTHROPIC_API_KEY?.trim() || null : o.key;
|
|
40
|
+
if (!key) return shipped;
|
|
41
|
+
const fetchImpl = o.fetchImpl ?? globalThis.fetch;
|
|
42
|
+
const listed: DiscoveredModel[] = [];
|
|
43
|
+
let after: string | null = null;
|
|
44
|
+
try {
|
|
45
|
+
for (let page = 0; page < 20; page++) {
|
|
46
|
+
const url = `${ANTHROPIC_MODELS_URL}?limit=1000${after ? `&after_id=${encodeURIComponent(after)}` : ""}`;
|
|
47
|
+
const res = await fetchImpl(url, {
|
|
48
|
+
headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
|
|
49
|
+
signal: AbortSignal.timeout(o.timeoutMs ?? 15_000),
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) return shipped;
|
|
52
|
+
const body = (await res.json()) as { data?: ApiModel[]; has_more?: unknown; last_id?: unknown };
|
|
53
|
+
for (const m of body.data ?? []) {
|
|
54
|
+
const d = fromApi(m);
|
|
55
|
+
if (d) listed.push(d);
|
|
56
|
+
}
|
|
57
|
+
if (body.has_more !== true || typeof body.last_id !== "string") break;
|
|
58
|
+
after = body.last_id;
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
return shipped;
|
|
62
|
+
}
|
|
63
|
+
if (listed.length === 0) return shipped;
|
|
64
|
+
return [...listed, ...shipped.filter((s) => !listed.some((l) => l.id === s.id))];
|
|
65
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DiscoveredModel } from "../backend.ts";
|
|
2
|
+
|
|
3
|
+
export const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Spec §5.2: Claude's models come from a shipped list (Claude Code has no model listing; research
|
|
7
|
+
* 2026-09-25-models.md §1.1). Full ids only: an alias resolves differently per provider.
|
|
8
|
+
*/
|
|
9
|
+
export const CLAUDE_MODELS: DiscoveredModel[] = [
|
|
10
|
+
{ id: "claude-fable-5-1", efforts: CLAUDE_EFFORTS, context: 1_000_000, imageIn: true },
|
|
11
|
+
{ id: "claude-opus-5-5", efforts: CLAUDE_EFFORTS, context: 1_000_000, imageIn: true },
|
|
12
|
+
{ id: "claude-sonnet-5", efforts: CLAUDE_EFFORTS, context: 1_000_000, imageIn: true },
|
|
13
|
+
{ id: "claude-haiku-4-5-20251001", efforts: [], context: 200_000, imageIn: true },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/** Claude Code aliases and dateless short ids that name a different model per provider or over time. */
|
|
17
|
+
export const CLAUDE_ALIASES: Record<string, string> = {
|
|
18
|
+
fable: "claude-fable-5-1",
|
|
19
|
+
best: "claude-fable-5-1",
|
|
20
|
+
opus: "claude-opus-5-5",
|
|
21
|
+
opusplan: "claude-opus-5-5",
|
|
22
|
+
default: "claude-opus-5-5",
|
|
23
|
+
sonnet: "claude-sonnet-5",
|
|
24
|
+
haiku: "claude-haiku-4-5-20251001",
|
|
25
|
+
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
|
|
26
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { scrubSecrets } from "../infra/env.ts";
|
|
2
|
+
import { log } from "../infra/log.ts";
|
|
3
|
+
import { killGroup } from "../infra/proc.ts";
|
|
4
|
+
|
|
5
|
+
export interface CliResult {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
out: string;
|
|
8
|
+
err: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Process groups of CLI calls still running: an exiting catherd (Ctrl-C is exit 130) kills them. */
|
|
12
|
+
const live = new Set<number>();
|
|
13
|
+
let hooked = false;
|
|
14
|
+
function track(pid: number): void {
|
|
15
|
+
live.add(pid);
|
|
16
|
+
if (hooked) return;
|
|
17
|
+
hooked = true;
|
|
18
|
+
// a detached group gets no terminal SIGINT, and process.exit skips the timeout, so kill it on the way out
|
|
19
|
+
process.on("exit", () => {
|
|
20
|
+
for (const g of live) killGroup(g, "SIGKILL");
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Runs `bin <args>` without catherd's secrets and with no stdin; null when `bin` is not on PATH. A call
|
|
26
|
+
* past `timeoutMs` is killed with every process it started (it runs in its own process group) and is
|
|
27
|
+
* reported as failed, so neither a wedged CLI nor a child holding its pipes stalls the caller.
|
|
28
|
+
*/
|
|
29
|
+
export async function runCli(
|
|
30
|
+
bin: string,
|
|
31
|
+
args: string[],
|
|
32
|
+
o: { timeoutMs: number; env?: Record<string, string>; cwd?: string },
|
|
33
|
+
): Promise<CliResult | null> {
|
|
34
|
+
if (!Bun.which(bin, { PATH: process.env.PATH ?? "" })) return null;
|
|
35
|
+
log("debug", "spawn", { argv: [bin, ...args], env: o.env ?? {} });
|
|
36
|
+
const p = Bun.spawn([bin, ...args], {
|
|
37
|
+
cwd: o.cwd,
|
|
38
|
+
env: { ...scrubSecrets(process.env), ...o.env },
|
|
39
|
+
stdin: "ignore",
|
|
40
|
+
stdout: "pipe",
|
|
41
|
+
stderr: "pipe",
|
|
42
|
+
detached: true,
|
|
43
|
+
});
|
|
44
|
+
track(p.pid);
|
|
45
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
46
|
+
const late = new Promise<null>((resolve) => {
|
|
47
|
+
timer = setTimeout(() => resolve(null), o.timeoutMs);
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
const done = await Promise.race([
|
|
51
|
+
Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]),
|
|
52
|
+
late,
|
|
53
|
+
]);
|
|
54
|
+
if (!done) {
|
|
55
|
+
killGroup(p.pid, "SIGKILL");
|
|
56
|
+
return { ok: false, out: "", err: `${bin} ${args.join(" ")} timed out after ${o.timeoutMs} ms` };
|
|
57
|
+
}
|
|
58
|
+
const [code, out, err] = done;
|
|
59
|
+
return { ok: code === 0, out, err };
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
live.delete(p.pid);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The JSON a CLI printed, or null for empty or unreadable output. */
|
|
67
|
+
export function jsonOf(out: string | undefined): unknown {
|
|
68
|
+
if (!out?.trim()) return null;
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(out);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type Tokens, ZERO_TOKENS } from "../../domain/record.ts";
|
|
2
|
+
|
|
3
|
+
export interface CodexFold {
|
|
4
|
+
thread: string | null;
|
|
5
|
+
tokens: Tokens;
|
|
6
|
+
turnFailed: boolean;
|
|
7
|
+
failure: string | null;
|
|
8
|
+
limit: boolean;
|
|
9
|
+
tooOld: boolean;
|
|
10
|
+
lastEvent: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* An account limit, not any error that says "try again later" (an overloaded server does too). A rate
|
|
15
|
+
* limit codex retried until it gave up reads "exceeded retry limit, last status: 429 Too Many Requests".
|
|
16
|
+
*/
|
|
17
|
+
export const CODEX_LIMIT = [
|
|
18
|
+
/usage limit/i,
|
|
19
|
+
/rate limit reached/i,
|
|
20
|
+
/quota exceeded/i,
|
|
21
|
+
/status: 429|too many requests/i,
|
|
22
|
+
];
|
|
23
|
+
export const CODEX_TOO_OLD = [/not supported when using Codex with a ChatGPT account/i];
|
|
24
|
+
|
|
25
|
+
type Event = Record<string, any>;
|
|
26
|
+
|
|
27
|
+
export function parseCodexLine(line: string): Event | null {
|
|
28
|
+
if (!line.trim().startsWith("{")) return null;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(line) as Event;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Only `turn.failed` fails a run: an `error` event mid-stream is a transient reconnect. */
|
|
37
|
+
export function foldCodexEvents(lines: string[]): CodexFold {
|
|
38
|
+
const f: CodexFold = {
|
|
39
|
+
thread: null,
|
|
40
|
+
tokens: { ...ZERO_TOKENS },
|
|
41
|
+
turnFailed: false,
|
|
42
|
+
failure: null,
|
|
43
|
+
limit: false,
|
|
44
|
+
tooOld: false,
|
|
45
|
+
lastEvent: null,
|
|
46
|
+
};
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
const e = parseCodexLine(line);
|
|
49
|
+
if (!e) continue;
|
|
50
|
+
f.lastEvent = e.item?.type ? `${e.type}/${e.item.type}` : e.type;
|
|
51
|
+
const msg: string = e.message ?? e.error?.message ?? e.item?.message ?? "";
|
|
52
|
+
if (CODEX_TOO_OLD.some((r) => r.test(msg))) f.tooOld = true;
|
|
53
|
+
if (e.type === "thread.started") f.thread = e.thread_id ?? null;
|
|
54
|
+
else if (e.type === "turn.completed" && e.usage) {
|
|
55
|
+
f.tokens.input += e.usage.input_tokens ?? 0;
|
|
56
|
+
f.tokens.cached += e.usage.cached_input_tokens ?? 0;
|
|
57
|
+
f.tokens.output += e.usage.output_tokens ?? 0;
|
|
58
|
+
} else if (e.type === "turn.failed") {
|
|
59
|
+
f.turnFailed = true;
|
|
60
|
+
f.failure = msg || "turn failed";
|
|
61
|
+
if (CODEX_LIMIT.some((r) => r.test(msg))) f.limit = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return f;
|
|
65
|
+
}
|
|
@@ -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
|
+
}
|