catherd-cli 0.2.1 → 1.0.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 +114 -30
- 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 +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -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 +227 -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 +316 -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 +58 -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 +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -26
- 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 +33 -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 +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -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 +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -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 +133 -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 +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -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 +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -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 +196 -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/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -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/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -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 +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -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
- /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createInterface, type Interface } from "node:readline/promises";
|
|
2
|
+
import { EXIT } from "./cli-kit.ts";
|
|
3
|
+
|
|
4
|
+
/** Plain questions on stdin; `secret` does not echo what is typed. */
|
|
5
|
+
export interface Prompter {
|
|
6
|
+
ask(question: string): Promise<string>;
|
|
7
|
+
secret(question: string): Promise<string>;
|
|
8
|
+
/** A question this machine does not need asked: piped, its line is still consumed, so answers keep their place */
|
|
9
|
+
skip?(): void;
|
|
10
|
+
close(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A terminal's hidden input, one character at a time; Ctrl-C exits 130 (spec §8). */
|
|
14
|
+
function readSecret(question: string): Promise<string> {
|
|
15
|
+
process.stdout.write(question);
|
|
16
|
+
const stdin = process.stdin;
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let value = "";
|
|
19
|
+
const done = () => {
|
|
20
|
+
stdin.off("data", onData);
|
|
21
|
+
stdin.setRawMode(false);
|
|
22
|
+
stdin.pause();
|
|
23
|
+
process.stdout.write("\n");
|
|
24
|
+
resolve(value.trim());
|
|
25
|
+
};
|
|
26
|
+
const onData = (chunk: Buffer) => {
|
|
27
|
+
for (const ch of chunk.toString("utf8")) {
|
|
28
|
+
if (ch === "\r" || ch === "\n") return done();
|
|
29
|
+
if (ch === "\u0003") {
|
|
30
|
+
stdin.setRawMode(false);
|
|
31
|
+
process.exit(EXIT.interrupted);
|
|
32
|
+
}
|
|
33
|
+
if (ch === "\u007f") {
|
|
34
|
+
if (value) process.stdout.write("\b \b");
|
|
35
|
+
value = value.slice(0, -1);
|
|
36
|
+
} else {
|
|
37
|
+
value += ch;
|
|
38
|
+
process.stdout.write("*");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
stdin.setRawMode(true);
|
|
43
|
+
stdin.resume();
|
|
44
|
+
stdin.on("data", onData);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What `terminalAsk` needs of a readline interface. */
|
|
49
|
+
interface Line {
|
|
50
|
+
question(q: string): Promise<string>;
|
|
51
|
+
on(event: "SIGINT", listener: () => void): unknown;
|
|
52
|
+
}
|
|
53
|
+
const isAbort = (e: unknown) =>
|
|
54
|
+
e instanceof Error && (e.name === "AbortError" || (e as { code?: string }).code === "ABORT_ERR");
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A terminal question on `rl`, trimmed. Ctrl-C exits 130 (spec §8), as the secret prompt does: readline
|
|
58
|
+
* emits SIGINT, or the pending question rejects with an AbortError, depending on the runtime.
|
|
59
|
+
*/
|
|
60
|
+
export function terminalAsk(
|
|
61
|
+
rl: Line,
|
|
62
|
+
exit: (code: number) => never = (code) => process.exit(code),
|
|
63
|
+
): (q: string) => Promise<string> {
|
|
64
|
+
rl.on("SIGINT", () => exit(EXIT.interrupted));
|
|
65
|
+
return async (q) => {
|
|
66
|
+
try {
|
|
67
|
+
return (await rl.question(q)).trim();
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (isAbort(e)) exit(EXIT.interrupted);
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Questions for `catherd init`. On a terminal they are asked one by one; piped, stdin is read once and each
|
|
77
|
+
* question takes its own line (an empty or missing line takes the default), whether or not this machine
|
|
78
|
+
* asks it, so a script's answers never shift onto another question.
|
|
79
|
+
*/
|
|
80
|
+
export async function prompter(): Promise<Prompter> {
|
|
81
|
+
if (!process.stdin.isTTY) {
|
|
82
|
+
const lines = (await Bun.stdin.text()).split("\n");
|
|
83
|
+
const next = async (q: string) => {
|
|
84
|
+
const answer = (lines.shift() ?? "").trim();
|
|
85
|
+
process.stdout.write(`${q}\n`);
|
|
86
|
+
return answer;
|
|
87
|
+
};
|
|
88
|
+
return { ask: next, secret: next, skip: () => void lines.shift(), close() {} };
|
|
89
|
+
}
|
|
90
|
+
let rl: Interface | null = null;
|
|
91
|
+
let ask: ((q: string) => Promise<string>) | null = null;
|
|
92
|
+
return {
|
|
93
|
+
ask(q) {
|
|
94
|
+
rl ??= createInterface({ input: process.stdin, output: process.stdout });
|
|
95
|
+
ask ??= terminalAsk(rl);
|
|
96
|
+
return ask(q);
|
|
97
|
+
},
|
|
98
|
+
secret: (q) => readSecret(q),
|
|
99
|
+
close() {
|
|
100
|
+
rl?.close();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import { formatBudget } from "../domain/budget.ts";
|
|
4
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { gitToplevel } from "../infra/git.ts";
|
|
6
|
+
import { redact } from "../infra/log.ts";
|
|
7
|
+
import { cancel } from "../services/dispatch-service.ts";
|
|
8
|
+
import { registerSavedSecrets } from "../services/jev-service.ts";
|
|
9
|
+
import { runDebug } from "../services/run-debug.ts";
|
|
10
|
+
import { findRun, listRuns, readRecords } from "../services/run-store.ts";
|
|
11
|
+
import { type RunSummary, status, summarizeRun } from "../services/summary.ts";
|
|
12
|
+
import { JSON_ARG, mark, printJson } from "./cli-kit.ts";
|
|
13
|
+
import { defaultDeps } from "./deps.ts";
|
|
14
|
+
|
|
15
|
+
const json = JSON_ARG;
|
|
16
|
+
const n = (x: number) => x.toLocaleString("en-US");
|
|
17
|
+
|
|
18
|
+
/** One run at a glance: what is live, what finished, spend against the budget, landed milestones. */
|
|
19
|
+
export function formatRun(s: RunSummary, now: number = Date.now()): string[] {
|
|
20
|
+
const t = s.totals;
|
|
21
|
+
const lines = [
|
|
22
|
+
`run ${s.id} ${s.title}`,
|
|
23
|
+
` repo ${s.repo} · started ${s.createdAt} · ${Math.round((now - Date.parse(s.createdAt)) / 60_000)} min`,
|
|
24
|
+
];
|
|
25
|
+
for (const l of s.live) lines.push(` live ${l.name} ${l.rung} ${l.state} ${l.secs}s`);
|
|
26
|
+
lines.push(
|
|
27
|
+
` done ${t.runs} role run(s), ${t.ok} ok${t.notOk.length ? `; not ok: ${t.notOk.join(", ")}` : ""}`,
|
|
28
|
+
` tokens ${n(t.tokens.input)} in (${n(t.tokens.cached)} cached) · ${n(t.tokens.output)} out · $${t.costUsd.toFixed(2)}` +
|
|
29
|
+
(s.agents.runs
|
|
30
|
+
? ` · native agents ${s.agents.runs} run(s), ${n(s.agents.totalTokens)} tokens (reported)`
|
|
31
|
+
: ""),
|
|
32
|
+
);
|
|
33
|
+
if (s.harness.length)
|
|
34
|
+
lines.push(
|
|
35
|
+
` harness ${s.harness.map((h) => `${h.backend} ${h.native} native, ${h.isolated} isolated`).join(" · ")}`,
|
|
36
|
+
);
|
|
37
|
+
if (s.budget) lines.push(` budget ${formatBudget(s.budget)}`);
|
|
38
|
+
if (s.jev.decisions) lines.push(` jev ${s.jev.decisions} decision(s), ${s.jev.fallbacks} fallback(s)`);
|
|
39
|
+
for (const m of s.milestones) lines.push(` landed ${m}`);
|
|
40
|
+
for (const l of s.stateTail) lines.push(` | ${l}`);
|
|
41
|
+
for (const w of s.warnings) lines.push(` ${mark("warn")} ${w}`);
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function printStatus(runId: string | undefined, asJson: boolean): void {
|
|
46
|
+
// redacted as `runs show` is: the state.md tail can quote a secret, and status goes to shared terminals
|
|
47
|
+
registerSavedSecrets();
|
|
48
|
+
const r = redact(status(defaultDeps(), runId));
|
|
49
|
+
if (asJson) return printJson(r);
|
|
50
|
+
if (r.runs.length === 0) console.log("no runs yet");
|
|
51
|
+
for (const s of r.runs) for (const l of formatRun(s)) console.log(l);
|
|
52
|
+
for (const w of r.warnings) console.log(`${mark("warn")} ${w}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Spec §8 `catherd status [run] [--json]`: that run, else every run with a live role, else the newest. */
|
|
56
|
+
export const statusCommand = defineCommand({
|
|
57
|
+
meta: {
|
|
58
|
+
name: "status",
|
|
59
|
+
description: "A run at a glance (default: runs with a live role, else the newest)",
|
|
60
|
+
},
|
|
61
|
+
args: { run: { type: "positional", required: false, description: "run id" }, ...json },
|
|
62
|
+
run({ args }) {
|
|
63
|
+
printStatus(args.run, args.json === true);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** `watch --interval <secs>` in ms: 2 s when absent, never under 1 s (so 0 means 1 s); not a number is refused. */
|
|
68
|
+
export function redrawMs(interval: string | undefined): number {
|
|
69
|
+
if (interval === undefined) return 2000;
|
|
70
|
+
const secs = interval.trim() === "" ? Number.NaN : Number(interval);
|
|
71
|
+
if (!Number.isFinite(secs) || secs < 0)
|
|
72
|
+
throw new CatherdError("E_INPUT_INVALID", `--interval takes a number of seconds, not "${interval}"`, {
|
|
73
|
+
fix: "catherd watch --interval <seconds>",
|
|
74
|
+
});
|
|
75
|
+
return Math.max(1, secs) * 1000;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Spec §8 `catherd watch [--once]`: the TUI's Runs tab on a terminal, else a plain redraw. */
|
|
79
|
+
export const watchCommand = defineCommand({
|
|
80
|
+
meta: {
|
|
81
|
+
name: "watch",
|
|
82
|
+
description:
|
|
83
|
+
"The live runs: the dashboard's Runs tab on a terminal, else redrawn as text (--once: print once)",
|
|
84
|
+
},
|
|
85
|
+
args: {
|
|
86
|
+
once: { type: "boolean", description: "print one snapshot and exit" },
|
|
87
|
+
interval: { type: "string", description: "seconds between text redraws when piped (default 2)" },
|
|
88
|
+
plain: {
|
|
89
|
+
type: "boolean",
|
|
90
|
+
description: "the dashboard in ASCII without colour (NO_COLOR also drops colour)",
|
|
91
|
+
},
|
|
92
|
+
"reduced-motion": { type: "boolean", description: "the dashboard without animation" },
|
|
93
|
+
...json,
|
|
94
|
+
},
|
|
95
|
+
async run({ args }) {
|
|
96
|
+
const every = redrawMs(args.interval);
|
|
97
|
+
if (args.once || args.json) return printStatus(undefined, args.json === true);
|
|
98
|
+
// on a terminal, watch is the dashboard's Runs tab (plan 6 Ruling 10); piped output keeps the plain view
|
|
99
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
100
|
+
const { openTui } = await import("./tui/run.tsx");
|
|
101
|
+
const rawArgs = [
|
|
102
|
+
...(args.plain ? ["--plain"] : []),
|
|
103
|
+
...(args["reduced-motion"] ? ["--reduced-motion"] : []),
|
|
104
|
+
];
|
|
105
|
+
process.exitCode = await openTui({ rawArgs, tab: "runs", tty: true });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
for (;;) {
|
|
109
|
+
if (process.stdout.isTTY) process.stdout.write("\x1b[2J\x1b[H");
|
|
110
|
+
printStatus(undefined, false);
|
|
111
|
+
console.log(`updated ${new Date().toLocaleTimeString()} · Ctrl-C to stop`);
|
|
112
|
+
await Bun.sleep(every);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const list = defineCommand({
|
|
118
|
+
meta: { name: "list", description: "Every run, newest first" },
|
|
119
|
+
args: { repo: { type: "string", description: "only the runs of the git repo at this path" }, ...json },
|
|
120
|
+
async run({ args }) {
|
|
121
|
+
const top = args.repo ? await gitToplevel(resolve(args.repo)) : null;
|
|
122
|
+
if (args.repo && !top)
|
|
123
|
+
throw new CatherdError("E_INPUT_INVALID", `${resolve(args.repo)} is not inside a git repository`, {
|
|
124
|
+
fix: "pass a path inside the repo, or leave out --repo",
|
|
125
|
+
});
|
|
126
|
+
const { runs, corrupt } = listRuns();
|
|
127
|
+
const rows = runs
|
|
128
|
+
.filter((r) => !args.repo || r.meta.repo === top)
|
|
129
|
+
.map((r) => {
|
|
130
|
+
const s = summarizeRun(defaultDeps(), r);
|
|
131
|
+
return {
|
|
132
|
+
id: r.id,
|
|
133
|
+
title: r.meta.title,
|
|
134
|
+
repo: r.meta.repo,
|
|
135
|
+
createdAt: r.meta.createdAt,
|
|
136
|
+
live: s.live.length,
|
|
137
|
+
roleRuns: s.totals.runs,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
if (args.json) return printJson({ runs: rows, corrupt });
|
|
141
|
+
if (rows.length === 0) console.log("no runs yet");
|
|
142
|
+
for (const r of rows)
|
|
143
|
+
console.log(
|
|
144
|
+
`${r.id} ${r.live ? `${r.live} live` : "idle"} ${r.roleRuns} role run(s) ${r.title} ${r.repo}`,
|
|
145
|
+
);
|
|
146
|
+
for (const c of corrupt) console.log(`${mark("warn")} skipped run ${c.id}: ${c.reason}`);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const show = defineCommand({
|
|
151
|
+
meta: {
|
|
152
|
+
name: "show",
|
|
153
|
+
description: "One run and its role runs; --debug adds exit.json and the stderr and event tails",
|
|
154
|
+
},
|
|
155
|
+
args: {
|
|
156
|
+
id: { type: "positional", required: true, description: "run id" },
|
|
157
|
+
debug: {
|
|
158
|
+
type: "boolean",
|
|
159
|
+
description: "per dispatch: the record, exit.json, and the stderr, event and supervisor tails",
|
|
160
|
+
},
|
|
161
|
+
name: { type: "string", description: "with --debug: only this role name" },
|
|
162
|
+
...json,
|
|
163
|
+
},
|
|
164
|
+
run({ args }) {
|
|
165
|
+
const run = findRun(args.id);
|
|
166
|
+
// every part redacted, not only the dispatches: state.md and a record's reply can quote a secret too;
|
|
167
|
+
// the saved Jev key is a secret even in a process that never called Jev
|
|
168
|
+
registerSavedSecrets();
|
|
169
|
+
const { summary, records } = redact({
|
|
170
|
+
summary: summarizeRun(defaultDeps(), run),
|
|
171
|
+
records: readRecords(run).records,
|
|
172
|
+
});
|
|
173
|
+
const debug = args.debug ? runDebug(run, args.name) : undefined;
|
|
174
|
+
if (args.json) return printJson({ summary, records, ...(debug ? { dispatches: debug } : {}) });
|
|
175
|
+
for (const l of formatRun(summary)) console.log(l);
|
|
176
|
+
for (const r of records)
|
|
177
|
+
console.log(
|
|
178
|
+
` ${r.name} ${r.rung} ${r.status}${r.replyStatus ? `/${r.replyStatus}` : ""} ${r.secs}s ${n(r.tokens.input + r.tokens.output)} tokens`,
|
|
179
|
+
);
|
|
180
|
+
for (const d of debug ?? []) {
|
|
181
|
+
console.log(`\n--- ${d.name} ${d.dispatchId} (${d.rung}, admitted ${d.admittedAt})`);
|
|
182
|
+
console.log(`record: ${d.record ? JSON.stringify(d.record) : "none yet"}`);
|
|
183
|
+
console.log(`exit.json: ${d.exit ? JSON.stringify(d.exit) : "none yet"}`);
|
|
184
|
+
for (const [label, lines] of [
|
|
185
|
+
["stderr", d.stderrTail],
|
|
186
|
+
["events", d.eventsTail],
|
|
187
|
+
["supervisor.log", d.supervisorTail],
|
|
188
|
+
] as const) {
|
|
189
|
+
console.log(`${label} (last ${lines.length} lines):`);
|
|
190
|
+
for (const l of lines) console.log(` ${l}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const cancelCmd = defineCommand({
|
|
197
|
+
meta: {
|
|
198
|
+
name: "cancel",
|
|
199
|
+
description: "Stop a live role (interrupt, SIGTERM, SIGKILL) and record it cancelled",
|
|
200
|
+
},
|
|
201
|
+
args: {
|
|
202
|
+
id: { type: "positional", required: true, description: "run id" },
|
|
203
|
+
name: { type: "positional", required: true, description: "the role's name, as status lists it" },
|
|
204
|
+
},
|
|
205
|
+
async run({ args }) {
|
|
206
|
+
const r = await cancel(defaultDeps(), args.id, args.name);
|
|
207
|
+
console.log(`${mark("ok")} ${r.record.name} ${r.record.status}`);
|
|
208
|
+
for (const h of r.hints) console.log(` ${h}`);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
/** Spec §8 `catherd runs list|show [--debug]|cancel`; a bare `catherd runs` lists them, as `status` needs no run. */
|
|
213
|
+
export const runsCommand = defineCommand({
|
|
214
|
+
meta: { name: "runs", description: "Runs: list them (the default), show one, cancel a live role" },
|
|
215
|
+
subCommands: { list, show, cancel: cancelCmd },
|
|
216
|
+
default: "list",
|
|
217
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// The detached supervisor's entry: it imports only what supervising needs, never src/cli.ts and its TUI.
|
|
3
|
+
import { errorMessage } from "../domain/errors.ts";
|
|
4
|
+
import { runSupervise } from "./supervise-command.ts";
|
|
5
|
+
|
|
6
|
+
const spec = process.argv[2];
|
|
7
|
+
if (!spec) {
|
|
8
|
+
console.error("usage: supervise-bin.ts <spec.json>");
|
|
9
|
+
process.exit(2);
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
await runSupervise(spec);
|
|
13
|
+
} catch (e) {
|
|
14
|
+
console.error(`catherd supervisor: ${errorMessage(e)}`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
// exit.json is written: a hook's call the supervisor cut off (a hung `opencode api`) must not keep it alive.
|
|
18
|
+
process.exit(0);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { adapterFor } from "../adapters/registry.ts";
|
|
3
|
+
import { workerEnv } from "../infra/env.ts";
|
|
4
|
+
import { readVersioned } from "../infra/store.ts";
|
|
5
|
+
import { SuperviseSpecSchema, supervise } from "../infra/supervisor.ts";
|
|
6
|
+
import "../adapters/all.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Supervises the worker a spec file describes, with its backend's hooks, until exit.json is written.
|
|
10
|
+
* spec.json holds only the adapter's env overrides; the worker's env is built here, from this process's
|
|
11
|
+
* own inherited env (already without catherd's secrets), so no credential is ever on disk (spec §10.4).
|
|
12
|
+
*/
|
|
13
|
+
export async function runSupervise(specPath: string): Promise<void> {
|
|
14
|
+
const read = readVersioned(specPath, SuperviseSpecSchema, 1);
|
|
15
|
+
const spec = { ...read, env: workerEnv(process.env, read.env, read.cwd) };
|
|
16
|
+
const a = adapterFor(spec.backend);
|
|
17
|
+
const busy = a?.isBusy?.bind(a);
|
|
18
|
+
const stop = a?.interrupt?.bind(a);
|
|
19
|
+
await supervise(spec, {
|
|
20
|
+
onLine: (line) => {
|
|
21
|
+
const d = a?.parse(line) ?? {};
|
|
22
|
+
return { final: d.final, thread: d.thread, item: d.item };
|
|
23
|
+
},
|
|
24
|
+
isBusy: busy
|
|
25
|
+
? (thread, sinceMs) => (thread ? busy(thread, spec.cwd, sinceMs) : Promise.resolve(false))
|
|
26
|
+
: undefined,
|
|
27
|
+
interrupt: stop ? (thread) => (thread ? stop(thread, spec.cwd) : Promise.resolve()) : undefined,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const superviseCommand = defineCommand({
|
|
32
|
+
meta: { name: "_supervise", description: "internal: supervise one worker process", hidden: true },
|
|
33
|
+
args: { spec: { type: "positional", required: true } },
|
|
34
|
+
async run({ args }) {
|
|
35
|
+
await runSupervise(args.spec);
|
|
36
|
+
},
|
|
37
|
+
});
|