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,144 @@
|
|
|
1
|
+
import type { ErrorCode } from "../domain/errors.ts";
|
|
2
|
+
import type { ADAPTER_IDS, Rung } from "../domain/ids.ts";
|
|
3
|
+
import type { BillingMode } from "../domain/cost.ts";
|
|
4
|
+
import type { Access, ExitInfo, RunStatus, Tokens } from "../domain/record.ts";
|
|
5
|
+
|
|
6
|
+
type AdapterId = (typeof ADAPTER_IDS)[number];
|
|
7
|
+
|
|
8
|
+
export interface Probe {
|
|
9
|
+
installed: boolean;
|
|
10
|
+
version: string | null;
|
|
11
|
+
versionOk: boolean;
|
|
12
|
+
/** null when the CLI offers no way to ask */
|
|
13
|
+
loggedIn: boolean | null;
|
|
14
|
+
/** how the CLI is logged in, in a word or two ("ChatGPT", "API key"), when it says */
|
|
15
|
+
login?: string;
|
|
16
|
+
/** the billing mode that login implies, when it implies one: doctor compares it with the profiles' */
|
|
17
|
+
billing?: BillingMode;
|
|
18
|
+
problems: { code: ErrorCode; message: string; fix: string }[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DiscoveredModel {
|
|
22
|
+
id: string;
|
|
23
|
+
efforts: string[];
|
|
24
|
+
context: number | null;
|
|
25
|
+
imageIn: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RunRequest {
|
|
29
|
+
rung: Rung;
|
|
30
|
+
access: Access;
|
|
31
|
+
thread: string | null;
|
|
32
|
+
isolated: boolean;
|
|
33
|
+
repo: string;
|
|
34
|
+
briefPath: string;
|
|
35
|
+
replyPath: string;
|
|
36
|
+
dispatchDir: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `env` holds only the adapter's overrides, never PWD. Admission writes them to spec.json with PWD added
|
|
41
|
+
* and nothing else, so no credential reaches disk; the `_supervise` process builds the worker's env at
|
|
42
|
+
* spawn time with `workerEnv(process.env, spec.env, spec.cwd)`, the only place secrets are scrubbed.
|
|
43
|
+
*/
|
|
44
|
+
export interface SpawnPlan {
|
|
45
|
+
cmd: string;
|
|
46
|
+
args: string[];
|
|
47
|
+
env: Record<string, string>;
|
|
48
|
+
cwd: string;
|
|
49
|
+
stdinPath: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface EventDelta {
|
|
53
|
+
thread?: string;
|
|
54
|
+
tokens?: Tokens;
|
|
55
|
+
/** the input tokens of one model request, from an event that reports them per request (the harness cost) */
|
|
56
|
+
requestInput?: number;
|
|
57
|
+
costUsd?: number;
|
|
58
|
+
lastEvent?: string;
|
|
59
|
+
failure?: string;
|
|
60
|
+
limit?: boolean;
|
|
61
|
+
tooOld?: boolean;
|
|
62
|
+
retrying?: boolean;
|
|
63
|
+
/** the stream's terminal event: the supervisor may kill a CLI that lingers after it */
|
|
64
|
+
final?: boolean;
|
|
65
|
+
/** a tool call starting (`open`) or ending: while one is open the run is busy, however quiet */
|
|
66
|
+
item?: { id: string; open: boolean };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface FinishedRun {
|
|
70
|
+
request: RunRequest;
|
|
71
|
+
eventLines: string[];
|
|
72
|
+
reply: string;
|
|
73
|
+
stderr: string;
|
|
74
|
+
exit: ExitInfo;
|
|
75
|
+
startedAtMs: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface Outcome {
|
|
79
|
+
status: RunStatus;
|
|
80
|
+
thread: string | null;
|
|
81
|
+
tokens: Tokens;
|
|
82
|
+
costUsd: number | null;
|
|
83
|
+
images: string[];
|
|
84
|
+
error: { code: string; message: string } | null;
|
|
85
|
+
/** The role's reply, for a CLI that streams it rather than writing the reply file (absent: the file is the reply). */
|
|
86
|
+
reply?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What earlier records on the same thread already counted, so a session total becomes this run's share. */
|
|
90
|
+
export interface Spent {
|
|
91
|
+
tokens: Tokens;
|
|
92
|
+
costUsd: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface BackendAdapter {
|
|
96
|
+
id: AdapterId;
|
|
97
|
+
minVersion: string;
|
|
98
|
+
probe(): Promise<Probe>;
|
|
99
|
+
/** The models the backend offers; in `repo` when given, for a backend whose listing depends on it. */
|
|
100
|
+
listModels(repo?: string): Promise<DiscoveredModel[]>;
|
|
101
|
+
/**
|
|
102
|
+
* Refuses a rung this backend cannot run and readies the backend's own config, before admission writes
|
|
103
|
+
* anything (spec §6.3: variants are validated before dispatch). Throws a CatherdError.
|
|
104
|
+
*/
|
|
105
|
+
prepare?(req: { rung: Rung; access: Access; isolated: boolean; repo: string }): Promise<void>;
|
|
106
|
+
plan(req: RunRequest): SpawnPlan;
|
|
107
|
+
parse(line: string): EventDelta;
|
|
108
|
+
finalize(run: FinishedRun): Outcome;
|
|
109
|
+
/**
|
|
110
|
+
* Spec §6.3: the backend's own account of a finished session, for a stream that undercounts. Returns
|
|
111
|
+
* `o` refined; never throws (the caller also cuts it off after a timeout and keeps `o`).
|
|
112
|
+
*/
|
|
113
|
+
settle?(o: Outcome, run: FinishedRun, prior: Spent): Promise<Outcome>;
|
|
114
|
+
enforcement: Record<Access, "enforced" | "advisory">;
|
|
115
|
+
errors: { limit: RegExp[]; tooOld: RegExp[] };
|
|
116
|
+
resume: { supported: boolean; sameAccessOnly: boolean; threadPattern: RegExp };
|
|
117
|
+
interrupt?(thread: string, cwd: string): Promise<void>;
|
|
118
|
+
/** `sinceMs`: when this run started, so what an earlier run on the thread left behind does not count */
|
|
119
|
+
isBusy?(thread: string, cwd: string, sinceMs?: number): Promise<boolean>;
|
|
120
|
+
/** Spec §4.5: this backend's own stand-in for a rung on a usage limit, when the profile names none. */
|
|
121
|
+
failoverFor?(rung: Rung, repo?: string): Rung | null;
|
|
122
|
+
/**
|
|
123
|
+
* Spec §10.3: whether a workspace-write worker of this backend can write `dir` (the heavy-lock dir, so
|
|
124
|
+
* `catherd lock` works inside it); null when it cannot be tested on this machine.
|
|
125
|
+
*/
|
|
126
|
+
canWrite?(dir: string): Promise<{ ok: boolean; fix?: string } | null>;
|
|
127
|
+
/** Spec §10.3: why this backend's isolation is weak; doctor warns when a profile uses it. */
|
|
128
|
+
isolationNote?: string;
|
|
129
|
+
graceAfterFinalMs: number | null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function extractVersion(s: string): string | null {
|
|
133
|
+
return /(\d+)\.(\d+)\.(\d+)/.exec(s)?.[0] ?? null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function compareVersions(a: string, b: string): number {
|
|
137
|
+
const pa = a.split(".").map(Number);
|
|
138
|
+
const pb = b.split(".").map(Number);
|
|
139
|
+
for (let i = 0; i < 3; i++) {
|
|
140
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
141
|
+
if (d !== 0) return d;
|
|
142
|
+
}
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type Tokens, ZERO_TOKENS } from "../../domain/record.ts";
|
|
2
|
+
|
|
3
|
+
interface ClaudeResult {
|
|
4
|
+
isError: boolean;
|
|
5
|
+
text: string;
|
|
6
|
+
apiStatus: number | null;
|
|
7
|
+
tokens: Tokens;
|
|
8
|
+
costUsd: number | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ClaudeFold {
|
|
12
|
+
thread: string | null;
|
|
13
|
+
result: ClaudeResult | null;
|
|
14
|
+
limit: boolean;
|
|
15
|
+
lastEvent: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A usage or rate limit, in the result text Claude Code prints. */
|
|
19
|
+
export const CLAUDE_LIMIT = [/usage limit/i, /hit your (?:usage )?limit/i, /rate[ _-]?limit/i];
|
|
20
|
+
/** What an older `claude` prints for a flag catherd passes (commander's messages). */
|
|
21
|
+
export const CLAUDE_TOO_OLD = [
|
|
22
|
+
/unknown option '--/i,
|
|
23
|
+
/option '--[\w-]+ <[\w.]+>' argument '[^']*' is invalid/i,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
type Event = Record<string, any>;
|
|
27
|
+
|
|
28
|
+
export function parseClaudeLine(line: string): Event | null {
|
|
29
|
+
if (!line.trim().startsWith("{")) return null;
|
|
30
|
+
try {
|
|
31
|
+
const e = JSON.parse(line) as unknown;
|
|
32
|
+
return e && typeof e === "object" && !Array.isArray(e) ? (e as Event) : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Spec §4.3's convention: input counts cached and freshly cached tokens too. */
|
|
39
|
+
export function claudeTokens(u: Event | undefined): Tokens {
|
|
40
|
+
if (!u) return { ...ZERO_TOKENS };
|
|
41
|
+
const n = (k: string) => (typeof u[k] === "number" ? (u[k] as number) : 0);
|
|
42
|
+
const cached = n("cache_read_input_tokens");
|
|
43
|
+
return {
|
|
44
|
+
input: n("input_tokens") + n("cache_creation_input_tokens") + cached,
|
|
45
|
+
cached,
|
|
46
|
+
output: n("output_tokens"),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function eventName(e: Event): string {
|
|
51
|
+
if (e.type === "system") return e.subtype === "api_retry" ? "retrying" : `system/${e.subtype ?? "?"}`;
|
|
52
|
+
if (e.type === "assistant") {
|
|
53
|
+
const c = (e.message?.content ?? []).at(-1) as Event | undefined;
|
|
54
|
+
return c?.type === "tool_use" ? `tool_use/${c.name}` : `assistant/${c?.type ?? "?"}`;
|
|
55
|
+
}
|
|
56
|
+
if (e.type === "user") return "tool_result";
|
|
57
|
+
return String(e.type ?? "?");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const limited = (e: Event): boolean =>
|
|
61
|
+
(e.type === "rate_limit_event" && e.rate_limit_info?.status === "rejected") ||
|
|
62
|
+
(e.type === "assistant" && e.error === "rate_limit") ||
|
|
63
|
+
(e.type === "result" &&
|
|
64
|
+
e.is_error === true &&
|
|
65
|
+
(e.api_error_status === 429 || CLAUDE_LIMIT.some((r) => r.test(String(e.result ?? "")))));
|
|
66
|
+
|
|
67
|
+
/** Tokens and cost come from the final `result` event only: assistant events repeat one message's usage. */
|
|
68
|
+
export function foldClaudeEvents(lines: string[]): ClaudeFold {
|
|
69
|
+
const f: ClaudeFold = { thread: null, result: null, limit: false, lastEvent: null };
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
const e = parseClaudeLine(line);
|
|
72
|
+
if (!e) continue;
|
|
73
|
+
f.lastEvent = eventName(e);
|
|
74
|
+
if (typeof e.session_id === "string" && e.session_id) f.thread ??= e.session_id;
|
|
75
|
+
if (limited(e)) f.limit = true;
|
|
76
|
+
if (e.type === "result")
|
|
77
|
+
f.result = {
|
|
78
|
+
isError: e.is_error === true,
|
|
79
|
+
text: typeof e.result === "string" ? e.result : "",
|
|
80
|
+
apiStatus: typeof e.api_error_status === "number" ? e.api_error_status : null,
|
|
81
|
+
tokens: claudeTokens(e.usage),
|
|
82
|
+
costUsd: typeof e.total_cost_usd === "number" ? e.total_cost_usd : null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return f;
|
|
86
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
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 BackendAdapter,
|
|
6
|
+
compareVersions,
|
|
7
|
+
type EventDelta,
|
|
8
|
+
extractVersion,
|
|
9
|
+
type FinishedRun,
|
|
10
|
+
type Outcome,
|
|
11
|
+
type Probe,
|
|
12
|
+
type RunRequest,
|
|
13
|
+
type SpawnPlan,
|
|
14
|
+
} from "../backend.ts";
|
|
15
|
+
import { jsonOf, runCli } from "../cli.ts";
|
|
16
|
+
import {
|
|
17
|
+
CLAUDE_LIMIT,
|
|
18
|
+
CLAUDE_TOO_OLD,
|
|
19
|
+
claudeTokens,
|
|
20
|
+
eventName,
|
|
21
|
+
foldClaudeEvents,
|
|
22
|
+
parseClaudeLine,
|
|
23
|
+
} from "./events.ts";
|
|
24
|
+
import { CLAUDE_ALIASES, CLAUDE_EFFORTS, CLAUDE_MODELS } from "./models.ts";
|
|
25
|
+
import { listClaudeModels } from "./models-api.ts";
|
|
26
|
+
|
|
27
|
+
/** The version whose flags catherd was verified against (`claude --help`, 2026-09-25). */
|
|
28
|
+
const CLAUDE_MIN_VERSION = "2.1.282";
|
|
29
|
+
const THREAD = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
30
|
+
|
|
31
|
+
/** How long a `claude` query (version, login) may take before it counts as failed. */
|
|
32
|
+
export const claudeShell = { timeoutMs: 15_000 };
|
|
33
|
+
|
|
34
|
+
const READ_TOOLS = ["Read", "Glob", "Grep", "WebFetch", "WebSearch"];
|
|
35
|
+
const NEVER = ["git commit", "git push", "git reset --hard"].map((c) => `Bash(${c} *)`);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Spec §6.2 access → Claude Code permission flags (advisory: Bash can still write where it likes).
|
|
39
|
+
* `dontAsk` denies anything not allowed; `--permission-prompts none` (always passed) denies any prompt.
|
|
40
|
+
* Read-only gets no Bash at all, as catherd-ro on opencode: `rg --pre=<cmd>` executes and
|
|
41
|
+
* `git diff --output=<file>` writes, so no Bash pattern is read-only. It reads with Read, Glob and Grep.
|
|
42
|
+
*/
|
|
43
|
+
export const CLAUDE_ACCESS: Record<Access, string[]> = {
|
|
44
|
+
"read-only": [
|
|
45
|
+
"--permission-mode",
|
|
46
|
+
"dontAsk",
|
|
47
|
+
"--allowedTools",
|
|
48
|
+
READ_TOOLS.join(","),
|
|
49
|
+
"--disallowedTools",
|
|
50
|
+
"Edit,Write,NotebookEdit,Bash",
|
|
51
|
+
],
|
|
52
|
+
"workspace-write": [
|
|
53
|
+
"--permission-mode",
|
|
54
|
+
"acceptEdits",
|
|
55
|
+
"--allowedTools",
|
|
56
|
+
[...READ_TOOLS, "Edit", "Write", "NotebookEdit", "Bash"].join(","),
|
|
57
|
+
"--disallowedTools",
|
|
58
|
+
NEVER.join(","),
|
|
59
|
+
],
|
|
60
|
+
full: ["--permission-mode", "bypassPermissions"],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function plan(r: RunRequest): SpawnPlan {
|
|
64
|
+
if (r.thread !== null && !THREAD.test(r.thread))
|
|
65
|
+
throw new CatherdError("E_ADMIT_THREAD", `"${r.thread}" is not a Claude Code session id`, {
|
|
66
|
+
fix: "pass the thread from the earlier RunRecord",
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
cmd: "claude",
|
|
70
|
+
args: [
|
|
71
|
+
"-p",
|
|
72
|
+
"--output-format",
|
|
73
|
+
"stream-json",
|
|
74
|
+
"--verbose",
|
|
75
|
+
"--model",
|
|
76
|
+
r.rung.model,
|
|
77
|
+
// "default" leaves the effort to Claude Code (spec §5.1); Haiku has none
|
|
78
|
+
...(r.rung.effort === "default" ? [] : ["--effort", r.rung.effort]),
|
|
79
|
+
// a fresh session gets its id from catherd, so the thread is known before the first event
|
|
80
|
+
...(r.thread === null ? ["--session-id", crypto.randomUUID()] : ["--resume", r.thread]),
|
|
81
|
+
"--permission-prompts",
|
|
82
|
+
"none",
|
|
83
|
+
...CLAUDE_ACCESS[r.access],
|
|
84
|
+
// --bare would also drop OAuth, so a Claude plan could not log in; --safe-mode keeps auth
|
|
85
|
+
...(r.isolated ? ["--safe-mode"] : []),
|
|
86
|
+
],
|
|
87
|
+
env: {},
|
|
88
|
+
cwd: r.repo,
|
|
89
|
+
stdinPath: r.briefPath,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Spec §6.2: full model ids only, and an effort the model has. */
|
|
94
|
+
async function prepare(req: { rung: Rung; access: Access; isolated: boolean }): Promise<void> {
|
|
95
|
+
const { model, effort } = req.rung;
|
|
96
|
+
const full = CLAUDE_ALIASES[model];
|
|
97
|
+
if (full || !model.startsWith("claude-"))
|
|
98
|
+
throw new CatherdError("E_BACKEND_MODEL_UNKNOWN", `"${model}" is not a full Claude model id`, {
|
|
99
|
+
fix: `use the full id${full ? `, ${full}` : ", e.g. claude-opus-5-5"}`,
|
|
100
|
+
});
|
|
101
|
+
const known = CLAUDE_MODELS.find((m) => m.id === model);
|
|
102
|
+
const efforts = known ? known.efforts : CLAUDE_EFFORTS;
|
|
103
|
+
if (effort !== "default" && !efforts.includes(effort))
|
|
104
|
+
throw new CatherdError("E_BACKEND_MODEL_UNKNOWN", `${model} has no effort "${effort}"`, {
|
|
105
|
+
fix: efforts.length ? `use one of: default, ${efforts.join(", ")}` : `use ${model}#default`,
|
|
106
|
+
});
|
|
107
|
+
if (req.access === "full" && process.getuid?.() === 0 && !process.env.IS_SANDBOX)
|
|
108
|
+
throw new CatherdError(
|
|
109
|
+
"E_ADMIT_RUNG",
|
|
110
|
+
"claude refuses full access (bypassPermissions) when run as root",
|
|
111
|
+
{
|
|
112
|
+
fix: "run catherd as a normal user, or give this role workspace-write access",
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function finalize(run: FinishedRun): Outcome {
|
|
118
|
+
const f = foldClaudeEvents(run.eventLines);
|
|
119
|
+
const res = f.result;
|
|
120
|
+
const stopped = run.exit.reason;
|
|
121
|
+
const failed = res === null || res.isError;
|
|
122
|
+
const status: RunStatus =
|
|
123
|
+
stopped === "cancelled"
|
|
124
|
+
? "cancelled"
|
|
125
|
+
: stopped === "idle-timeout" || stopped === "wall-timeout"
|
|
126
|
+
? "timeout"
|
|
127
|
+
: !failed
|
|
128
|
+
? "ok"
|
|
129
|
+
: res === null && CLAUDE_TOO_OLD.some((r) => r.test(run.stderr))
|
|
130
|
+
? "cli-too-old"
|
|
131
|
+
: f.limit
|
|
132
|
+
? "limit"
|
|
133
|
+
: "failed";
|
|
134
|
+
const lastErr = run.stderr.trim().split("\n").at(-1) ?? "";
|
|
135
|
+
const message =
|
|
136
|
+
res?.isError && res.text
|
|
137
|
+
? res.text
|
|
138
|
+
: lastErr || (res ? "" : `no result event (${stopped}, exit ${run.exit.code ?? run.exit.signal})`);
|
|
139
|
+
return {
|
|
140
|
+
status,
|
|
141
|
+
thread: f.thread ?? run.request.thread,
|
|
142
|
+
tokens: res?.tokens ?? claudeTokens(undefined),
|
|
143
|
+
costUsd: res?.costUsd ?? null,
|
|
144
|
+
images: [],
|
|
145
|
+
error: status === "ok" ? null : { code: status, message: message || status },
|
|
146
|
+
...(res && !res.isError ? { reply: res.text } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parse(line: string): EventDelta {
|
|
151
|
+
const e = parseClaudeLine(line);
|
|
152
|
+
if (!e) return {};
|
|
153
|
+
const d: EventDelta = { lastEvent: eventName(e) };
|
|
154
|
+
if (typeof e.session_id === "string" && e.session_id) d.thread = e.session_id;
|
|
155
|
+
if (e.type === "system" && e.subtype === "api_retry") d.retrying = true;
|
|
156
|
+
if (e.type === "rate_limit_event" && e.rate_limit_info?.status === "rejected") d.limit = true;
|
|
157
|
+
// each assistant message of the main thread carries its own request's usage; `result` sums the session
|
|
158
|
+
if (e.type === "assistant" && !e.parent_tool_use_id && e.message?.usage)
|
|
159
|
+
d.requestInput = claudeTokens(e.message.usage).input;
|
|
160
|
+
if (e.type === "result") {
|
|
161
|
+
const f = foldClaudeEvents([line]);
|
|
162
|
+
d.final = true;
|
|
163
|
+
d.tokens = f.result?.tokens;
|
|
164
|
+
if (typeof e.total_cost_usd === "number") d.costUsd = e.total_cost_usd;
|
|
165
|
+
if (e.is_error === true) d.failure = String(e.result ?? "error");
|
|
166
|
+
if (f.limit) d.limit = true;
|
|
167
|
+
}
|
|
168
|
+
return d;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function probe(): Promise<Probe> {
|
|
172
|
+
const v = await runCli("claude", ["--version"], claudeShell);
|
|
173
|
+
if (!v)
|
|
174
|
+
return {
|
|
175
|
+
installed: false,
|
|
176
|
+
version: null,
|
|
177
|
+
versionOk: false,
|
|
178
|
+
loggedIn: null,
|
|
179
|
+
problems: [
|
|
180
|
+
{
|
|
181
|
+
code: "E_BACKEND_MISSING",
|
|
182
|
+
message: "claude is not on PATH",
|
|
183
|
+
fix: "npm i -g @anthropic-ai/claude-code",
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
const version = extractVersion(v.out);
|
|
188
|
+
const versionOk = version !== null && compareVersions(version, CLAUDE_MIN_VERSION) >= 0;
|
|
189
|
+
const status = jsonOf((await runCli("claude", ["auth", "status", "--json"], claudeShell))?.out) as {
|
|
190
|
+
loggedIn?: unknown;
|
|
191
|
+
} | null;
|
|
192
|
+
const loggedIn = status?.loggedIn === true;
|
|
193
|
+
const problems: Probe["problems"] = [];
|
|
194
|
+
if (!versionOk)
|
|
195
|
+
problems.push({
|
|
196
|
+
code: "E_BACKEND_TOO_OLD",
|
|
197
|
+
message: `claude ${version ?? "?"} is older than ${CLAUDE_MIN_VERSION}`,
|
|
198
|
+
fix: "claude update",
|
|
199
|
+
});
|
|
200
|
+
if (!loggedIn)
|
|
201
|
+
problems.push({
|
|
202
|
+
code: "E_BACKEND_NOT_LOGGED_IN",
|
|
203
|
+
message: "claude is not logged in",
|
|
204
|
+
fix: "claude auth login",
|
|
205
|
+
});
|
|
206
|
+
return { installed: true, version, versionOk, loggedIn, problems };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export const claudeCodeAdapter: BackendAdapter = {
|
|
210
|
+
id: "claude-code",
|
|
211
|
+
minVersion: CLAUDE_MIN_VERSION,
|
|
212
|
+
probe,
|
|
213
|
+
listModels: () => listClaudeModels(),
|
|
214
|
+
prepare,
|
|
215
|
+
plan,
|
|
216
|
+
parse,
|
|
217
|
+
finalize,
|
|
218
|
+
enforcement: { "read-only": "advisory", "workspace-write": "advisory", full: "advisory" },
|
|
219
|
+
errors: { limit: CLAUDE_LIMIT, tooOld: CLAUDE_TOO_OLD },
|
|
220
|
+
resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
|
|
221
|
+
// the `result` event is the last thing claude prints; a CLI still running 30 s later is stuck
|
|
222
|
+
graceAfterFinalMs: 30_000,
|
|
223
|
+
};
|
|
@@ -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
|
+
}
|