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,6 +34,41 @@ export function repoKey(toplevel: string): string {
34
34
  }
35
35
 
36
36
  export const repoDir = (toplevel: string): string => join(dataDir(), "repos", repoKey(toplevel));
37
+
38
+ /**
39
+ * One repository however it is cloned or checked out (spec 1.5 "Knowledge keyed by git origin"):
40
+ * `git@github.com:a/b.git`, `https://user@github.com/a/b` and `ssh://git@github.com/a/b.git/` are all
41
+ * `github.com/a/b`. The host is lower-cased; the path keeps its case.
42
+ */
43
+ export function normalizeOrigin(url: string): string {
44
+ let s = url
45
+ .trim()
46
+ .replace(/\/+$/, "")
47
+ .replace(/\.git$/, "");
48
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(s)?.[1]?.toLowerCase() ?? null;
49
+ const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/)(.+)$/.exec(s);
50
+ if (scp && !scheme) s = `${scp[1]}/${scp[2]}`;
51
+ else s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/^[^@/]+@/, "");
52
+ const [host = "", ...path] = s.split("/");
53
+ // only the scheme's own default port goes: two servers on one host on other ports are two repos (PR #50)
54
+ const port = /:(\d+)$/.exec(host)?.[1];
55
+ const bare = port !== undefined && scheme !== null && DEFAULT_PORTS[scheme] === port;
56
+ return [(bare ? host.slice(0, -port.length - 1) : host).toLowerCase(), ...path].join("/");
57
+ }
58
+
59
+ /** Each git URL scheme's default port, which a URL may name or leave out for the same server. */
60
+ const DEFAULT_PORTS: Record<string, string> = {
61
+ ssh: "22",
62
+ "git+ssh": "22",
63
+ "ssh+git": "22",
64
+ https: "443",
65
+ http: "80",
66
+ git: "9418",
67
+ };
68
+
69
+ /** Where what runs of one repository learned lives, keyed by its origin, whatever worktree it runs in. */
70
+ export const originDir = (url: string): string =>
71
+ join(dataDir(), "repos", `origin-${repoKey(normalizeOrigin(url))}`);
37
72
  export const runsDir = (toplevel: string): string => join(repoDir(toplevel), "runs");
38
73
  export const logsDir = (): string => join(dataDir(), "logs");
39
74
  export const discoveryDir = (): string => join(dataDir(), "discovery");
