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.
Files changed (159) hide show
  1. package/README.md +144 -42
  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 +11 -0
  7. package/plugin/.mcp-codex.json +10 -0
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +150 -79
  10. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  11. package/src/adapters/access.ts +7 -1
  12. package/src/adapters/antigravity/index.ts +18 -3
  13. package/src/adapters/backend.ts +18 -2
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/cli.ts +1 -64
  16. package/src/adapters/codex/events.ts +10 -0
  17. package/src/adapters/codex/index.ts +17 -5
  18. package/src/adapters/cursor/index.ts +7 -1
  19. package/src/adapters/grok/home.ts +75 -20
  20. package/src/adapters/grok/index.ts +27 -8
  21. package/src/adapters/opencode/index.ts +38 -1
  22. package/src/cli.ts +53 -19
  23. package/src/domain/agents.ts +3 -1
  24. package/src/domain/brief.ts +91 -0
  25. package/src/domain/calibration.ts +7 -0
  26. package/src/domain/catalog.ts +12 -1
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +8 -3
  29. package/src/domain/errors.ts +4 -0
  30. package/src/domain/failover.ts +17 -5
  31. package/src/domain/gate-brief.ts +52 -0
  32. package/src/domain/hints.ts +13 -2
  33. package/src/domain/host.ts +17 -0
  34. package/src/domain/lane.ts +99 -3
  35. package/src/domain/notice.ts +14 -4
  36. package/src/domain/profile-rules.ts +129 -16
  37. package/src/domain/profile.ts +56 -5
  38. package/src/domain/record.ts +17 -2
  39. package/src/domain/reply.ts +29 -0
  40. package/src/domain/role-prompts.ts +56 -16
  41. package/src/domain/role-scope.ts +106 -0
  42. package/src/domain/role-tools.ts +34 -0
  43. package/src/domain/route.ts +73 -0
  44. package/src/domain/select.ts +177 -25
  45. package/src/domain/state.ts +5 -0
  46. package/src/domain/util.ts +22 -0
  47. package/src/domain/workspace.ts +130 -0
  48. package/src/entry/catalog-command.ts +18 -8
  49. package/src/entry/cli-kit.ts +17 -1
  50. package/src/entry/deps.ts +8 -3
  51. package/src/entry/doctor-command.ts +29 -2
  52. package/src/entry/host-arg.ts +56 -0
  53. package/src/entry/init-command.ts +20 -8
  54. package/src/entry/knowledge-command.ts +78 -3
  55. package/src/entry/lock-command.ts +111 -10
  56. package/src/entry/mcp/dispatch-tools.ts +3 -3
  57. package/src/entry/mcp/handshake.ts +8 -2
  58. package/src/entry/mcp/lane-tools.ts +55 -8
  59. package/src/entry/mcp/protocol-tools.ts +10 -4
  60. package/src/entry/mcp/push-tools.ts +16 -0
  61. package/src/entry/mcp/role-bin.ts +20 -0
  62. package/src/entry/mcp/role-server.ts +116 -0
  63. package/src/entry/mcp/run-tools.ts +25 -6
  64. package/src/entry/mcp/server.ts +224 -44
  65. package/src/entry/mcp/setup-tools.ts +11 -5
  66. package/src/entry/mcp/workspace-tools.ts +142 -0
  67. package/src/entry/pause-command.ts +67 -0
  68. package/src/entry/profile-command.ts +131 -16
  69. package/src/entry/role-cli-command.ts +115 -0
  70. package/src/entry/runs-command.ts +157 -11
  71. package/src/entry/supervise-command.ts +30 -3
  72. package/src/entry/tui/effects.ts +23 -8
  73. package/src/entry/tui/fixtures.ts +33 -10
  74. package/src/entry/tui/run.tsx +10 -4
  75. package/src/entry/tui/state.ts +56 -16
  76. package/src/entry/tui/stories.ts +11 -4
  77. package/src/entry/tui/views/profile-actions.ts +4 -2
  78. package/src/entry/tui/views/profiles.tsx +3 -3
  79. package/src/entry/tui/views/runs.tsx +66 -20
  80. package/src/entry/tui/views/save-dialog.tsx +8 -4
  81. package/src/entry/tui/views/status.tsx +6 -2
  82. package/src/entry/workspace-command.ts +44 -0
  83. package/src/infra/boot-lock.ts +25 -0
  84. package/src/infra/cli.ts +65 -0
  85. package/src/infra/codex-queue.ts +165 -0
  86. package/src/infra/delivery.ts +86 -0
  87. package/src/infra/dispatch-dir.ts +3 -0
  88. package/src/infra/env.ts +15 -2
  89. package/src/infra/git.ts +6 -0
  90. package/src/infra/heavy-lock.ts +93 -4
  91. package/src/infra/host-context.ts +58 -0
  92. package/src/infra/host-probe.ts +16 -0
  93. package/src/infra/lock-activity.ts +88 -0
  94. package/src/infra/login-env.ts +48 -0
  95. package/src/infra/paths.ts +35 -0
  96. package/src/infra/peer-inbox.ts +4 -1
  97. package/src/infra/role-mcp.ts +69 -0
  98. package/src/infra/sources/arena.ts +11 -3
  99. package/src/infra/sources/artificial-analysis.ts +8 -1
  100. package/src/infra/sources/cache.ts +2 -0
  101. package/src/infra/supervisor.ts +110 -18
  102. package/src/services/admission.ts +117 -26
  103. package/src/services/agent-links.ts +30 -5
  104. package/src/services/backends.ts +7 -0
  105. package/src/services/budget.ts +26 -6
  106. package/src/services/catalog-service.ts +20 -12
  107. package/src/services/credentials.ts +11 -6
  108. package/src/services/dispatch-service.ts +220 -22
  109. package/src/services/dispatches.ts +36 -3
  110. package/src/services/doctor-access.ts +8 -3
  111. package/src/services/doctor-backends.ts +30 -7
  112. package/src/services/doctor-checks.ts +20 -5
  113. package/src/services/doctor-docker.ts +221 -0
  114. package/src/services/doctor-push.ts +69 -90
  115. package/src/services/doctor-role-mcp.ts +65 -0
  116. package/src/services/doctor.ts +172 -38
  117. package/src/services/end-push.ts +157 -0
  118. package/src/services/finalize.ts +13 -3
  119. package/src/services/gate-env.ts +218 -0
  120. package/src/services/gate-service.ts +216 -45
  121. package/src/services/global-install.ts +46 -13
  122. package/src/services/lane-edit.ts +154 -0
  123. package/src/services/lane-service.ts +258 -52
  124. package/src/services/milestones.ts +137 -26
  125. package/src/services/notifier.ts +377 -72
  126. package/src/services/orchestrator-wait.ts +82 -0
  127. package/src/services/pause.ts +161 -0
  128. package/src/services/peek.ts +122 -23
  129. package/src/services/ports.ts +39 -4
  130. package/src/services/preflight.ts +153 -19
  131. package/src/services/profile-service.ts +117 -30
  132. package/src/services/profile-store.ts +31 -7
  133. package/src/services/protocol.ts +119 -25
  134. package/src/services/provenance.ts +16 -12
  135. package/src/services/questions.ts +15 -0
  136. package/src/services/reconcile.ts +22 -1
  137. package/src/services/reentry.ts +7 -6
  138. package/src/services/role-access.ts +27 -0
  139. package/src/services/routing-service.ts +150 -37
  140. package/src/services/run-debug.ts +98 -4
  141. package/src/services/run-pin.ts +125 -0
  142. package/src/services/run-service.ts +153 -19
  143. package/src/services/run-store.ts +119 -19
  144. package/src/services/runs-page.ts +45 -9
  145. package/src/services/scratch.ts +60 -0
  146. package/src/services/session-view.ts +18 -13
  147. package/src/services/sessions.ts +34 -16
  148. package/src/services/setup.ts +31 -10
  149. package/src/services/source-derive.ts +28 -5
  150. package/src/services/source-sync.ts +9 -2
  151. package/src/services/standins.ts +40 -6
  152. package/src/services/state.ts +21 -0
  153. package/src/services/summary.ts +86 -11
  154. package/src/services/treat-likes.ts +41 -12
  155. package/src/services/verifier-brief.ts +23 -0
  156. package/src/services/verifier-step.ts +48 -0
  157. package/src/services/workspace-admission.ts +267 -0
  158. package/src/services/workspace-service.ts +254 -0
  159. 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
- function printStatus(runId: string | undefined, asJson: boolean): void {
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 r = redact(status(defaultDeps(), runId));
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
- /** 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. */
91
129
  export const statusCommand = defineCommand({
92
130
  meta: {
93
131
  name: "status",
94
- 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)",
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: { name: "runs", description: "Runs: list them (the default), show one, cancel a live role" },
258
- subCommands: { list, show, cancel: cancelCmd },
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: 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({
@@ -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?.sessionId ?? null,
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(repo: string | null = null): Effects {
318
- const deps = defaultDeps();
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: () => doctor({ bunVersion: Bun.version, version: VERSION, handshake: () => mcpHandshake() }),
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, shown === undefined ? {} : { expect: shown });
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: { sessionId: "s-jobs", hostSessionId: null, name: "jobs screen", live: true },
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: { sessionId: "s-auth", hostSessionId: null, name: "auth build", live: false },
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) ? validateProfile(resolveProfile(before, name), c, BACKENDS) : null;
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: () => ({}),
@@ -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({ rawArgs: ctx.rawArgs });
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
  }