catherd-cli 1.2.1 → 1.4.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 (104) hide show
  1. package/README.md +146 -36
  2. package/catalog/models.json +148 -5
  3. package/catalog/scores.json +24 -0
  4. package/catalog/sources.json +12 -2
  5. package/package.json +3 -2
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +11 -0
  8. package/plugin/.mcp-codex.json +10 -0
  9. package/plugin/bin/catherd-mcp +1 -1
  10. package/plugin/skills/catherd/SKILL.md +45 -39
  11. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  12. package/src/adapters/access.ts +61 -18
  13. package/src/adapters/all.ts +6 -0
  14. package/src/adapters/antigravity/events.ts +131 -0
  15. package/src/adapters/antigravity/home.ts +46 -0
  16. package/src/adapters/antigravity/index.ts +320 -0
  17. package/src/adapters/antigravity/models.ts +37 -0
  18. package/src/adapters/backend.ts +34 -1
  19. package/src/adapters/claude-code/index.ts +2 -0
  20. package/src/adapters/cli.ts +1 -64
  21. package/src/adapters/codex/index.ts +2 -0
  22. package/src/adapters/cursor/events.ts +120 -0
  23. package/src/adapters/cursor/home.ts +79 -0
  24. package/src/adapters/cursor/index.ts +331 -0
  25. package/src/adapters/cursor/models.ts +30 -0
  26. package/src/adapters/discovery.ts +21 -0
  27. package/src/adapters/grok/events.ts +112 -0
  28. package/src/adapters/grok/home.ts +132 -0
  29. package/src/adapters/grok/index.ts +339 -0
  30. package/src/adapters/grok/models.ts +48 -0
  31. package/src/adapters/opencode/index.ts +2 -0
  32. package/src/cli.ts +49 -19
  33. package/src/domain/catalog.ts +10 -1
  34. package/src/domain/cost.ts +1 -0
  35. package/src/domain/errors.ts +1 -0
  36. package/src/domain/failover.ts +5 -2
  37. package/src/domain/host.ts +17 -0
  38. package/src/domain/ids.ts +2 -2
  39. package/src/domain/notice.ts +5 -2
  40. package/src/domain/profile-rules.ts +17 -0
  41. package/src/domain/profile.ts +89 -5
  42. package/src/domain/record.ts +3 -0
  43. package/src/domain/util.ts +3 -0
  44. package/src/entry/catalog-command.ts +4 -2
  45. package/src/entry/deps.ts +6 -3
  46. package/src/entry/doctor-command.ts +11 -2
  47. package/src/entry/host-arg.ts +28 -0
  48. package/src/entry/init-command.ts +18 -7
  49. package/src/entry/knowledge-command.ts +50 -0
  50. package/src/entry/lock-command.ts +7 -2
  51. package/src/entry/mcp/handshake.ts +3 -1
  52. package/src/entry/mcp/run-tools.ts +5 -2
  53. package/src/entry/mcp/server.ts +129 -40
  54. package/src/entry/mcp/setup-tools.ts +7 -2
  55. package/src/entry/profile-command.ts +108 -12
  56. package/src/entry/runs-command.ts +81 -5
  57. package/src/entry/tui/effects.ts +30 -10
  58. package/src/entry/tui/fixtures.ts +34 -10
  59. package/src/entry/tui/profile-tree.ts +10 -1
  60. package/src/entry/tui/run.tsx +10 -4
  61. package/src/entry/tui/state.ts +19 -6
  62. package/src/entry/tui/stories.ts +11 -4
  63. package/src/entry/tui/views/profile-actions.ts +4 -2
  64. package/src/entry/tui/views/profiles.tsx +16 -3
  65. package/src/entry/tui/views/save-dialog.tsx +2 -2
  66. package/src/infra/cli.ts +65 -0
  67. package/src/infra/codex-queue.ts +165 -0
  68. package/src/infra/delivery.ts +83 -0
  69. package/src/infra/dispatch-dir.ts +1 -0
  70. package/src/infra/env.ts +4 -1
  71. package/src/infra/host-context.ts +58 -0
  72. package/src/infra/paths.ts +3 -1
  73. package/src/infra/peer-inbox.ts +4 -1
  74. package/src/infra/supervisor.ts +3 -2
  75. package/src/services/admission.ts +34 -7
  76. package/src/services/agent-links.ts +30 -5
  77. package/src/services/backends.ts +49 -4
  78. package/src/services/capture.ts +112 -54
  79. package/src/services/catalog-refresh.ts +2 -1
  80. package/src/services/dispatch-service.ts +17 -7
  81. package/src/services/dispatches.ts +8 -1
  82. package/src/services/doctor-access.ts +14 -7
  83. package/src/services/doctor-backends.ts +39 -7
  84. package/src/services/doctor-checks.ts +43 -3
  85. package/src/services/doctor-push.ts +69 -90
  86. package/src/services/doctor.ts +107 -32
  87. package/src/services/finalize.ts +2 -1
  88. package/src/services/lane-service.ts +4 -10
  89. package/src/services/notifier.ts +322 -70
  90. package/src/services/peek.ts +46 -9
  91. package/src/services/ports.ts +8 -2
  92. package/src/services/profile-service.ts +117 -31
  93. package/src/services/profile-store.ts +126 -5
  94. package/src/services/routing-service.ts +6 -1
  95. package/src/services/run-debug.ts +98 -4
  96. package/src/services/run-service.ts +39 -4
  97. package/src/services/run-store.ts +21 -5
  98. package/src/services/runs-page.ts +3 -2
  99. package/src/services/session-view.ts +18 -13
  100. package/src/services/sessions.ts +32 -16
  101. package/src/services/setup.ts +31 -10
  102. package/src/services/source-derive.ts +32 -7
  103. package/src/services/summary.ts +40 -5
  104. package/src/services/treat-likes.ts +22 -9
