catherd-cli 0.2.1 → 1.1.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 +141 -33
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +282 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +333 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +60 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +49 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +160 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +197 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { dispatchHints } from "../domain/hints.ts";
|
|
5
|
+
import { ACCESS, type RunRecord } from "../domain/record.ts";
|
|
6
|
+
import { ROLES } from "../domain/roles.ts";
|
|
7
|
+
import { dispatchPaths, readExit, supervisorAlive } from "../infra/dispatch-dir.ts";
|
|
8
|
+
import { isAlive } from "../infra/proc.ts";
|
|
9
|
+
import { readVersioned, writeTextAtomic } from "../infra/store.ts";
|
|
10
|
+
import { readRecords, type Run, runPaths } from "./run-store.ts";
|
|
11
|
+
|
|
12
|
+
/** admit.json: what admission decided. Written last, so a dispatch exists once it does. */
|
|
13
|
+
const AdmitSchema = z.looseObject({
|
|
14
|
+
schema: z.literal(1),
|
|
15
|
+
runId: z.string(),
|
|
16
|
+
dispatchId: z.string(),
|
|
17
|
+
name: z.string(),
|
|
18
|
+
role: z.enum(ROLES),
|
|
19
|
+
lane: z.string().nullable(),
|
|
20
|
+
owns: z.array(z.string()),
|
|
21
|
+
rung: z.string(),
|
|
22
|
+
backend: z.string(),
|
|
23
|
+
thread: z.string().nullable(),
|
|
24
|
+
attempt: z.number().int().min(1),
|
|
25
|
+
failoverFrom: z.string().nullable(),
|
|
26
|
+
/** the limited dispatch a failover stand-in replaces (plan 9); absent on any other dispatch */
|
|
27
|
+
failoverOf: z.string().optional(),
|
|
28
|
+
access: z.enum(ACCESS),
|
|
29
|
+
isolated: z.boolean(),
|
|
30
|
+
cliVersion: z.string().nullable(),
|
|
31
|
+
admittedAt: z.string(),
|
|
32
|
+
repo: z.string(),
|
|
33
|
+
/** `git status` fingerprints when admitted, for changedOwned and violations */
|
|
34
|
+
before: z.record(z.string(), z.string()),
|
|
35
|
+
/** the Claude Code session that dispatched it (spec §3.3); absent on 1.0 dispatches and outside Claude Code */
|
|
36
|
+
sessionId: z.string().optional(),
|
|
37
|
+
});
|
|
38
|
+
export type Admit = z.infer<typeof AdmitSchema>;
|
|
39
|
+
|
|
40
|
+
export interface Dispatch {
|
|
41
|
+
dir: string;
|
|
42
|
+
admit: Admit;
|
|
43
|
+
}
|
|
44
|
+
export type DispatchState = "starting" | "running" | "finished";
|
|
45
|
+
|
|
46
|
+
/** How long an admitted dispatch may go without a launch.json before it counts as never started. */
|
|
47
|
+
export const STARTING_GRACE_MS = 30_000;
|
|
48
|
+
/** The start grace in force; tests shorten it. */
|
|
49
|
+
export const startLimits = { graceMs: STARTING_GRACE_MS };
|
|
50
|
+
|
|
51
|
+
export const admitPath = (dir: string): string => join(dir, "admit.json");
|
|
52
|
+
export const launchPath = (dir: string): string => join(dir, "launch.json");
|
|
53
|
+
export const roleDir = (run: Run, name: string): string => join(runPaths(run.dir).roles, name);
|
|
54
|
+
|
|
55
|
+
/** Every admitted dispatch of the run, oldest first; a folder whose admit.json cannot be read is skipped. */
|
|
56
|
+
export function listDispatches(run: Run): Dispatch[] {
|
|
57
|
+
const roles = runPaths(run.dir).roles;
|
|
58
|
+
const out: Dispatch[] = [];
|
|
59
|
+
if (!existsSync(roles)) return out;
|
|
60
|
+
for (const name of readdirSync(roles, { withFileTypes: true })) {
|
|
61
|
+
if (!name.isDirectory()) continue;
|
|
62
|
+
for (const id of readdirSync(join(roles, name.name), { withFileTypes: true })) {
|
|
63
|
+
if (!id.isDirectory()) continue;
|
|
64
|
+
const dir = join(roles, name.name, id.name);
|
|
65
|
+
try {
|
|
66
|
+
out.push({ dir, admit: readVersioned(admitPath(dir), AdmitSchema, 1) });
|
|
67
|
+
} catch {
|
|
68
|
+
// admission never finished writing this folder
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out.sort((a, b) => a.admit.dispatchId.localeCompare(b.admit.dispatchId));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface ProcFile {
|
|
76
|
+
pid: number;
|
|
77
|
+
/** the worker's process group; the detached worker leads it */
|
|
78
|
+
pgid?: number;
|
|
79
|
+
startTime: string | null;
|
|
80
|
+
supervisorPid: number;
|
|
81
|
+
supervisorStartTime: string | null;
|
|
82
|
+
startedAt: string;
|
|
83
|
+
}
|
|
84
|
+
interface LaunchFile {
|
|
85
|
+
supervisorPid: number;
|
|
86
|
+
supervisorStartTime: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readJson<T>(file: string): T | null {
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(readFileSync(file, "utf8")) as T;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const readProc = (dir: string): ProcFile | null => readJson<ProcFile>(dispatchPaths(dir).proc);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Spec §3.3: finished once exit.json exists, or once neither the supervisor nor its worker is alive,
|
|
101
|
+
* each identified by pid and start time, so a reused pid never keeps a dispatch alive (audit C3).
|
|
102
|
+
*/
|
|
103
|
+
export function dispatchState(d: Dispatch, now = Date.now()): DispatchState {
|
|
104
|
+
if (readExit(d.dir)) return "finished";
|
|
105
|
+
// a live supervisor holds its dispatch's lock from its first moment, before launch.json or proc.json
|
|
106
|
+
if (!readProc(d.dir) && supervisorAlive(d.dir)) return "starting";
|
|
107
|
+
const proc = readProc(d.dir);
|
|
108
|
+
if (proc)
|
|
109
|
+
return isAlive(proc.supervisorPid, proc.supervisorStartTime) || isAlive(proc.pid, proc.startTime)
|
|
110
|
+
? "running"
|
|
111
|
+
: "finished";
|
|
112
|
+
const launch = readJson<LaunchFile>(launchPath(d.dir));
|
|
113
|
+
if (launch) return isAlive(launch.supervisorPid, launch.supervisorStartTime) ? "starting" : "finished";
|
|
114
|
+
return now - Date.parse(d.admit.admittedAt) < startLimits.graceMs ? "starting" : "finished";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type LiveDispatch = Dispatch & { state: DispatchState };
|
|
118
|
+
|
|
119
|
+
/** Dispatches that have no record yet, with their state; `live` keeps only the unfinished ones. */
|
|
120
|
+
export function pendingDispatches(
|
|
121
|
+
run: Run,
|
|
122
|
+
now = Date.now(),
|
|
123
|
+
records: RunRecord[] = readRecords(run).records,
|
|
124
|
+
): LiveDispatch[] {
|
|
125
|
+
const recorded = new Set(records.map((r) => r.dispatchId));
|
|
126
|
+
return listDispatches(run)
|
|
127
|
+
.filter((d) => !recorded.has(d.admit.dispatchId))
|
|
128
|
+
.map((d) => ({ ...d, state: dispatchState(d, now) }));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const liveDispatches = (run: Run, now = Date.now()): LiveDispatch[] =>
|
|
132
|
+
pendingDispatches(run, now).filter((d) => d.state !== "finished");
|
|
133
|
+
|
|
134
|
+
/** Spec §4.1: `roles/<name>/latest` names the newest dispatch of a role. */
|
|
135
|
+
export function setLatest(run: Run, name: string, dispatchId: string): void {
|
|
136
|
+
writeTextAtomic(join(roleDir(run, name), "latest"), dispatchId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function latestDispatch(run: Run, name: string): Dispatch | null {
|
|
140
|
+
const all = listDispatches(run).filter((d) => d.admit.name === name);
|
|
141
|
+
let id: string | null = null;
|
|
142
|
+
try {
|
|
143
|
+
id = readFileSync(join(roleDir(run, name), "latest"), "utf8").trim();
|
|
144
|
+
} catch {
|
|
145
|
+
// no pointer yet
|
|
146
|
+
}
|
|
147
|
+
return all.find((d) => d.admit.dispatchId === id) ?? all.at(-1) ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** failover.json: what failover did for a limited dispatch (plan 10), written once, under its lock. */
|
|
151
|
+
const FailoverSchema = z.looseObject({
|
|
152
|
+
schema: z.literal(1),
|
|
153
|
+
at: z.string(),
|
|
154
|
+
/** the stand-in it launched, if any */
|
|
155
|
+
standIn: z.object({ dispatchId: z.string(), rung: z.string() }).nullable(),
|
|
156
|
+
/** the limited record's hints, as failover left them ("limit: … failed over to …", "failover: …") */
|
|
157
|
+
hints: z.array(z.string()),
|
|
158
|
+
/** the pause it wrote to state.md, if any */
|
|
159
|
+
pause: z.string().nullable(),
|
|
160
|
+
});
|
|
161
|
+
export type FailoverFile = z.infer<typeof FailoverSchema>;
|
|
162
|
+
|
|
163
|
+
export function readFailover(dir: string): FailoverFile | null {
|
|
164
|
+
try {
|
|
165
|
+
return readVersioned(dispatchPaths(dir).failover, FailoverSchema, 1);
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Spec §4.4: what to do next about one finished record; for a usage limit, the failover's own hints. */
|
|
172
|
+
export function recordHints(run: Run, d: Dispatch, r: RunRecord): string[] {
|
|
173
|
+
return readFailover(d.dir)?.hints ?? dispatchHints(r, d.admit.owns, relative(run.dir, d.dir));
|
|
174
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { realTmpdir } from "../adapters/access.ts";
|
|
2
|
+
import type { AccessShell } from "../adapters/backend.ts";
|
|
3
|
+
import { adapterFor } from "../adapters/registry.ts";
|
|
4
|
+
import "../adapters/all.ts";
|
|
5
|
+
import { tryParseRung } from "../domain/ids.ts";
|
|
6
|
+
import type { Profile } from "../domain/profile.ts";
|
|
7
|
+
import { ROLES } from "../domain/roles.ts";
|
|
8
|
+
import { locksDir } from "../infra/paths.ts";
|
|
9
|
+
import { ensurePrivateDir } from "../infra/store.ts";
|
|
10
|
+
import type { Check } from "./doctor-checks.ts";
|
|
11
|
+
|
|
12
|
+
// Spec §5 and §12: the five access probes doctor runs per backend that serves a workspace-write role, each
|
|
13
|
+
// in that backend's worker shell with the grants a worker gets, and the rows that report them.
|
|
14
|
+
|
|
15
|
+
export type ProbeId = "lock" | "temp" | "loopback" | "https" | "docker";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What the probes reach: npm's ping, through HTTPS_PROXY when it is set (Bun's fetch honours it), and
|
|
19
|
+
* `docker`. CATHERD_PROBE_URL and CATHERD_PROBE_DOCKER replace them (tests: a local server, no docker).
|
|
20
|
+
*/
|
|
21
|
+
export const probeTargets = {
|
|
22
|
+
get url(): string {
|
|
23
|
+
return process.env.CATHERD_PROBE_URL || "https://registry.npmjs.org/-/ping";
|
|
24
|
+
},
|
|
25
|
+
get docker(): string {
|
|
26
|
+
return process.env.CATHERD_PROBE_DOCKER || "docker";
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
interface ProbeDef {
|
|
31
|
+
id: ProbeId;
|
|
32
|
+
label: string;
|
|
33
|
+
/** off when the role's network is off (Docker access goes with the network) */
|
|
34
|
+
network: boolean;
|
|
35
|
+
script: string;
|
|
36
|
+
args: () => string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// every path and URL goes in as an argument, never into the script text
|
|
40
|
+
const WRITE = 'f="$1/.catherd-doctor-$$" && touch "$f" && rm -f "$f"';
|
|
41
|
+
const BUN_EVAL = '"$1" -e "$2"';
|
|
42
|
+
const BIND = 'Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }).stop(true)';
|
|
43
|
+
// npm's ping answers 200; any 4xx or 5xx fails, so a proxy's 407 or 403 for a denied host is not a false green
|
|
44
|
+
// a fetch that throws (no route, DNS, refused) prints its error code, not Bun's stack and version trailer
|
|
45
|
+
const FETCH =
|
|
46
|
+
"let r; try { r = await fetch(process.argv[1], { method: 'HEAD' }) } catch (e) { console.error([e?.code, e?.message].filter(Boolean).join(': ') || String(e)); process.exit(1) } if (r.status >= 400) { console.error(`HTTP ${r.status}${r.status === 407 ? ' (proxy authentication required)' : r.status === 403 ? ' (refused: a proxy denying the host?)' : ''}`); process.exit(1) }";
|
|
47
|
+
|
|
48
|
+
/** Why a probe failed: its last non-empty stderr line (Bun's version trailer skipped), else stdout's. */
|
|
49
|
+
export function lastErrorLine(err: string, out: string): string | undefined {
|
|
50
|
+
const lines = (t: string) =>
|
|
51
|
+
t
|
|
52
|
+
.split("\n")
|
|
53
|
+
.map((l) => l.trim())
|
|
54
|
+
.filter((l) => l && !/^Bun v\d/.test(l));
|
|
55
|
+
return lines(err).at(-1) ?? lines(out).at(-1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const PROBES: ProbeDef[] = [
|
|
59
|
+
{ id: "lock", label: "lock-dir write", network: false, script: WRITE, args: () => [locksDir()] },
|
|
60
|
+
{ id: "temp", label: "temp write", network: false, script: WRITE, args: () => [realTmpdir()] },
|
|
61
|
+
{
|
|
62
|
+
id: "loopback",
|
|
63
|
+
label: "loopback bind",
|
|
64
|
+
network: true,
|
|
65
|
+
script: BUN_EVAL,
|
|
66
|
+
args: () => [process.execPath, BIND],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "https",
|
|
70
|
+
label: "outbound HTTPS",
|
|
71
|
+
network: true,
|
|
72
|
+
script: `${BUN_EVAL} "$3"`,
|
|
73
|
+
args: () => [process.execPath, FETCH, probeTargets.url],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "docker",
|
|
77
|
+
label: "docker version",
|
|
78
|
+
network: true,
|
|
79
|
+
script: '"$1" version',
|
|
80
|
+
args: () => [probeTargets.docker],
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
export interface ProbeResult {
|
|
85
|
+
id: ProbeId;
|
|
86
|
+
label: string;
|
|
87
|
+
/** "off": the role's network is off; "skip": docker is not installed */
|
|
88
|
+
state: "ok" | "failed" | "off" | "skip";
|
|
89
|
+
/** the last line the probe printed on failure */
|
|
90
|
+
why?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Runs the five probes in `shell`; the network three (docker among them) only when granted, docker only when installed. */
|
|
94
|
+
export async function runProbes(shell: AccessShell, network: boolean): Promise<ProbeResult[]> {
|
|
95
|
+
const out: ProbeResult[] = [];
|
|
96
|
+
let noLocks: string | null = null;
|
|
97
|
+
try {
|
|
98
|
+
ensurePrivateDir(locksDir());
|
|
99
|
+
} catch (e) {
|
|
100
|
+
noLocks = `cannot create ${locksDir()}: ${e instanceof Error ? e.message : String(e)}`;
|
|
101
|
+
}
|
|
102
|
+
for (const p of PROBES) {
|
|
103
|
+
if (p.id === "lock" && noLocks) {
|
|
104
|
+
out.push({ id: p.id, label: p.label, state: "failed", why: noLocks });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (p.network && !network) {
|
|
108
|
+
out.push({ id: p.id, label: p.label, state: "off" });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (p.id === "docker" && !Bun.which(probeTargets.docker, { PATH: process.env.PATH ?? "" })) {
|
|
112
|
+
out.push({ id: p.id, label: p.label, state: "skip" });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const r = await shell.run(p.script, p.args());
|
|
116
|
+
const last = lastErrorLine(r?.err ?? "", r?.out ?? "");
|
|
117
|
+
const late = last && / timed out after (\d+) ms$/.exec(last);
|
|
118
|
+
const why =
|
|
119
|
+
r === null
|
|
120
|
+
? "the probe shell did not start"
|
|
121
|
+
: late
|
|
122
|
+
? `timed out after ${Number(late[1]) / 1000} s`
|
|
123
|
+
: last;
|
|
124
|
+
out.push({
|
|
125
|
+
id: p.id,
|
|
126
|
+
label: p.label,
|
|
127
|
+
state: r?.ok ? "ok" : "failed",
|
|
128
|
+
...(r?.ok ? {} : { why: why || "no output" }),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
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 {
|
|
136
|
+
if (backend === "codex") {
|
|
137
|
+
const override =
|
|
138
|
+
"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";
|
|
139
|
+
if (id === "docker")
|
|
140
|
+
return "the Codex sandbox cannot reach the Docker socket here: run the Docker checks in the verifier (full access), or give the role full access";
|
|
141
|
+
return override;
|
|
142
|
+
}
|
|
143
|
+
const FIX: Record<ProbeId, string> = {
|
|
144
|
+
lock: `chmod -R u+w ${locksDir()}`,
|
|
145
|
+
temp: `check that ${realTmpdir()} is writable`,
|
|
146
|
+
loopback: "something on this machine refuses a bind to 127.0.0.1: check the firewall",
|
|
147
|
+
https: "check the network, or set HTTPS_PROXY for this shell",
|
|
148
|
+
docker: `start Docker: ${probeTargets.docker} version fails`,
|
|
149
|
+
};
|
|
150
|
+
return FIX[id];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Every backend an enabled workspace-write role runs on or fails over to, with whether any of those roles has network. */
|
|
154
|
+
export function workspaceWriteNetwork(profiles: Profile[]): Map<string, boolean> {
|
|
155
|
+
const out = new Map<string, boolean>();
|
|
156
|
+
for (const p of profiles)
|
|
157
|
+
for (const role of ROLES) {
|
|
158
|
+
const rc = p.roles[role];
|
|
159
|
+
if (!rc.enabled || rc.access !== "workspace-write") continue;
|
|
160
|
+
for (const r of [...rc.rungs, ...rc.rungs.flatMap((x) => p.failover[x] ?? [])]) {
|
|
161
|
+
const b = tryParseRung(r)?.backend;
|
|
162
|
+
if (b && b !== "claude") out.set(b, (out.get(b) ?? false) || rc.network !== false);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The `sandbox:codex` row (which `codex sandbox` form runs) and one `access:<backend>` row per backend; a
|
|
170
|
+
* backend not in `installed` (its CLI is not on PATH) is skipped without running a probe.
|
|
171
|
+
*/
|
|
172
|
+
export async function accessChecks(profiles: Profile[], installed: ReadonlySet<string>): Promise<Check[]> {
|
|
173
|
+
const checks: Check[] = [];
|
|
174
|
+
for (const [id, network] of workspaceWriteNetwork(profiles)) {
|
|
175
|
+
const a = adapterFor(id);
|
|
176
|
+
if (!a?.accessShell) continue;
|
|
177
|
+
if (!installed.has(id)) {
|
|
178
|
+
checks.push({
|
|
179
|
+
id: `access:${id}`,
|
|
180
|
+
label: `${id} worker access`,
|
|
181
|
+
state: "skip",
|
|
182
|
+
word: "not installed",
|
|
183
|
+
detail: `${id} is not installed: no worker runs on it here`,
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const shell = await a.accessShell({ network }).catch((e: unknown) => String(e));
|
|
188
|
+
if (id === "codex")
|
|
189
|
+
checks.push(
|
|
190
|
+
typeof shell === "string"
|
|
191
|
+
? { id: "sandbox:codex", label: "codex sandbox", state: "skip", word: "not tested", detail: shell }
|
|
192
|
+
: { id: "sandbox:codex", label: "codex sandbox", state: "ok", word: "ready", detail: shell.how },
|
|
193
|
+
);
|
|
194
|
+
const base = { id: `access:${id}`, label: `${id} worker access` };
|
|
195
|
+
if (typeof shell === "string") {
|
|
196
|
+
checks.push({ ...base, state: "skip", word: "not tested", detail: shell });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
let results: ProbeResult[];
|
|
200
|
+
try {
|
|
201
|
+
results = await runProbes(shell, network);
|
|
202
|
+
} finally {
|
|
203
|
+
shell.close();
|
|
204
|
+
}
|
|
205
|
+
const failed = results.filter((r) => r.state === "failed");
|
|
206
|
+
const passed = results.filter((r) => r.state === "ok").map((r) => r.label);
|
|
207
|
+
const notes = [
|
|
208
|
+
...(results.some((r) => r.state === "off") ? ["network off by profile"] : []),
|
|
209
|
+
...(results.some((r) => r.state === "skip") ? ["no docker"] : []),
|
|
210
|
+
// only Codex's sandbox can take the network away; the others' shells keep it
|
|
211
|
+
...(id !== "codex" && !network ? [`network: false is not enforced by ${id}'s shell`] : []),
|
|
212
|
+
];
|
|
213
|
+
checks.push(
|
|
214
|
+
failed.length
|
|
215
|
+
? {
|
|
216
|
+
...base,
|
|
217
|
+
state: "warn",
|
|
218
|
+
word: "blocked",
|
|
219
|
+
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"),
|
|
221
|
+
}
|
|
222
|
+
: {
|
|
223
|
+
...base,
|
|
224
|
+
state: "ok",
|
|
225
|
+
word: "ready",
|
|
226
|
+
detail: [`${passed.join(", ")} in ${shell.how}`, ...notes].join(" · "),
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return checks;
|
|
231
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { Probe } from "../adapters/backend.ts";
|
|
2
|
+
import { adapterFor } from "../adapters/registry.ts";
|
|
3
|
+
import "../adapters/all.ts";
|
|
4
|
+
import { ADAPTER_IDS, tryParseRung } from "../domain/ids.ts";
|
|
5
|
+
import type { Profile } from "../domain/profile.ts";
|
|
6
|
+
import { ROLES, type Role } from "../domain/roles.ts";
|
|
7
|
+
import { refreshDiscovery } from "./catalog-service.ts";
|
|
8
|
+
import { type Check, errText, fixOf } from "./doctor-checks.ts";
|
|
9
|
+
|
|
10
|
+
// The backend rows of `catherd doctor` (spec §10.3) and the backends the linked profiles use.
|
|
11
|
+
|
|
12
|
+
/** A rung's backend; null for a malformed rung, which validate reports. */
|
|
13
|
+
const backendOf = (rung: string): string | null => tryParseRung(rung)?.backend ?? null;
|
|
14
|
+
|
|
15
|
+
/** Every backend a linked profile runs a role on ("role"), or only fails over to ("failover"). */
|
|
16
|
+
export function usedBackends(profiles: Profile[]): Map<string, "role" | "failover"> {
|
|
17
|
+
const used = new Map<string, "role" | "failover">();
|
|
18
|
+
const note = (rung: string, how: "role" | "failover") => {
|
|
19
|
+
const b = backendOf(rung);
|
|
20
|
+
if (b && used.get(b) !== "role") used.set(b, how);
|
|
21
|
+
};
|
|
22
|
+
for (const p of profiles) {
|
|
23
|
+
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");
|
|
25
|
+
}
|
|
26
|
+
return used;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PROBLEM_WORD: Record<string, string> = {
|
|
30
|
+
E_BACKEND_MISSING: "missing",
|
|
31
|
+
E_BACKEND_TOO_OLD: "too old",
|
|
32
|
+
E_BACKEND_NOT_LOGGED_IN: "not logged in",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Spec §7.2 keeps the default profile Codex-first. When a backend a role runs on is missing and another is
|
|
37
|
+
* ready, its fix also offers to move those roles there, onto a rung the shipped catalog scores.
|
|
38
|
+
*/
|
|
39
|
+
const MOVE_TO: Record<string, string> = {
|
|
40
|
+
"claude-code": "claude-code:claude-opus-5-5#medium",
|
|
41
|
+
opencode: "opencode:opencode-go/gpt-6-luna#high",
|
|
42
|
+
codex: "codex:gpt-6-sol#medium",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** `w` as one shell word: bare when plainly safe, else single-quoted (zsh can glob a bare `#`). */
|
|
46
|
+
const shellWord = (w: string): string => (/^[\w./:=@,+-]+$/.test(w) ? w : `'${w.replaceAll("'", `'\\''`)}'`);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Install `id`, or move the roles that run on it to `to`: one runnable `catherd profile set` per line, for
|
|
50
|
+
* each linked profile, any default rung the new ladder would not hold reset first. The artist draws, which only Codex does, so
|
|
51
|
+
* off Codex it is turned off instead.
|
|
52
|
+
*/
|
|
53
|
+
export function moveRolesFix(install: string, id: string, to: string, profiles: Profile[]): string {
|
|
54
|
+
const onIt = (r: string | undefined) => r !== undefined && backendOf(r) === id;
|
|
55
|
+
const set = (p: Profile, path: string, v: string) =>
|
|
56
|
+
`catherd profile set ${path} ${shellWord(v)} --profile ${shellWord(p.name)}`;
|
|
57
|
+
const stuck = new Set<Role>();
|
|
58
|
+
const commands: string[] = [];
|
|
59
|
+
for (const p of profiles)
|
|
60
|
+
for (const role of ROLES) {
|
|
61
|
+
const rc = p.roles[role];
|
|
62
|
+
if (!rc.enabled || !rc.rungs.some(onIt)) continue;
|
|
63
|
+
if (role === "artist" && to !== "codex") {
|
|
64
|
+
stuck.add(role);
|
|
65
|
+
commands.push(set(p, `roles.${role}.enabled`, "false"));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const target = MOVE_TO[to] as string;
|
|
69
|
+
// the new ladder holds only `target`, so any other default would fail validation: reset it first
|
|
70
|
+
if (rc.defaultRung !== undefined && rc.defaultRung !== null && rc.defaultRung !== target)
|
|
71
|
+
commands.push(set(p, `roles.${role}.defaultRung`, "null"));
|
|
72
|
+
commands.push(set(p, `roles.${role}.rungs`, target));
|
|
73
|
+
}
|
|
74
|
+
if (!commands.length) return install;
|
|
75
|
+
const off = stuck.size ? ` (${[...stuck].join(", ")} needs ${id}, so it is turned off)` : "";
|
|
76
|
+
return [
|
|
77
|
+
install,
|
|
78
|
+
`or move its roles to ${to}: /catherd-setup in Claude Code, or run${off}`,
|
|
79
|
+
...commands,
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One row per backend; `installed`, when given, collects the backends whose CLI is on PATH. */
|
|
84
|
+
export async function backendChecks(
|
|
85
|
+
used: Map<string, "role" | "failover">,
|
|
86
|
+
profiles: Profile[],
|
|
87
|
+
installed?: Set<string>,
|
|
88
|
+
): Promise<Check[]> {
|
|
89
|
+
const checks: Check[] = [];
|
|
90
|
+
const ready: string[] = [];
|
|
91
|
+
for (const id of ADAPTER_IDS) {
|
|
92
|
+
const a = adapterFor(id);
|
|
93
|
+
if (!a) continue;
|
|
94
|
+
const probe: Probe = await a.probe().catch((e: unknown) => ({
|
|
95
|
+
installed: false,
|
|
96
|
+
version: null,
|
|
97
|
+
versionOk: false,
|
|
98
|
+
loggedIn: null,
|
|
99
|
+
problems: [
|
|
100
|
+
{ code: "E_IO_UNEXPECTED" as const, message: errText(e), fix: `run ${id} --version to see why` },
|
|
101
|
+
],
|
|
102
|
+
}));
|
|
103
|
+
if (probe.installed) installed?.add(id);
|
|
104
|
+
const problem = probe.problems[0];
|
|
105
|
+
const use = used.get(id);
|
|
106
|
+
if (!problem) {
|
|
107
|
+
ready.push(id);
|
|
108
|
+
const detail = [probe.version, probe.login && `${probe.login} login`].filter(Boolean).join(" · ");
|
|
109
|
+
// a login billed apart from what a profile says (a plan, or per token) ranks that backend's cost wrongly;
|
|
110
|
+
// only a profile that routes something to this backend is ranked by it
|
|
111
|
+
const billed =
|
|
112
|
+
probe.billing &&
|
|
113
|
+
profiles.find((p) => p.billing[id] && p.billing[id] !== probe.billing && usedBackends([p]).has(id));
|
|
114
|
+
checks.push(
|
|
115
|
+
billed
|
|
116
|
+
? {
|
|
117
|
+
id: `backend:${id}`,
|
|
118
|
+
label: id,
|
|
119
|
+
state: "warn",
|
|
120
|
+
word: "billing",
|
|
121
|
+
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}`,
|
|
123
|
+
}
|
|
124
|
+
: { id: `backend:${id}`, label: id, state: "ok", word: "ready", detail },
|
|
125
|
+
);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
checks.push({
|
|
129
|
+
id: `backend:${id}`,
|
|
130
|
+
label: id,
|
|
131
|
+
state: use === "role" ? "fail" : use === "failover" ? "warn" : "skip",
|
|
132
|
+
word: PROBLEM_WORD[problem.code] ?? "not ready",
|
|
133
|
+
detail: `${problem.message}${use === "failover" ? " (a failover stand-in uses it)" : use ? "" : " (no profile uses it)"}`,
|
|
134
|
+
fix: problem.fix,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const to = Object.keys(MOVE_TO).find((b) => ready.includes(b));
|
|
138
|
+
for (const c of checks) {
|
|
139
|
+
const id = c.id.slice("backend:".length);
|
|
140
|
+
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);
|
|
142
|
+
}
|
|
143
|
+
// spec §5.2: doctor refreshes discovery; a listing that fails keeps the last one
|
|
144
|
+
let refreshed: Awaited<ReturnType<typeof refreshDiscovery>> = [];
|
|
145
|
+
try {
|
|
146
|
+
refreshed = await refreshDiscovery({ backends: ready });
|
|
147
|
+
} catch (e) {
|
|
148
|
+
checks.push({
|
|
149
|
+
id: "discovery",
|
|
150
|
+
label: "model discovery",
|
|
151
|
+
state: "fail",
|
|
152
|
+
word: "unreadable",
|
|
153
|
+
detail: errText(e),
|
|
154
|
+
fix: fixOf(e) ?? "catherd catalog refresh --verbose",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
for (const r of refreshed) {
|
|
158
|
+
const c = checks.find((x) => x.id === `backend:${r.backend}`);
|
|
159
|
+
if (!c) continue;
|
|
160
|
+
if (r.error)
|
|
161
|
+
Object.assign(c, {
|
|
162
|
+
state: "warn",
|
|
163
|
+
word: "no listing",
|
|
164
|
+
detail: `${c.detail} · ${r.error}`,
|
|
165
|
+
fix: "catherd catalog refresh",
|
|
166
|
+
});
|
|
167
|
+
else c.detail = `${c.detail} · ${r.models} models`;
|
|
168
|
+
}
|
|
169
|
+
return checks;
|
|
170
|
+
}
|