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.
Files changed (187) hide show
  1. package/README.md +114 -30
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -26
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -84
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /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
+ });