@@ -1,8 +1,11 @@
1
+ import type { OrchestrationHost } from "../domain/host.ts";
2
+ import { nativeClaudeIssue } from "../domain/profile-rules.ts";
1
3
  import type { BackendAdapter, Probe } from "../adapters/backend.ts";
2
4
  import { adapterFor } from "../adapters/registry.ts";
3
5
  import "../adapters/all.ts";
4
6
  import { CatherdError } from "../domain/errors.ts";
5
7
  import { formatRung, tryParseRung } from "../domain/ids.ts";
8
+ import { PAIRED_FAILOVER } from "../domain/profile.ts";
6
9
 
7
10
  const READY_TTL_MS = 10 * 60_000;
8
11
  const ready = new Map<string, { at: number; probe: Probe }>();
@@ -10,6 +13,41 @@ const ready = new Map<string, { at: number; probe: Probe }>();
10
13
  /** Forget every cached probe (tests, and after the user fixes a backend). */
11
14
  export const resetReadiness = (): void => ready.clear();
12
15
 
16
+ /** Spec 1.3 §3.3: what Bun's spawn throws for a binary built for another OS (a Linux build on macOS). */
17
+ function execFormatError(e: unknown): string | null {
18
+ if (!(e instanceof Error)) return null;
19
+ if ((e as { code?: unknown }).code !== "ENOEXEC" && !/exec format error/i.test(e.message)) return null;
20
+ // macOS names the path (posix_spawn '/…/grok'), Linux only the command (uv_spawn 'grok')
21
+ const bin = /spawn '([^']+)'/.exec(e.message)?.[1];
22
+ return bin ? (Bun.which(bin, { PATH: process.env.PATH ?? "" }) ?? bin) : "exec format error";
23
+ }
24
+
25
+ /**
26
+ * The adapter's probe, with a CLI on PATH that the OS cannot execute reported as installed but unable to run
27
+ * (spec 1.3 §3.3), with the reinstall command; any other failure of the probe is thrown.
28
+ */
29
+ export async function probeBackend(adapter: BackendAdapter): Promise<Probe> {
30
+ try {
31
+ return await adapter.probe();
32
+ } catch (e) {
33
+ const why = execFormatError(e);
34
+ if (why === null) throw e;
35
+ return {
36
+ installed: true,
37
+ version: null,
38
+ versionOk: false,
39
+ loggedIn: null,
40
+ problems: [
41
+ {
42
+ code: "E_BACKEND_CANNOT_RUN",
43
+ message: `${adapter.id} is installed but cannot run on this OS: ${why}`,
44
+ fix: adapter.install ?? `reinstall ${adapter.id} for this OS`,
45
+ },
46
+ ],
47
+ };
48
+ }
49
+ }
50
+
13
51
  /**
14
52
  * Spec §4.4: the adapter for `backend` once its last probe says it is ready. A ready probe is kept
15
53
  * for 10 minutes; a failing one is never kept, so a fixed backend works on the next dispatch.
@@ -23,22 +61,29 @@ export async function readyAdapter(backend: string): Promise<{ adapter: BackendA
23
61
  });
24
62
  const hit = ready.get(backend);
25
63
  if (hit && now - hit.at < READY_TTL_MS) return { adapter, probe: hit.probe };
26
- const probe = await adapter.probe();
64
+ const probe = await probeBackend(adapter);
27
65
  const problem = probe.problems[0];
66
+ // spec 1.3 §3.3: a probe that finds the CLI logged out lists E_BACKEND_NOT_LOGGED_IN, so a dispatch never
67
+ // reaches it (agy opens a browser and waits); logged out alone is no problem where a CLI runs without a login
28
68
  if (problem) throw new CatherdError(problem.code, problem.message, { fix: problem.fix });
29
69
  ready.set(backend, { at: now, probe });
30
70
  return { adapter, probe };
31
71
  }
32
72
 
33
73
  /**
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.
74
+ * Spec §4.5: a rung's stand-in on a usage limit: the profile's, else the same model on its paired backend
75
+ * (spec 1.3 §7.3), else its backend's default (for `repo`, when given), else none.
36
76
  */
