catherd-cli 1.3.0 → 1.5.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 +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- package/src/services/workspace-store.ts +95 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { defineCommand } from "citty";
|
|
3
|
+
import { assertId } from "../domain/ids.ts";
|
|
4
|
+
import { knownQueueCapability, UNCHECKED_QUEUE } from "../infra/codex-queue.ts";
|
|
5
|
+
import { listDispatches } from "../services/dispatches.ts";
|
|
6
|
+
import { retryDelivery } from "../services/notifier.ts";
|
|
7
|
+
import { pauseLine } from "../services/pause.ts";
|
|
8
|
+
import { inspectDelivery } from "../services/run-debug.ts";
|
|
9
|
+
import { terminalHost } from "./host-arg.ts";
|
|
3
10
|
import { formatBudget } from "../domain/budget.ts";
|
|
4
11
|
import { CatherdError } from "../domain/errors.ts";
|
|
5
12
|
import { gitToplevel } from "../infra/git.ts";
|
|
@@ -7,10 +14,13 @@ import { redact } from "../infra/log.ts";
|
|
|
7
14
|
import { cancel } from "../services/dispatch-service.ts";
|
|
8
15
|
import { registerSavedSecrets } from "../services/jev-service.ts";
|
|
9
16
|
import { runDebug } from "../services/run-debug.ts";
|
|
17
|
+
import { cleanScratch } from "../services/scratch.ts";
|
|
18
|
+
import { repin } from "../services/run-pin.ts";
|
|
19
|
+
import { supersedeRun } from "../services/run-service.ts";
|
|
10
20
|
import { findRun, listRuns, readRecords, type Run } from "../services/run-store.ts";
|
|
11
21
|
import { groupRuns, type RunSession, type SessionGroup } from "../services/session-view.ts";
|
|
12
22
|
import { type RunSummary, status, summarizeRun } from "../services/summary.ts";
|
|
13
|
-
import { JSON_ARG, mark, printJson } from "./cli-kit.ts";
|
|
23
|
+
import { JSON_ARG, mark, printJson, refuseInRole } from "./cli-kit.ts";
|
|
14
24
|
import { defaultDeps } from "./deps.ts";
|
|
15
25
|
|
|
16
26
|
const json = JSON_ARG;
|
|
@@ -34,6 +44,10 @@ export function formatRun(s: RunSummary, now: number = Date.now()): string[] {
|
|
|
34
44
|
];
|
|
35
45
|
for (const q of s.questions) lines.push(` ${mark("warn")} parked ${q.milestone}: ${q.question}`);
|
|
36
46
|
for (const l of s.live) lines.push(` live ${l.name} ${l.rung} ${l.state} ${l.secs}s`);
|
|
47
|
+
if (s.waiting)
|
|
48
|
+
lines.push(
|
|
49
|
+
` ${s.waiting.stalled ? `${mark("warn")} stalled · ` : ""}waiting for orchestrator since ${s.waiting.since} · ${s.waiting.seconds}s · ${s.waiting.unread} unread result(s)`,
|
|
50
|
+
);
|
|
37
51
|
lines.push(
|
|
38
52
|
` done ${t.runs} role run(s), ${t.ok} ok${t.notOk.length ? `; not ok: ${t.notOk.join(", ")}` : ""}`,
|
|
39
53
|
` tokens ${n(t.tokens.input)} in (${n(t.tokens.cached)} cached) · ${n(t.tokens.output)} out · $${t.costUsd.toFixed(2)}` +
|
|
@@ -50,18 +64,35 @@ export function formatRun(s: RunSummary, now: number = Date.now()): string[] {
|
|
|
50
64
|
for (const m of s.milestones) lines.push(` landed ${m}`);
|
|
51
65
|
if (s.verifier)
|
|
52
66
|
lines.push(
|
|
53
|
-
` verifier step ${s.verifier.item}${s.verifier.carried ? " (carried over)" : ""} at ${s.verifier.at.slice(11, 16)}`,
|
|
67
|
+
` verifier step ${s.verifier.item}${s.verifier.carried ? " (carried over)" : ""} at ${s.verifier.at.slice(11, 16)} · ${Math.floor(s.verifier.secs / 60)} min ago · ${s.verifier.open ? "open" : `closed (${s.verifier.closedBy})`}`,
|
|
68
|
+
);
|
|
69
|
+
for (const d of s.delivery)
|
|
70
|
+
lines.push(
|
|
71
|
+
` ${JSON.parse(d.eventId)[2] === "stalled" ? "stalled notice" : d.delivery === "collected" ? "collected" : "unread"} ${d.name} · delivery ${d.delivery}${d.receipt ? ` · queue receipt ${d.receipt.msgId}` : ""}`,
|
|
54
72
|
);
|
|
55
73
|
for (const l of s.stateTail) lines.push(` | ${l}`);
|
|
56
74
|
for (const w of s.warnings) lines.push(` ${mark("warn")} ${w}`);
|
|
57
75
|
return lines;
|
|
58
76
|
}
|
|
59
77
|
|
|
60
|
-
|
|
78
|
+
/**
|
|
79
|
+
* `live`: a long-lived watch, which reads the queue capability a background probe keeps fresh. A one-shot
|
|
80
|
+
* status never probes it: a slow Codex CLI must not delay the stored run state, nor hold the process open.
|
|
81
|
+
*/
|
|
82
|
+
async function printStatus(runId: string | undefined, asJson: boolean, live = false): Promise<void> {
|
|
61
83
|
// redacted as `runs show` is: the state.md tail can quote a secret, and status goes to shared terminals
|
|
62
84
|
registerSavedSecrets();
|
|
63
|
-
const
|
|
85
|
+
const host = terminalHost(undefined);
|
|
86
|
+
const queue =
|
|
87
|
+
host.host === "codex" && !host.conflict
|
|
88
|
+
? live
|
|
89
|
+
? knownQueueCapability(process.env)
|
|
90
|
+
: UNCHECKED_QUEUE
|
|
91
|
+
: null;
|
|
92
|
+
const r = redact(status(defaultDeps(host), runId, queue));
|
|
64
93
|
if (asJson) return printJson(r);
|
|
94
|
+
// spec 1.5 "Group pause": a pause comes first, before any run
|
|
95
|
+
for (const p of r.paused) console.log(`${mark("warn")} ${pauseLine(p)}`);
|
|
65
96
|
if (r.runs.length === 0) console.log("no runs yet");
|
|
66
97
|
// grouped by session, as the runs page is (spec §4); a run shows once, under the session that started it
|
|
67
98
|
const byId = new Map(r.runs.map((s) => [s.id, s]));
|
|
@@ -84,18 +115,26 @@ function printStatus(runId: string | undefined, asJson: boolean): void {
|
|
|
84
115
|
for (const l of rest) console.log(l);
|
|
85
116
|
}
|
|
86
117
|
}
|
|
118
|
+
console.log(`inspection host ${r.host.host}${r.host.conflict ? ` · conflict: ${r.host.conflict}` : ""}`);
|
|
119
|
+
if (r.queue)
|
|
120
|
+
console.log(
|
|
121
|
+
r.queue.checked === false
|
|
122
|
+
? `queue unchecked · ${r.queue.reason}`
|
|
123
|
+
: `queue CLI ${r.queue.cli ? "supported" : "unavailable"} · server ${r.queue.server}${r.queue.reason ? ` · ${r.queue.reason}` : ""}`,
|
|
124
|
+
);
|
|
87
125
|
for (const w of r.warnings) console.log(`${mark("warn")} ${w}`);
|
|
88
126
|
}
|
|
89
127
|
|
|
90
|
-
/**
|
|
128
|
+
/** `catherd status [run] [--json]`: that run, else the live runs, else the runs waiting for their orchestrator, else the newest. */
|
|
91
129
|
export const statusCommand = defineCommand({
|
|
92
130
|
meta: {
|
|
93
131
|
name: "status",
|
|
94
|
-
description:
|
|
132
|
+
description:
|
|
133
|
+
"A run at a glance (default: live runs, else runs waiting for their orchestrator, else the newest)",
|
|
95
134
|
},
|
|
96
135
|
args: { run: { type: "positional", required: false, description: "run id" }, ...json },
|
|
97
136
|
run({ args }) {
|
|
98
|
-
printStatus(args.run, args.json === true);
|
|
137
|
+
return printStatus(args.run, args.json === true);
|
|
99
138
|
},
|
|
100
139
|
});
|
|
101
140
|
|
|
@@ -142,7 +181,7 @@ export const watchCommand = defineCommand({
|
|
|
142
181
|
}
|
|
143
182
|
for (;;) {
|
|
144
183
|
if (process.stdout.isTTY) process.stdout.write("\x1b[2J\x1b[H");
|
|
145
|
-
printStatus(undefined, false);
|
|
184
|
+
await printStatus(undefined, false, true);
|
|
146
185
|
console.log(`updated ${new Date().toLocaleTimeString()} · Ctrl-C to stop`);
|
|
147
186
|
await Bun.sleep(every);
|
|
148
187
|
}
|
|
@@ -172,6 +211,8 @@ const list = defineCommand({
|
|
|
172
211
|
roleRuns: s.totals.runs,
|
|
173
212
|
session: s.session,
|
|
174
213
|
continuedIn: s.continuedIn,
|
|
214
|
+
waiting: s.waiting,
|
|
215
|
+
supersededBy: s.supersededBy ?? null,
|
|
175
216
|
};
|
|
176
217
|
};
|
|
177
218
|
const groups = groupRuns(shown);
|
|
@@ -182,7 +223,7 @@ const list = defineCommand({
|
|
|
182
223
|
for (const x of g.runs) {
|
|
183
224
|
const r = row(x.run);
|
|
184
225
|
console.log(
|
|
185
|
-
` ${r.id} ${r.live ? `${r.live} live` : "idle"} ${r.roleRuns} role run(s) ${r.title} ${r.repo}${movedNote(x)}`,
|
|
226
|
+
` ${r.id} ${r.supersededBy ? `superseded by ${r.supersededBy}` : r.live ? `${r.live} live` : r.waiting ? `${r.waiting.stalled ? "stalled · " : ""}waiting for orchestrator ${r.waiting.seconds}s` : "idle"} ${r.roleRuns} role run(s) ${r.title} ${r.repo}${movedNote(x)}`,
|
|
186
227
|
);
|
|
187
228
|
}
|
|
188
229
|
}
|
|
@@ -223,6 +264,10 @@ const show = defineCommand({
|
|
|
223
264
|
for (const d of debug ?? []) {
|
|
224
265
|
console.log(`\n--- ${d.name} ${d.dispatchId} (${d.rung}, admitted ${d.admittedAt})`);
|
|
225
266
|
console.log(`record: ${d.record ? JSON.stringify(d.record) : "none yet"}`);
|
|
267
|
+
for (const e of d.deliveries.length ? d.deliveries : [d])
|
|
268
|
+
console.log(
|
|
269
|
+
`delivery: ${e.delivery}; event ${e.eventId}; receipt ${e.receipt ? JSON.stringify(e.receipt) : "none"}`,
|
|
270
|
+
);
|
|
226
271
|
console.log(`exit.json: ${d.exit ? JSON.stringify(d.exit) : "none yet"}`);
|
|
227
272
|
for (const [label, lines] of [
|
|
228
273
|
["stderr", d.stderrTail],
|
|
@@ -246,15 +291,116 @@ const cancelCmd = defineCommand({
|
|
|
246
291
|
name: { type: "positional", required: true, description: "the role's name, as status lists it" },
|
|
247
292
|
},
|
|
248
293
|
async run({ args }) {
|
|
294
|
+
refuseInRole(process.env, "catherd runs cancel");
|
|
249
295
|
const r = await cancel(defaultDeps(), args.id, args.name);
|
|
250
296
|
console.log(`${mark("ok")} ${r.record.name} ${r.record.status}`);
|
|
251
297
|
for (const h of r.hints) console.log(` ${h}`);
|
|
252
298
|
},
|
|
253
299
|
});
|
|
254
300
|
|
|
301
|
+
const clean = defineCommand({
|
|
302
|
+
meta: {
|
|
303
|
+
name: "clean",
|
|
304
|
+
description:
|
|
305
|
+
"Remove the roles' scratch folders (their TMPDIR) of a run with no live role, or of every such run",
|
|
306
|
+
},
|
|
307
|
+
args: { id: { type: "positional", required: false, description: "run id (default: every run)" }, ...json },
|
|
308
|
+
run({ args }) {
|
|
309
|
+
refuseInRole(process.env, "catherd runs clean");
|
|
310
|
+
const r = cleanScratch({ run: args.id });
|
|
311
|
+
if (args.json) return printJson(r);
|
|
312
|
+
for (const x of r.removed)
|
|
313
|
+
console.log(`${mark("ok")} ${x.run} scratch removed (${n(Math.round(x.bytes / 1024))} KB)`);
|
|
314
|
+
for (const x of r.kept) console.log(`${mark("skip")} ${x.run} scratch kept: ${x.why}`);
|
|
315
|
+
if (!r.removed.length && !r.kept.length) console.log("no scratch to remove");
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const retryPush = defineCommand({
|
|
320
|
+
meta: {
|
|
321
|
+
name: "retry-push",
|
|
322
|
+
description: "Retry a stored event for its current owner, acknowledging possible duplicate input",
|
|
323
|
+
},
|
|
324
|
+
args: {
|
|
325
|
+
run: { type: "positional", required: true, description: "stored run id" },
|
|
326
|
+
name: { type: "positional", required: true, description: "stored role name" },
|
|
327
|
+
event: { type: "string", required: true, description: "exact event ID shown by peek/status" },
|
|
328
|
+
"acknowledge-possible-duplicate": {
|
|
329
|
+
type: "boolean",
|
|
330
|
+
description: "explicitly acknowledge possible duplicate native input",
|
|
331
|
+
},
|
|
332
|
+
...json,
|
|
333
|
+
},
|
|
334
|
+
async run({ args }) {
|
|
335
|
+
refuseInRole(process.env, "catherd runs retry-push");
|
|
336
|
+
if (args["acknowledge-possible-duplicate"] !== true)
|
|
337
|
+
throw new CatherdError("E_INPUT_INVALID", "Retry requires --acknowledge-possible-duplicate");
|
|
338
|
+
assertId("role name", args.name);
|
|
339
|
+
const run = findRun(args.run);
|
|
340
|
+
const d = listDispatches(run).find(
|
|
341
|
+
(d) =>
|
|
342
|
+
d.admit.name === args.name &&
|
|
343
|
+
["finished", "stalled"].some(
|
|
344
|
+
(kind) => args.event === JSON.stringify([run.id, d.admit.dispatchId, kind]),
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
if (!d) throw new CatherdError("E_INPUT_INVALID", "Event does not belong to this stored run and role");
|
|
348
|
+
const deps = defaultDeps(terminalHost(undefined));
|
|
349
|
+
await retryDelivery(deps, d.dir, args.event, { allowPossibleDuplicate: true });
|
|
350
|
+
const inspection = inspectDelivery(
|
|
351
|
+
run,
|
|
352
|
+
d,
|
|
353
|
+
readRecords(run).records.some((r) => r.dispatchId === d.admit.dispatchId),
|
|
354
|
+
args.event,
|
|
355
|
+
);
|
|
356
|
+
if (args.json) printJson({ ...inspection, processing: "unconfirmed" });
|
|
357
|
+
else
|
|
358
|
+
console.log(
|
|
359
|
+
`retry inspected: ${inspection.delivery}; event ${args.event}${inspection.receipt ? `; queue receipt ${inspection.receipt.msgId}` : ""}; processing unconfirmed; use peek/result`,
|
|
360
|
+
);
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const supersede = defineCommand({
|
|
365
|
+
meta: {
|
|
366
|
+
name: "supersede",
|
|
367
|
+
description: "Close a run with a pointer to the run that took it over; status hides it",
|
|
368
|
+
},
|
|
369
|
+
args: {
|
|
370
|
+
id: { type: "positional", required: true, description: "the run to close" },
|
|
371
|
+
by: { type: "string", required: true, description: "the run that took it over" },
|
|
372
|
+
...json,
|
|
373
|
+
},
|
|
374
|
+
async run({ args }) {
|
|
375
|
+
refuseInRole(process.env, "catherd runs supersede");
|
|
376
|
+
const r = await supersedeRun(defaultDeps(), { run: args.id, by: args.by });
|
|
377
|
+
if (args.json) return printJson(r);
|
|
378
|
+
console.log(`${mark("ok")} ${r.run} superseded by ${r.by}`);
|
|
379
|
+
for (const h of r.hints ?? []) console.log(` ${h}`);
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const pin = defineCommand({
|
|
384
|
+
meta: {
|
|
385
|
+
name: "pin",
|
|
386
|
+
description: "Re-pin a run to the profile, access and isolation its repo runs on now",
|
|
387
|
+
},
|
|
388
|
+
args: { id: { type: "positional", required: true, description: "run id" }, ...json },
|
|
389
|
+
async run({ args }) {
|
|
390
|
+
refuseInRole(process.env, "catherd runs pin");
|
|
391
|
+
const r = await repin(defaultDeps(), { run: args.id });
|
|
392
|
+
if (args.json) return printJson(r);
|
|
393
|
+
console.log(`${mark("ok")} ${args.id} pinned to ${r.pin.profile}`);
|
|
394
|
+
for (const c of r.changed) console.log(` was: ${c}`);
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
255
398
|
/** Spec §8 `catherd runs list|show [--debug]|cancel`; a bare `catherd runs` lists them, as `status` needs no run. */
|
|
256
399
|
export const runsCommand = defineCommand({
|
|
257
|
-
meta: {
|
|
258
|
-
|
|
400
|
+
meta: {
|
|
401
|
+
name: "runs",
|
|
402
|
+
description: "Runs: list them (the default), show one, cancel a live role, supersede or re-pin one",
|
|
403
|
+
},
|
|
404
|
+
subCommands: { list, show, cancel: cancelCmd, clean, "retry-push": retryPush, supersede, pin },
|
|
259
405
|
default: "list",
|
|
260
406
|
});
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { adapterFor } from "../adapters/registry.ts";
|
|
3
3
|
import { workerEnv } from "../infra/env.ts";
|
|
4
|
+
import { log } from "../infra/log.ts";
|
|
5
|
+
import { loginEnv } from "../infra/login-env.ts";
|
|
4
6
|
import { readVersioned } from "../infra/store.ts";
|
|
5
7
|
import { SuperviseSpecSchema, supervise } from "../infra/supervisor.ts";
|
|
8
|
+
import { resolveRefs } from "../services/gate-env.ts";
|
|
6
9
|
import "../adapters/all.ts";
|
|
7
10
|
|
|
8
11
|
/**
|
|
@@ -10,22 +13,46 @@ import "../adapters/all.ts";
|
|
|
10
13
|
* spec.json holds only the adapter's env overrides; the worker's env is built here, from this process's
|
|
11
14
|
* own inherited env (already without catherd's secrets), so no credential is ever on disk (spec §10.4).
|
|
12
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* The worker's env: this process's env, the gate env's references (plan 23: read here from this env, else the
|
|
18
|
+
* login env), then the spec's own env last, so no reference ever takes the role's identity, scratch or home.
|
|
19
|
+
*/
|
|
20
|
+
export function superviseEnv(
|
|
21
|
+
read: { env: Record<string, string>; envFrom?: Record<string, string>; cwd: string; dispatchDir?: string },
|
|
22
|
+
base: Record<string, string | undefined>,
|
|
23
|
+
login: () => Record<string, string | undefined>,
|
|
24
|
+
): Record<string, string> {
|
|
25
|
+
const refs = resolveRefs({}, read.envFrom ?? {}, base, login);
|
|
26
|
+
if (refs.missing.length) log("warn", "gate-env", { dispatch: read.dispatchDir, unset: refs.missing });
|
|
27
|
+
return workerEnv(base, { ...refs.env, ...read.env }, read.cwd);
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
export async function runSupervise(specPath: string): Promise<void> {
|
|
14
31
|
const read = readVersioned(specPath, SuperviseSpecSchema, 1);
|
|
15
|
-
const spec = { ...read, env:
|
|
32
|
+
const spec = { ...read, env: superviseEnv(read, process.env, loginEnv) };
|
|
16
33
|
const a = adapterFor(spec.backend);
|
|
17
34
|
const busy = a?.isBusy?.bind(a);
|
|
18
35
|
const stop = a?.interrupt?.bind(a);
|
|
19
|
-
|
|
36
|
+
const retrying = a?.providerRetry?.bind(a);
|
|
37
|
+
const ended = await supervise(spec, {
|
|
20
38
|
onLine: (line) => {
|
|
21
39
|
const d = a?.parse(line) ?? {};
|
|
22
|
-
return { final: d.final, thread: d.thread, item: d.item };
|
|
40
|
+
return { final: d.final, thread: d.thread, item: d.item, step: d.step };
|
|
23
41
|
},
|
|
24
42
|
isBusy: busy
|
|
25
43
|
? (thread, sinceMs) => (thread ? busy(thread, spec.cwd, sinceMs) : Promise.resolve(false))
|
|
26
44
|
: undefined,
|
|
27
45
|
interrupt: stop ? (thread) => (thread ? stop(thread, spec.cwd) : Promise.resolve()) : undefined,
|
|
46
|
+
providerRetry: retrying
|
|
47
|
+
? (thread, sinceMs) => (thread ? retrying(thread, spec.cwd, sinceMs) : Promise.resolve(null))
|
|
48
|
+
: undefined,
|
|
28
49
|
});
|
|
50
|
+
// plan 22: a Codex owner hears of the role from here too, when its thread's MCP server is gone. Loaded only
|
|
51
|
+
// once the worker ended (a supervisor that lost the lock to another pushes nothing); never throws.
|
|
52
|
+
if (ended && process.env.CATHERD_NO_END_PUSH !== "1") {
|
|
53
|
+
const { pushFromEnd } = await import("../services/end-push.ts");
|
|
54
|
+
await pushFromEnd(spec.dispatchDir);
|
|
55
|
+
}
|
|
29
56
|
}
|
|
30
57
|
|
|
31
58
|
export const superviseCommand = defineCommand({
|
package/src/entry/tui/effects.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { HostContext } from "../../domain/host.ts";
|
|
2
|
+
import { sessionKey } from "../../domain/host.ts";
|
|
1
3
|
import { realpathSync, statSync, watch } from "node:fs";
|
|
2
4
|
import { adapterFor } from "../../adapters/registry.ts";
|
|
3
5
|
import "../../adapters/all.ts";
|
|
@@ -70,6 +72,7 @@ export type {
|
|
|
70
72
|
|
|
71
73
|
/** One line of the run list. */
|
|
72
74
|
export interface RunRow {
|
|
75
|
+
waiting?: RunSummary["waiting"];
|
|
73
76
|
id: string;
|
|
74
77
|
title: string;
|
|
75
78
|
repo: string;
|
|
@@ -88,6 +91,7 @@ export interface RunRow {
|
|
|
88
91
|
* The live set calls the services; tests and the storybook pass fakes.
|
|
89
92
|
*/
|
|
90
93
|
export interface Effects {
|
|
94
|
+
host: HostContext;
|
|
91
95
|
version: string;
|
|
92
96
|
doctor(): Promise<DoctorReport>;
|
|
93
97
|
runs(): { rows: RunRow[]; warnings: string[] };
|
|
@@ -204,6 +208,7 @@ export function stampOf(dir: string): string {
|
|
|
204
208
|
p.jev,
|
|
205
209
|
p.agents,
|
|
206
210
|
p.state,
|
|
211
|
+
p.stateJson,
|
|
207
212
|
p.ledger,
|
|
208
213
|
p.roles,
|
|
209
214
|
profilesDir(),
|
|
@@ -230,6 +235,7 @@ export function memoRuns(compute: (run: Run) => RunRow, stamp: (dir: string) =>
|
|
|
230
235
|
runs.map((r) => {
|
|
231
236
|
const s = stamp(r.dir);
|
|
232
237
|
const hit = cache.get(r.dir);
|
|
238
|
+
// the wait is computed with the row, once per stamp change; the views age it (waitingLine)
|
|
233
239
|
if (hit && hit.stamp === s && hit.row.live === 0) return hit.row;
|
|
234
240
|
const row = compute(r);
|
|
235
241
|
cache.set(r.dir, { stamp: s, row });
|
|
@@ -239,6 +245,7 @@ export function memoRuns(compute: (run: Run) => RunRow, stamp: (dir: string) =>
|
|
|
239
245
|
|
|
240
246
|
export function rowOf(s: RunSummary): RunRow {
|
|
241
247
|
return {
|
|
248
|
+
waiting: s.waiting,
|
|
242
249
|
id: s.id,
|
|
243
250
|
title: s.title,
|
|
244
251
|
repo: s.repo,
|
|
@@ -247,7 +254,7 @@ export function rowOf(s: RunSummary): RunRow {
|
|
|
247
254
|
roleRuns: s.totals.runs,
|
|
248
255
|
landed: s.milestones.length,
|
|
249
256
|
budget: s.budget?.fraction ?? null,
|
|
250
|
-
session: s.session
|
|
257
|
+
session: s.session ? sessionKey(s.session) : null,
|
|
251
258
|
};
|
|
252
259
|
}
|
|
253
260
|
|
|
@@ -314,14 +321,19 @@ export function watchDirs(
|
|
|
314
321
|
* The live effects. `repo` is the git toplevel the TUI was opened in (null outside one): inside a repo
|
|
315
322
|
* bound to a profile, `here` is that profile and activating binds the repo instead of the global profile.
|
|
316
323
|
*/
|
|
317
|
-
export function liveEffects(
|
|
318
|
-
|
|
324
|
+
export function liveEffects(
|
|
325
|
+
repo: string | null = null,
|
|
326
|
+
host: HostContext = { host: "unknown", session: null, conflict: null },
|
|
327
|
+
): Effects {
|
|
328
|
+
const deps = defaultDeps(host);
|
|
319
329
|
const rows = memoRuns((r) => rowOf(summarizeRun(deps, r)));
|
|
320
330
|
const harnesses = HARNESS_KEYS.filter((h) => adapterFor(h));
|
|
321
331
|
const bound = () => (repo !== null && readProjects().bindings[repo] !== undefined ? repo : null);
|
|
322
332
|
return {
|
|
333
|
+
host,
|
|
323
334
|
version: VERSION,
|
|
324
|
-
doctor: () =>
|
|
335
|
+
doctor: () =>
|
|
336
|
+
doctor({ host, repo, bunVersion: Bun.version, version: VERSION, handshake: () => mcpHandshake() }),
|
|
325
337
|
runs() {
|
|
326
338
|
const { runs, corrupt } = listRuns();
|
|
327
339
|
return { rows: rows(runs), warnings: corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`) };
|
|
@@ -343,7 +355,7 @@ export function liveEffects(repo: string | null = null): Effects {
|
|
|
343
355
|
models: catalogQuery({ scoredOnly: false, limit: Number.MAX_SAFE_INTEGER }, billing).models,
|
|
344
356
|
};
|
|
345
357
|
},
|
|
346
|
-
validate: (p, c) => validateHere(p, c),
|
|
358
|
+
validate: (p, c) => validateHere(p, c, undefined, host.host),
|
|
347
359
|
enforcement: enforcementOf,
|
|
348
360
|
harnesses,
|
|
349
361
|
isolation(h) {
|
|
@@ -356,10 +368,13 @@ export function liveEffects(repo: string | null = null): Effects {
|
|
|
356
368
|
agents: (p) => agentFiles(p, VERSION).map((f) => f.name),
|
|
357
369
|
async save(name, patch, treatLikes, shown) {
|
|
358
370
|
for (const [rung, like] of Object.entries(treatLikes)) await saveTreatLike(rung, like);
|
|
359
|
-
return patchProfile(name, patch,
|
|
371
|
+
return patchProfile(name, patch, {
|
|
372
|
+
host: host.host,
|
|
373
|
+
...(shown === undefined ? {} : { expect: shown }),
|
|
374
|
+
});
|
|
360
375
|
},
|
|
361
|
-
activate: (name, scope) => activate(name, scope),
|
|
362
|
-
create: createProfile,
|
|
376
|
+
activate: (name, scope) => activate(name, scope, host.host),
|
|
377
|
+
create: (name, from) => createProfile(name, from, host.host),
|
|
363
378
|
remove: deleteProfile,
|
|
364
379
|
refreshCatalog: () => refreshDiscovery(),
|
|
365
380
|
syncSources: () => syncSources(),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../../domain/host.ts";
|
|
2
|
+
import { sessionKey } from "../../domain/host.ts";
|
|
1
3
|
import { isDeepStrictEqual } from "node:util";
|
|
2
4
|
import {
|
|
3
5
|
applyPatch,
|
|
@@ -30,6 +32,9 @@ import { withStaged } from "./profile-tree.ts";
|
|
|
30
32
|
* the catalog is the shipped one, read through the catalog service.
|
|
31
33
|
*/
|
|
32
34
|
export const FIXTURE_REPORT: DoctorReport = {
|
|
35
|
+
host: { host: "unknown", session: null, conflict: null },
|
|
36
|
+
queue: null,
|
|
37
|
+
push: null,
|
|
33
38
|
ready: false,
|
|
34
39
|
version: "1.0.0",
|
|
35
40
|
checks: [
|
|
@@ -79,6 +84,7 @@ export const FIXTURE_REPORT: DoctorReport = {
|
|
|
79
84
|
};
|
|
80
85
|
|
|
81
86
|
const summary = (o: Partial<RunSummary> & Pick<RunSummary, "id" | "title">): RunSummary => ({
|
|
87
|
+
delivery: [],
|
|
82
88
|
repo: "/home/me/app",
|
|
83
89
|
createdAt: "2026-09-26T11:48:00.000Z",
|
|
84
90
|
session: null,
|
|
@@ -112,7 +118,13 @@ const FIXTURE_RUNS: RunSummary[] = [
|
|
|
112
118
|
summary({
|
|
113
119
|
id: JOBS,
|
|
114
120
|
title: "Jobs screen",
|
|
115
|
-
session: {
|
|
121
|
+
session: {
|
|
122
|
+
host: "claude-code",
|
|
123
|
+
sessionId: "s-jobs",
|
|
124
|
+
hostSessionId: null,
|
|
125
|
+
name: "jobs screen",
|
|
126
|
+
live: true,
|
|
127
|
+
},
|
|
116
128
|
stateTail: ["M1 in review", "Next: fix round for M1.L2"],
|
|
117
129
|
live: [
|
|
118
130
|
{
|
|
@@ -141,7 +153,13 @@ const FIXTURE_RUNS: RunSummary[] = [
|
|
|
141
153
|
title: "Auth refactor",
|
|
142
154
|
repo: "/home/me/api",
|
|
143
155
|
createdAt: "2026-09-25T09:00:00.000Z",
|
|
144
|
-
session: {
|
|
156
|
+
session: {
|
|
157
|
+
host: "claude-code",
|
|
158
|
+
sessionId: "s-auth",
|
|
159
|
+
hostSessionId: null,
|
|
160
|
+
name: "auth build",
|
|
161
|
+
live: false,
|
|
162
|
+
},
|
|
145
163
|
continuedIn: "kit follow-up",
|
|
146
164
|
stateTail: ["Next: done"],
|
|
147
165
|
totals: {
|
|
@@ -200,7 +218,7 @@ const auth = (continued: SessionRun["continued"]): SessionRun => ({
|
|
|
200
218
|
export const FIXTURE_SESSIONS: { row: SessionRow; runs: SessionRun[] }[] = [
|
|
201
219
|
{
|
|
202
220
|
row: {
|
|
203
|
-
key: "s-jobs",
|
|
221
|
+
key: sessionKey({ host: "claude-code", sessionId: "s-jobs" }),
|
|
204
222
|
name: "jobs screen",
|
|
205
223
|
live: true,
|
|
206
224
|
runs: 1,
|
|
@@ -267,7 +285,7 @@ export const FIXTURE_SESSIONS: { row: SessionRow; runs: SessionRun[] }[] = [
|
|
|
267
285
|
},
|
|
268
286
|
{
|
|
269
287
|
row: {
|
|
270
|
-
key: "s-kit",
|
|
288
|
+
key: sessionKey({ host: "claude-code", sessionId: "s-kit" }),
|
|
271
289
|
name: "kit follow-up",
|
|
272
290
|
live: false,
|
|
273
291
|
runs: 1,
|
|
@@ -279,7 +297,7 @@ export const FIXTURE_SESSIONS: { row: SessionRow; runs: SessionRun[] }[] = [
|
|
|
279
297
|
},
|
|
280
298
|
{
|
|
281
299
|
row: {
|
|
282
|
-
key: "s-auth",
|
|
300
|
+
key: sessionKey({ host: "claude-code", sessionId: "s-auth" }),
|
|
283
301
|
name: "auth build",
|
|
284
302
|
live: false,
|
|
285
303
|
runs: 2,
|
|
@@ -394,6 +412,7 @@ const BACKENDS = ["claude", "codex", "claude-code", "opencode"];
|
|
|
394
412
|
* directory the TUI stands in and `bindings` the repo bindings, as `liveEffects(repo)` reads them.
|
|
395
413
|
*/
|
|
396
414
|
export interface FixtureOptions {
|
|
415
|
+
host?: OrchestrationHost;
|
|
397
416
|
runs?: RunSummary[];
|
|
398
417
|
sessions?: { row: SessionRow; runs: SessionRun[] }[];
|
|
399
418
|
report?: DoctorReport;
|
|
@@ -418,6 +437,7 @@ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
|
|
|
418
437
|
/** the run folders watched now */
|
|
419
438
|
watched: string[][];
|
|
420
439
|
} {
|
|
440
|
+
const host = o.host ?? "claude-code";
|
|
421
441
|
const runs = o.runs ?? FIXTURE_RUNS;
|
|
422
442
|
const sessions = o.sessions ?? FIXTURE_SESSIONS;
|
|
423
443
|
const listeners = new Set<() => void>();
|
|
@@ -434,15 +454,17 @@ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
|
|
|
434
454
|
after: ProfileDoc,
|
|
435
455
|
staged: Record<string, string> = {},
|
|
436
456
|
) => {
|
|
437
|
-
const p = resolveProfile(after, name);
|
|
457
|
+
const p = resolveProfile(after, name, host);
|
|
438
458
|
const c = withStaged(loadCatalog({ timings: false }), staged);
|
|
439
|
-
const v = validateProfile(p, c, BACKENDS);
|
|
459
|
+
const v = validateProfile(p, c, BACKENDS, undefined, host);
|
|
440
460
|
// spec 1.2 §6.2, as the ProfileService rules: a save that repairs part of an invalid profile goes through
|
|
441
|
-
const was = docs.has(name)
|
|
461
|
+
const was = docs.has(name)
|
|
462
|
+
? validateProfile(resolveProfile(before, name, host), c, BACKENDS, undefined, host)
|
|
463
|
+
: null;
|
|
442
464
|
return {
|
|
443
465
|
saved: repairs(was, v),
|
|
444
466
|
...v,
|
|
445
|
-
diff: diffProfiles(resolveProfile(before, name), p),
|
|
467
|
+
diff: diffProfiles(resolveProfile(before, name, host), p),
|
|
446
468
|
linked: [],
|
|
447
469
|
pruned: [],
|
|
448
470
|
newSessionNeededFor: [],
|
|
@@ -454,6 +476,7 @@ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
|
|
|
454
476
|
touch: () => {
|
|
455
477
|
for (const l of listeners) l();
|
|
456
478
|
},
|
|
479
|
+
host: { host, session: null, conflict: null },
|
|
457
480
|
version: "1.0.0",
|
|
458
481
|
doctor: async () => o.report ?? FIXTURE_REPORT,
|
|
459
482
|
runs: () => ({ rows: runs.map(rowOf), warnings: [] }),
|
|
@@ -520,7 +543,7 @@ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
|
|
|
520
543
|
catalog: loadCatalog({ timings: false }),
|
|
521
544
|
models: catalogQuery({ scoredOnly: false, limit: Number.MAX_SAFE_INTEGER }, billing).models,
|
|
522
545
|
}),
|
|
523
|
-
validate: (p, c) => validateProfile(p, c, BACKENDS),
|
|
546
|
+
validate: (p, c) => validateProfile(p, c, BACKENDS, undefined, host),
|
|
524
547
|
enforcement: (rung) => (rung.startsWith("codex:") ? "enforced" : "advisory"),
|
|
525
548
|
harnesses: ["codex", "claude-code", "opencode"],
|
|
526
549
|
isolation: () => ({}),
|
package/src/entry/tui/run.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { terminalHost } from "../host-arg.ts";
|
|
1
2
|
import { type CliRenderer, createCliRenderer, SystemClock } from "@opentui/core";
|
|
2
3
|
import { createRoot } from "@opentui/react";
|
|
3
4
|
import { gitToplevel } from "../../infra/git.ts";
|
|
@@ -26,6 +27,7 @@ import { App } from "./views/app.tsx";
|
|
|
26
27
|
|
|
27
28
|
export interface TuiOptions {
|
|
28
29
|
rawArgs: string[];
|
|
30
|
+
host?: string;
|
|
29
31
|
tab?: Tab;
|
|
30
32
|
env?: Record<string, string | undefined>;
|
|
31
33
|
/** Whether stdin and stdout are a terminal; tests pass it so none reads the real one. */
|
|
@@ -59,8 +61,9 @@ export async function openTui(o: TuiOptions): Promise<number> {
|
|
|
59
61
|
}
|
|
60
62
|
const keybinds = keybindsFromConfig();
|
|
61
63
|
const story = Boolean(env.CATHERD_STORY);
|
|
64
|
+
const host = terminalHost(o.host, env);
|
|
62
65
|
// the repo this directory is in, resolved once at start (the repo ruling)
|
|
63
|
-
const effects: Effects = story ? fixtureEffects() : liveEffects(await gitToplevel(process.cwd()));
|
|
66
|
+
const effects: Effects = story ? fixtureEffects() : liveEffects(await gitToplevel(process.cwd()), host);
|
|
64
67
|
const renderer = await createCliRenderer({
|
|
65
68
|
exitOnCtrlC: false,
|
|
66
69
|
useMouse: false,
|
|
@@ -83,7 +86,7 @@ export async function openTui(o: TuiOptions): Promise<number> {
|
|
|
83
86
|
keymap={createAppKeymap(renderer)}
|
|
84
87
|
effects={effects}
|
|
85
88
|
clock={new WallClock()}
|
|
86
|
-
initial={initialState(o.tab ?? "status")}
|
|
89
|
+
initial={initialState(o.tab ?? "status", effects.host.host)}
|
|
87
90
|
copy={(text) => renderer.copyToClipboardOSC52(text)}
|
|
88
91
|
onExit={(code, kept) => {
|
|
89
92
|
result = { code, kept };
|
|
@@ -103,6 +106,9 @@ export async function openTui(o: TuiOptions): Promise<number> {
|
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
/** Bare `catherd`: `cli.ts` imports this only when no subcommand was named, so no subcommand loads OpenTUI. */
|
|
106
|
-
export async function tuiRun(ctx: { rawArgs: string[] }): Promise<void> {
|
|
107
|
-
process.exitCode = await openTui({
|
|
109
|
+
export async function tuiRun(ctx: { rawArgs: string[]; args?: Record<string, unknown> }): Promise<void> {
|
|
110
|
+
process.exitCode = await openTui({
|
|
111
|
+
rawArgs: ctx.rawArgs,
|
|
112
|
+
host: typeof ctx.args?.host === "string" ? ctx.args.host : undefined,
|
|
113
|
+
});
|
|
108
114
|
}
|