catherd-cli 1.2.0 → 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.
Files changed (60) hide show
  1. package/README.md +74 -3
  2. package/catalog/models.json +168 -5
  3. package/catalog/scores.json +24 -0
  4. package/catalog/sources.json +13 -2
  5. package/package.json +3 -2
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/bin/catherd-mcp +1 -1
  8. package/plugin/skills/catherd/SKILL.md +3 -3
  9. package/plugin/skills/catherd-setup/SKILL.md +3 -3
  10. package/src/adapters/access.ts +54 -17
  11. package/src/adapters/all.ts +6 -0
  12. package/src/adapters/antigravity/events.ts +131 -0
  13. package/src/adapters/antigravity/home.ts +46 -0
  14. package/src/adapters/antigravity/index.ts +320 -0
  15. package/src/adapters/antigravity/models.ts +37 -0
  16. package/src/adapters/backend.ts +34 -1
  17. package/src/adapters/claude-code/index.ts +2 -0
  18. package/src/adapters/claude-code/models.ts +2 -1
  19. package/src/adapters/codex/index.ts +2 -0
  20. package/src/adapters/cursor/events.ts +120 -0
  21. package/src/adapters/cursor/home.ts +79 -0
  22. package/src/adapters/cursor/index.ts +331 -0
  23. package/src/adapters/cursor/models.ts +30 -0
  24. package/src/adapters/discovery.ts +21 -0
  25. package/src/adapters/grok/events.ts +112 -0
  26. package/src/adapters/grok/home.ts +132 -0
  27. package/src/adapters/grok/index.ts +339 -0
  28. package/src/adapters/grok/models.ts +48 -0
  29. package/src/adapters/opencode/index.ts +2 -0
  30. package/src/cli.ts +1 -0
  31. package/src/domain/catalog.ts +10 -1
  32. package/src/domain/cost.ts +1 -0
  33. package/src/domain/errors.ts +1 -0
  34. package/src/domain/failover.ts +5 -2
  35. package/src/domain/ids.ts +2 -2
  36. package/src/domain/profile.ts +57 -0
  37. package/src/domain/record.ts +2 -0
  38. package/src/domain/util.ts +3 -0
  39. package/src/entry/knowledge-command.ts +50 -0
  40. package/src/entry/tui/effects.ts +12 -3
  41. package/src/entry/tui/fixtures.ts +1 -0
  42. package/src/entry/tui/profile-tree.ts +10 -1
  43. package/src/entry/tui/views/profiles.tsx +14 -1
  44. package/src/infra/paths.ts +3 -1
  45. package/src/services/admission.ts +27 -4
  46. package/src/services/backends.ts +42 -4
  47. package/src/services/capture.ts +112 -54
  48. package/src/services/catalog-refresh.ts +2 -1
  49. package/src/services/dispatches.ts +2 -0
  50. package/src/services/doctor-access.ts +14 -7
  51. package/src/services/doctor-backends.ts +10 -1
  52. package/src/services/doctor-checks.ts +25 -0
  53. package/src/services/doctor.ts +3 -0
  54. package/src/services/finalize.ts +1 -0
  55. package/src/services/lane-service.ts +3 -10
  56. package/src/services/profile-service.ts +3 -3
  57. package/src/services/profile-store.ts +106 -2
  58. package/src/services/run-service.ts +37 -4
  59. package/src/services/run-store.ts +11 -1
  60. package/src/services/source-derive.ts +32 -7
@@ -13,6 +13,7 @@ export const DEFAULT_BILLING: Record<BillingKey, BillingMode> = {
13
13
  opencode: "metered",
14
14
  cursor: "metered",
15
15
  grok: "metered",
16
+ antigravity: "metered",
16
17
  };
17
18
 
18
19
  /**
@@ -5,6 +5,7 @@ export type ErrorCode =
5
5
  | "E_BACKEND_MISSING"
6
6
  | "E_BACKEND_TOO_OLD"
7
7
  | "E_BACKEND_NOT_LOGGED_IN"
8
+ | "E_BACKEND_CANNOT_RUN"
8
9
  | "E_BACKEND_MODEL_UNKNOWN"
9
10
  | "E_RUN_NOT_FOUND"
10
11
  | "E_RUN_CORRUPT"
@@ -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
- for (const e of m.efforts) out.add(`${BACKEND_OF_KEY[key] ?? key}:${m.id}#${e}`);
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 and grok: plan 8). */
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;
@@ -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
 
@@ -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(),
@@ -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
+ });
@@ -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 { type Validation, validateProfile } from "../../domain/profile-rules.ts";
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
- runnableBackends,
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) => validateProfile(p, c, runnableBackends()),
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 = ["claude", "claude-code", "codex", "opencode-go", "opencode", "cursor", "grok"];
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
- : [{ text: ` ${(row && ABOUT[row.action.type]) ?? ""}`, tone: "muted" as const }];
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);
@@ -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 this config
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, { rung, access: rc.access, isolated, repo: run.meta.repo });
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: rc.network !== false,
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,
@@ -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.probe();
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 its backend's default (for `repo`,
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;
@@ -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
- const plan = adapter.plan(request);
111
- const startedAtMs = Date.now();
112
- const p = Bun.spawn([plan.cmd, ...plan.args], {
113
- cwd: plan.cwd,
114
- env: workerEnv(process.env, plan.env, plan.cwd),
115
- stdin: Bun.file(briefPath),
116
- stdout: "pipe",
117
- stderr: "pipe",
118
- // its own group, so a timeout stops whatever the CLI started too, as the supervisor does
119
- detached: true,
120
- });
121
- let timedOut = false;
122
- const timer = setTimeout(() => {
123
- timedOut = true;
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(),