@@ -0,0 +1,69 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { type RoleMcpContext, ROLE_MCP_SERVER, roleMcpTools } from "../domain/role-tools.ts";
3
+ import { scrubSecrets } from "./env.ts";
4
+ import { configDir, dataDir } from "./paths.ts";
5
+
6
+ export const ROLE_MCP_ENTRY = fileURLToPath(new URL("../entry/mcp/role-bin.ts", import.meta.url));
7
+
8
+ /** How long Codex waits for the role server to start (spec 1.5 plan 21, #42 finding 8). */
9
+ export const ROLE_MCP_STARTUP_SEC = 30;
10
+
11
+ export type RoleServerStart = { ok: true; ms: number } | { ok: false; error: string };
12
+
13
+ /**
14
+ * Doctor's probe: a cold start of the role server's entry, which loads the server's whole module graph and
15
+ * exits (`--probe`), timed against the startup timeout. Never a model turn.
16
+ */
17
+ export async function probeRoleServer(timeoutMs = ROLE_MCP_STARTUP_SEC * 1000): Promise<RoleServerStart> {
18
+ const started = performance.now();
19
+ const p = Bun.spawn([process.execPath, ROLE_MCP_ENTRY, "--probe"], {
20
+ stdin: "ignore",
21
+ stdout: "pipe",
22
+ stderr: "pipe",
23
+ env: { ...scrubSecrets(process.env), CATHERD_NO_SYNC: "1" },
24
+ });
25
+ const timer = setTimeout(() => p.kill("SIGKILL"), timeoutMs);
26
+ try {
27
+ const code = await p.exited;
28
+ const ms = performance.now() - started;
29
+ const out = await new Response(p.stdout).text();
30
+ if (code === 0 && out.trim() === "ready") return { ok: true, ms };
31
+ const err = (await new Response(p.stderr).text()).trim().split("\n").at(-1);
32
+ return {
33
+ ok: false,
34
+ error: p.signalCode === "SIGKILL" ? `no answer in ${timeoutMs} ms` : err || `exit ${code}`,
35
+ };
36
+ } finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+
41
+ /** Launch the same installed package as the dispatching process, without resolving another release. */
42
+ export function roleMcpConfig(context: RoleMcpContext): {
43
+ command: string;
44
+ args: string[];
45
+ env: Record<string, string>;
46
+ } {
47
+ return {
48
+ command: process.execPath,
49
+ args: [ROLE_MCP_ENTRY, context.role, context.run],
50
+ env: { CATHERD_CONFIG_DIR: configDir(), CATHERD_DATA_DIR: dataDir() },
51
+ };
52
+ }
53
+
54
+ export function codexRoleMcpArgs(context: RoleMcpContext): string[] {
55
+ const config = roleMcpConfig(context);
56
+ const prefix = `mcp_servers.${ROLE_MCP_SERVER}`;
57
+ const settings = [
58
+ `${prefix}.command=${JSON.stringify(config.command)}`,
59
+ `${prefix}.args=${JSON.stringify(config.args)}`,
60
+ ...Object.entries(config.env).map(([key, value]) => `${prefix}.env.${key}=${JSON.stringify(value)}`),
61
+ `${prefix}.enabled=true`,
62
+ // required: a role without its run tools cannot work; doctor's probe shows it starts well inside the timeout
63
+ `${prefix}.required=true`,
64
+ `${prefix}.startup_timeout_sec=${ROLE_MCP_STARTUP_SEC}`,
65
+ `${prefix}.enabled_tools=${JSON.stringify(roleMcpTools(context.role))}`,
66
+ ...roleMcpTools(context.role).map((tool) => `${prefix}.tools.${tool}.approval_mode="approve"`),
67
+ ];
68
+ return settings.flatMap((setting) => ["-c", setting]);
69
+ }
@@ -1,5 +1,5 @@
1
1
  import { effortWord } from "../../domain/sources.ts";
2
- import { type SourceTransport, sourceGet } from "./http.ts";
2
+ import { SourceError, type SourceTransport, sourceGet } from "./http.ts";
3
3
  import { isoDay, num, type SourceRow } from "./rows.ts";
4
4
 
5
5
  /** Spec 1.2 §3.1: the Agent Arena boards and WebDev (CC-BY-4.0). */
@@ -18,10 +18,18 @@ export const ARENA_PAGE = "https://huggingface.co/datasets/lmarena-ai/leaderboar
18
18
  export const arenaUrl = (config: ArenaConfig): string =>
19
19
  `https://datasets-server.huggingface.co/rows?dataset=lmarena-ai/leaderboard-dataset&config=${config}&split=latest&length=100`;
20
20
 
21
- /** Every config, in parallel; one failure fails the source, which keeps its last answer. */
21
+ /**
22
+ * Every config, in parallel; one failure fails the source, which keeps its last answer. A config that answers
23
+ * no rows is a failure too (1.2 minor): an answer of the wrong shape never replaces a good one.
24
+ */
22
25
  export async function fetchArena(t: SourceTransport = {}): Promise<Record<ArenaConfig, unknown>> {
23
26
  const got = await Promise.all(
24
- ARENA_CONFIGS.map(async (c) => [c, (await sourceGet(arenaUrl(c), t)).json()]),
27
+ ARENA_CONFIGS.map(async (c) => {
28
+ const body = (await sourceGet(arenaUrl(c), t)).json() as { rows?: unknown } | null;
29
+ if (!Array.isArray(body?.rows) || body.rows.length === 0)
30
+ throw new SourceError(`Arena ${c} answered no rows`);
31
+ return [c, body];
32
+ }),
25
33
  );
26
34
  return Object.fromEntries(got) as Record<ArenaConfig, unknown>;
27
35
  }
@@ -26,7 +26,11 @@ export async function fetchArtificialAnalysis(key: string, t: SourceTransport =
26
26
  const o = { ...t, headers: { "x-api-key": key } };
27
27
  try {
28
28
  const r = await sourceGet(AA_MODELS_URL, o);
29
- return { path: "models", pages: [r.json()], rateLimitRemaining: rateLimitRemaining(r.headers) };
29
+ const body = r.json() as { data?: unknown } | null;
30
+ // (1.2 minor) an answer of the wrong shape is a failure: the last good one stays
31
+ if (!Array.isArray(body?.data) || body.data.length === 0)
32
+ throw new SourceError("Artificial Analysis answered no models", r.status, r.headers);
33
+ return { path: "models", pages: [body], rateLimitRemaining: rateLimitRemaining(r.headers) };
30
34
  } catch (e) {
31
35
  if (!(e instanceof SourceError) || (e.status !== 403 && e.status !== 404)) throw e;
32
36
  }
@@ -41,6 +45,7 @@ export async function fetchArtificialAnalysis(key: string, t: SourceTransport =
41
45
  const total = num(body.pagination?.total_pages);
42
46
  if (total !== null && page >= total) break;
43
47
  }
48
+ if (pages.length === 0) throw new SourceError("Artificial Analysis answered an empty first page");
44
49
  return { path: "free", pages, rateLimitRemaining: remaining };
45
50
  }
46
51
 
@@ -53,10 +58,12 @@ export async function testAaKey(
53
58
  t: SourceTransport = {},
54
59
  ): Promise<{ result: "ok" | "refused" | "unchecked"; error?: string; rateLimitRemaining: number | null }> {
55
60
  try {
61
+ // spec 1.2 §9: one request, never retried (a 429 or 5xx leaves the key unchecked)
56
62
  const r = await sourceGet(aaFreeUrl(1), {
57
63
  attemptMs: 10_000,
58
64
  deadlineMs: 20_000,
59
65
  ...t,
66
+ retries: 0,
60
67
  headers: { "x-api-key": key },
61
68
  });
62
69
  return { result: "ok", rateLimitRemaining: rateLimitRemaining(r.headers) };
@@ -59,6 +59,8 @@ const StateSchema = z.looseObject({
59
59
  error: z.string().nullable(),
60
60
  /** Artificial Analysis: `x-ratelimit-remaining` of its last answer */
61
61
  rateLimitRemaining: z.number().nullable().default(null),
62
+ /** when that count was read (1.2 minor); absent in a state written before 1.5 */
63
+ rateLimitAt: z.iso.datetime().nullable().optional(),
62
64
  }),
63
65
  )
64
66
  .default({}),
@@ -1,11 +1,13 @@
1
1
  import { scrubSecrets } from "./env.ts";
2
2
  import type { Subprocess } from "bun";
3
3
  import { appendFileSync, closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
4
+ import { basename } from "node:path";
4
5
  import { z } from "zod";
5
6
  import { errorMessage } from "../domain/errors.ts";
6
7
  import type { ExitInfo, ExitReason } from "../domain/record.ts";
7
8
  import { dispatchPaths, supervisorLockTarget } from "./dispatch-dir.ts";
8
9
  import { tryLock } from "./filelock.ts";
10
+ import { lastLockOutput } from "./lock-activity.ts";
9
11
  import { log } from "./log.ts";
10
12
  import { killGroup, processStartTime } from "./proc.ts";
11
13
  import { PRIVATE_FILE, writeJsonAtomic } from "./store.ts";
@@ -17,6 +19,8 @@ export const SuperviseSpecSchema = z.looseObject({
17
19
  cmd: z.string(),
18
20
  args: z.array(z.string()),
19
21
  env: z.record(z.string(), z.string()),
22
+ /** plan 23: env vars set from another one at spawn (a gate env secret by reference): name → source name */
23
+ envFrom: z.record(z.string(), z.string()).optional(),
20
24
  cwd: z.string(),
21
25
  stdinPath: z.string().nullable(),
22
26
  idleMs: z.number().positive(),
@@ -24,15 +28,28 @@ export const SuperviseSpecSchema = z.looseObject({
24
28
  killGraceMs: z.number().nonnegative(),
25
29
  graceAfterFinalMs: z.number().nonnegative().nullable(),
26
30
  pollMs: z.number().positive(),
31
+ /** plan 23: when provider retries end an attempt; PROVIDER_RETRY when absent (tests shorten it) */
32
+ providerRetry: z
33
+ .object({ attempts: z.number().positive(), ms: z.number().positive(), pollMs: z.number().positive() })
34
+ .partial()
35
+ .optional(),
27
36
  });
28
37
  export type SuperviseSpec = z.infer<typeof SuperviseSpecSchema>;
29
38
 
39
+ /**
40
+ * Plan 23: an attempt that has only retried its provider (3 retries, or 3 min of nothing else) ends as
41
+ * provider-unavailable; a quiet session's own retry field is read every pollMs.
42
+ */
43
+ export const PROVIDER_RETRY = { attempts: 3, ms: 180_000, pollMs: 30_000 };
44
+
30
45
  /** What one stream line tells the supervisor: the terminal event, the thread, a tool call opening or closing. */
31
46
  interface LineInfo {
32
47
  final?: boolean;
33
48
  thread?: string;
34
49
  /** a tool call the CLI started (`open`) or finished: while one is open the run is busy, however quiet */
35
50
  item?: { id: string; open: boolean };
51
+ /** plan 23: a model step started; one right after another, with nothing between, is a provider retry */
52
+ step?: boolean;
36
53
  }
37
54
 
38
55
  export interface SuperviseHooks {
@@ -40,8 +57,23 @@ export interface SuperviseHooks {
40
57
  /** `sinceMs`: when this run started, so a backend can ignore what an earlier run on the thread left */
41
58
  isBusy?(thread: string | null, sinceMs: number): Promise<boolean>;
42
59
  interrupt?(thread: string | null): Promise<void>;
60
+ /** plan 23: the provider retry attempt the session waits out (its own retry field), or null */
61
+ providerRetry?(thread: string | null, sinceMs: number): Promise<number | null>;
62
+ /**
63
+ * The poll loop's time: what it reads as now and how it waits a poll. The real clock when absent; a test
64
+ * passes its own so the wall, idle and retry limits are decided by the time it drives, never by how fast a
65
+ * loaded runner happens to schedule timers.
66
+ */
67
+ clock?: SuperviseClock;
68
+ }
69
+
70
+ export interface SuperviseClock {
71
+ now(): number;
72
+ sleep(ms: number): Promise<void>;
43
73
  }
44
74
 
75
+ const REAL_CLOCK: SuperviseClock = { now: () => Date.now(), sleep: (ms) => Bun.sleep(ms) };
76
+
45
77
  /** Reads whatever the child appended to `file` since `offset`, as complete lines. */
46
78
  function readNew(file: string, state: { offset: number; rest: string; decoder: TextDecoder }): string[] {
47
79
  if (!existsSync(file)) return [];
@@ -79,7 +111,7 @@ async function bounded<T>(call: () => Promise<T> | undefined, ms: number, fallba
79
111
  }
80
112
  }
81
113
 
82
- function groupAlive(pgid: number): boolean {
114
+ export function groupAlive(pgid: number): boolean {
83
115
  try {
84
116
  process.kill(-pgid, 0);
85
117
  return true;
@@ -89,7 +121,7 @@ function groupAlive(pgid: number): boolean {
89
121
  }
90
122
 
91
123
  /** SIGTERM the group, give it `graceMs` to leave, then SIGKILL whatever is left of it. */
92
- async function stopGroup(pgid: number, graceMs: number, pollMs: number): Promise<void> {
124
+ export async function stopGroup(pgid: number, graceMs: number, pollMs: number): Promise<void> {
93
125
  killGroup(pgid, "SIGTERM");
94
126
  const end = Date.now() + graceMs;
95
127
  while (Date.now() < end && groupAlive(pgid))
@@ -168,6 +200,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
168
200
  }
169
201
 
170
202
  const hookMs = Math.min(10_000, spec.idleMs);
203
+ const clock = hooks.clock ?? REAL_CLOCK;
171
204
  let reason: ExitReason | null = null;
172
205
  let thread: string | null = null;
173
206
  let failure: { error: unknown } | null = null;
@@ -187,8 +220,12 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
187
220
  startedAt: new Date().toISOString(),
188
221
  });
189
222
 
190
- const started = Date.now();
223
+ const started = clock.now();
224
+ const dispatchId = basename(spec.dispatchDir);
191
225
  let lastActivity = started;
226
+ // plan 23: the last output seen from a `catherd lock` of the role, kept after the lock exits (its file goes),
227
+ // so the wall counts from that output, never from the role's start again
228
+ let lockAt = started;
192
229
  let finalAt: number | null = null;
193
230
  // spec §3.6: a quiet stretch of half the idle timeout, not busy, is a stall, reported once per dispatch
194
231
  let stalled = false;
@@ -196,34 +233,88 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
196
233
  const open = new Set<string>();
197
234
  const stream = { offset: 0, rest: "", decoder: new TextDecoder("utf-8") };
198
235
 
236
+ // plan 23: a step that starts right after another with nothing between is a provider retry; retries are
237
+ // not progress, and enough of them (or a long enough stretch) end the attempt as provider-unavailable
238
+ let lastWasStep = false;
239
+ let retries = 0;
240
+ let retrySince: number | null = null;
241
+ let lastRetryPoll = started;
242
+ const retry = { ...PROVIDER_RETRY, ...spec.providerRetry };
243
+
199
244
  while (!done && reason === null) {
200
- await Bun.sleep(spec.pollMs);
245
+ await clock.sleep(spec.pollMs);
201
246
  const lines = readNew(p.events, stream);
202
- if (lines.length) {
203
- lastActivity = Date.now();
204
- stallChecked = false;
205
- }
247
+ let progress = false;
206
248
  for (const line of lines) {
207
249
  let d: LineInfo | undefined;
208
250
  try {
209
251
  d = hooks.onLine?.(line);
210
252
  } catch {
211
- continue; // one line the hook cannot read must not end supervision
253
+ // one line the hook cannot read must not end supervision; it is output all the same
254
+ progress = true;
255
+ lastWasStep = false;
256
+ continue;
212
257
  }
213
- if (d?.thread) thread = d.thread;
214
- if (d?.final) finalAt ??= Date.now();
258
+ if (d?.step && lastWasStep) {
259
+ retries++;
260
+ retrySince ??= clock.now();
261
+ } else {
262
+ progress = true;
263
+ if (!d?.step) {
264
+ retries = 0;
265
+ retrySince = null;
266
+ }
267
+ }
268
+ lastWasStep = d?.step === true;
269
+ if (d?.thread && d.thread !== thread) {
270
+ thread = d.thread;
271
+ // spec 1.5 plan 21: a live role's thread is on disk, so no delivery ever targets it
272
+ try {
273
+ writeJsonAtomic(p.thread, { schema: 1, thread, at: new Date().toISOString() });
274
+ } catch (e) {
275
+ log("warn", "supervise", { dispatch: spec.dispatchDir, error: errorMessage(e) });
276
+ }
277
+ }
278
+ if (d?.final) finalAt ??= clock.now();
215
279
  if (d?.item) {
216
280
  if (d.item.open) open.add(d.item.id);
217
281
  else open.delete(d.item.id);
218
282
  }
219
283
  }
284
+ if (progress) {
285
+ lastActivity = clock.now();
286
+ stallChecked = false;
287
+ }
288
+ // plan 23: a session quiet for a while may be waiting out a provider retry the stream does not show
289
+ if (
290
+ hooks.providerRetry &&
291
+ clock.now() - lastActivity >= retry.pollMs &&
292
+ clock.now() - lastRetryPoll >= retry.pollMs
293
+ ) {
294
+ lastRetryPoll = clock.now();
295
+ const attempt = await bounded(() => hooks.providerRetry?.(thread, started), hookMs, null);
296
+ if (attempt !== null) {
297
+ retrySince ??= clock.now();
298
+ retries = Math.max(retries, attempt);
299
+ } else {
300
+ // the session says it is not retrying: the steps seen were a retry that worked, and the quiet since
301
+ // is work the stream does not show yet (a long tool call), never a provider outage
302
+ retries = 0;
303
+ retrySince = null;
304
+ }
305
+ }
220
306
  // a worker that ended on its own is recorded as it ended, even if a cancel or a limit arrived meanwhile
221
307
  if (done) break;
222
- const now = Date.now();
308
+ const now = clock.now();
309
+ lockAt = Math.max(lockAt, lastLockOutput(dispatchId) ?? lockAt);
223
310
  if (existsSync(p.cancel)) reason = "cancelled";
224
- else if (now - started >= spec.wallMs) reason = "wall-timeout";
311
+ else if (now - lockAt >= spec.wallMs)
312
+ // plan 23: once a `catherd lock` of the role has written, the wall counts from its last output
313
+ reason = "wall-timeout";
225
314
  else if (finalAt !== null && spec.graceAfterFinalMs !== null && now - finalAt >= spec.graceAfterFinalMs)
226
315
  reason = "after-final";
316
+ else if (retries >= retry.attempts || (retrySince !== null && now - retrySince >= retry.ms))
317
+ reason = "provider-unavailable";
227
318
  else if (now - lastActivity >= spec.idleMs) {
228
319
  const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
229
320
  // the busy check can take up to hookMs: a worker that ended meanwhile is recorded as it ended, read
@@ -231,7 +322,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
231
322
  if (done || child.exitCode !== null || child.signalCode !== null) break;
232
323
  if (busy) {
233
324
  // a new quiet stretch starts: it gets its own stall check
234
- lastActivity = Date.now();
325
+ lastActivity = clock.now();
235
326
  stallChecked = false;
236
327
  } else reason = "idle-timeout";
237
328
  } else if (!stalled && !stallChecked && now - lastActivity >= spec.idleMs / 2) {
@@ -240,7 +331,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
240
331
  stallChecked = true;
241
332
  const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
242
333
  if (busy) {
243
- lastActivity = Date.now();
334
+ lastActivity = clock.now();
244
335
  stallChecked = false;
245
336
  } else if (!done) {
246
337
  stalled = true;
@@ -249,7 +340,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
249
340
  writeJsonAtomic(p.stall, {
250
341
  schema: 1,
251
342
  at: new Date().toISOString(),
252
- quietMs: Date.now() - lastActivity,
343
+ quietMs: clock.now() - lastActivity,
253
344
  });
254
345
  } catch (e) {
255
346
  log("warn", "stall", { dispatch: spec.dispatchDir, error: errorMessage(e) });
@@ -1,18 +1,23 @@
1
1
  import type { KnownHost } from "../domain/host.ts";
2
- import { appendFileSync, existsSync, readFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import type { BackendAdapter } from "../adapters/backend.ts";
5
5
  import { budgetStatus, formatBudget } from "../domain/budget.ts";
6
6
  import { CatherdError, errorMessage } from "../domain/errors.ts";
7
7
  import { assertId, formatRung, newDispatchId, parseRung } from "../domain/ids.ts";
8
8
  import { assertLaneHeader, overlaps } from "../domain/lane.ts";
9
+ import { roleTimeouts } from "../domain/profile.ts";
9
10
  import type { RunRecord } from "../domain/record.ts";
10
- import { withReplyContract } from "../domain/role-prompts.ts";
11
+ import { composeBrief } from "../domain/brief.ts";
12
+ import { briefOwns, DOCS_OWNS } from "../domain/changes.ts";
13
+ import { grantsScratch, ROLE_SERVER_BACKENDS } from "../domain/role-tools.ts";
11
14
  import type { Role } from "../domain/roles.ts";
15
+ import { formatRoleScope, ROLE_ENV } from "../domain/role-scope.ts";
12
16
  import { dispatchPaths, markForCollect } from "../infra/dispatch-dir.ts";
13
17
  import { withFileLock } from "../infra/filelock.ts";
14
18
  import { statusSnapshot } from "../infra/git.ts";
15
19
  import { launchSupervisor } from "../infra/launch.ts";
20
+ import { DISPATCH_ID_ENV } from "../infra/lock-activity.ts";
16
21
  import { log } from "../infra/log.ts";
17
22
  import { processStartTime } from "../infra/proc.ts";
18
23
  import { ensurePrivateDir, PRIVATE_FILE, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
@@ -27,12 +32,20 @@ import {
27
32
  listDispatches,
28
33
  pendingDispatches,
29
34
  roleDir,
35
+ scratchDir,
30
36
  setLatest,
31
37
  } from "./dispatches.ts";
32
38
  import { finalizeDispatch } from "./finalize.ts";
39
+ import { gateEnvParts, readGateEnv } from "./gate-env.ts";
40
+ import { assertNotPaused } from "./pause.ts";
41
+ import { currentOwns } from "./lane-edit.ts";
42
+ import { unfinishedAfter } from "./protocol.ts";
43
+ import { runProfile } from "./run-pin.ts";
33
44
  import type { Deps } from "./ports.ts";
34
- import { readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
45
+ import { readRecords, recordsOnThread, type Run, runPaths, supersededBy } from "./run-store.ts";
35
46
  import { currentSession } from "./sessions.ts";
47
+ import { verifierBrief } from "./verifier-brief.ts";
48
+ import { withRunAdmission } from "./workspace-admission.ts";
36
49
 
37
50
  export interface AdmitInput {
38
51
  role: Role;
@@ -54,7 +67,15 @@ export const KILL_GRACE_MS = 10_000;
54
67
 
55
68
  export const laneFile = (run: Run, lane: string): string => join(runPaths(run.dir).lanes, `${lane}.md`);
56
69
 
57
- function laneOwns(run: Run, lane: string): string[] {
70
+ /** The role's scratch folder, created (0700) and by its real path, on a backend that grants it; else null. */
71
+ function roleScratch(run: Run, name: string, backend: string, isolated: boolean): string | null {
72
+ if (!grantsScratch(backend, isolated)) return null;
73
+ const dir = scratchDir(run, name);
74
+ ensurePrivateDir(dir);
75
+ return realpathSync(dir);
76
+ }
77
+
78
+ export function laneOwns(run: Run, lane: string): string[] {
58
79
  const file = laneFile(run, lane);
59
80
  if (!existsSync(file))
60
81
  throw new CatherdError("E_LANE_INVALID", `no lane file lanes/${lane}.md`, {
@@ -124,6 +145,15 @@ async function prepared(adapter: BackendAdapter, req: Parameters<NonNullable<Bac
124
145
  }
125
146
  }
126
147
 
148
+ /** Refuses a dispatch into a run `runs supersede` closed, naming the run that took over. */
149
+ function assertNotSuperseded(run: Run): void {
150
+ const closed = supersededBy(run);
151
+ if (closed)
152
+ throw new CatherdError("E_RUN_NOT_LIVE", `run ${run.id} is superseded by ${closed.by}`, {
153
+ fix: `dispatch in run ${closed.by}`,
154
+ });
155
+ }
156
+
127
157
  /**
128
158
  * Spec §4.4 step 1. The checks that read shared state and the write that makes the dispatch live
129
159
  * happen under the run's admission lock, so parallel dispatches always see each other (audit C1).
@@ -136,8 +166,12 @@ export async function admit(
136
166
  ): Promise<{ d: Dispatch; specPath: string }> {
137
167
  assertId("role name", i.name);
138
168
  if (i.lane !== null) assertId("lane", i.lane);
169
+ // a machine or workspace pause refuses every dispatch it covers, with its reason (spec 1.5 "Group pause")
170
+ assertNotPaused(run);
171
+ assertNotSuperseded(run);
139
172
  const rung = parseRung(i.rung);
140
- const profile = deps.profiles.forRepo(run.meta.repo);
173
+ // spec 1.5: the run's pinned profile, access and isolation, whatever the repo runs on now
174
+ const profile = runProfile(deps, run);
141
175
  const rc = profile.roles[i.role];
142
176
  if (!rc?.enabled)
143
177
  throw new CatherdError("E_ADMIT_RUNG", `the ${i.role} role is off in profile ${profile.name}`, {
@@ -183,10 +217,23 @@ export async function admit(
183
217
  { fix: "dispatch a fresh thread (omit `thread`)" },
184
218
  );
185
219
  const owns = i.lane === null ? [] : laneOwns(run, i.lane);
220
+ // spec 1.5 plan 21: the writer's implicit docs lane, for attribution only
221
+ const ownsImplicit = i.role === "writer" && i.lane === null ? (briefOwns(i.brief) ?? DOCS_OWNS) : null;
222
+ // spec 1.5 "Lane editing": an After: line orders lanes that compile against each other
223
+ const before = i.lane === null ? [] : unfinishedAfter(run, i.lane);
224
+ if (before.length)
225
+ throw new CatherdError(
226
+ "E_ADMIT_ORDER",
227
+ `${i.lane} runs after ${before.join(", ")} (its After: line), which ${before.length > 1 ? "have" : "has"} not finished`,
228
+ { fix: `dispatch ${i.lane} once ${before.join(", ")} ${before.length > 1 ? "end" : "ends"} ok` },
229
+ );
186
230
  const id = newDispatchId();
187
231
  const dir = join(roleDir(run, i.name), id);
188
232
  const p = dispatchPaths(dir);
189
233
  const isolated = started?.isolated ?? profile.isolated[rung.backend] ?? false;
234
+ // spec 1.5 plan 21 (#42 findings 1, 2): the role server goes in whether the harness is isolated or not, so no
235
+ // role is refused for isolation, and a thread an isolated run started resumes as it started
236
+ const roleServer = ROLE_SERVER_BACKENDS.includes(rung.backend);
190
237
  await prepared(adapter, {
191
238
  rung,
192
239
  access: rc.access,
@@ -194,20 +241,27 @@ export async function admit(
194
241
  repo: run.meta.repo,
195
242
  network,
196
243
  });
197
- const plan = adapter.plan({
198
- rung,
199
- access: rc.access,
200
- network,
201
- thread: i.thread,
202
- isolated,
203
- repo: run.meta.repo,
204
- briefPath: p.brief,
205
- replyPath: p.reply,
206
- dispatchDir: dir,
207
- });
208
244
 
209
245
  await finalizeFinished(run, deps.now(), onRecorded);
210
- return withFileLock(runPaths(run.dir).admission, async () => {
246
+ return withRunAdmission(run, deps.now, async () => {
247
+ // again under the lock `runs supersede` writes under: a supersede that landed since the check above wins
248
+ assertNotSuperseded(run);
249
+ // the scratch is created under the admission lock `catherd runs clean` takes too, so cleanup never removes
250
+ // the scratch of a dispatch it does not see live yet (PR #46)
251
+ const scratch = roleScratch(run, i.name, rung.backend, isolated);
252
+ const plan = adapter.plan({
253
+ rung,
254
+ access: rc.access,
255
+ network,
256
+ thread: i.thread,
257
+ isolated,
258
+ repo: run.meta.repo,
259
+ briefPath: p.brief,
260
+ replyPath: p.reply,
261
+ dispatchDir: dir,
262
+ ...(roleServer ? { roleMcp: { run: run.id, role: i.role } } : {}),
263
+ ...(scratch ? { scratch } : {}),
264
+ });
211
265
  // A dispatch blocks until its record is written, not only while it runs: its finalizer diffs the
212
266
  // tree after the exit, so a later dispatch's writes must not land in between. A finished one here
213
267
  // could not be recorded just now. The records and the pending dispatches are one snapshot, taken
@@ -230,7 +284,9 @@ export async function admit(
230
284
  },
231
285
  );
232
286
  for (const d of pending) {
233
- const shared = overlaps(owns, d.admit.owns);
287
+ // a running lane holds what owns_add granted it since its admission, too
288
+ const held = d.admit.lane ? [...d.admit.owns, ...currentOwns(run, d.admit.lane)] : d.admit.owns;
289
+ const shared = overlaps(owns, held);
234
290
  if (shared.length)
235
291
  throw new CatherdError(
236
292
  "E_ADMIT_OVERLAP",
@@ -257,6 +313,7 @@ export async function admit(
257
313
  role: i.role,
258
314
  lane: i.lane,
259
315
  owns,
316
+ ...(ownsImplicit ? { ownsImplicit } : {}),
260
317
  rung: formatRung(rung),
261
318
  backend: rung.backend,
262
319
  thread: i.thread,
@@ -273,8 +330,23 @@ export async function admit(
273
330
  ...(sessionId ? { sessionId, host: i.host ?? session?.host ?? "claude-code" } : {}),
274
331
  };
275
332
  ensurePrivateDir(dir);
276
- // spec 1.1 §6: every brief ends with its role's reply contract, failover stand-ins' included
277
- writeTextAtomic(p.brief, withReplyContract(i.role, i.brief));
333
+ // plan 23: the verifier runs with the repo's gate environment (DOCKER_HOST, a proxy, …)
334
+ const gate =
335
+ i.role === "verifier" ? gateEnvParts(await readGateEnv(run.meta.repo)) : { values: {}, refs: {} };
336
+ // spec 1.1 §6: every brief ends with its role's reply contract, failover stand-ins' included; spec 1.5 plan 21:
337
+ // before it, the lane file as it stands now, and who the role is, its scratch and its catherd tools; plan 23:
338
+ // a verifier's brief also carries the gate's rules and the run's recorded items
339
+ writeTextAtomic(
340
+ p.brief,
341
+ composeBrief(await verifierBrief(run, i.role, i.name, i.brief), {
342
+ run: run.id,
343
+ name: i.name,
344
+ role: i.role,
345
+ lane: i.lane === null ? null : { id: i.lane, text: readFileSync(laneFile(run, i.lane), "utf8") },
346
+ scratch,
347
+ roleServer,
348
+ }),
349
+ );
278
350
  // Spec §10.4: the adapter's overrides only; the supervisor adds its own inherited env at spawn
279
351
  // time (src/entry/supervise-command.ts), so no credential is ever written to disk. 0600 all the same.
280
352
  writeJsonAtomic(
@@ -285,11 +357,26 @@ export async function admit(
285
357
  dispatchDir: dir,
286
358
  cmd: plan.cmd,
287
359
  args: plan.args,
288
- env: { ...plan.env, PWD: plan.cwd },
360
+ // spec 1.5 plan 21: the supervisor gives the role its identity and, where granted, its scratch TMPDIR;
361
+ // plan 23: a `catherd lock` in the role reports to this dispatch, so a long gate keeps its wall alive
362
+ // plan 23: each dispatch its own testcontainers session, so parallel lanes never share a reaper;
363
+ // the gate env never overrides the role's identity, scratch, dispatch id, PWD or isolated home
364
+ env: {
365
+ ...gate.values,
366
+ ...plan.env,
367
+ [ROLE_ENV]: formatRoleScope({ run: run.id, name: i.name }),
368
+ ...(scratch ? { TMPDIR: scratch } : {}),
369
+ [DISPATCH_ID_ENV]: id,
370
+ TESTCONTAINERS_SESSION_ID: id,
371
+ PWD: plan.cwd,
372
+ },
373
+ // a secret of the gate env by reference only: the supervisor reads it from its own env at spawn
374
+ ...(Object.keys(gate.refs).length ? { envFrom: gate.refs } : {}),
289
375
  cwd: plan.cwd,
290
376
  stdinPath: plan.stdinPath,
291
- idleMs: profile.timeouts.idleMin * 60_000,
292
- wallMs: profile.timeouts.wallMin * 60_000,
377
+ // plan 23: a role's own timeouts win over the profile's (a verifier's long gate)
378
+ idleMs: roleTimeouts(profile, rc).idleMin * 60_000,
379
+ wallMs: roleTimeouts(profile, rc).wallMin * 60_000,
293
380
  killGraceMs: KILL_GRACE_MS,
294
381
  graceAfterFinalMs: adapter.graceAfterFinalMs,
295
382
  pollMs: deps.pollMs,