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.
- package/README.md +146 -36
- 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/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +45 -39
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +61 -18
- 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/cli.ts +1 -64
- 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 +49 -19
- 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/host.ts +17 -0
- package/src/domain/ids.ts +2 -2
- package/src/domain/notice.ts +5 -2
- package/src/domain/profile-rules.ts +17 -0
- package/src/domain/profile.ts +89 -5
- package/src/domain/record.ts +3 -0
- package/src/domain/util.ts +3 -0
- package/src/entry/catalog-command.ts +4 -2
- package/src/entry/deps.ts +6 -3
- package/src/entry/doctor-command.ts +11 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +18 -7
- package/src/entry/knowledge-command.ts +50 -0
- package/src/entry/lock-command.ts +7 -2
- package/src/entry/mcp/handshake.ts +3 -1
- package/src/entry/mcp/run-tools.ts +5 -2
- package/src/entry/mcp/server.ts +129 -40
- package/src/entry/mcp/setup-tools.ts +7 -2
- package/src/entry/profile-command.ts +108 -12
- package/src/entry/runs-command.ts +81 -5
- package/src/entry/tui/effects.ts +30 -10
- package/src/entry/tui/fixtures.ts +34 -10
- package/src/entry/tui/profile-tree.ts +10 -1
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +19 -6
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +16 -3
- package/src/entry/tui/views/save-dialog.tsx +2 -2
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +83 -0
- package/src/infra/dispatch-dir.ts +1 -0
- package/src/infra/env.ts +4 -1
- package/src/infra/host-context.ts +58 -0
- package/src/infra/paths.ts +3 -1
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/supervisor.ts +3 -2
- package/src/services/admission.ts +34 -7
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +49 -4
- package/src/services/capture.ts +112 -54
- package/src/services/catalog-refresh.ts +2 -1
- package/src/services/dispatch-service.ts +17 -7
- package/src/services/dispatches.ts +8 -1
- package/src/services/doctor-access.ts +14 -7
- package/src/services/doctor-backends.ts +39 -7
- package/src/services/doctor-checks.ts +43 -3
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor.ts +107 -32
- package/src/services/finalize.ts +2 -1
- package/src/services/lane-service.ts +4 -10
- package/src/services/notifier.ts +322 -70
- package/src/services/peek.ts +46 -9
- package/src/services/ports.ts +8 -2
- package/src/services/profile-service.ts +117 -31
- package/src/services/profile-store.ts +126 -5
- package/src/services/routing-service.ts +6 -1
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-service.ts +39 -4
- package/src/services/run-store.ts +21 -5
- package/src/services/runs-page.ts +3 -2
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +32 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +32 -7
- package/src/services/summary.ts +40 -5
- package/src/services/treat-likes.ts +22 -9
package/src/services/backends.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
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(),
|
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
135
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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>> = [];
|