catherd-cli 1.2.1 → 1.3.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 +74 -3
- package/catalog/models.json +148 -5
- package/catalog/scores.json +24 -0
- package/catalog/sources.json +12 -2
- package/package.json +3 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +3 -3
- package/plugin/skills/catherd-setup/SKILL.md +3 -3
- package/src/adapters/access.ts +54 -17
- package/src/adapters/all.ts +6 -0
- package/src/adapters/antigravity/events.ts +131 -0
- package/src/adapters/antigravity/home.ts +46 -0
- package/src/adapters/antigravity/index.ts +320 -0
- package/src/adapters/antigravity/models.ts +37 -0
- package/src/adapters/backend.ts +34 -1
- package/src/adapters/claude-code/index.ts +2 -0
- package/src/adapters/codex/index.ts +2 -0
- package/src/adapters/cursor/events.ts +120 -0
- package/src/adapters/cursor/home.ts +79 -0
- package/src/adapters/cursor/index.ts +331 -0
- package/src/adapters/cursor/models.ts +30 -0
- package/src/adapters/discovery.ts +21 -0
- package/src/adapters/grok/events.ts +112 -0
- package/src/adapters/grok/home.ts +132 -0
- package/src/adapters/grok/index.ts +339 -0
- package/src/adapters/grok/models.ts +48 -0
- package/src/adapters/opencode/index.ts +2 -0
- package/src/cli.ts +1 -0
- package/src/domain/catalog.ts +10 -1
- package/src/domain/cost.ts +1 -0
- package/src/domain/errors.ts +1 -0
- package/src/domain/failover.ts +5 -2
- package/src/domain/ids.ts +2 -2
- package/src/domain/profile.ts +57 -0
- package/src/domain/record.ts +2 -0
- package/src/domain/util.ts +3 -0
- package/src/entry/knowledge-command.ts +50 -0
- package/src/entry/tui/effects.ts +12 -3
- package/src/entry/tui/fixtures.ts +1 -0
- package/src/entry/tui/profile-tree.ts +10 -1
- package/src/entry/tui/views/profiles.tsx +14 -1
- package/src/infra/paths.ts +3 -1
- package/src/services/admission.ts +27 -4
- package/src/services/backends.ts +42 -4
- package/src/services/capture.ts +112 -54
- package/src/services/catalog-refresh.ts +2 -1
- package/src/services/dispatches.ts +2 -0
- package/src/services/doctor-access.ts +14 -7
- package/src/services/doctor-backends.ts +10 -1
- package/src/services/doctor-checks.ts +25 -0
- package/src/services/doctor.ts +3 -0
- package/src/services/finalize.ts +1 -0
- package/src/services/lane-service.ts +3 -10
- package/src/services/profile-service.ts +3 -3
- package/src/services/profile-store.ts +106 -2
- package/src/services/run-service.ts +37 -4
- package/src/services/run-store.ts +11 -1
- package/src/services/source-derive.ts +32 -7
package/src/domain/cost.ts
CHANGED
package/src/domain/errors.ts
CHANGED
package/src/domain/failover.ts
CHANGED
|
@@ -113,7 +113,8 @@ function effortGap(a: string, b: string): number {
|
|
|
113
113
|
const BACKEND_OF_KEY: Record<string, string> = { "opencode-go": "opencode" };
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
|
-
* Every rung the catalog knows how to name: each shipped family's model on each backend at each effort
|
|
116
|
+
* Every rung the catalog knows how to name: each shipped family's model on each backend at each effort (at
|
|
117
|
+
* `#default` when it has none),
|
|
117
118
|
* each listed model at each effort, and each treat-like whose model is an opencode id (`opencode-go/…`,
|
|
118
119
|
* `opencode/…`), such as the shipped Kimi K3 stand-in. Sorted, without duplicates.
|
|
119
120
|
*/
|
|
@@ -122,7 +123,9 @@ export function catalogRungs(c: Catalog): string[] {
|
|
|
122
123
|
for (const f of c.families)
|
|
123
124
|
for (const [key, m] of Object.entries(f.on)) {
|
|
124
125
|
if (!m) continue;
|
|
125
|
-
|
|
126
|
+
// a model with no effort has one rung, #default (Claude Code's Haiku, Cursor's Composer; spec 1.3 §7.1)
|
|
127
|
+
for (const e of m.efforts.length ? m.efforts : ["default"])
|
|
128
|
+
out.add(`${BACKEND_OF_KEY[key] ?? key}:${m.id}#${e}`);
|
|
126
129
|
}
|
|
127
130
|
for (const [backend, l] of Object.entries(c.listed))
|
|
128
131
|
for (const m of l.models) for (const e of m.efforts) out.add(`${backend}:${m.id}#${e}`);
|
package/src/domain/ids.ts
CHANGED
|
@@ -11,8 +11,8 @@ export function assertId(kind: string, value: string): string {
|
|
|
11
11
|
return value;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
/** The backends that run as a process through a `BackendAdapter` (cursor
|
|
15
|
-
export const ADAPTER_IDS = ["codex", "claude-code", "opencode", "cursor", "grok"] as const;
|
|
14
|
+
/** The backends that run as a process through a `BackendAdapter` (spec 1.3: cursor, grok and antigravity). */
|
|
15
|
+
export const ADAPTER_IDS = ["codex", "claude-code", "opencode", "cursor", "grok", "antigravity"] as const;
|
|
16
16
|
|
|
17
17
|
/** `claude` is the native Claude Code subagent path (no process); the rest are adapters. */
|
|
18
18
|
const RUNG_BACKENDS = [...ADAPTER_IDS, "claude"] as const;
|
package/src/domain/profile.ts
CHANGED
|
@@ -169,6 +169,63 @@ export const DEFAULT_FAILOVER: Record<string, string> = {
|
|
|
169
169
|
[SOL("medium")]: "opencode:opencode-go/kimi-k3#max",
|
|
170
170
|
};
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Cursor's live slug and efforts for a paired family (`cursor-agent models`, 2026-10-01). Empty efforts means the
|
|
174
|
+
* bare slug.
|
|
175
|
+
*/
|
|
176
|
+
const CURSOR_PAIR: Record<string, { id: string; efforts: readonly string[] }> = {
|
|
177
|
+
"grok-4.7": { id: "grok-4.7", efforts: ["low", "medium", "high", "xhigh"] },
|
|
178
|
+
"grok-4.6": { id: "cursor-grok-4.6", efforts: ["low", "medium", "high", "xhigh"] },
|
|
179
|
+
"grok-4.5": { id: "cursor-grok-4.5", efforts: ["low", "medium", "high"] },
|
|
180
|
+
"gemini-3.8-flash": { id: "gemini-3.8-flash", efforts: ["low", "medium", "high"] },
|
|
181
|
+
"gemini-3.7-flash": { id: "gemini-3.7-flash", efforts: ["low", "medium", "high"] },
|
|
182
|
+
"gemini-3.6-flash": { id: "gemini-3.6-flash", efforts: ["minimal", "low", "medium", "high"] },
|
|
183
|
+
"gemini-3.1-pro": { id: "gemini-3.1-pro", efforts: [] },
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** The Cursor effort a paired rung can actually run: the same one when listed, else `high`, else the bare slug. */
|
|
187
|
+
function cursorPairEffort(model: string, effort: string): string {
|
|
188
|
+
const list = CURSOR_PAIR[model]?.efforts ?? [];
|
|
189
|
+
if (list.length === 0) return "default";
|
|
190
|
+
if (effort !== "default" && list.includes(effort)) return effort;
|
|
191
|
+
return list.includes("high") ? "high" : (list[0] as string);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Spec 1.3 §7.3: the same model through two backends bills two pools, so each stands in for the other on a
|
|
196
|
+
* limit, at the same effort where both list it. Otherwise the stand-in runs at `high`: Grok 4.5 `xhigh` at Cursor
|
|
197
|
+
* `high`, Cursor's Gemini 3.6 `minimal` at agy `high`; Gemini 3.1 Pro's bare Cursor slug pairs with agy `high`.
|
|
198
|
+
* Grok and Gemini are paired with Cursor. Consulted after the profile's own failover and never written into a
|
|
199
|
+
* profile, where a pair whose rung is on no ladder would warn "never runs"; never a stand-in for a shipped Codex,
|
|
200
|
+
* Claude or opencode rung.
|
|
201
|
+
*/
|
|
202
|
+
export const PAIRED_FAILOVER: Record<string, string> = Object.fromEntries(
|
|
203
|
+
(
|
|
204
|
+
[
|
|
205
|
+
["grok", ["grok-4.7", "grok-4.6", "grok-4.5"], ["low", "medium", "high", "xhigh"]],
|
|
206
|
+
[
|
|
207
|
+
"antigravity",
|
|
208
|
+
["gemini-3.8-flash", "gemini-3.7-flash", "gemini-3.6-flash"],
|
|
209
|
+
["low", "medium", "high"],
|
|
210
|
+
],
|
|
211
|
+
["antigravity", ["gemini-3.1-pro"], ["low", "high"]],
|
|
212
|
+
] as const
|
|
213
|
+
).flatMap(([backend, models, efforts]) =>
|
|
214
|
+
models.flatMap((m) => {
|
|
215
|
+
const cursor = CURSOR_PAIR[m] as { id: string; efforts: readonly string[] };
|
|
216
|
+
const back = ["default", ...efforts].map(
|
|
217
|
+
(e) => [`${backend}:${m}#${e}`, `cursor:${cursor.id}#${cursorPairEffort(m, e)}`] as const,
|
|
218
|
+
);
|
|
219
|
+
// `#default` too, as on the other side: a Cursor rung with no effort flag fails over the same way
|
|
220
|
+
const fore = [...new Set(["default", ...cursor.efforts])].map((e) => {
|
|
221
|
+
const there = e !== "default" && (efforts as readonly string[]).includes(e) ? e : "high";
|
|
222
|
+
return [`cursor:${cursor.id}#${e}`, `${backend}:${m}#${there}`] as const;
|
|
223
|
+
});
|
|
224
|
+
return [...back, ...fore];
|
|
225
|
+
}),
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
|
|
172
229
|
/** The five billing keys spec §7.1 writes out; cursor and grok arrive with their backends. */
|
|
173
230
|
const WRITTEN_BILLING: BillingKey[] = ["codex", "claude", "claude-code", "opencode-go", "opencode"];
|
|
174
231
|
|
package/src/domain/record.ts
CHANGED
|
@@ -68,6 +68,8 @@ export const RunRecordSchema = z.looseObject({
|
|
|
68
68
|
threadHeavy: z.boolean(),
|
|
69
69
|
access: z.enum(ACCESS),
|
|
70
70
|
isolated: z.boolean(),
|
|
71
|
+
/** the role's network grant (1.1 §5) it ran under, copied from admit.json; absent before 1.3 */
|
|
72
|
+
network: z.boolean().optional(),
|
|
71
73
|
images: z.array(z.string()),
|
|
72
74
|
error: z.object({ code: z.string(), message: z.string() }).nullable(),
|
|
73
75
|
replyPath: z.string(),
|
package/src/domain/util.ts
CHANGED
|
@@ -5,6 +5,9 @@ export const slug = (s: string): string =>
|
|
|
5
5
|
.replace(/[^a-z0-9]+/g, "-")
|
|
6
6
|
.replace(/^-|-$/g, "");
|
|
7
7
|
|
|
8
|
+
/** One table cell or one line of text: `|` and newlines become `/`, whitespace runs one space, trimmed. */
|
|
9
|
+
export const cell = (s: string): string => s.replace(/[|\n]/g, "/").replace(/\s+/g, " ").trim();
|
|
10
|
+
|
|
8
11
|
/** The middle value, or the mean of the two middle values for an even count; null when there are none. */
|
|
9
12
|
export function median(xs: readonly number[]): number | null {
|
|
10
13
|
if (xs.length === 0) return null;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import { addKnowledge, knowledgeLines, knowledgePath, readKnowledge } from "../services/run-service.ts";
|
|
4
|
+
import { JSON_ARG, printJson } from "./cli-kit.ts";
|
|
5
|
+
|
|
6
|
+
/** `--repo`, else the repository the command runs in, as run_start and read_knowledge resolve it. */
|
|
7
|
+
const REPO_ARG = {
|
|
8
|
+
repo: { type: "string", description: "any path inside the git repo (default: the one you are in)" },
|
|
9
|
+
} as const;
|
|
10
|
+
const repoOf = (repo: string | undefined): string => resolve(repo ?? process.cwd());
|
|
11
|
+
|
|
12
|
+
const show = defineCommand({
|
|
13
|
+
meta: { name: "show", description: "Print what catherd knows about the repo (its knowledge.md)" },
|
|
14
|
+
args: { ...REPO_ARG, ...JSON_ARG },
|
|
15
|
+
async run({ args }) {
|
|
16
|
+
if (args.json) return printJson(await knowledgeLines(repoOf(args.repo)));
|
|
17
|
+
const text = await readKnowledge(repoOf(args.repo));
|
|
18
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const add = defineCommand({
|
|
23
|
+
meta: {
|
|
24
|
+
name: "add",
|
|
25
|
+
description: 'Append one fact to the repo\'s knowledge.md, marked "by hand"',
|
|
26
|
+
},
|
|
27
|
+
args: {
|
|
28
|
+
line: { type: "positional", required: true, description: "the fact, on one line" },
|
|
29
|
+
...REPO_ARG,
|
|
30
|
+
},
|
|
31
|
+
async run({ args }) {
|
|
32
|
+
console.log(await addKnowledge(repoOf(args.repo), args.line));
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const path = defineCommand({
|
|
37
|
+
meta: { name: "path", description: "Print the path of the repo's knowledge.md (it may not exist yet)" },
|
|
38
|
+
args: { ...REPO_ARG, ...JSON_ARG },
|
|
39
|
+
async run({ args }) {
|
|
40
|
+
const at = await knowledgePath(repoOf(args.repo));
|
|
41
|
+
if (args.json) return printJson(at);
|
|
42
|
+
console.log(at.path);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** What past runs of a repo learned, which the read_knowledge tool gives every new run (spec §4.7). */
|
|
47
|
+
export const knowledgeCommand = defineCommand({
|
|
48
|
+
meta: { name: "knowledge", description: "What catherd knows about a repo: show, add or path" },
|
|
49
|
+
subCommands: { show, add, path },
|
|
50
|
+
});
|
package/src/entry/tui/effects.ts
CHANGED
|
@@ -4,7 +4,7 @@ import "../../adapters/all.ts";
|
|
|
4
4
|
import { agentFiles } from "../../domain/agents.ts";
|
|
5
5
|
import { type Catalog, rungInfo } from "../../domain/catalog.ts";
|
|
6
6
|
import { HARNESS_KEYS, type Profile, type ProfileDoc, type ProfilePatch } from "../../domain/profile.ts";
|
|
7
|
-
import {
|
|
7
|
+
import type { Validation } from "../../domain/profile-rules.ts";
|
|
8
8
|
import type { Access } from "../../domain/record.ts";
|
|
9
9
|
import { VERSION } from "../../infra/version.ts";
|
|
10
10
|
import {
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
projectsFile,
|
|
40
40
|
readProfileDoc,
|
|
41
41
|
readProjects,
|
|
42
|
-
|
|
42
|
+
validateHere,
|
|
43
43
|
} from "../../services/profile-store.ts";
|
|
44
44
|
import { listRuns, type Run, runPaths } from "../../services/run-store.ts";
|
|
45
45
|
import {
|
|
@@ -121,6 +121,8 @@ export interface Effects {
|
|
|
121
121
|
enforcement(rung: string, access: Access): "enforced" | "advisory";
|
|
122
122
|
/** the harnesses a profile can isolate */
|
|
123
123
|
harnesses: readonly string[];
|
|
124
|
+
/** spec 1.3 §8: what a harness's native mode loads, and the API key its isolation needs, when it says */
|
|
125
|
+
isolation(harness: string): { note?: string; key?: string };
|
|
124
126
|
/** the native agents a profile links, by name */
|
|
125
127
|
agents(p: Profile): string[];
|
|
126
128
|
/**
|
|
@@ -341,9 +343,16 @@ export function liveEffects(repo: string | null = null): Effects {
|
|
|
341
343
|
models: catalogQuery({ scoredOnly: false, limit: Number.MAX_SAFE_INTEGER }, billing).models,
|
|
342
344
|
};
|
|
343
345
|
},
|
|
344
|
-
validate: (p, c) =>
|
|
346
|
+
validate: (p, c) => validateHere(p, c),
|
|
345
347
|
enforcement: enforcementOf,
|
|
346
348
|
harnesses,
|
|
349
|
+
isolation(h) {
|
|
350
|
+
const a = adapterFor(h);
|
|
351
|
+
return {
|
|
352
|
+
...(a?.isolationNote ? { note: a.isolationNote } : {}),
|
|
353
|
+
...(a?.isolationKey ? { key: a.isolationKey } : {}),
|
|
354
|
+
};
|
|
355
|
+
},
|
|
347
356
|
agents: (p) => agentFiles(p, VERSION).map((f) => f.name),
|
|
348
357
|
async save(name, patch, treatLikes, shown) {
|
|
349
358
|
for (const [rung, like] of Object.entries(treatLikes)) await saveTreatLike(rung, like);
|
|
@@ -523,6 +523,7 @@ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
|
|
|
523
523
|
validate: (p, c) => validateProfile(p, c, BACKENDS),
|
|
524
524
|
enforcement: (rung) => (rung.startsWith("codex:") ? "enforced" : "advisory"),
|
|
525
525
|
harnesses: ["codex", "claude-code", "opencode"],
|
|
526
|
+
isolation: () => ({}),
|
|
526
527
|
agents: (p) =>
|
|
527
528
|
(["architect", "verifier"] as const)
|
|
528
529
|
.filter((r) => p.roles[r].enabled)
|
|
@@ -75,7 +75,16 @@ export function withStaged(c: Catalog, staged: Record<string, string>): Catalog
|
|
|
75
75
|
return { ...c, treatLike };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
const GROUP_ORDER = [
|
|
78
|
+
const GROUP_ORDER = [
|
|
79
|
+
"claude",
|
|
80
|
+
"claude-code",
|
|
81
|
+
"codex",
|
|
82
|
+
"opencode-go",
|
|
83
|
+
"opencode",
|
|
84
|
+
"cursor",
|
|
85
|
+
"grok",
|
|
86
|
+
"antigravity",
|
|
87
|
+
];
|
|
79
88
|
const GROUP_NOTE: Record<string, string> = { claude: "native subagent", "claude-code": "headless" };
|
|
80
89
|
|
|
81
90
|
interface ModelEntry {
|
|
@@ -41,6 +41,10 @@ const ABOUT: Partial<Record<RowAction["type"], string>> = {
|
|
|
41
41
|
notify: "space turns this notification on or off",
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
/** Spec 1.3 §8: a harness row's line: what isolating it needs, and what its native mode loads. */
|
|
45
|
+
export const isolationAbout = (i: { note?: string; key?: string }): string =>
|
|
46
|
+
[`${ABOUT.isolated}${i.key ? ` (it needs ${i.key})` : ""}`, i.note].filter(Boolean).join("; ");
|
|
47
|
+
|
|
44
48
|
/**
|
|
45
49
|
* The profiles poll failed after a good read (its error is newer than the value it keeps): one error line
|
|
46
50
|
* and, when the failure says, its fix; none once a read succeeds again.
|
|
@@ -411,7 +415,16 @@ export function ProfilesView(props: { width: number; height: number }) {
|
|
|
411
415
|
},
|
|
412
416
|
...(row.issue.fix ? [{ text: ` fix: ${row.issue.fix}`, tone: "muted" as const }] : []),
|
|
413
417
|
]
|
|
414
|
-
: [
|
|
418
|
+
: [
|
|
419
|
+
{
|
|
420
|
+
text: ` ${
|
|
421
|
+
row?.action.type === "isolated"
|
|
422
|
+
? isolationAbout(app.effects.isolation(row.action.harness))
|
|
423
|
+
: ((row && ABOUT[row.action.type]) ?? "")
|
|
424
|
+
}`,
|
|
425
|
+
tone: "muted" as const,
|
|
426
|
+
},
|
|
427
|
+
];
|
|
415
428
|
const aboutLines = about
|
|
416
429
|
.flatMap((p) => wrap(p.text, props.width).map((text) => ({ ...p, text })))
|
|
417
430
|
.slice(0, 2);
|
package/src/infra/paths.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
function root(kind: "config" | "data"): string {
|
|
5
|
-
// set for an isolated worker whose XDG_CONFIG_HOME points elsewhere, so its `catherd lock` reads
|
|
5
|
+
// set for an isolated worker whose XDG_CONFIG_HOME or HOME points elsewhere, so its `catherd lock` reads
|
|
6
|
+
// this config and takes this lock dir
|
|
6
7
|
if (kind === "config" && process.env.CATHERD_CONFIG_DIR) return process.env.CATHERD_CONFIG_DIR;
|
|
8
|
+
if (kind === "data" && process.env.CATHERD_DATA_DIR) return process.env.CATHERD_DATA_DIR;
|
|
7
9
|
const home = process.env.CATHERD_HOME;
|
|
8
10
|
if (home) return join(home, kind);
|
|
9
11
|
if (kind === "config") return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "catherd");
|
|
@@ -162,18 +162,40 @@ export async function admit(
|
|
|
162
162
|
throw new CatherdError("E_ADMIT_THREAD", `"${i.thread}" is not a ${adapter.id} thread id`, {
|
|
163
163
|
fix: "pass the thread from the role's earlier record, or none for a fresh thread",
|
|
164
164
|
});
|
|
165
|
+
// a resumed thread lives in the home it started in (CODEX_HOME), whatever the profile says now
|
|
166
|
+
const started = i.thread === null ? undefined : recordsOnThread(run, rung.backend, i.thread).at(-1);
|
|
167
|
+
const network = rc.network !== false;
|
|
168
|
+
// spec 1.3 §3.2: a backend that fixes a thread's access when it starts it refuses another on resume
|
|
169
|
+
if (started && adapter.resume.sameAccessOnly && started.access !== rc.access)
|
|
170
|
+
throw new CatherdError(
|
|
171
|
+
"E_ADMIT_THREAD",
|
|
172
|
+
`${adapter.id} keeps the access a thread started with: ${i.thread} ran ${started.access}, and the ${i.role} role runs ${rc.access}`,
|
|
173
|
+
{ fix: "dispatch a fresh thread (omit `thread`)" },
|
|
174
|
+
);
|
|
175
|
+
// and the network grant: grok keeps a session's sandbox profile, agy's isolated homes differ by it.
|
|
176
|
+
// A record from before 1.3 carries no grant to compare.
|
|
177
|
+
if (started?.network !== undefined && adapter.resume.sameAccessOnly && started.network !== network)
|
|
178
|
+
throw new CatherdError(
|
|
179
|
+
"E_ADMIT_THREAD",
|
|
180
|
+
`${adapter.id} keeps the network grant a thread started with: ${i.thread} ran ${started.network ? "with" : "without"} the network, and the ${i.role} role runs ${network ? "with" : "without"} it`,
|
|
181
|
+
{ fix: "dispatch a fresh thread (omit `thread`)" },
|
|
182
|
+
);
|
|
165
183
|
const owns = i.lane === null ? [] : laneOwns(run, i.lane);
|
|
166
184
|
const id = newDispatchId();
|
|
167
185
|
const dir = join(roleDir(run, i.name), id);
|
|
168
186
|
const p = dispatchPaths(dir);
|
|
169
|
-
// a resumed thread lives in the home it started in (CODEX_HOME), whatever the profile says now
|
|
170
|
-
const started = i.thread === null ? undefined : recordsOnThread(run, rung.backend, i.thread).at(-1);
|
|
171
187
|
const isolated = started?.isolated ?? profile.isolated[rung.backend] ?? false;
|
|
172
|
-
await prepared(adapter, {
|
|
188
|
+
await prepared(adapter, {
|
|
189
|
+
rung,
|
|
190
|
+
access: rc.access,
|
|
191
|
+
isolated,
|
|
192
|
+
repo: run.meta.repo,
|
|
193
|
+
network,
|
|
194
|
+
});
|
|
173
195
|
const plan = adapter.plan({
|
|
174
196
|
rung,
|
|
175
197
|
access: rc.access,
|
|
176
|
-
network
|
|
198
|
+
network,
|
|
177
199
|
thread: i.thread,
|
|
178
200
|
isolated,
|
|
179
201
|
repo: run.meta.repo,
|
|
@@ -239,6 +261,7 @@ export async function admit(
|
|
|
239
261
|
...(i.failoverOf ? { failoverOf: i.failoverOf } : {}),
|
|
240
262
|
access: rc.access,
|
|
241
263
|
isolated,
|
|
264
|
+
network,
|
|
242
265
|
cliVersion: probe.version,
|
|
243
266
|
admittedAt: new Date(deps.now()).toISOString(),
|
|
244
267
|
repo: run.meta.repo,
|
package/src/services/backends.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { adapterFor } from "../adapters/registry.ts";
|
|
|
3
3
|
import "../adapters/all.ts";
|
|
4
4
|
import { CatherdError } from "../domain/errors.ts";
|
|
5
5
|
import { formatRung, tryParseRung } from "../domain/ids.ts";
|
|
6
|
+
import { PAIRED_FAILOVER } from "../domain/profile.ts";
|
|
6
7
|
|
|
7
8
|
const READY_TTL_MS = 10 * 60_000;
|
|
8
9
|
const ready = new Map<string, { at: number; probe: Probe }>();
|
|
@@ -10,6 +11,41 @@ const ready = new Map<string, { at: number; probe: Probe }>();
|
|
|
10
11
|
/** Forget every cached probe (tests, and after the user fixes a backend). */
|
|
11
12
|
export const resetReadiness = (): void => ready.clear();
|
|
12
13
|
|
|
14
|
+
/** Spec 1.3 §3.3: what Bun's spawn throws for a binary built for another OS (a Linux build on macOS). */
|
|
15
|
+
function execFormatError(e: unknown): string | null {
|
|
16
|
+
if (!(e instanceof Error)) return null;
|
|
17
|
+
if ((e as { code?: unknown }).code !== "ENOEXEC" && !/exec format error/i.test(e.message)) return null;
|
|
18
|
+
// macOS names the path (posix_spawn '/…/grok'), Linux only the command (uv_spawn 'grok')
|
|
19
|
+
const bin = /spawn '([^']+)'/.exec(e.message)?.[1];
|
|
20
|
+
return bin ? (Bun.which(bin, { PATH: process.env.PATH ?? "" }) ?? bin) : "exec format error";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The adapter's probe, with a CLI on PATH that the OS cannot execute reported as installed but unable to run
|
|
25
|
+
* (spec 1.3 §3.3), with the reinstall command; any other failure of the probe is thrown.
|
|
26
|
+
*/
|
|
27
|
+
export async function probeBackend(adapter: BackendAdapter): Promise<Probe> {
|
|
28
|
+
try {
|
|
29
|
+
return await adapter.probe();
|
|
30
|
+
} catch (e) {
|
|
31
|
+
const why = execFormatError(e);
|
|
32
|
+
if (why === null) throw e;
|
|
33
|
+
return {
|
|
34
|
+
installed: true,
|
|
35
|
+
version: null,
|
|
36
|
+
versionOk: false,
|
|
37
|
+
loggedIn: null,
|
|
38
|
+
problems: [
|
|
39
|
+
{
|
|
40
|
+
code: "E_BACKEND_CANNOT_RUN",
|
|
41
|
+
message: `${adapter.id} is installed but cannot run on this OS: ${why}`,
|
|
42
|
+
fix: adapter.install ?? `reinstall ${adapter.id} for this OS`,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
13
49
|
/**
|
|
14
50
|
* Spec §4.4: the adapter for `backend` once its last probe says it is ready. A ready probe is kept
|
|
15
51
|
* for 10 minutes; a failing one is never kept, so a fixed backend works on the next dispatch.
|
|
@@ -23,19 +59,21 @@ export async function readyAdapter(backend: string): Promise<{ adapter: BackendA
|
|
|
23
59
|
});
|
|
24
60
|
const hit = ready.get(backend);
|
|
25
61
|
if (hit && now - hit.at < READY_TTL_MS) return { adapter, probe: hit.probe };
|
|
26
|
-
const probe = await adapter
|
|
62
|
+
const probe = await probeBackend(adapter);
|
|
27
63
|
const problem = probe.problems[0];
|
|
64
|
+
// spec 1.3 §3.3: a probe that finds the CLI logged out lists E_BACKEND_NOT_LOGGED_IN, so a dispatch never
|
|
65
|
+
// reaches it (agy opens a browser and waits); logged out alone is no problem where a CLI runs without a login
|
|
28
66
|
if (problem) throw new CatherdError(problem.code, problem.message, { fix: problem.fix });
|
|
29
67
|
ready.set(backend, { at: now, probe });
|
|
30
68
|
return { adapter, probe };
|
|
31
69
|
}
|
|
32
70
|
|
|
33
71
|
/**
|
|
34
|
-
* Spec §4.5: a rung's stand-in on a usage limit: the profile's, else
|
|
35
|
-
* when given), else none.
|
|
72
|
+
* Spec §4.5: a rung's stand-in on a usage limit: the profile's, else the same model on its paired backend
|
|
73
|
+
* (spec 1.3 §7.3), else its backend's default (for `repo`, when given), else none.
|
|
36
74
|
*/
|
|
37
75
|
export function standInFor(failover: Record<string, string>, rung: string, repo?: string): string | null {
|
|
38
|
-
const own = failover[rung];
|
|
76
|
+
const own = failover[rung] ?? PAIRED_FAILOVER[rung];
|
|
39
77
|
if (own) return own;
|
|
40
78
|
const r = tryParseRung(rung);
|
|
41
79
|
if (!r) return null;
|
package/src/services/capture.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir, tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import type { BackendAdapter, FinishedRun } from "../adapters/backend.ts";
|
|
4
|
+
import type { BackendAdapter, FinishedRun, Outcome, RunRequest } from "../adapters/backend.ts";
|
|
5
5
|
import { isCatherdError } from "../domain/errors.ts";
|
|
6
6
|
import { parseRung } from "../domain/ids.ts";
|
|
7
7
|
import type { Access } from "../domain/record.ts";
|
|
@@ -21,14 +21,26 @@ interface CaptureCase {
|
|
|
21
21
|
rung: string;
|
|
22
22
|
access: Access;
|
|
23
23
|
brief: string;
|
|
24
|
+
/** a second brief that resumes the first run's thread in the same repo; the resumed run is captured */
|
|
25
|
+
resume?: string;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
const SAY_HELLO =
|
|
27
29
|
"Reply with the single word hello. The last line of your reply is exactly: STATUS: complete — said hello";
|
|
28
30
|
const TRY_WRITE =
|
|
29
31
|
"Create a file named out.txt containing the word hi. If you cannot, say why. The last line of your reply is: STATUS: complete|blocked — <one line why>";
|
|
32
|
+
const WORK =
|
|
33
|
+
"Create a file named notes.txt containing hi, read it back, then run the shell command `git status --short`. The last line of your reply is exactly: STATUS: complete — wrote notes.txt";
|
|
34
|
+
const RECALL =
|
|
35
|
+
"Which single word did you reply with earlier in this chat? Reply with it. The last line of your reply is exactly: STATUS: complete — recalled";
|
|
30
36
|
const HAIKU = "claude-code:claude-haiku-4-5-20251001#default";
|
|
37
|
+
/** spec 1.3 §4.6: `auto` has no family; only the live kit and the capture run it */
|
|
38
|
+
const AUTO = "cursor:auto#default";
|
|
31
39
|
const BUNNY = "opencode:opencode/space-bunny-free#default";
|
|
40
|
+
/** spec 1.3 §5.6: grok 1.0.44's default model (research §3.4) at its cheapest effort */
|
|
41
|
+
const GROK = "grok:grok-4.6#low";
|
|
42
|
+
/** spec 1.3 §6.6: the cheapest Gemini effort; capture runs isolated, on GEMINI_API_KEY */
|
|
43
|
+
const FLASH_LOW = "antigravity:gemini-3.8-flash#low";
|
|
32
44
|
|
|
33
45
|
/** Spec §11.7–8: one cheap run per backend, plus a read-only role trying to write where enforcement is advisory. */
|
|
34
46
|
const CAPTURE_CASES: CaptureCase[] = [
|
|
@@ -37,6 +49,26 @@ const CAPTURE_CASES: CaptureCase[] = [
|
|
|
37
49
|
{ backend: "claude-code", name: "read-only-write", rung: HAIKU, access: "read-only", brief: TRY_WRITE },
|
|
38
50
|
{ backend: "opencode", name: "ok", rung: BUNNY, access: "read-only", brief: SAY_HELLO },
|
|
39
51
|
{ backend: "opencode", name: "read-only-write", rung: BUNNY, access: "read-only", brief: TRY_WRITE },
|
|
52
|
+
// spec 1.3 §4.7: a read, a write and a shell call; a resumed chat (research §2.5); ask mode trying to write
|
|
53
|
+
{ backend: "cursor", name: "ok", rung: AUTO, access: "workspace-write", brief: WORK },
|
|
54
|
+
{ backend: "cursor", name: "resume", rung: AUTO, access: "read-only", brief: SAY_HELLO, resume: RECALL },
|
|
55
|
+
{ backend: "cursor", name: "read-only-write", rung: AUTO, access: "read-only", brief: TRY_WRITE },
|
|
56
|
+
// spec 1.3 §5.6: the same three on grok; its read-only is the kernel profile, or the read tools on a Mac
|
|
57
|
+
// with a symlinked docker socket
|
|
58
|
+
{ backend: "grok", name: "ok", rung: GROK, access: "workspace-write", brief: WORK },
|
|
59
|
+
{ backend: "grok", name: "resume", rung: GROK, access: "read-only", brief: SAY_HELLO, resume: RECALL },
|
|
60
|
+
{ backend: "grok", name: "read-only-write", rung: GROK, access: "read-only", brief: TRY_WRITE },
|
|
61
|
+
// spec 1.3 §6.6: the same three on agy; read-only runs under catherd's deny rules (research §8 agy 2, 5, 6)
|
|
62
|
+
{ backend: "antigravity", name: "ok", rung: FLASH_LOW, access: "workspace-write", brief: WORK },
|
|
63
|
+
{
|
|
64
|
+
backend: "antigravity",
|
|
65
|
+
name: "resume",
|
|
66
|
+
rung: FLASH_LOW,
|
|
67
|
+
access: "read-only",
|
|
68
|
+
brief: SAY_HELLO,
|
|
69
|
+
resume: RECALL,
|
|
70
|
+
},
|
|
71
|
+
{ backend: "antigravity", name: "read-only-write", rung: FLASH_LOW, access: "read-only", brief: TRY_WRITE },
|
|
40
72
|
];
|
|
41
73
|
export const CAPTURE_BACKENDS = [...new Set(CAPTURE_CASES.map((c) => c.backend))];
|
|
42
74
|
|
|
@@ -79,12 +111,71 @@ function cleanStrings(v: unknown, clean: (s: string) => string): unknown {
|
|
|
79
111
|
return v;
|
|
80
112
|
}
|
|
81
113
|
|
|
114
|
+
/** One run as an isolated dispatch would start it (plan, the worker env), without the supervisor. */
|
|
115
|
+
async function runOnce(
|
|
116
|
+
adapter: BackendAdapter,
|
|
117
|
+
request: RunRequest,
|
|
118
|
+
timeoutMs: number,
|
|
119
|
+
): Promise<{ run: FinishedRun; events: string; stderr: string; o: Outcome }> {
|
|
120
|
+
const plan = adapter.plan(request);
|
|
121
|
+
const startedAtMs = Date.now();
|
|
122
|
+
const p = Bun.spawn([plan.cmd, ...plan.args], {
|
|
123
|
+
cwd: plan.cwd,
|
|
124
|
+
env: workerEnv(process.env, plan.env, plan.cwd),
|
|
125
|
+
// spec 1.3 §3.3: the brief goes on stdin only to a CLI whose plan reads it there (grok reads a file)
|
|
126
|
+
stdin: plan.stdinPath ? Bun.file(plan.stdinPath) : "ignore",
|
|
127
|
+
stdout: "pipe",
|
|
128
|
+
stderr: "pipe",
|
|
129
|
+
// its own group, so a timeout stops whatever the CLI started too, as the supervisor does
|
|
130
|
+
detached: true,
|
|
131
|
+
});
|
|
132
|
+
let timedOut = false;
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
timedOut = true;
|
|
135
|
+
killGroup(p.pid, "SIGKILL");
|
|
136
|
+
}, timeoutMs);
|
|
137
|
+
// as preflight does: a process the CLI left behind (in its own session) may hold the pipes open
|
|
138
|
+
const out = collect(p.stdout);
|
|
139
|
+
const err = collect(p.stderr);
|
|
140
|
+
const code = await p.exited;
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
const drained = await Promise.race([
|
|
143
|
+
Promise.all([out.done, err.done]).then(() => true),
|
|
144
|
+
Bun.sleep(DRAIN_MS).then(() => false),
|
|
145
|
+
]);
|
|
146
|
+
if (!drained) killGroup(p.pid, "SIGKILL"); // leftovers still in the CLI's group
|
|
147
|
+
out.stop();
|
|
148
|
+
err.stop();
|
|
149
|
+
const events = out.text();
|
|
150
|
+
const stderr = err.text();
|
|
151
|
+
const run: FinishedRun = {
|
|
152
|
+
request,
|
|
153
|
+
eventLines: events.split("\n").filter((l) => l.trim()),
|
|
154
|
+
reply: "",
|
|
155
|
+
stderr,
|
|
156
|
+
exit: {
|
|
157
|
+
code: p.signalCode ? null : code,
|
|
158
|
+
signal: p.signalCode ?? null,
|
|
159
|
+
reason: timedOut ? "wall-timeout" : "exited",
|
|
160
|
+
endedAt: new Date().toISOString(),
|
|
161
|
+
},
|
|
162
|
+
startedAtMs,
|
|
163
|
+
};
|
|
164
|
+
// as a dispatch records it: the backend's own totals and limits, on a fresh thread (nothing prior)
|
|
165
|
+
const o = await settled(adapter, adapter.finalize(run), run, () => ({
|
|
166
|
+
tokens: { input: 0, cached: 0, output: 0 },
|
|
167
|
+
costUsd: 0,
|
|
168
|
+
}));
|
|
169
|
+
return { run, events, stderr, o };
|
|
170
|
+
}
|
|
171
|
+
|
|
82
172
|
/**
|
|
83
173
|
* Runs one case the way an isolated dispatch would (prepare, plan, the worker env), without the
|
|
84
174
|
* supervisor. Isolated, so the stream names none of the owner's own hooks, plugins, MCP servers or config:
|
|
85
|
-
* the fixtures are committed.
|
|
175
|
+
* the fixtures are committed. A case with `resume` runs its brief, then resumes that thread in the same
|
|
176
|
+
* scratch repo with `resume`, and captures the resumed run.
|
|
86
177
|
*/
|
|
87
|
-
async function captureOne(
|
|
178
|
+
export async function captureOne(
|
|
88
179
|
adapter: BackendAdapter,
|
|
89
180
|
version: string,
|
|
90
181
|
c: CaptureCase,
|
|
@@ -96,8 +187,8 @@ async function captureOne(
|
|
|
96
187
|
const briefPath = join(work, "brief.md");
|
|
97
188
|
writeFileSync(briefPath, c.brief);
|
|
98
189
|
const rung = parseRung(c.rung);
|
|
99
|
-
await adapter.prepare?.({ rung, access: c.access, isolated: true, repo });
|
|
100
|
-
const request = {
|
|
190
|
+
await adapter.prepare?.({ rung, access: c.access, isolated: true, repo, network: true });
|
|
191
|
+
const request: RunRequest = {
|
|
101
192
|
rung,
|
|
102
193
|
access: c.access,
|
|
103
194
|
thread: null,
|
|
@@ -107,54 +198,20 @@ async function captureOne(
|
|
|
107
198
|
replyPath: join(work, "reply.md"),
|
|
108
199
|
dispatchDir: work,
|
|
109
200
|
};
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
killGroup(p.pid, "SIGKILL");
|
|
125
|
-
}, timeoutMs);
|
|
126
|
-
// as preflight does: a process the CLI left behind (in its own session) may hold the pipes open
|
|
127
|
-
const out = collect(p.stdout);
|
|
128
|
-
const err = collect(p.stderr);
|
|
129
|
-
const code = await p.exited;
|
|
130
|
-
clearTimeout(timer);
|
|
131
|
-
const drained = await Promise.race([
|
|
132
|
-
Promise.all([out.done, err.done]).then(() => true),
|
|
133
|
-
Bun.sleep(DRAIN_MS).then(() => false),
|
|
134
|
-
]);
|
|
135
|
-
if (!drained) killGroup(p.pid, "SIGKILL"); // leftovers still in the CLI's group
|
|
136
|
-
out.stop();
|
|
137
|
-
err.stop();
|
|
138
|
-
const events = out.text();
|
|
139
|
-
const stderr = err.text();
|
|
140
|
-
const run: FinishedRun = {
|
|
141
|
-
request,
|
|
142
|
-
eventLines: events.split("\n").filter((l) => l.trim()),
|
|
143
|
-
reply: "",
|
|
144
|
-
stderr,
|
|
145
|
-
exit: {
|
|
146
|
-
code: p.signalCode ? null : code,
|
|
147
|
-
signal: p.signalCode ?? null,
|
|
148
|
-
reason: timedOut ? "wall-timeout" : "exited",
|
|
149
|
-
endedAt: new Date().toISOString(),
|
|
150
|
-
},
|
|
151
|
-
startedAtMs,
|
|
152
|
-
};
|
|
153
|
-
// as a dispatch records it: the backend's own totals and limits, on a fresh thread (nothing prior)
|
|
154
|
-
const o = await settled(adapter, adapter.finalize(run), run, () => ({
|
|
155
|
-
tokens: { input: 0, cached: 0, output: 0 },
|
|
156
|
-
costUsd: 0,
|
|
157
|
-
}));
|
|
201
|
+
let captured = await runOnce(adapter, request, timeoutMs);
|
|
202
|
+
const resumed = c.resume === undefined ? null : captured.o.thread;
|
|
203
|
+
if (c.resume !== undefined) {
|
|
204
|
+
if (!resumed)
|
|
205
|
+
return {
|
|
206
|
+
backend: c.backend,
|
|
207
|
+
name: c.name,
|
|
208
|
+
status: "skipped",
|
|
209
|
+
reason: `the first run left no thread to resume (${captured.o.error?.message ?? captured.o.status})`,
|
|
210
|
+
};
|
|
211
|
+
writeFileSync(briefPath, c.resume);
|
|
212
|
+
captured = await runOnce(adapter, { ...request, thread: resumed }, timeoutMs);
|
|
213
|
+
}
|
|
214
|
+
const { run, events, stderr, o } = captured;
|
|
158
215
|
const scrub: Scrub = {
|
|
159
216
|
secrets: secretEnvValues(process.env),
|
|
160
217
|
paths: [
|
|
@@ -180,7 +237,8 @@ async function captureOne(
|
|
|
180
237
|
rung: c.rung,
|
|
181
238
|
access: c.access,
|
|
182
239
|
isolated: true,
|
|
183
|
-
brief: c.brief,
|
|
240
|
+
brief: c.resume ?? c.brief,
|
|
241
|
+
...(resumed ? { resumed } : {}),
|
|
184
242
|
exitCode: run.exit.code,
|
|
185
243
|
reason: run.exit.reason,
|
|
186
244
|
capturedAt: new Date().toISOString(),
|