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
@@ -1,4 +1,5 @@
1
- import { type MutableRefObject, useEffect, useRef, useState } from "react";
1
+ import { type MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
2
+ import { waitingLine } from "../../../services/orchestrator-wait.ts";
2
3
  import { useApp, useBack, useNow } from "../providers/app.tsx";
3
4
  import { useData, usePoll } from "../providers/data.tsx";
4
5
  import { useCommandLayer } from "../providers/keymap.tsx";
@@ -7,7 +8,7 @@ import { useUi } from "../providers/theme.tsx";
7
8
  import { isArmed } from "../state.ts";
8
9
  import { ago, clock, plural, shortRung, wrap, wrapHanging } from "../text.ts";
9
10
  import { glyph, mascot, type Token } from "../theme.ts";
10
- import type { RoleRow, SessionRow, SessionRun } from "../effects.ts";
11
+ import type { RoleRow, SessionDetail, SessionRow, SessionRun } from "../effects.ts";
11
12
  import { Line, type Part } from "../widgets/line.tsx";
12
13
  import { List, type ListItem, useSelected } from "../widgets/list.tsx";
13
14
 
@@ -166,6 +167,8 @@ function roleMark(r: RoleRow, plain: boolean): Part {
166
167
  function runHeading(r: SessionRun, now: number, plain: boolean): Part[] {
167
168
  const bits = [r.repo, `started ${ago(now - Date.parse(r.createdAt))}`];
168
169
  if (r.budget !== null) bits.push(`budget ${Math.round(r.budget * 100)}%`);
170
+ const waiting = waitingLine(r.waiting, now);
171
+ if (waiting) bits.push(waiting);
169
172
  const moved =
170
173
  r.continued === "here" ? "continued here" : r.continuedIn ? `continued in ${r.continuedIn}` : null;
171
174
  return [
@@ -181,8 +184,11 @@ function useLiveRead<T extends { dirs: string[] }>(read: () => T, key: string) {
181
184
  const app = useApp();
182
185
  const [watching, setWatching] = useState(false);
183
186
  const polled = usePoll(read, watching ? WATCHED_EVERY_MS : RUN_EVERY_MS, { paused: app.state.paused, key });
187
+ // the watch calls the latest refresh; the ref moves after each commit, never during a render
184
188
  const refresh = useRef(polled.refresh);
185
- refresh.current = polled.refresh;
189
+ useEffect(() => {
190
+ refresh.current = polled.refresh;
191
+ });
186
192
  const dirs = polled.value?.dirs.join("\n") ?? "";
187
193
  useEffect(() => {
188
194
  if (app.state.paused || dirs === "") {
@@ -196,8 +202,26 @@ function useLiveRead<T extends { dirs: string[] }>(read: () => T, key: string) {
196
202
  return { ...polled, watching };
197
203
  }
198
204
 
205
+ /**
206
+ * The row a session's screen starts on before the cursor moves: the first row of the run it was opened for,
207
+ * else its first live role (live roles can sit below many landed milestones), else its first row.
208
+ */
209
+ function startRow(d: SessionDetail, run: string | undefined): string | null {
210
+ const rows = (r: SessionDetail["runs"][number]) => [
211
+ ...r.milestones.map((m) => milestoneKey(r.id, m.name)),
212
+ ...r.roles.map((x) => roleKey(x.run, x.dispatchId)),
213
+ ];
214
+ const asked = run === undefined ? undefined : d.runs.find((r) => r.id === run);
215
+ const first = asked ? rows(asked)[0] : undefined;
216
+ if (first) return first;
217
+ const live = d.runs.flatMap((r) => r.roles).find((x) => x.live);
218
+ if (live) return roleKey(live.run, live.dispatchId);
219
+ return d.runs.flatMap(rows)[0] ?? null;
220
+ }
221
+
199
222
  function SessionView(props: {
200
223
  sessionKey: string | null;
224
+ run: string | undefined;
201
225
  width: number;
202
226
  height: number;
203
227
  initial: string | null;
@@ -206,17 +230,26 @@ function SessionView(props: {
206
230
  const app = useApp();
207
231
  const ui = useUi();
208
232
  const now = useNow(1_000);
209
- const [selected, setSelected, selectedNow] = useSelection(props.initial);
233
+ const [chosen, setSelected, chosenNow] = useSelection(props.initial);
210
234
  const polled = useLiveRead(() => app.effects.session(props.sessionKey), String(props.sessionKey));
211
235
  useRefresher(props.refresher, polled.refresh);
212
236
  useBack(true, "view", () => app.dispatch({ type: "up" }));
213
237
  const d = polled.value;
214
- const roleAt = (key: string | null) =>
215
- d?.runs.flatMap((r) => r.roles).find((x) => key === roleKey(x.run, x.dispatchId)) ?? null;
216
- const milestoneAt = (key: string | null) =>
217
- d?.runs
218
- .flatMap((r) => r.milestones.map((m) => ({ run: r.id, name: m.name })))
219
- .find((x) => key === milestoneKey(x.run, x.name)) ?? null;
238
+ // each row's role or milestone by its key, built once per read rather than on every key
239
+ const byKey = useMemo(() => {
240
+ const roles = new Map<string, RoleRow>();
241
+ const milestones = new Map<string, { run: string; name: string }>();
242
+ for (const r of d?.runs ?? []) {
243
+ for (const m of r.milestones) milestones.set(milestoneKey(r.id, m.name), { run: r.id, name: m.name });
244
+ for (const x of r.roles) roles.set(roleKey(x.run, x.dispatchId), x);
245
+ }
246
+ return { roles, milestones, start: d ? startRow(d, props.run) : null };
247
+ }, [d, props.run]);
248
+ // until the cursor moves, it stands on the start row
249
+ const selected = chosen ?? byKey.start;
250
+ const selectedNow = () => chosenNow() ?? byKey.start;
251
+ const roleAt = (key: string | null) => (key === null ? null : (byKey.roles.get(key) ?? null));
252
+ const milestoneAt = (key: string | null) => (key === null ? null : (byKey.milestones.get(key) ?? null));
220
253
  useCommandLayer("row.runs", {
221
254
  "runs.open": () => {
222
255
  const k = selectedNow();
@@ -275,7 +308,7 @@ function SessionView(props: {
275
308
  : { text: `${glyph("waiting", ui.plain)} ${m.name}`, tone: "muted" },
276
309
  m.landed
277
310
  ? { text: m.what ? ` ${m.what}` : "", tone: "muted" }
278
- : { text: " not landed", tone: "muted" },
311
+ : { text: ` not landed${m.what ? ` · ${m.what}` : ""}`, tone: "muted" },
279
312
  ]}
280
313
  />
281
314
  ),
@@ -349,10 +382,17 @@ function RoleView(props: {
349
382
  const app = useApp();
350
383
  const ui = useUi();
351
384
  const [selected, setSelected] = useSelection();
385
+ // a role with its record is finished for good: nothing on its screen changes, so it is read no more
386
+ // (r still reads it again)
387
+ const [done, setDone] = useState(false);
352
388
  const polled = usePoll(() => app.effects.role(props.run, props.dispatchId), RUN_EVERY_MS, {
353
- paused: app.state.paused,
389
+ paused: app.state.paused || done,
354
390
  key: `${props.run}/${props.dispatchId}`,
355
391
  });
392
+ const recorded = polled.value?.record != null;
393
+ useEffect(() => {
394
+ if (recorded) setDone(true);
395
+ }, [recorded]);
356
396
  useRefresher(props.refresher, polled.refresh);
357
397
  useBack(true, "view", () => app.dispatch({ type: "up" }));
358
398
  const d = polled.value;
@@ -532,13 +572,16 @@ export function RunsView(props: { width: number; height: number }) {
532
572
  app.toast({ variant: "info", message: app.getState().paused ? "Updates paused" : "Updates resumed" });
533
573
  },
534
574
  });
535
- const { milestone, role, session } = app.state;
575
+ const { milestone, role, session, back } = app.state;
536
576
  // esc lands on the row it came from: the last session opened, and the last milestone or role opened in it
537
- const back = useRef<{ session: string | null; row: string | null }>({ session: null, row: null });
538
- if (session && back.current.session !== keyOf(session.key))
539
- back.current = { session: keyOf(session.key), row: null };
540
- if (milestone) back.current.row = milestoneKey(milestone.run, milestone.name);
541
- if (role) back.current.row = roleKey(role.run, role.dispatchId);
577
+ const backRow =
578
+ back && session && back.session === session.key
579
+ ? back.milestone
580
+ ? milestoneKey(back.milestone.run, back.milestone.name)
581
+ : back.role
582
+ ? roleKey(back.role.run, back.role.dispatchId)
583
+ : null
584
+ : null;
542
585
  if (milestone)
543
586
  return (
544
587
  <MilestoneView
@@ -563,11 +606,14 @@ export function RunsView(props: { width: number; height: number }) {
563
606
  return (
564
607
  <SessionView
565
608
  sessionKey={session.key}
609
+ run={session.run}
566
610
  width={props.width}
567
611
  height={props.height}
568
- initial={back.current.row}
612
+ initial={backRow}
569
613
  refresher={refresher}
570
614
  />
571
615
  );
572
- return <SessionList width={props.width} height={props.height} initial={back.current.session} />;
616
+ return (
617
+ <SessionList width={props.width} height={props.height} initial={back ? keyOf(back.session) : null} />
618
+ );
573
619
  }
@@ -48,7 +48,7 @@ export interface SavePreview {
48
48
  */
49
49
  function previewSave(
50
50
  d: Draft,
51
- fx: Pick<Effects, "catalog" | "validate" | "agents" | "readProfile">,
51
+ fx: Pick<Effects, "catalog" | "validate" | "agents" | "readProfile" | "profiles">,
52
52
  now: ProfileDoc = fx.readProfile(d.name),
53
53
  ): SavePreview {
54
54
  const before = resolveProfile(now, d.name, d.host);
@@ -62,7 +62,11 @@ function previewSave(
62
62
  changes: diffProfiles(before, after),
63
63
  treatLikes: Object.entries(d.treatLikes),
64
64
  validation,
65
- repair: validation.errors.length > 0 && repairs(fx.validate(before, staged), validation),
65
+ // as the ProfileService rules: a profile with no stored file has no errors a save could repair (1.2 minor)
66
+ repair:
67
+ validation.errors.length > 0 &&
68
+ fx.profiles().names.includes(d.name) &&
69
+ repairs(fx.validate(before, staged), validation),
66
70
  agentsAdded: [...b].filter((x) => !a.has(x)),
67
71
  agentsRemoved: [...a].filter((x) => !b.has(x)),
68
72
  };
@@ -1,4 +1,5 @@
1
1
  import type { Check } from "../../../services/doctor-checks.ts";
2
+ import { waitingLine } from "../../../services/orchestrator-wait.ts";
2
3
  import { useApp, useNow } from "../providers/app.tsx";
3
4
  import { useData } from "../providers/data.tsx";
4
5
  import { useCommandLayer } from "../providers/keymap.tsx";
@@ -19,6 +20,8 @@ function stateParts(state: Check["state"], word: string, plain: boolean): Part[]
19
20
  /** A run in one line: its state word first, then title, repo and progress (research C4). */
20
21
  export function runParts(r: RunRow, now: number, plain: boolean): Part[] {
21
22
  const bits = [plural(r.roleRuns, "role run"), `${r.landed} landed`];
23
+ const waiting = waitingLine(r.waiting, now);
24
+ if (waiting) bits.push(waiting);
22
25
  if (r.budget !== null) bits.push(`${Math.round(r.budget * 100)}% budget`);
23
26
  return [
24
27
  r.live
@@ -189,10 +192,11 @@ export function StatusView(props: { width: number; height: number }) {
189
192
  if (name !== null) {
190
193
  showProfile(app, name);
191
194
  } else if (selected?.startsWith("run:")) {
192
- // spec §4: a run opens in its session's screen, which shows it with the session's other runs
195
+ // spec §4: a run opens in its session's screen, which shows it with the session's other runs; the
196
+ // cursor starts on that run
193
197
  const row = runs.find((r) => `run:${r.id}` === selected);
194
198
  app.dispatch({ type: "tab", tab: "runs" });
195
- if (row) app.dispatch({ type: "session", key: row.session });
199
+ if (row) app.dispatch({ type: "session", key: row.session, run: row.id });
196
200
  }
197
201
  },
198
202
  });
@@ -0,0 +1,44 @@
1
+ import { resolve } from "node:path";
2
+ import { defineCommand } from "citty";
3
+ import { inspectWorkspace, workspaceStatus } from "../services/workspace-service.ts";
4
+ import { JSON_ARG, printJson } from "./cli-kit.ts";
5
+ import { defaultDeps } from "./deps.ts";
6
+
7
+ const inspect = defineCommand({
8
+ meta: { name: "inspect", description: "Read the explicit repositories in a workspace manifest" },
9
+ args: {
10
+ root: { type: "positional", description: "workspace root (default: current directory)" },
11
+ ...JSON_ARG,
12
+ },
13
+ async run({ args }) {
14
+ const view = await inspectWorkspace(resolve(args.root ?? process.cwd()));
15
+ if (args.json) return printJson(view);
16
+ console.log(view.root);
17
+ for (const [name, path] of Object.entries(view.repos)) console.log(`${name}: ${path}`);
18
+ },
19
+ });
20
+
21
+ const status = defineCommand({
22
+ meta: { name: "status", description: "Read a workspace run's children, dependencies and budget" },
23
+ args: {
24
+ workspace: { type: "positional", required: true, description: "workspace run id" },
25
+ ...JSON_ARG,
26
+ },
27
+ async run({ args }) {
28
+ const view = await workspaceStatus(defaultDeps(), args.workspace);
29
+ if (args.json) return printJson(view);
30
+ console.log(`${view.workspace.id}: ${view.workspace.title}`);
31
+ for (const step of view.steps) {
32
+ const blocked = step.blockedBy.length ? ` (waiting for ${step.blockedBy.join(", ")})` : "";
33
+ console.log(`${step.id}: ${step.repo} ${step.state}${step.run ? ` ${step.run}` : ""}${blocked}`);
34
+ }
35
+ console.log(
36
+ `spend: ${view.spend.tokens} tokens, ${view.spend.minutes.toFixed(2)} min, $${view.spend.usd.toFixed(2)}`,
37
+ );
38
+ },
39
+ });
40
+
41
+ export const workspaceCommand = defineCommand({
42
+ meta: { name: "workspace", description: "Inspect workspace members and coordinated runs" },
43
+ subCommands: { inspect, status },
44
+ });
@@ -0,0 +1,25 @@
1
+ import { join } from "node:path";
2
+ import { tryLock } from "./filelock.ts";
3
+ import { log } from "./log.ts";
4
+ import { locksDir } from "./paths.ts";
5
+ import { ensurePrivateDir } from "./store.ts";
6
+
7
+ /** The machine-wide lock the MCP server that runs the boot sync and reconcile holds for its life. */
8
+ export const bootLockTarget = (): string => join(locksDir(), "mcp-boot");
9
+
10
+ /**
11
+ * Plan 22 (the three MCP servers of one Codex session): boot sync and reconcile are single-flight across
12
+ * processes. The first server takes this lock (its pid and start time in it; a dead holder's is reclaimed) and
13
+ * keeps it until it closes; a second server finds it held and skips both. Returns the release, or null while
14
+ * another live server holds it. A lock that cannot be taken for another reason never costs a reconcile: the
15
+ * server runs the boot work, with nothing to release.
16
+ */
17
+ export function takeBootLock(): (() => void) | null {
18
+ try {
19
+ ensurePrivateDir(locksDir());
20
+ return tryLock(bootLockTarget());
21
+ } catch (e) {
22
+ log("warn", "boot", { lock: bootLockTarget(), error: e instanceof Error ? e.message : String(e) });
23
+ return () => {};
24
+ }
25
+ }
@@ -24,6 +24,9 @@ const AttemptSchema = z
24
24
  .refine((a) => a.status !== "accepted" || Boolean(a.msgId?.trim()), "accepted attempt needs a receipt");
25
25
  const DeliverySchema = z.looseObject({ schema: z.literal(1), attempts: z.array(AttemptSchema) });
26
26
  export type DeliveryAttempt = z.infer<typeof AttemptSchema>;
27
+ /** How a delivery to a role's thread is recorded (spec 1.5 plan 21); `status` warns about it. */
28
+ export const ROLE_THREAD_REFUSAL = "refused: the target is a role's thread";
29
+
27
30
  export type DeliveryState = "pending" | "enqueue-accepted" | "ambiguous" | "collected";
28
31
 
29
32
  export function readDelivery(dir: string): DeliveryAttempt[] {
@@ -45,6 +45,8 @@ export function dispatchPaths(dir: string) {
45
45
  stall: join(dir, "stall.json"),
46
46
  /** the message that announced the stall, once sent */
47
47
  stallNotified: join(dir, "stall-notified.json"),
48
+ /** the role's own thread, written by the supervisor once the CLI names it (spec 1.5 plan 21) */
49
+ thread: join(dir, "thread.json"),
48
50
  };
49
51
  }
50
52
 
package/src/infra/env.ts CHANGED
@@ -17,6 +17,9 @@ const SECRET_ENV = new Set([
17
17
  "CLAUDE_CODE_HOST_SESSION_ID",
18
18
  ]);
19
19
 
20
+ /** Whether `name` is one of catherd's own secrets, which no process catherd starts ever gets. */
21
+ export const isCatherdSecret = (name: string): boolean => SECRET_ENV.has(name);
22
+
20
23
  /** `base` without catherd's own secrets and without unset keys; for every process catherd starts. */
21
24
  export function scrubSecrets(base: Record<string, string | undefined>): Record<string, string> {
22
25
  const env: Record<string, string> = {};
@@ -62,11 +65,18 @@ const CHECK_ENV = new Set([
62
65
  "CATHERD_LOCK_SLOTS",
63
66
  ]);
64
67
  const CHECK_ENV_PATTERN = /^(LC_[A-Z_]+|XDG_[A-Z_]+_(HOME|DIR|DIRS))$/;
68
+ /**
69
+ * Plan 23: where the toolchains a check runs find their daemon, proxy and caches (DOCKER_HOST for OrbStack or
70
+ * rootless Docker, a proxy, Go's and pnpm's caches); without them a check that would run fails as if broken.
71
+ */
72
+ const TOOL_ENV_PATTERN =
73
+ /^(DOCKER_(HOST|CONTEXT|CONFIG|CERT_PATH|TLS_VERIFY)|TESTCONTAINERS_[A-Z_]+|(HTTPS?|NO|ALL)_PROXY|(https?|no|all)_proxy|GO(PATH|CACHE|MODCACHE|FLAGS|PROXY|PRIVATE|NOSUMDB|TOOLCHAIN)|PNPM_HOME|npm_config_store_dir|BUN_INSTALL(_CACHE_DIR)?|NVM_DIR|JAVA_HOME|CARGO_HOME|RUSTUP_HOME)$/;
65
74
 
66
75
  /** A strict allowlist of `base` plus PWD, for lane-authored shell text such as preflight checks. */
67
76
  export function checkEnv(base: Record<string, string | undefined>, cwd: string): Record<string, string> {
68
77
  const env: Record<string, string> = {};
69
78
  for (const [k, v] of Object.entries(base))
70
- if (v !== undefined && (CHECK_ENV.has(k) || CHECK_ENV_PATTERN.test(k))) env[k] = v;
79
+ if (v !== undefined && (CHECK_ENV.has(k) || CHECK_ENV_PATTERN.test(k) || TOOL_ENV_PATTERN.test(k)))
80
+ env[k] = v;
71
81
  return { ...env, PWD: cwd };
72
82
  }
package/src/infra/git.ts CHANGED
@@ -54,6 +54,12 @@ export async function gitToplevel(dir: string): Promise<string | null> {
54
54
  return r.kind === "ok" ? r.out.trim() || null : null;
55
55
  }
56
56
 
57
+ /** The repo's `origin` remote URL, or null when it has none (or git cannot say). */
58
+ export async function gitOrigin(repo: string): Promise<string | null> {
59
+ const r = await git(repo, ["config", "--get", "remote.origin.url"]);
60
+ return (r.kind === "ok" && r.out.trim()) || null;
61
+ }
62
+
57
63
  /** The short HEAD, or null when git cannot say (no commit, not a repo, failure, timeout). */
58
64
  export async function gitHead(repo: string, timeoutMs?: number): Promise<string | null> {
59
65
  const r = await git(repo, ["rev-parse", "--short", "HEAD"], timeoutMs);
@@ -1,8 +1,11 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
1
3
  import { availableParallelism } from "node:os";
2
4
  import { join } from "node:path";
3
- import { tryLock } from "./filelock.ts";
5
+ import { tryLock, withFileLock } from "./filelock.ts";
4
6
  import { locksDir } from "./paths.ts";
5
- import { ensurePrivateDir } from "./store.ts";
7
+ import { isAlive, selfIdentity } from "./proc.ts";
8
+ import { ensurePrivateDir, writeJsonAtomic } from "./store.ts";
6
9
 
7
10
  /** A profile's `lock.heavy` as a slot count: a number, or half the cores. */
8
11
  export function heavySlots(setting: number | "cpus/2" | undefined): number {
@@ -17,20 +20,106 @@ export function heavySlots(setting: number | "cpus/2" | undefined): number {
17
20
  export async function withHeavySlot<T>(
18
21
  slots: number,
19
22
  fn: (slot: number) => T | Promise<T>,
20
- o: { pollMs?: number } = {},
23
+ o: { pollMs?: number; onWait?: () => void } = {},
21
24
  ): Promise<T> {
25
+ const r = await withHeavySlotWithin(slots, Number.POSITIVE_INFINITY, fn, o);
26
+ if (r.busy) throw new Error("unreachable: an unbounded wait never gives up");
27
+ return r.value;
28
+ }
29
+
30
+ /**
31
+ * withHeavySlot with a wait budget (plan 23): `{ busy: true }` when no slot came free within `waitMs`, so a
32
+ * caller with a deadline of its own (preflight) reports the lock as busy instead of timing out behind it.
33
+ */
34
+ export async function withHeavySlotWithin<T>(
35
+ slots: number,
36
+ waitMs: number,
37
+ fn: (slot: number) => T | Promise<T>,
38
+ o: { pollMs?: number; onWait?: () => void } = {},
39
+ ): Promise<{ busy: false; value: T } | { busy: true }> {
22
40
  const dir = locksDir();
23
41
  ensurePrivateDir(dir);
42
+ const deadline = Date.now() + waitMs;
24
43
  for (;;) {
25
44
  for (let i = 0; i < slots; i++) {
26
45
  const release = tryLock(join(dir, `slot-${i}`));
27
46
  if (!release) continue;
28
47
  try {
29
- return await fn(i);
48
+ return { busy: false, value: await fn(i) };
30
49
  } finally {
31
50
  release();
32
51
  }
33
52
  }
53
+ const left = deadline - Date.now();
54
+ if (left <= 0) return { busy: true };
55
+ o.onWait?.();
56
+ await Bun.sleep(Math.min(o.pollMs ?? 500, left));
57
+ }
58
+ }
59
+
60
+ /** A process inside a role lock: who, so a dead one is dropped, and a token, so one process can hold twice. */
61
+ interface RoleHolder {
62
+ pid: number;
63
+ startTime: string | null;
64
+ token: string;
65
+ }
66
+
67
+ interface RoleLockState {
68
+ /** whose the lock is: a run id (holders of one run share it), or a lone process's own key */
69
+ owner: string;
70
+ holders: RoleHolder[];
71
+ }
72
+
73
+ function readRoleState(file: string): RoleLockState | null {
74
+ if (!existsSync(file)) return null;
75
+ try {
76
+ const v = JSON.parse(readFileSync(file, "utf8")) as RoleLockState;
77
+ return typeof v?.owner === "string" && Array.isArray(v.holders) ? v : null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Spec 1.5 "Cross-run verifier contention": one owner at a time machine-wide holds the lock of `role`.
85
+ * Commands of the same owner (one run's verifier, side by side within its slots) share it; another owner's
86
+ * wait until the last of them ends, so two runs' verifiers never overlap. A dead holder is dropped.
87
+ */
88
+ export async function withRoleLock<T>(
89
+ role: string,
90
+ owner: string,
91
+ fn: () => T | Promise<T>,
92
+ o: { pollMs?: number; onWait?: () => void } = {},
93
+ ): Promise<T> {
94
+ const dir = locksDir();
95
+ ensurePrivateDir(dir);
96
+ const file = join(dir, `role-${role}.json`);
97
+ const me: RoleHolder = { ...selfIdentity(), token: randomUUID() };
98
+ const enter = () =>
99
+ withFileLock(file, () => {
100
+ const state = readRoleState(file);
101
+ const live = (state?.holders ?? []).filter((h) => isAlive(h.pid, h.startTime));
102
+ if (live.length && state?.owner !== owner) return false;
103
+ writeJsonAtomic(file, { owner, holders: [...live, me] } satisfies RoleLockState);
104
+ return true;
105
+ });
106
+ while (!(await enter())) {
107
+ o.onWait?.();
34
108
  await Bun.sleep(o.pollMs ?? 500);
35
109
  }
110
+ try {
111
+ return await fn();
112
+ } finally {
113
+ await withFileLock(file, () => {
114
+ const state = readRoleState(file);
115
+ if (state)
116
+ writeJsonAtomic(file, { ...state, holders: state.holders.filter((h) => h.token !== me.token) });
117
+ });
118
+ }
119
+ }
120
+
121
+ /** Who holds the role lock now, or null. */
122
+ export function roleLockOwner(role: string): string | null {
123
+ const state = readRoleState(join(locksDir(), `role-${role}.json`));
124
+ return state && state.holders.some((h) => isAlive(h.pid, h.startTime)) ? state.owner : null;
36
125
  }
@@ -0,0 +1,16 @@
1
+ // Plan 23: a host probe that fails once may have caught the network mid-change (a VPN just dropped, a route
2
+ // still settling). catherd's own host probes run twice, this far apart, before they call the host blocked.
3
+
4
+ /** The pause before the second try; tests shorten it. */
5
+ export const hostProbe = { retryMs: 5_000 };
6
+
7
+ /** `probe`, and once more after hostProbe.retryMs when `ok` says the first answer failed. */
8
+ export async function probeTwice<T>(
9
+ probe: () => Promise<T>,
10
+ ok: (r: T) => boolean,
11
+ ): Promise<{ result: T; attempts: 1 | 2 }> {
12
+ const first = await probe();
13
+ if (ok(first)) return { result: first, attempts: 1 };
14
+ await Bun.sleep(hostProbe.retryMs);
15
+ return { result: await probe(), attempts: 2 };
16
+ }
@@ -0,0 +1,88 @@
1
+ import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { locksDir } from "./paths.ts";
4
+ import { isAlive, processStartTime } from "./proc.ts";
5
+ import { ensurePrivateDir, writeJsonAtomic } from "./store.ts";
6
+
7
+ // Plan 23: a role's long gate runs behind `catherd lock`. While such a lock child is alive and writing, the
8
+ // role's wall timeout counts from its last output, not from the role's start. The lock writes here, in the
9
+ // locks dir every role may write; the supervisor reads it.
10
+
11
+ /** The env var the supervisor's spec carries, so a `catherd lock` inside the role knows its dispatch. */
12
+ export const DISPATCH_ID_ENV = "CATHERD_DISPATCH_ID";
13
+
14
+ /** A lock child reports at most this often, so a chatty command does not write a file per line. */
15
+ export const ACTIVITY_EVERY_MS = 1_000;
16
+
17
+ const SAFE_ID = /^[A-Za-z0-9][\w.-]{0,127}$/;
18
+
19
+ /** `<locks>/activity/<dispatch id>`: one file per live `catherd lock` of that dispatch. */
20
+ export const activityDir = (dispatchId: string): string => join(locksDir(), "activity", dispatchId);
21
+
22
+ interface Activity {
23
+ schema: 1;
24
+ pid: number;
25
+ startTime: string | null;
26
+ /** the last time the lock's command wrote output (its start, before the first line) */
27
+ at: string;
28
+ }
29
+
30
+ /**
31
+ * A reporter for this `catherd lock` process, or null outside a dispatch (no id, or one that is not a plain
32
+ * id: it names a path). `tick` records output, at most once per ACTIVITY_EVERY_MS; `done` removes the file.
33
+ */
34
+ export function activityReporter(
35
+ dispatchId: string | undefined,
36
+ now: () => number = Date.now,
37
+ everyMs = ACTIVITY_EVERY_MS,
38
+ ): { tick: () => void; done: () => void } | null {
39
+ if (!dispatchId || !SAFE_ID.test(dispatchId) || dispatchId.includes("..")) return null;
40
+ const dir = activityDir(dispatchId);
41
+ const file = join(dir, `${process.pid}.json`);
42
+ const startTime = processStartTime(process.pid);
43
+ let last = 0;
44
+ const write = (t: number): void => {
45
+ try {
46
+ ensurePrivateDir(dir);
47
+ writeJsonAtomic(file, {
48
+ schema: 1,
49
+ pid: process.pid,
50
+ startTime,
51
+ at: new Date(t).toISOString(),
52
+ } satisfies Activity);
53
+ last = t;
54
+ } catch {
55
+ // advisory: a lock that cannot report still runs its command
56
+ }
57
+ };
58
+ write(now());
59
+ return {
60
+ tick: () => {
61
+ const t = now();
62
+ if (t - last >= everyMs) write(t);
63
+ },
64
+ done: () => rmSync(file, { force: true }),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * When a live `catherd lock` of the dispatch last wrote output, in ms; null when none is alive. A file whose
70
+ * process is gone is left alone (its lock may still be finishing its own cleanup) and never counts.
71
+ */
72
+ export function lastLockOutput(dispatchId: string): number | null {
73
+ const dir = activityDir(dispatchId);
74
+ if (!existsSync(dir)) return null;
75
+ let latest: number | null = null;
76
+ for (const name of readdirSync(dir)) {
77
+ if (!name.endsWith(".json")) continue;
78
+ try {
79
+ const a = JSON.parse(readFileSync(join(dir, name), "utf8")) as Activity;
80
+ if (a.schema !== 1 || !isAlive(a.pid, a.startTime)) continue;
81
+ const at = Date.parse(a.at);
82
+ if (!Number.isNaN(at)) latest = Math.max(latest ?? at, at);
83
+ } catch {
84
+ // a file being written, or damaged: skipped this poll
85
+ }
86
+ }
87
+ return latest;
88
+ }
@@ -0,0 +1,48 @@
1
+ import { scrubSecrets } from "./env.ts";
2
+
3
+ // Plan 23: what a login shell of the user's sees (DOCKER_HOST for OrbStack or rootless Docker, a proxy, a
4
+ // toolchain's PATH), which a server started by a desktop app or a plugin launcher may not inherit.
5
+ // `$SHELL -lc env` is run once per process and kept.
6
+
7
+ /** How long the login shell may take; one that hangs (a prompt in a profile) counts as an empty env. */
8
+ export const loginShell = { timeoutMs: 5_000 };
9
+
10
+ let captured: Record<string, string> | null = null;
11
+
12
+ /** Parses `env -0` output, or plain `env` lines from a shell whose env has no -0. */
13
+ export function parseEnv(out: string): Record<string, string> {
14
+ const env: Record<string, string> = {};
15
+ const rows = out.includes("\0") ? out.split("\0") : out.split("\n");
16
+ for (const row of rows) {
17
+ const eq = row.indexOf("=");
18
+ if (eq > 0) env[row.slice(0, eq)] = row.slice(eq + 1);
19
+ }
20
+ return env;
21
+ }
22
+
23
+ function capture(): Record<string, string> {
24
+ const shell = process.env.SHELL || "/bin/sh";
25
+ try {
26
+ const r = Bun.spawnSync([shell, "-lc", "env -0 2>/dev/null || env"], {
27
+ env: scrubSecrets(process.env),
28
+ stdin: "ignore",
29
+ stdout: "pipe",
30
+ stderr: "ignore",
31
+ timeout: loginShell.timeoutMs,
32
+ });
33
+ return r.exitCode === 0 ? scrubSecrets(parseEnv(r.stdout.toString())) : {};
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ /** The user's login environment, without catherd's secrets; empty when the shell cannot say. Captured once. */
40
+ export function loginEnv(): Record<string, string> {
41
+ captured ??= capture();
42
+ return captured;
43
+ }
44
+
45
+ /** Forgets the captured env (tests, which change SHELL). */
46
+ export function resetLoginEnv(): void {
47
+ captured = null;
48
+ }