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.
Files changed (207) hide show
  1. package/README.md +141 -33
  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 +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -0,0 +1,260 @@
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, type Run } from "../services/run-store.ts";
11
+ import { groupRuns, type RunSession, type SessionGroup } from "../services/session-view.ts";
12
+ import { type RunSummary, status, summarizeRun } from "../services/summary.ts";
13
+ import { JSON_ARG, mark, printJson } from "./cli-kit.ts";
14
+ import { defaultDeps } from "./deps.ts";
15
+
16
+ const json = JSON_ARG;
17
+ const n = (x: number) => x.toLocaleString("en-US");
18
+
19
+ /** A session's heading in `runs list` and `status` (spec §4): `● live <name>` or `· idle <name>`. */
20
+ export function sessionHeading(s: RunSession | null): string {
21
+ return s ? `session ${s.live ? "● live" : "· idle"} ${s.name}` : "earlier runs";
22
+ }
23
+
24
+ /** How a run moved between sessions, after its line: `(continued here)`, `(continued in <name>)`. */
25
+ const movedNote = (g: SessionGroup["runs"][number]): string =>
26
+ g.continued === "here" ? " (continued here)" : g.continuedIn ? ` (continued in ${g.continuedIn})` : "";
27
+
28
+ /** One run at a glance: what is live, what finished, spend against the budget, landed milestones. */
29
+ export function formatRun(s: RunSummary, now: number = Date.now()): string[] {
30
+ const t = s.totals;
31
+ const lines = [
32
+ `run ${s.id} ${s.title}`,
33
+ ` repo ${s.repo} · started ${s.createdAt} · ${Math.round((now - Date.parse(s.createdAt)) / 60_000)} min`,
34
+ ];
35
+ for (const q of s.questions) lines.push(` ${mark("warn")} parked ${q.milestone}: ${q.question}`);
36
+ for (const l of s.live) lines.push(` live ${l.name} ${l.rung} ${l.state} ${l.secs}s`);
37
+ lines.push(
38
+ ` done ${t.runs} role run(s), ${t.ok} ok${t.notOk.length ? `; not ok: ${t.notOk.join(", ")}` : ""}`,
39
+ ` tokens ${n(t.tokens.input)} in (${n(t.tokens.cached)} cached) · ${n(t.tokens.output)} out · $${t.costUsd.toFixed(2)}` +
40
+ (s.agents.runs
41
+ ? ` · native agents ${s.agents.runs} run(s), ${n(s.agents.totalTokens)} tokens (reported)`
42
+ : ""),
43
+ );
44
+ if (s.harness.length)
45
+ lines.push(
46
+ ` harness ${s.harness.map((h) => `${h.backend} ${h.native} native, ${h.isolated} isolated`).join(" · ")}`,
47
+ );
48
+ if (s.budget) lines.push(` budget ${formatBudget(s.budget)}`);
49
+ if (s.jev.decisions) lines.push(` jev ${s.jev.decisions} decision(s), ${s.jev.fallbacks} fallback(s)`);
50
+ for (const m of s.milestones) lines.push(` landed ${m}`);
51
+ if (s.verifier)
52
+ lines.push(
53
+ ` verifier step ${s.verifier.item}${s.verifier.carried ? " (carried over)" : ""} at ${s.verifier.at.slice(11, 16)}`,
54
+ );
55
+ for (const l of s.stateTail) lines.push(` | ${l}`);
56
+ for (const w of s.warnings) lines.push(` ${mark("warn")} ${w}`);
57
+ return lines;
58
+ }
59
+
60
+ function printStatus(runId: string | undefined, asJson: boolean): void {
61
+ // redacted as `runs show` is: the state.md tail can quote a secret, and status goes to shared terminals
62
+ registerSavedSecrets();
63
+ const r = redact(status(defaultDeps(), runId));
64
+ if (asJson) return printJson(r);
65
+ if (r.runs.length === 0) console.log("no runs yet");
66
+ // grouped by session, as the runs page is (spec §4); a run shows once, under the session that started it
67
+ const byId = new Map(r.runs.map((s) => [s.id, s]));
68
+ const runs = r.runs.flatMap((s) => {
69
+ try {
70
+ return [findRun(s.id)];
71
+ } catch {
72
+ return [];
73
+ }
74
+ });
75
+ for (const g of groupRuns(runs)) {
76
+ const own = g.runs.filter((x) => x.continued !== "here");
77
+ if (own.length === 0) continue;
78
+ console.log(sessionHeading(g.session));
79
+ for (const x of own) {
80
+ const s = byId.get(x.run.id);
81
+ if (!s) continue;
82
+ const [head, ...rest] = formatRun(s);
83
+ console.log(`${head}${movedNote(x)}`);
84
+ for (const l of rest) console.log(l);
85
+ }
86
+ }
87
+ for (const w of r.warnings) console.log(`${mark("warn")} ${w}`);
88
+ }
89
+
90
+ /** Spec §8 `catherd status [run] [--json]`: that run, else every run with a live role, else the newest. */
91
+ export const statusCommand = defineCommand({
92
+ meta: {
93
+ name: "status",
94
+ description: "A run at a glance (default: runs with a live role, else the newest)",
95
+ },
96
+ args: { run: { type: "positional", required: false, description: "run id" }, ...json },
97
+ run({ args }) {
98
+ printStatus(args.run, args.json === true);
99
+ },
100
+ });
101
+
102
+ /** `watch --interval <secs>` in ms: 2 s when absent, never under 1 s (so 0 means 1 s); not a number is refused. */
103
+ export function redrawMs(interval: string | undefined): number {
104
+ if (interval === undefined) return 2000;
105
+ const secs = interval.trim() === "" ? Number.NaN : Number(interval);
106
+ if (!Number.isFinite(secs) || secs < 0)
107
+ throw new CatherdError("E_INPUT_INVALID", `--interval takes a number of seconds, not "${interval}"`, {
108
+ fix: "catherd watch --interval <seconds>",
109
+ });
110
+ return Math.max(1, secs) * 1000;
111
+ }
112
+
113
+ /** Spec §8 `catherd watch [--once]`: the TUI's Runs tab on a terminal, else a plain redraw. */
114
+ export const watchCommand = defineCommand({
115
+ meta: {
116
+ name: "watch",
117
+ description:
118
+ "The live runs: the dashboard's Runs tab on a terminal, else redrawn as text (--once: print once)",
119
+ },
120
+ args: {
121
+ once: { type: "boolean", description: "print one snapshot and exit" },
122
+ interval: { type: "string", description: "seconds between text redraws when piped (default 2)" },
123
+ plain: {
124
+ type: "boolean",
125
+ description: "the dashboard in ASCII without colour (NO_COLOR also drops colour)",
126
+ },
127
+ "reduced-motion": { type: "boolean", description: "the dashboard without animation" },
128
+ ...json,
129
+ },
130
+ async run({ args }) {
131
+ const every = redrawMs(args.interval);
132
+ if (args.once || args.json) return printStatus(undefined, args.json === true);
133
+ // on a terminal, watch is the dashboard's Runs tab (plan 6 Ruling 10); piped output keeps the plain view
134
+ if (process.stdin.isTTY && process.stdout.isTTY) {
135
+ const { openTui } = await import("./tui/run.tsx");
136
+ const rawArgs = [
137
+ ...(args.plain ? ["--plain"] : []),
138
+ ...(args["reduced-motion"] ? ["--reduced-motion"] : []),
139
+ ];
140
+ process.exitCode = await openTui({ rawArgs, tab: "runs", tty: true });
141
+ return;
142
+ }
143
+ for (;;) {
144
+ if (process.stdout.isTTY) process.stdout.write("\x1b[2J\x1b[H");
145
+ printStatus(undefined, false);
146
+ console.log(`updated ${new Date().toLocaleTimeString()} · Ctrl-C to stop`);
147
+ await Bun.sleep(every);
148
+ }
149
+ },
150
+ });
151
+
152
+ const list = defineCommand({
153
+ meta: { name: "list", description: "Every run, newest first" },
154
+ args: { repo: { type: "string", description: "only the runs of the git repo at this path" }, ...json },
155
+ async run({ args }) {
156
+ const top = args.repo ? await gitToplevel(resolve(args.repo)) : null;
157
+ if (args.repo && !top)
158
+ throw new CatherdError("E_INPUT_INVALID", `${resolve(args.repo)} is not inside a git repository`, {
159
+ fix: "pass a path inside the repo, or leave out --repo",
160
+ });
161
+ const { runs, corrupt } = listRuns();
162
+ const shown = runs.filter((r) => !args.repo || r.meta.repo === top);
163
+ const deps = defaultDeps();
164
+ const row = (r: Run) => {
165
+ const s = summarizeRun(deps, r);
166
+ return {
167
+ id: r.id,
168
+ title: r.meta.title,
169
+ repo: r.meta.repo,
170
+ createdAt: r.meta.createdAt,
171
+ live: s.live.length,
172
+ roleRuns: s.totals.runs,
173
+ session: s.session,
174
+ continuedIn: s.continuedIn,
175
+ };
176
+ };
177
+ const groups = groupRuns(shown);
178
+ if (args.json) return printJson({ runs: shown.map(row), corrupt });
179
+ if (shown.length === 0) console.log("no runs yet");
180
+ for (const g of groups) {
181
+ console.log(sessionHeading(g.session));
182
+ for (const x of g.runs) {
183
+ const r = row(x.run);
184
+ console.log(
185
+ ` ${r.id} ${r.live ? `${r.live} live` : "idle"} ${r.roleRuns} role run(s) ${r.title} ${r.repo}${movedNote(x)}`,
186
+ );
187
+ }
188
+ }
189
+ for (const c of corrupt) console.log(`${mark("warn")} skipped run ${c.id}: ${c.reason}`);
190
+ },
191
+ });
192
+
193
+ const show = defineCommand({
194
+ meta: {
195
+ name: "show",
196
+ description: "One run and its role runs; --debug adds exit.json and the stderr and event tails",
197
+ },
198
+ args: {
199
+ id: { type: "positional", required: true, description: "run id" },
200
+ debug: {
201
+ type: "boolean",
202
+ description: "per dispatch: the record, exit.json, and the stderr, event and supervisor tails",
203
+ },
204
+ name: { type: "string", description: "with --debug: only this role name" },
205
+ ...json,
206
+ },
207
+ run({ args }) {
208
+ const run = findRun(args.id);
209
+ // every part redacted, not only the dispatches: state.md and a record's reply can quote a secret too;
210
+ // the saved Jev key is a secret even in a process that never called Jev
211
+ registerSavedSecrets();
212
+ const { summary, records } = redact({
213
+ summary: summarizeRun(defaultDeps(), run),
214
+ records: readRecords(run).records,
215
+ });
216
+ const debug = args.debug ? runDebug(run, args.name) : undefined;
217
+ if (args.json) return printJson({ summary, records, ...(debug ? { dispatches: debug } : {}) });
218
+ for (const l of formatRun(summary)) console.log(l);
219
+ for (const r of records)
220
+ console.log(
221
+ ` ${r.name} ${r.rung} ${r.status}${r.replyStatus ? `/${r.replyStatus}` : ""} ${r.secs}s ${n(r.tokens.input + r.tokens.output)} tokens`,
222
+ );
223
+ for (const d of debug ?? []) {
224
+ console.log(`\n--- ${d.name} ${d.dispatchId} (${d.rung}, admitted ${d.admittedAt})`);
225
+ console.log(`record: ${d.record ? JSON.stringify(d.record) : "none yet"}`);
226
+ console.log(`exit.json: ${d.exit ? JSON.stringify(d.exit) : "none yet"}`);
227
+ for (const [label, lines] of [
228
+ ["stderr", d.stderrTail],
229
+ ["events", d.eventsTail],
230
+ ["supervisor.log", d.supervisorTail],
231
+ ] as const) {
232
+ console.log(`${label} (last ${lines.length} lines):`);
233
+ for (const l of lines) console.log(` ${l}`);
234
+ }
235
+ }
236
+ },
237
+ });
238
+
239
+ const cancelCmd = defineCommand({
240
+ meta: {
241
+ name: "cancel",
242
+ description: "Stop a live role (interrupt, SIGTERM, SIGKILL) and record it cancelled",
243
+ },
244
+ args: {
245
+ id: { type: "positional", required: true, description: "run id" },
246
+ name: { type: "positional", required: true, description: "the role's name, as status lists it" },
247
+ },
248
+ async run({ args }) {
249
+ const r = await cancel(defaultDeps(), args.id, args.name);
250
+ console.log(`${mark("ok")} ${r.record.name} ${r.record.status}`);
251
+ for (const h of r.hints) console.log(` ${h}`);
252
+ },
253
+ });
254
+
255
+ /** Spec §8 `catherd runs list|show [--debug]|cancel`; a bare `catherd runs` lists them, as `status` needs no run. */
256
+ export const runsCommand = defineCommand({
257
+ meta: { name: "runs", description: "Runs: list them (the default), show one, cancel a live role" },
258
+ subCommands: { list, show, cancel: cancelCmd },
259
+ default: "list",
260
+ });
@@ -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
+ });