37
77
  export function standInFor(failover: Record<string, string>, rung: string, repo?: string): string | null {
38
- const own = failover[rung];
78
+ const own = failover[rung] ?? PAIRED_FAILOVER[rung];
39
79
  if (own) return own;
40
80
  const r = tryParseRung(rung);
41
81
  if (!r) return null;
42
82
  const byAdapter = adapterFor(r.backend)?.failoverFor?.(r, repo) ?? null;
43
83
  return byAdapter && formatRung(byAdapter);
44
84
  }
85
+
86
+ export function assertNativeHost(rung: string, host: OrchestrationHost): void {
87
+ const issue = nativeClaudeIssue(rung, host, "rung");
88
+ if (issue) throw new CatherdError("E_CONFIG_INVALID", `${issue.message}; ${issue.fix}`, { fix: issue.fix });
89
+ }
@@ -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(),
@@ -12,7 +12,7 @@ import {
12
12
  scoresOf,
13
13
  } from "../domain/catalog.ts";
14
14
  import { DIFFICULTIES, type Difficulty, KINDS, type Kind } from "../domain/lane.ts";
15
- import type { SourcesFile } from "../domain/sources.ts";
15
+ import { defaultEffortOf, type SourcesFile } from "../domain/sources.ts";
16
16
  import type { SourceTransport } from "../infra/sources/http.ts";
17
17
  import { adjacent, derive, type RawAnswers } from "./source-derive.ts";
18
18
  import { cachedAnswers, syncSources } from "./source-sync.ts";
@@ -72,6 +72,7 @@ export function rebuildShipped(
72
72
  direct.filter((s) => s.confidence !== "inferred"),
73
73
  new Set(),
74
74
  ctx.now,
75
+ (f) => defaultEffortOf(ctx.sources, f),
75
76
  );
76
77
  const scores = best([...direct, ...spread], ctx.now);
