catherd-cli 1.4.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 +93 -30
- 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 +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +110 -45
- package/plugin/skills/catherd-setup/SKILL.md +2 -2
- 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/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 +5 -0
- 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/lane.ts +99 -3
- package/src/domain/notice.ts +9 -2
- package/src/domain/profile-rules.ts +112 -16
- package/src/domain/profile.ts +24 -0
- package/src/domain/record.ts +16 -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 +14 -6
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +2 -1
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +104 -8
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +6 -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 +20 -4
- package/src/entry/mcp/server.ts +98 -7
- package/src/entry/mcp/setup-tools.ts +4 -3
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +23 -4
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +78 -8
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +4 -0
- package/src/entry/tui/state.ts +37 -10
- package/src/entry/tui/views/profiles.tsx +1 -1
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +6 -2
- 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/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -1
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- 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/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 +107 -16
- package/src/services/admission.ts +110 -23
- 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 +204 -16
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +68 -6
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +12 -2
- 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 +257 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +56 -3
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/ports.ts +31 -2
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +1 -0
- package/src/services/profile-store.ts +10 -3
- 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 +144 -36
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +151 -19
- package/src/services/run-store.ts +109 -15
- package/src/services/runs-page.ts +42 -7
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- 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 +49 -9
- package/src/services/treat-likes.ts +19 -3
- 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
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
runnableBackends,
|
|
35
35
|
validateNamed,
|
|
36
36
|
} from "../services/profile-store.ts";
|
|
37
|
-
import { EXIT, JSON_ARG, mark, printJson } from "./cli-kit.ts";
|
|
37
|
+
import { EXIT, JSON_ARG, mark, printJson, refuseInRole } from "./cli-kit.ts";
|
|
38
38
|
|
|
39
39
|
const json = JSON_ARG;
|
|
40
40
|
|
|
@@ -82,6 +82,10 @@ export function formatProfile(
|
|
|
82
82
|
`objective ${p.objective} · jev ${p.jev.use} · heavy slots ${p.lock.heavy} · notify ${p.notify.join(", ") || "none"}`,
|
|
83
83
|
"roles",
|
|
84
84
|
];
|
|
85
|
+
const accessOf = (role: Role) =>
|
|
86
|
+
`${p.roles[role].access}${p.roles[role].network === false ? " (no network)" : ""}, ${o.enforcement[role]}`;
|
|
87
|
+
// the ladders start in one column, however long the longest access text ("(no network)" included)
|
|
88
|
+
const accessWidth = Math.max(27, ...ROLES.filter((r) => p.roles[r].enabled).map((r) => accessOf(r).length));
|
|
85
89
|
for (const role of ROLES) {
|
|
86
90
|
const rc = p.roles[role];
|
|
87
91
|
if (!rc.enabled) {
|
|
@@ -89,9 +93,7 @@ export function formatProfile(
|
|
|
89
93
|
continue;
|
|
90
94
|
}
|
|
91
95
|
const ladder = rc.rungs.map((r) => (r === rc.defaultRung ? `${r} (default)` : r)).join(" → ");
|
|
92
|
-
lines.push(
|
|
93
|
-
` ${role.padEnd(width)} ${`${rc.access}${rc.network === false ? " (no network)" : ""}, ${o.enforcement[role]}`.padEnd(27)} ${ladder || "no rungs"}`,
|
|
94
|
-
);
|
|
96
|
+
lines.push(` ${role.padEnd(width)} ${accessOf(role).padEnd(accessWidth)} ${ladder || "no rungs"}`);
|
|
95
97
|
}
|
|
96
98
|
// only what catherd can run today: a backend without an adapter has nothing to bill or isolate
|
|
97
99
|
const runs = ([key]: [string, unknown]) => keyRunnable(key, o.backends);
|
|
@@ -104,6 +106,16 @@ export function formatProfile(
|
|
|
104
106
|
const budget = Object.entries(p.budget).map(([k, v]) => (k === "usd" ? `$${v}` : `${v} ${k}`));
|
|
105
107
|
lines.push(`budget ${budget.join(" · ") || "no cap"}`);
|
|
106
108
|
lines.push(`timeouts idle ${p.timeouts.idleMin} min · wall ${p.timeouts.wallMin} min`);
|
|
109
|
+
// plan 23: a role's own timeouts, under the profile's
|
|
110
|
+
for (const role of ROLES) {
|
|
111
|
+
const t = p.roles[role].timeouts;
|
|
112
|
+
if (!t) continue;
|
|
113
|
+
const parts = [
|
|
114
|
+
...(t.idleMin !== undefined ? [`idle ${t.idleMin} min`] : []),
|
|
115
|
+
...(t.wallMin !== undefined ? [`wall ${t.wallMin} min`] : []),
|
|
116
|
+
];
|
|
117
|
+
lines.push(` ${role} ${parts.join(" · ")}`);
|
|
118
|
+
}
|
|
107
119
|
lines.push(
|
|
108
120
|
`preflight ${p.preflight.confirm ? "shows its commands and asks first" : "runs the checks without asking"}`,
|
|
109
121
|
);
|
|
@@ -206,6 +218,7 @@ const use = defineCommand({
|
|
|
206
218
|
clear: { type: "boolean", description: "with --repo and no name: unbind the git repo you are in" },
|
|
207
219
|
},
|
|
208
220
|
async run({ args }) {
|
|
221
|
+
refuseInRole(process.env, "catherd profile use");
|
|
209
222
|
if (args.clear) {
|
|
210
223
|
if (!args.repo || args.name !== undefined)
|
|
211
224
|
throw new CatherdError("E_INPUT_INVALID", "--clear goes with --repo and no profile name", {
|
|
@@ -234,6 +247,7 @@ const create = defineCommand({
|
|
|
234
247
|
from: { type: "string", description: "copy this profile instead of the default one" },
|
|
235
248
|
},
|
|
236
249
|
run({ args }) {
|
|
250
|
+
refuseInRole(process.env, "catherd profile new");
|
|
237
251
|
const r = createProfile(args.name, args.from, terminalHost(args.host).host);
|
|
238
252
|
if (!r.saved) throw refused(r.errors);
|
|
239
253
|
console.log(`${mark("ok")} created ${args.name}${args.from ? ` from ${args.from}` : ""}`);
|
|
@@ -249,6 +263,7 @@ const copy = defineCommand({
|
|
|
249
263
|
to: { type: "positional", required: true, description: "new profile name" },
|
|
250
264
|
},
|
|
251
265
|
run({ args }) {
|
|
266
|
+
refuseInRole(process.env, "catherd profile copy");
|
|
252
267
|
const r = createProfile(args.to, args.from, terminalHost(args.host).host);
|
|
253
268
|
if (!r.saved) throw refused(r.errors);
|
|
254
269
|
console.log(`${mark("ok")} copied ${args.from} to ${args.to}`);
|
|
@@ -263,6 +278,7 @@ const rm = defineCommand({
|
|
|
263
278
|
name: { type: "positional", required: true, description: "profile name" },
|
|
264
279
|
},
|
|
265
280
|
run({ args }) {
|
|
281
|
+
refuseInRole(process.env, "catherd profile rm");
|
|
266
282
|
deleteProfile(args.name);
|
|
267
283
|
console.log(`${mark("ok")} deleted ${args.name}`);
|
|
268
284
|
},
|
|
@@ -281,6 +297,7 @@ const set = defineCommand({
|
|
|
281
297
|
profile: { type: "string", description: "the profile to change (default: the one this repo runs on)" },
|
|
282
298
|
},
|
|
283
299
|
async run({ args }) {
|
|
300
|
+
refuseInRole(process.env, "catherd profile set");
|
|
284
301
|
// the CLI never creates a profile by setting a field of it: a typo would start a new one
|
|
285
302
|
if (args.profile !== undefined && !profileExists(assertProfileName(args.profile)))
|
|
286
303
|
throw new CatherdError("E_INPUT_INVALID", `no profile named "${args.profile}"`, {
|
|
@@ -342,6 +359,7 @@ const validate = defineCommand({
|
|
|
342
359
|
args.name === undefined ? activeName(repo) : requireProfile(args.name),
|
|
343
360
|
repo,
|
|
344
361
|
terminalHost(args.host).host,
|
|
362
|
+
{ reach: true },
|
|
345
363
|
);
|
|
346
364
|
if (args.json) printJson({ valid: v.errors.length === 0, ...v });
|
|
347
365
|
else if (v.errors.length === 0 && v.warnings.length === 0) console.log(`${mark("ok")} valid`);
|
|
@@ -370,6 +388,7 @@ const resetDefaults = defineCommand({
|
|
|
370
388
|
let r;
|
|
371
389
|
if (args.preview) r = resetHostDefaults(name, host, { preview: true });
|
|
372
390
|
else {
|
|
391
|
+
refuseInRole(process.env, "catherd profile reset-host-defaults");
|
|
373
392
|
if (!args.expect)
|
|
374
393
|
throw new CatherdError(
|
|
375
394
|
"E_INPUT_INVALID",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
3
|
+
import { ID_PATTERN } from "../domain/ids.ts";
|
|
4
|
+
import { roleScopeFromEnv } from "../domain/role-scope.ts";
|
|
5
|
+
import { gateCheckOrList, gatePass } from "../services/gate-service.ts";
|
|
6
|
+
import { assertRoleMay } from "../services/role-access.ts";
|
|
7
|
+
import { readRunFile, writeRunFile } from "../services/run-service.ts";
|
|
8
|
+
import { printJson } from "./cli-kit.ts";
|
|
9
|
+
import { defaultDeps } from "./deps.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Spec 1.5 plan 21: the role server's operations as commands any role can run from its shell, on a backend
|
|
13
|
+
* whose harness has no role server (opencode, Cursor, Grok Build, agy) or when no MCP tool is listed. In a role
|
|
14
|
+
* (CATHERD_ROLE, or its scratch TMPDIR) they are bound to its own run and its role's operations.
|
|
15
|
+
*/
|
|
16
|
+
const scope = () => roleScopeFromEnv(process.env);
|
|
17
|
+
|
|
18
|
+
const RUN_PATH = {
|
|
19
|
+
run: { type: "positional", required: true, description: "the run id" },
|
|
20
|
+
path: { type: "positional", required: true, description: "a path relative to the run folder" },
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
const read = defineCommand({
|
|
24
|
+
meta: { name: "read", description: "Print a file of the run folder (read_run_file)" },
|
|
25
|
+
args: RUN_PATH,
|
|
26
|
+
run({ args }) {
|
|
27
|
+
assertRoleMay(scope(), args.run, "read_run_file");
|
|
28
|
+
const text = readRunFile({ run: args.run, path: args.path });
|
|
29
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const write = defineCommand({
|
|
34
|
+
meta: { name: "write", description: "Write stdin to a file of the run folder (write_run_file)" },
|
|
35
|
+
args: RUN_PATH,
|
|
36
|
+
async run({ args }) {
|
|
37
|
+
assertRoleMay(scope(), args.run, "write_run_file");
|
|
38
|
+
const content = await Bun.stdin.text();
|
|
39
|
+
printJson(writeRunFile({ run: args.run, path: args.path, content }));
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const runFileCommand = defineCommand({
|
|
44
|
+
meta: { name: "run-file", description: "Read or write a run's plan, lanes, dossier and notes" },
|
|
45
|
+
subCommands: { read, write },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const GATE = {
|
|
49
|
+
run: { type: "positional", required: true, description: "the run id" },
|
|
50
|
+
item: { type: "string", required: true, description: "the gate item's name" },
|
|
51
|
+
command: { type: "string", required: true, description: "the command that checks it" },
|
|
52
|
+
paths: { type: "string", required: true, description: "the repo paths it depends on, comma-separated" },
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
const pathsOf = (s: string): string[] => {
|
|
56
|
+
const paths = s
|
|
57
|
+
.split(",")
|
|
58
|
+
.map((p) => p.trim())
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
if (!paths.length)
|
|
61
|
+
throw new CatherdError("E_INPUT_INVALID", "--paths names no path", {
|
|
62
|
+
fix: "pass --paths src,package.json",
|
|
63
|
+
});
|
|
64
|
+
return paths;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const check = defineCommand({
|
|
68
|
+
meta: { name: "check", description: "Whether a gate item passed on unchanged content (gate_check)" },
|
|
69
|
+
// plan 23: with only the run and --milestone, it lists the milestone's recorded items (as gate_check does)
|
|
70
|
+
args: {
|
|
71
|
+
run: GATE.run,
|
|
72
|
+
item: { ...GATE.item, required: false },
|
|
73
|
+
command: { ...GATE.command, required: false },
|
|
74
|
+
paths: { ...GATE.paths, required: false },
|
|
75
|
+
milestone: { type: "string", description: "the milestone you verify, like M1" },
|
|
76
|
+
},
|
|
77
|
+
async run({ args }) {
|
|
78
|
+
assertRoleMay(scope(), args.run, "gate_check");
|
|
79
|
+
if (args.milestone !== undefined && !ID_PATTERN.test(args.milestone))
|
|
80
|
+
throw new CatherdError("E_INPUT_INVALID", `bad milestone "${args.milestone}"`, {
|
|
81
|
+
fix: "pass --milestone M1",
|
|
82
|
+
});
|
|
83
|
+
printJson(
|
|
84
|
+
await gateCheckOrList(defaultDeps(), {
|
|
85
|
+
run: args.run,
|
|
86
|
+
item: args.item,
|
|
87
|
+
command: args.command,
|
|
88
|
+
paths: args.paths === undefined ? undefined : pathsOf(args.paths),
|
|
89
|
+
...(args.milestone ? { milestone: args.milestone } : {}),
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const pass = defineCommand({
|
|
96
|
+
meta: { name: "pass", description: "Record a passed gate item with its evidence (gate_pass)" },
|
|
97
|
+
args: { ...GATE, evidence: { type: "string", required: true, description: "what showed it passed" } },
|
|
98
|
+
async run({ args }) {
|
|
99
|
+
assertRoleMay(scope(), args.run, "gate_pass");
|
|
100
|
+
printJson(
|
|
101
|
+
await gatePass(defaultDeps(), {
|
|
102
|
+
run: args.run,
|
|
103
|
+
item: args.item,
|
|
104
|
+
command: args.command,
|
|
105
|
+
paths: pathsOf(args.paths),
|
|
106
|
+
evidence: args.evidence,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export const gateCommand = defineCommand({
|
|
113
|
+
meta: { name: "gate", description: "A verifier's gate evidence: check an item, record a pass" },
|
|
114
|
+
subCommands: { check, pass },
|
|
115
|
+
});
|
|
@@ -4,6 +4,7 @@ import { assertId } from "../domain/ids.ts";
|
|
|
4
4
|
import { knownQueueCapability, UNCHECKED_QUEUE } from "../infra/codex-queue.ts";
|
|
5
5
|
import { listDispatches } from "../services/dispatches.ts";
|
|
6
6
|
import { retryDelivery } from "../services/notifier.ts";
|
|
7
|
+
import { pauseLine } from "../services/pause.ts";
|
|
7
8
|
import { inspectDelivery } from "../services/run-debug.ts";
|
|
8
9
|
import { terminalHost } from "./host-arg.ts";
|
|
9
10
|
import { formatBudget } from "../domain/budget.ts";
|
|
@@ -13,10 +14,13 @@ import { redact } from "../infra/log.ts";
|
|
|
13
14
|
import { cancel } from "../services/dispatch-service.ts";
|
|
14
15
|
import { registerSavedSecrets } from "../services/jev-service.ts";
|
|
15
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";
|
|
16
20
|
import { findRun, listRuns, readRecords, type Run } from "../services/run-store.ts";
|
|
17
21
|
import { groupRuns, type RunSession, type SessionGroup } from "../services/session-view.ts";
|
|
18
22
|
import { type RunSummary, status, summarizeRun } from "../services/summary.ts";
|
|
19
|
-
import { JSON_ARG, mark, printJson } from "./cli-kit.ts";
|
|
23
|
+
import { JSON_ARG, mark, printJson, refuseInRole } from "./cli-kit.ts";
|
|
20
24
|
import { defaultDeps } from "./deps.ts";
|
|
21
25
|
|
|
22
26
|
const json = JSON_ARG;
|
|
@@ -40,6 +44,10 @@ export function formatRun(s: RunSummary, now: number = Date.now()): string[] {
|
|
|
40
44
|
];
|
|
41
45
|
for (const q of s.questions) lines.push(` ${mark("warn")} parked ${q.milestone}: ${q.question}`);
|
|
42
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
|
+
);
|
|
43
51
|
lines.push(
|
|
44
52
|
` done ${t.runs} role run(s), ${t.ok} ok${t.notOk.length ? `; not ok: ${t.notOk.join(", ")}` : ""}`,
|
|
45
53
|
` tokens ${n(t.tokens.input)} in (${n(t.tokens.cached)} cached) · ${n(t.tokens.output)} out · $${t.costUsd.toFixed(2)}` +
|
|
@@ -56,7 +64,7 @@ export function formatRun(s: RunSummary, now: number = Date.now()): string[] {
|
|
|
56
64
|
for (const m of s.milestones) lines.push(` landed ${m}`);
|
|
57
65
|
if (s.verifier)
|
|
58
66
|
lines.push(
|
|
59
|
-
` 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})`}`,
|
|
60
68
|
);
|
|
61
69
|
for (const d of s.delivery)
|
|
62
70
|
lines.push(
|
|
@@ -83,6 +91,8 @@ async function printStatus(runId: string | undefined, asJson: boolean, live = fa
|
|
|
83
91
|
: null;
|
|
84
92
|
const r = redact(status(defaultDeps(host), runId, queue));
|
|
85
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)}`);
|
|
86
96
|
if (r.runs.length === 0) console.log("no runs yet");
|
|
87
97
|
// grouped by session, as the runs page is (spec §4); a run shows once, under the session that started it
|
|
88
98
|
const byId = new Map(r.runs.map((s) => [s.id, s]));
|
|
@@ -105,7 +115,7 @@ async function printStatus(runId: string | undefined, asJson: boolean, live = fa
|
|
|
105
115
|
for (const l of rest) console.log(l);
|
|
106
116
|
}
|
|
107
117
|
}
|
|
108
|
-
console.log(`host ${r.host.host}${r.host.conflict ? ` · conflict: ${r.host.conflict}` : ""}`);
|
|
118
|
+
console.log(`inspection host ${r.host.host}${r.host.conflict ? ` · conflict: ${r.host.conflict}` : ""}`);
|
|
109
119
|
if (r.queue)
|
|
110
120
|
console.log(
|
|
111
121
|
r.queue.checked === false
|
|
@@ -115,11 +125,12 @@ async function printStatus(runId: string | undefined, asJson: boolean, live = fa
|
|
|
115
125
|
for (const w of r.warnings) console.log(`${mark("warn")} ${w}`);
|
|
116
126
|
}
|
|
117
127
|
|
|
118
|
-
/**
|
|
128
|
+
/** `catherd status [run] [--json]`: that run, else the live runs, else the runs waiting for their orchestrator, else the newest. */
|
|
119
129
|
export const statusCommand = defineCommand({
|
|
120
130
|
meta: {
|
|
121
131
|
name: "status",
|
|
122
|
-
description:
|
|
132
|
+
description:
|
|
133
|
+
"A run at a glance (default: live runs, else runs waiting for their orchestrator, else the newest)",
|
|
123
134
|
},
|
|
124
135
|
args: { run: { type: "positional", required: false, description: "run id" }, ...json },
|
|
125
136
|
run({ args }) {
|
|
@@ -200,6 +211,8 @@ const list = defineCommand({
|
|
|
200
211
|
roleRuns: s.totals.runs,
|
|
201
212
|
session: s.session,
|
|
202
213
|
continuedIn: s.continuedIn,
|
|
214
|
+
waiting: s.waiting,
|
|
215
|
+
supersededBy: s.supersededBy ?? null,
|
|
203
216
|
};
|
|
204
217
|
};
|
|
205
218
|
const groups = groupRuns(shown);
|
|
@@ -210,7 +223,7 @@ const list = defineCommand({
|
|
|
210
223
|
for (const x of g.runs) {
|
|
211
224
|
const r = row(x.run);
|
|
212
225
|
console.log(
|
|
213
|
-
` ${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)}`,
|
|
214
227
|
);
|
|
215
228
|
}
|
|
216
229
|
}
|
|
@@ -278,12 +291,31 @@ const cancelCmd = defineCommand({
|
|
|
278
291
|
name: { type: "positional", required: true, description: "the role's name, as status lists it" },
|
|
279
292
|
},
|
|
280
293
|
async run({ args }) {
|
|
294
|
+
refuseInRole(process.env, "catherd runs cancel");
|
|
281
295
|
const r = await cancel(defaultDeps(), args.id, args.name);
|
|
282
296
|
console.log(`${mark("ok")} ${r.record.name} ${r.record.status}`);
|
|
283
297
|
for (const h of r.hints) console.log(` ${h}`);
|
|
284
298
|
},
|
|
285
299
|
});
|
|
286
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
|
+
|
|
287
319
|
const retryPush = defineCommand({
|
|
288
320
|
meta: {
|
|
289
321
|
name: "retry-push",
|
|
@@ -300,6 +332,7 @@ const retryPush = defineCommand({
|
|
|
300
332
|
...json,
|
|
301
333
|
},
|
|
302
334
|
async run({ args }) {
|
|
335
|
+
refuseInRole(process.env, "catherd runs retry-push");
|
|
303
336
|
if (args["acknowledge-possible-duplicate"] !== true)
|
|
304
337
|
throw new CatherdError("E_INPUT_INVALID", "Retry requires --acknowledge-possible-duplicate");
|
|
305
338
|
assertId("role name", args.name);
|
|
@@ -328,9 +361,46 @@ const retryPush = defineCommand({
|
|
|
328
361
|
},
|
|
329
362
|
});
|
|
330
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
|
+
|
|
331
398
|
/** Spec §8 `catherd runs list|show [--debug]|cancel`; a bare `catherd runs` lists them, as `status` needs no run. */
|
|
332
399
|
export const runsCommand = defineCommand({
|
|
333
|
-
meta: {
|
|
334
|
-
|
|
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 },
|
|
335
405
|
default: "list",
|
|
336
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
|
@@ -72,6 +72,7 @@ export type {
|
|
|
72
72
|
|
|
73
73
|
/** One line of the run list. */
|
|
74
74
|
export interface RunRow {
|
|
75
|
+
waiting?: RunSummary["waiting"];
|
|
75
76
|
id: string;
|
|
76
77
|
title: string;
|
|
77
78
|
repo: string;
|
|
@@ -207,6 +208,7 @@ export function stampOf(dir: string): string {
|
|
|
207
208
|
p.jev,
|
|
208
209
|
p.agents,
|
|
209
210
|
p.state,
|
|
211
|
+
p.stateJson,
|
|
210
212
|
p.ledger,
|
|
211
213
|
p.roles,
|
|
212
214
|
profilesDir(),
|
|
@@ -233,6 +235,7 @@ export function memoRuns(compute: (run: Run) => RunRow, stamp: (dir: string) =>
|
|
|
233
235
|
runs.map((r) => {
|
|
234
236
|
const s = stamp(r.dir);
|
|
235
237
|
const hit = cache.get(r.dir);
|
|
238
|
+
// the wait is computed with the row, once per stamp change; the views age it (waitingLine)
|
|
236
239
|
if (hit && hit.stamp === s && hit.row.live === 0) return hit.row;
|
|
237
240
|
const row = compute(r);
|
|
238
241
|
cache.set(r.dir, { stamp: s, row });
|
|
@@ -242,6 +245,7 @@ export function memoRuns(compute: (run: Run) => RunRow, stamp: (dir: string) =>
|
|
|
242
245
|
|
|
243
246
|
export function rowOf(s: RunSummary): RunRow {
|
|
244
247
|
return {
|
|
248
|
+
waiting: s.waiting,
|
|
245
249
|
id: s.id,
|
|
246
250
|
title: s.title,
|
|
247
251
|
repo: s.repo,
|
package/src/entry/tui/state.ts
CHANGED
|
@@ -127,12 +127,21 @@ export interface AppState {
|
|
|
127
127
|
drafts: Record<string, Draft>;
|
|
128
128
|
/** the open dialogs; only the top one is drawn and takes keys */
|
|
129
129
|
dialogs: Dialog[];
|
|
130
|
-
/**
|
|
131
|
-
|
|
130
|
+
/**
|
|
131
|
+
* spec §4: the session the Runs tab has open (`key` null: "earlier runs"); null shows the sessions. `run`: the
|
|
132
|
+
* run it was opened for (a recent run on the Status tab), whose first row the cursor starts on
|
|
133
|
+
*/
|
|
134
|
+
session: { key: string | null; run?: string } | null;
|
|
132
135
|
/** the role the open session shows, by its dispatch */
|
|
133
136
|
role: { run: string; dispatchId: string } | null;
|
|
134
137
|
/** spec 1.1 §10: the milestone the open session shows, its digest */
|
|
135
138
|
milestone: { run: string; name: string } | null;
|
|
139
|
+
/** where esc lands: the last session opened, and the last role or milestone opened in it */
|
|
140
|
+
back: {
|
|
141
|
+
session: string | null;
|
|
142
|
+
role: { run: string; dispatchId: string } | null;
|
|
143
|
+
milestone: { run: string; name: string } | null;
|
|
144
|
+
} | null;
|
|
136
145
|
paused: boolean;
|
|
137
146
|
armed: Armed | null;
|
|
138
147
|
}
|
|
@@ -156,8 +165,8 @@ export type Action =
|
|
|
156
165
|
| { type: "saving"; name: string; on: boolean }
|
|
157
166
|
| { type: "input"; value: string }
|
|
158
167
|
| { type: "invalid"; error: string | null }
|
|
159
|
-
/** opens a session of the Runs tab (`key` null: "earlier runs") */
|
|
160
|
-
| { type: "session"; key: string | null }
|
|
168
|
+
/** opens a session of the Runs tab (`key` null: "earlier runs"), on `run`'s first row when given */
|
|
169
|
+
| { type: "session"; key: string | null; run?: string }
|
|
161
170
|
/** opens one role of the open session */
|
|
162
171
|
| { type: "role"; run: string; dispatchId: string }
|
|
163
172
|
/** opens one milestone of the open session, on its digest */
|
|
@@ -177,6 +186,7 @@ export const initialState = (tab: Tab = "status", host: OrchestrationHost = "unk
|
|
|
177
186
|
session: null,
|
|
178
187
|
role: null,
|
|
179
188
|
milestone: null,
|
|
189
|
+
back: null,
|
|
180
190
|
paused: false,
|
|
181
191
|
armed: null,
|
|
182
192
|
});
|
|
@@ -354,12 +364,29 @@ export function reduce(s: AppState, a: Action): AppState {
|
|
|
354
364
|
else if (top.kind === "save" && a.type === "invalid") next = { ...top, error: a.error };
|
|
355
365
|
return next === top ? s : { ...s, dialogs: [...s.dialogs.slice(0, -1), next] };
|
|
356
366
|
}
|
|
357
|
-
case "session":
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
367
|
+
case "session": {
|
|
368
|
+
// a run asked for starts the cursor on that run; reopening the same session keeps the row esc lands on
|
|
369
|
+
const back =
|
|
370
|
+
!a.run && s.back?.session === a.key ? s.back : { session: a.key, role: null, milestone: null };
|
|
371
|
+
return {
|
|
372
|
+
...s,
|
|
373
|
+
session: { key: a.key, ...(a.run ? { run: a.run } : {}) },
|
|
374
|
+
role: null,
|
|
375
|
+
milestone: null,
|
|
376
|
+
back,
|
|
377
|
+
armed: null,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
case "role": {
|
|
381
|
+
const role = { run: a.run, dispatchId: a.dispatchId };
|
|
382
|
+
const back = { session: s.session?.key ?? null, role, milestone: null };
|
|
383
|
+
return { ...s, role, milestone: null, back, armed: null };
|
|
384
|
+
}
|
|
385
|
+
case "milestone": {
|
|
386
|
+
const milestone = { run: a.run, name: a.name };
|
|
387
|
+
const back = { session: s.session?.key ?? null, role: null, milestone };
|
|
388
|
+
return { ...s, milestone, role: null, back, armed: null };
|
|
389
|
+
}
|
|
363
390
|
case "up":
|
|
364
391
|
if (s.milestone) return { ...s, milestone: null, armed: null };
|
|
365
392
|
return s.role ? { ...s, role: null, armed: null } : { ...s, session: null, armed: null };
|
|
@@ -267,7 +267,7 @@ export function ProfilesView(props: { width: number; height: number }) {
|
|
|
267
267
|
value: v.dim,
|
|
268
268
|
title: `${v.dim} ${v.value}`,
|
|
269
269
|
group: "values",
|
|
270
|
-
detail: `${v.inferred ? `inferred from ${v.from}` : v.confidence} · ${v.source} ${v.benchmark} · ${v.date}`,
|
|
270
|
+
detail: `${v.lent === "treat-like" ? `like ${v.from}` : v.inferred ? `inferred from ${v.from}` : v.confidence} · ${v.source} ${v.benchmark} · ${v.date}`,
|
|
271
271
|
})),
|
|
272
272
|
{
|
|
273
273
|
value: "runs",
|