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
@@ -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");
@@ -51,10 +51,12 @@ export function sendToInbox(
51
51
  const socketPath = target.socketPath;
52
52
  return new Promise<SendResult>((resolve) => {
53
53
  let settled = false;
54
+ // once the frame is being written a failure may still have delivered it: such a result carries its msg_id
55
+ let writing = false;
54
56
  const done = (r: SendResult) => {
55
57
  if (settled) return;
56
58
  settled = true;
57
- resolve(r);
59
+ resolve(writing && r.outcome === "error" ? { ...r, msgId } : r);
58
60
  };
59
61
  let s: ReturnType<typeof createConnection>;
60
62
  try {
@@ -77,6 +79,7 @@ export function sendToInbox(
77
79
  });
78
80
  s.on("connect", () => {
79
81
  clearTimeout(timer);
82
+ writing = true;
80
83
  s.write(frameText(content, priority, target.token, msgId), (err) => {
81
84
  if (err) {
82
85
  s.destroy();
@@ -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,10 +1,13 @@
1
+ import { scrubSecrets } from "./env.ts";
1
2
  import type { Subprocess } from "bun";
2
3
  import { appendFileSync, closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
4
+ import { basename } from "node:path";
3
5
  import { z } from "zod";
4
6
  import { errorMessage } from "../domain/errors.ts";
5
7
  import type { ExitInfo, ExitReason } from "../domain/record.ts";
6
8
  import { dispatchPaths, supervisorLockTarget } from "./dispatch-dir.ts";
7
9
  import { tryLock } from "./filelock.ts";
10
+ import { lastLockOutput } from "./lock-activity.ts";
8
11
  import { log } from "./log.ts";
9
12
  import { killGroup, processStartTime } from "./proc.ts";
10
13
  import { PRIVATE_FILE, writeJsonAtomic } from "./store.ts";
@@ -16,6 +19,8 @@ export const SuperviseSpecSchema = z.looseObject({
16
19
  cmd: z.string(),
17
20
  args: z.array(z.string()),
18
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(),
19
24
  cwd: z.string(),
20
25
  stdinPath: z.string().nullable(),
21
26
  idleMs: z.number().positive(),
@@ -23,15 +28,28 @@ export const SuperviseSpecSchema = z.looseObject({
23
28
  killGraceMs: z.number().nonnegative(),
24
29
  graceAfterFinalMs: z.number().nonnegative().nullable(),
25
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(),
26
36
  });
27
37
  export type SuperviseSpec = z.infer<typeof SuperviseSpecSchema>;
28
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
+
29
45
  /** What one stream line tells the supervisor: the terminal event, the thread, a tool call opening or closing. */
30
46
  interface LineInfo {
31
47
  final?: boolean;
32
48
  thread?: string;
33
49
  /** a tool call the CLI started (`open`) or finished: while one is open the run is busy, however quiet */
34
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;
35
53
  }
36
54
 
37
55
  export interface SuperviseHooks {
@@ -39,8 +57,23 @@ export interface SuperviseHooks {
39
57
  /** `sinceMs`: when this run started, so a backend can ignore what an earlier run on the thread left */
40
58
  isBusy?(thread: string | null, sinceMs: number): Promise<boolean>;
41
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>;
42
73
  }
43
74
 
75
+ const REAL_CLOCK: SuperviseClock = { now: () => Date.now(), sleep: (ms) => Bun.sleep(ms) };
76
+
44
77
  /** Reads whatever the child appended to `file` since `offset`, as complete lines. */
45
78
  function readNew(file: string, state: { offset: number; rest: string; decoder: TextDecoder }): string[] {
46
79
  if (!existsSync(file)) return [];
@@ -78,7 +111,7 @@ async function bounded<T>(call: () => Promise<T> | undefined, ms: number, fallba
78
111
  }
79
112
  }
80
113
 
81
- function groupAlive(pgid: number): boolean {
114
+ export function groupAlive(pgid: number): boolean {
82
115
  try {
83
116
  process.kill(-pgid, 0);
84
117
  return true;
@@ -88,7 +121,7 @@ function groupAlive(pgid: number): boolean {
88
121
  }
89
122
 
90
123
  /** SIGTERM the group, give it `graceMs` to leave, then SIGKILL whatever is left of it. */
91
- async function stopGroup(pgid: number, graceMs: number, pollMs: number): Promise<void> {
124
+ export async function stopGroup(pgid: number, graceMs: number, pollMs: number): Promise<void> {
92
125
  killGroup(pgid, "SIGTERM");
93
126
  const end = Date.now() + graceMs;
94
127
  while (Date.now() < end && groupAlive(pgid))
@@ -139,7 +172,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
139
172
  fds.push(stderr);
140
173
  child = Bun.spawn([spec.cmd, ...spec.args], {
141
174
  cwd: spec.cwd,
142
- env: spec.env,
175
+ env: scrubSecrets(spec.env),
143
176
  stdin,
144
177
  stdout,
145
178
  stderr,
@@ -149,7 +182,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
149
182
  backend: spec.backend,
150
183
  dispatch: spec.dispatchDir,
151
184
  argv: [spec.cmd, ...spec.args],
152
- env: spec.env,
185
+ env: scrubSecrets(spec.env),
153
186
  cwd: spec.cwd,
154
187
  pid: child.pid,
155
188
  });
@@ -167,6 +200,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
167
200
  }
168
201
 
169
202
  const hookMs = Math.min(10_000, spec.idleMs);
203
+ const clock = hooks.clock ?? REAL_CLOCK;
170
204
  let reason: ExitReason | null = null;
171
205
  let thread: string | null = null;
172
206
  let failure: { error: unknown } | null = null;
@@ -186,8 +220,12 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
186
220
  startedAt: new Date().toISOString(),
187
221
  });
188
222
 
189
- const started = Date.now();
223
+ const started = clock.now();
224
+ const dispatchId = basename(spec.dispatchDir);
190
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;
191
229
  let finalAt: number | null = null;
192
230
  // spec §3.6: a quiet stretch of half the idle timeout, not busy, is a stall, reported once per dispatch
193
231
  let stalled = false;
@@ -195,34 +233,88 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
195
233
  const open = new Set<string>();
196
234
  const stream = { offset: 0, rest: "", decoder: new TextDecoder("utf-8") };
197
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
+
198
244
  while (!done && reason === null) {
199
- await Bun.sleep(spec.pollMs);
245
+ await clock.sleep(spec.pollMs);
200
246
  const lines = readNew(p.events, stream);
201
- if (lines.length) {
202
- lastActivity = Date.now();
203
- stallChecked = false;
204
- }
247
+ let progress = false;
205
248
  for (const line of lines) {
206
249
  let d: LineInfo | undefined;
207
250
  try {
208
251
  d = hooks.onLine?.(line);
209
252
  } catch {
210
- 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;
211
257
  }
212
- if (d?.thread) thread = d.thread;
213
- 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();
214
279
  if (d?.item) {
215
280
  if (d.item.open) open.add(d.item.id);
216
281
  else open.delete(d.item.id);
217
282
  }
218
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
+ }
219
306
  // a worker that ended on its own is recorded as it ended, even if a cancel or a limit arrived meanwhile
220
307
  if (done) break;
221
- const now = Date.now();
308
+ const now = clock.now();
309
+ lockAt = Math.max(lockAt, lastLockOutput(dispatchId) ?? lockAt);
222
310
  if (existsSync(p.cancel)) reason = "cancelled";
223
- 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";
224
314
  else if (finalAt !== null && spec.graceAfterFinalMs !== null && now - finalAt >= spec.graceAfterFinalMs)
225
315
  reason = "after-final";
316
+ else if (retries >= retry.attempts || (retrySince !== null && now - retrySince >= retry.ms))
317
+ reason = "provider-unavailable";
226
318
  else if (now - lastActivity >= spec.idleMs) {
227
319
  const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
228
320
  // the busy check can take up to hookMs: a worker that ended meanwhile is recorded as it ended, read
@@ -230,7 +322,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
230
322
  if (done || child.exitCode !== null || child.signalCode !== null) break;
231
323
  if (busy) {
232
324
  // a new quiet stretch starts: it gets its own stall check
233
- lastActivity = Date.now();
325
+ lastActivity = clock.now();
234
326
  stallChecked = false;
235
327
  } else reason = "idle-timeout";
236
328
  } else if (!stalled && !stallChecked && now - lastActivity >= spec.idleMs / 2) {
@@ -239,7 +331,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
239
331
  stallChecked = true;
240
332
  const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
241
333
  if (busy) {
242
- lastActivity = Date.now();
334
+ lastActivity = clock.now();
243
335
  stallChecked = false;
244
336
  } else if (!done) {
245
337
  stalled = true;
@@ -248,7 +340,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
248
340
  writeJsonAtomic(p.stall, {
249
341
  schema: 1,
250
342
  at: new Date().toISOString(),
251
- quietMs: Date.now() - lastActivity,
343
+ quietMs: clock.now() - lastActivity,
252
344
  });
253
345
  } catch (e) {
254
346
  log("warn", "stall", { dispatch: spec.dispatchDir, error: errorMessage(e) });