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.
Files changed (139) hide show
  1. package/README.md +93 -30
  2. package/catalog/ATTRIBUTION.md +12 -4
  3. package/catalog/models.json +8 -0
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/bin/catherd-mcp +1 -1
  8. package/plugin/skills/catherd/SKILL.md +110 -45
  9. package/plugin/skills/catherd-setup/SKILL.md +2 -2
  10. package/src/adapters/antigravity/index.ts +18 -3
  11. package/src/adapters/backend.ts +18 -2
  12. package/src/adapters/claude-code/index.ts +126 -14
  13. package/src/adapters/codex/events.ts +10 -0
  14. package/src/adapters/codex/index.ts +17 -5
  15. package/src/adapters/cursor/index.ts +7 -1
  16. package/src/adapters/grok/home.ts +75 -20
  17. package/src/adapters/grok/index.ts +27 -8
  18. package/src/adapters/opencode/index.ts +38 -1
  19. package/src/cli.ts +5 -0
  20. package/src/domain/agents.ts +3 -1
  21. package/src/domain/brief.ts +91 -0
  22. package/src/domain/calibration.ts +7 -0
  23. package/src/domain/catalog.ts +12 -1
  24. package/src/domain/changes.ts +23 -1
  25. package/src/domain/cost.ts +8 -3
  26. package/src/domain/errors.ts +4 -0
  27. package/src/domain/failover.ts +17 -5
  28. package/src/domain/gate-brief.ts +52 -0
  29. package/src/domain/hints.ts +13 -2
  30. package/src/domain/lane.ts +99 -3
  31. package/src/domain/notice.ts +9 -2
  32. package/src/domain/profile-rules.ts +112 -16
  33. package/src/domain/profile.ts +24 -0
  34. package/src/domain/record.ts +16 -2
  35. package/src/domain/reply.ts +29 -0
  36. package/src/domain/role-prompts.ts +56 -16
  37. package/src/domain/role-scope.ts +106 -0
  38. package/src/domain/role-tools.ts +34 -0
  39. package/src/domain/route.ts +73 -0
  40. package/src/domain/select.ts +177 -25
  41. package/src/domain/state.ts +5 -0
  42. package/src/domain/util.ts +22 -0
  43. package/src/domain/workspace.ts +130 -0
  44. package/src/entry/catalog-command.ts +14 -6
  45. package/src/entry/cli-kit.ts +17 -1
  46. package/src/entry/deps.ts +2 -0
  47. package/src/entry/doctor-command.ts +20 -2
  48. package/src/entry/host-arg.ts +28 -0
  49. package/src/entry/init-command.ts +2 -1
  50. package/src/entry/knowledge-command.ts +78 -3
  51. package/src/entry/lock-command.ts +104 -8
  52. package/src/entry/mcp/dispatch-tools.ts +3 -3
  53. package/src/entry/mcp/handshake.ts +6 -2
  54. package/src/entry/mcp/lane-tools.ts +55 -8
  55. package/src/entry/mcp/protocol-tools.ts +10 -4
  56. package/src/entry/mcp/push-tools.ts +16 -0
  57. package/src/entry/mcp/role-bin.ts +20 -0
  58. package/src/entry/mcp/role-server.ts +116 -0
  59. package/src/entry/mcp/run-tools.ts +20 -4
  60. package/src/entry/mcp/server.ts +98 -7
  61. package/src/entry/mcp/setup-tools.ts +4 -3
  62. package/src/entry/mcp/workspace-tools.ts +142 -0
  63. package/src/entry/pause-command.ts +67 -0
  64. package/src/entry/profile-command.ts +23 -4
  65. package/src/entry/role-cli-command.ts +115 -0
  66. package/src/entry/runs-command.ts +78 -8
  67. package/src/entry/supervise-command.ts +30 -3
  68. package/src/entry/tui/effects.ts +4 -0
  69. package/src/entry/tui/state.ts +37 -10
  70. package/src/entry/tui/views/profiles.tsx +1 -1
  71. package/src/entry/tui/views/runs.tsx +66 -20
  72. package/src/entry/tui/views/save-dialog.tsx +6 -2
  73. package/src/entry/tui/views/status.tsx +6 -2
  74. package/src/entry/workspace-command.ts +44 -0
  75. package/src/infra/boot-lock.ts +25 -0
  76. package/src/infra/delivery.ts +3 -0
  77. package/src/infra/dispatch-dir.ts +2 -0
  78. package/src/infra/env.ts +11 -1
  79. package/src/infra/git.ts +6 -0
  80. package/src/infra/heavy-lock.ts +93 -4
  81. package/src/infra/host-probe.ts +16 -0
  82. package/src/infra/lock-activity.ts +88 -0
  83. package/src/infra/login-env.ts +48 -0
  84. package/src/infra/paths.ts +35 -0
  85. package/src/infra/role-mcp.ts +69 -0
  86. package/src/infra/sources/arena.ts +11 -3
  87. package/src/infra/sources/artificial-analysis.ts +8 -1
  88. package/src/infra/sources/cache.ts +2 -0
  89. package/src/infra/supervisor.ts +107 -16
  90. package/src/services/admission.ts +110 -23
  91. package/src/services/budget.ts +26 -6
  92. package/src/services/catalog-service.ts +20 -12
  93. package/src/services/credentials.ts +11 -6
  94. package/src/services/dispatch-service.ts +204 -16
  95. package/src/services/dispatches.ts +30 -2
  96. package/src/services/doctor-access.ts +8 -3
  97. package/src/services/doctor-docker.ts +221 -0
  98. package/src/services/doctor-role-mcp.ts +65 -0
  99. package/src/services/doctor.ts +68 -6
  100. package/src/services/end-push.ts +157 -0
  101. package/src/services/finalize.ts +12 -2
  102. package/src/services/gate-env.ts +218 -0
  103. package/src/services/gate-service.ts +216 -45
  104. package/src/services/global-install.ts +46 -13
  105. package/src/services/lane-edit.ts +154 -0
  106. package/src/services/lane-service.ts +257 -52
  107. package/src/services/milestones.ts +137 -26
  108. package/src/services/notifier.ts +56 -3
  109. package/src/services/orchestrator-wait.ts +82 -0
  110. package/src/services/pause.ts +161 -0
  111. package/src/services/peek.ts +79 -17
  112. package/src/services/ports.ts +31 -2
  113. package/src/services/preflight.ts +153 -19
  114. package/src/services/profile-service.ts +1 -0
  115. package/src/services/profile-store.ts +10 -3
  116. package/src/services/protocol.ts +119 -25
  117. package/src/services/provenance.ts +16 -12
  118. package/src/services/questions.ts +15 -0
  119. package/src/services/reconcile.ts +22 -1
  120. package/src/services/reentry.ts +7 -6
  121. package/src/services/role-access.ts +27 -0
  122. package/src/services/routing-service.ts +144 -36
  123. package/src/services/run-pin.ts +125 -0
  124. package/src/services/run-service.ts +151 -19
  125. package/src/services/run-store.ts +109 -15
  126. package/src/services/runs-page.ts +42 -7
  127. package/src/services/scratch.ts +60 -0
  128. package/src/services/sessions.ts +3 -1
  129. package/src/services/source-derive.ts +28 -5
  130. package/src/services/source-sync.ts +9 -2
  131. package/src/services/standins.ts +40 -6
  132. package/src/services/state.ts +21 -0
  133. package/src/services/summary.ts +49 -9
  134. package/src/services/treat-likes.ts +19 -3
  135. package/src/services/verifier-brief.ts +23 -0
  136. package/src/services/verifier-step.ts +48 -0
  137. package/src/services/workspace-admission.ts +267 -0
  138. package/src/services/workspace-service.ts +254 -0
  139. 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
- /** Spec §8 `catherd status [run] [--json]`: that run, else every run with a live role, else the newest. */
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: "A run at a glance (default: runs with a live role, else the newest)",
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: { name: "runs", description: "Runs: list them (the default), show one, cancel a live role" },
334
- subCommands: { list, show, cancel: cancelCmd, "retry-push": retryPush },
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: workerEnv(process.env, read.env, read.cwd) };
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
- await supervise(spec, {
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({
@@ -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,
@@ -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
- /** spec §4: the session the Runs tab has open (`key` null: "earlier runs"); null shows the sessions */
131
- session: { key: string | null } | null;
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
- return { ...s, session: { key: a.key }, role: null, milestone: null, armed: null };
359
- case "role":
360
- return { ...s, role: { run: a.run, dispatchId: a.dispatchId }, milestone: null, armed: null };
361
- case "milestone":
362
- return { ...s, milestone: { run: a.run, name: a.name }, role: null, armed: null };
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",