77
78
  const benchmark = (d: Dim) => {
@@ -1,3 +1,4 @@
1
+ import { assertNativeHost } from "./backends.ts";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { CatherdError, errorMessage, isCatherdError } from "../domain/errors.ts";
3
4
  import { assertId, parseRung } from "../domain/ids.ts";
@@ -34,7 +35,7 @@ import { finalizeDispatch, finalizingElsewhere, waitForFinish } from "./finalize
34
35
  import type { Deps } from "./ports.ts";
35
36
  import { route } from "./lane-service.ts";
36
37
  import { findRun, readRecords, readRoutes, type Run } from "./run-store.ts";
37
- import { claimRun, ownsRun } from "./sessions.ts";
38
+ import { claimRun, ownsRun, runOwner } from "./sessions.ts";
38
39
  import { type NotesPatch, refreshState } from "./state.ts";
39
40
 
40
41
  export interface DispatchInput {
@@ -101,17 +102,21 @@ export interface Stalled {
101
102
  export const stallHooks = new Set<(s: Stalled) => void>();
102
103
 
103
104
  /** A watcher's poll: the first time the dispatch's stall.json is there, every stall hook hears of it. */
105
+ /** A stall's quiet time; 0 when stall.json is being written or damaged, so one bad file never blocks a notice. */
106
+ export function readStallQuietMs(dir: string): number {
107
+ try {
108
+ return Number(JSON.parse(readFileSync(dispatchPaths(dir).stall, "utf8")).quietMs) || 0;
109
+ } catch {
110
+ return 0;
111
+ }
112
+ }
113
+
104
114
  export function stallPoll(run: Run, d: Dispatch): () => void {
105
115
  let seen = false;
106
116
  return () => {
107
117
  if (seen || !existsSync(dispatchPaths(d.dir).stall)) return;
108
118
  seen = true;
109
- let quietMs = 0;
110
- try {
111
- quietMs = Number(JSON.parse(readFileSync(dispatchPaths(d.dir).stall, "utf8")).quietMs) || 0;
112
- } catch {
113
- // being written: the stall is reported without its length
114
- }
119
+ const quietMs = readStallQuietMs(d.dir);
115
120
  for (const hook of stallHooks) {
116
121
  try {
117
122
  hook({ run, d, quietMs });
@@ -294,6 +299,8 @@ export async function dispatch(deps: Deps, i: DispatchInput): Promise<DispatchSt
294
299
  hints.push(`${i.rung} is not on ${i.lane}'s routed ladder: dispatched at ${routed.rung}`);
295
300
  }
296
301
  }
302
+ // the rung that runs, after routing: an off-ladder native rung routing replaced is never launched
303
+ assertNativeHost(rung, deps.host.host);
297
304
  const { d, specPath } = await admit(
298
305
  deps,
299
306
  run,
@@ -493,6 +500,8 @@ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord):
493
500
  // else that stand-in never ran and is over (recorded as lost, or past its start grace): admit a new one
494
501
  const standIn = standInFor(deps.profiles.forRepo(run.meta.repo).failover, limited.rung, run.meta.repo);
495
502
  if (!standIn) return { hints, started: null, pause: paused };
503
+ // the stand-in answers to whoever owns the run now, which a claim from another host may have changed
504
+ assertNativeHost(standIn, runOwner(run)?.host ?? deps.host.host);
496
505
  if (parseRung(standIn).backend === "claude") {
497
506
  const agent = deps.profiles.agentFor(run.meta.repo, d.admit.role, standIn);
498
507
  return {
@@ -517,6 +526,7 @@ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord):
517
526
  failoverOf: d.admit.dispatchId,
518
527
  // the stand-in answers to the session that dispatched the limited role, whoever fails it over
519
528
  sessionId: d.admit.sessionId ?? null,
529
+ host: d.admit.host,
520
530
  });
521
531
  } catch (e) {
522
532
  if (!isCatherdError(e)) throw e;
@@ -27,6 +27,8 @@ const AdmitSchema = z.looseObject({
27
27
  failoverOf: z.string().optional(),
28
28
  access: z.enum(ACCESS),
29
29
  isolated: z.boolean(),
30
+ /** the role's network grant (1.1 §5) when admitted; absent before 1.3 */
31
+ network: z.boolean().optional(),
30
32
  cliVersion: z.string().nullable(),
31
33
  admittedAt: z.string(),
32
34
  repo: z.string(),
@@ -34,6 +36,7 @@ const AdmitSchema = z.looseObject({
34
36
  before: z.record(z.string(), z.string()),
35
37
  /** the Claude Code session that dispatched it (spec §3.3); absent on 1.0 dispatches and outside Claude Code */
36
38
  sessionId: z.string().optional(),
39
+ host: z.enum(["claude-code", "codex"]).optional(),
37
40
  });
38
41
  export type Admit = z.infer<typeof AdmitSchema>;
39
42
 
@@ -63,7 +66,7 @@ export function listDispatches(run: Run): Dispatch[] {
63
66
  if (!id.isDirectory()) continue;
64
67
  const dir = join(roles, name.name, id.name);
65
68
  try {
66
- out.push({ dir, admit: readVersioned(admitPath(dir), AdmitSchema, 1) });
69
+ out.push({ dir, admit: decodeAdmit(readVersioned(admitPath(dir), AdmitSchema, 1)) });
67
70
  } catch {
68
71
  // admission never finished writing this folder
69
72
  }
@@ -172,3 +175,7 @@ export function readFailover(dir: string): FailoverFile | null {
172
175
  export function recordHints(run: Run, d: Dispatch, r: RunRecord): string[] {
173
176
  return readFailover(d.dir)?.hints ?? dispatchHints(r, d.admit.owns, relative(run.dir, d.dir));
174
177
  }
178
+
179
+ function decodeAdmit(a: Admit): Admit {
180
+ return a.sessionId && !a.host ? { ...a, host: "claude-code" } : a;
181
+ }
@@ -1,5 +1,5 @@
1
1
  import { realTmpdir } from "../adapters/access.ts";
2
- import type { AccessShell } from "../adapters/backend.ts";
2
+ import type { AccessProbeId, AccessShell } from "../adapters/backend.ts";
3
3
  import { adapterFor } from "../adapters/registry.ts";
4
4
  import "../adapters/all.ts";
5
5
  import { tryParseRung } from "../domain/ids.ts";
@@ -12,7 +12,7 @@ import type { Check } from "./doctor-checks.ts";
12
12
  // Spec §5 and §12: the five access probes doctor runs per backend that serves a workspace-write role, each
13
13
  // in that backend's worker shell with the grants a worker gets, and the rows that report them.
14
14
 
15
- export type ProbeId = "lock" | "temp" | "loopback" | "https" | "docker";
15
+ export type ProbeId = AccessProbeId;
16
16
 
17
17
  /**
18
18
  * What the probes reach: npm's ping, through HTTPS_PROXY when it is set (Bun's fetch honours it), and
@@ -131,8 +131,13 @@ export async function runProbes(shell: AccessShell, network: boolean): Promise<P
131
131
  return out;
132
132
  }
133
133
 
134
- /** The fix for a failed probe, per backend: Codex's sandbox holds catherd's grants; the others run unsandboxed. */
135
- function fixFor(backend: string, id: ProbeId): string {
134
+ /**
135
+ * The fix for a failed probe: the sandbox's own when its shell names one; Codex's sandbox holds catherd's grants;
136
+ * the others run unsandboxed.
137
+ */
138
+ function fixFor(backend: string, id: ProbeId, shell: AccessShell): string {
139
+ const own = shell.fixes?.[id];
140
+ if (own) return own;
136
141
  if (backend === "codex") {
137
142
  const override =
138
143
  "catherd passes this grant to Codex itself (with your top-level [sandbox_workspace_write] writable_roots added): check that no --profile or managed requirements.toml overrides it";
@@ -173,7 +178,7 @@ export async function accessChecks(profiles: Profile[], installed: ReadonlySet<s
173
178
  const checks: Check[] = [];
174
179
  for (const [id, network] of workspaceWriteNetwork(profiles)) {
175
180
  const a = adapterFor(id);
176
- if (!a?.accessShell) continue;
181
+ if (!a) continue;
177
182
  if (!installed.has(id)) {
178
183
  checks.push({
179
184
  id: `access:${id}`,
@@ -184,7 +189,9 @@ export async function accessChecks(profiles: Profile[], installed: ReadonlySet<s
184
189
  });
185
190
  continue;
186
191
  }
187
- const shell = await a.accessShell({ network }).catch((e: unknown) => String(e));
192
+ const shell = a.accessShell
193
+ ? await a.accessShell({ network }).catch((e: unknown) => String(e))
194
+ : `no way to run a shell in ${id}'s sandbox without a model turn; the live kit (docs/dev/live-verification.md) runs the five probes as one worker turn`;
188
195
  if (id === "codex")
189
196
  checks.push(
190
197
  typeof shell === "string"
@@ -217,7 +224,7 @@ export async function accessChecks(profiles: Profile[], installed: ReadonlySet<s
217
224
  state: "warn",
218
225
  word: "blocked",
219
226
  detail: `in ${shell.how}, a worker cannot: ${failed.map((r) => `${r.label} (${r.why})`).join("; ")}`,
220
- fix: failed.map((r) => `${r.label}: ${fixFor(id, r.id)}`).join("\n"),
227
+ fix: failed.map((r) => `${r.label}: ${fixFor(id, r.id, shell)}`).join("\n"),
221
228
  }
222
229
  : {
223
230
  ...base,
@@ -1,11 +1,13 @@
1
+ import type { OrchestrationHost } from "../domain/host.ts";
1
2
  import type { Probe } from "../adapters/backend.ts";
2
3
  import { adapterFor } from "../adapters/registry.ts";
3
4
  import "../adapters/all.ts";
4
5
  import { ADAPTER_IDS, tryParseRung } from "../domain/ids.ts";
5
6
  import type { Profile } from "../domain/profile.ts";
6
7
  import { ROLES, type Role } from "../domain/roles.ts";
8
+ import { standInFor, probeBackend } from "./backends.ts";
7
9
  import { refreshDiscovery } from "./catalog-service.ts";
8
- import { type Check, errText, fixOf } from "./doctor-checks.ts";
10
+ import { type Check, errText, fixOf, hostFlag } from "./doctor-checks.ts";
9
11
 
10
12
  // The backend rows of `catherd doctor` (spec §10.3) and the backends the linked profiles use.
11
13
 
@@ -21,7 +23,12 @@ export function usedBackends(profiles: Profile[]): Map<string, "role" | "failove
21
23
  };
22
24
  for (const p of profiles) {
23
25
  for (const role of ROLES) if (p.roles[role].enabled) for (const r of p.roles[role].rungs) note(r, "role");
24
- for (const to of Object.values(p.failover)) note(to, "failover");
26
+ for (const role of ROLES)
27
+ if (p.roles[role].enabled)
28
+ for (const r of p.roles[role].rungs) {
29
+ const to = standInFor(p.failover, r);
30
+ if (to) note(to, "failover");
31
+ }
25
32
  }
26
33
  return used;
27
34
  }
@@ -30,6 +37,7 @@ const PROBLEM_WORD: Record<string, string> = {
30
37
  E_BACKEND_MISSING: "missing",
31
38
  E_BACKEND_TOO_OLD: "too old",
32
39
  E_BACKEND_NOT_LOGGED_IN: "not logged in",
40
+ E_BACKEND_CANNOT_RUN: "cannot run",
33
41
  };
34
42
 
35
43
  /**
@@ -50,10 +58,16 @@ const shellWord = (w: string): string => (/^[\w./:=@,+-]+$/.test(w) ? w : `'${w.
50
58
  * each linked profile, any default rung the new ladder would not hold reset first. The artist draws, which only Codex does, so
51
59
  * off Codex it is turned off instead.
52
60
  */
53
- export function moveRolesFix(install: string, id: string, to: string, profiles: Profile[]): string {
61
+ export function moveRolesFix(
62
+ install: string,
63
+ id: string,
64
+ to: string,
65
+ profiles: Profile[],
66
+ host: OrchestrationHost = "unknown",
67
+ ): string {
54
68
  const onIt = (r: string | undefined) => r !== undefined && backendOf(r) === id;
55
69
  const set = (p: Profile, path: string, v: string) =>
56
- `catherd profile set ${path} ${shellWord(v)} --profile ${shellWord(p.name)}`;
70
+ `catherd profile set ${path} ${shellWord(v)} --profile ${shellWord(p.name)}${hostFlag(host)}`;
57
71
  const stuck = new Set<Role>();
58
72
  const commands: string[] = [];
59
73
  for (const p of profiles)
@@ -85,13 +99,24 @@ export async function backendChecks(
85
99
  used: Map<string, "role" | "failover">,
86
100
  profiles: Profile[],
87
101
  installed?: Set<string>,
102
+ host: OrchestrationHost = "unknown",
88
103
  ): Promise<Check[]> {
89
104
  const checks: Check[] = [];
90
105
  const ready: string[] = [];
91
106
  for (const id of ADAPTER_IDS) {
107
+ if (host === "codex" && id === "claude-code" && !used.has(id)) {
108
+ checks.push({
109
+ id: `backend:${id}`,
110
+ label: id,
111
+ state: "skip",
112
+ word: "not required",
113
+ detail: "not probed: the selected profile has no headless Claude role or reachable failover",
114
+ });
115
+ continue;
116
+ }
92
117
  const a = adapterFor(id);
93
118
  if (!a) continue;
94
- const probe: Probe = await a.probe().catch((e: unknown) => ({
119
+ const probe: Probe = await probeBackend(a).catch((e: unknown) => ({
95
120
  installed: false,
96
121
  version: null,
97
122
  versionOk: false,
@@ -101,6 +126,9 @@ export async function backendChecks(
101
126
  ],
102
127
  }));
103
128
  if (probe.installed) installed?.add(id);
129
+ // spec 1.3 §4.7: what the probe found worth knowing, nothing to fix (an `agent` on PATH that is not Cursor)
130
+ for (const i of probe.info ?? [])
131
+ checks.push({ id: i.id, label: i.label, state: "info", word: "info", detail: i.detail });
104
132
  const problem = probe.problems[0];
105
133
  const use = used.get(id);
106
134
  if (!problem) {
@@ -119,10 +147,14 @@ export async function backendChecks(
119
147
  state: "warn",
120
148
  word: "billing",
121
149
  detail: `${detail} · profile ${billed.name} bills ${id} as ${billed.billing[id]}, but this login is ${probe.billing}`,
122
- fix: `catherd profile set billing.${id} ${probe.billing} --profile ${billed.name}`,
150
+ fix: `catherd profile set billing.${id} ${probe.billing} --profile ${billed.name}${hostFlag(host)}`,
123
151
  }
124
152
  : { id: `backend:${id}`, label: id, state: "ok", word: "ready", detail },
125
153
  );
154
+ // spec 1.3 §6.6: asked only of a logged-in CLI (a logged-out agy -p would open a browser)
155
+ const quota = probe.loggedIn && a.quota ? await a.quota().catch(() => null) : null;
156
+ if (quota)
157
+ checks.push({ id: `quota:${id}`, label: `${id} quota`, state: "info", word: "quota", detail: quota });
126
158
  continue;
127
159
  }
128
160
  checks.push({
@@ -138,7 +170,7 @@ export async function backendChecks(
138
170
  for (const c of checks) {
139
171
  const id = c.id.slice("backend:".length);
140
172
  if (to && c.state === "fail" && c.word === PROBLEM_WORD.E_BACKEND_MISSING && c.fix && id !== to)
141
- c.fix = moveRolesFix(c.fix, id, to, profiles);
173
+ c.fix = moveRolesFix(c.fix, id, to, profiles, host);
142
174
  }
143
175
  // spec §5.2: doctor refreshes discovery; a listing that fails keeps the last one
144
176
  let refreshed: Awaited<ReturnType<typeof refreshDiscovery>> = [];