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
@@ -0,0 +1,221 @@
1
+ import {
2
+ accessSync,
3
+ constants,
4
+ existsSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ statfsSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { realTmpdir, toolchainCaches } from "../adapters/access.ts";
14
+ import { runCli } from "../adapters/cli.ts";
15
+ import { probeTwice } from "../infra/host-probe.ts";
16
+ import { probeTargets } from "./doctor-access.ts";
17
+ import type { Check } from "./doctor-checks.ts";
18
+
19
+ // Plan 23: the rows that catch what cost the identity and payment runs their verifier rounds: a Docker client
20
+ // that injects proxies into every container, a compose network whose services cannot reach each other by
21
+ // name, a Docker disk about to fill, and the toolchain caches a sandboxed worker writes.
22
+
23
+ /** Under this much free space in Docker's data root, doctor warns: image builds start failing near it (tests raise it). */
24
+ export const dockerDisk = { minFreeBytes: 10 * 1024 ** 3 };
25
+
26
+ /** How long the compose probe may take, an image pull included. */
27
+ export const composeProbe = { timeoutMs: 120_000 };
28
+
29
+ /** The Docker client's config file: $DOCKER_CONFIG/config.json, else ~/.docker/config.json. */
30
+ export const dockerConfigFile = (env = process.env): string =>
31
+ join(env.DOCKER_CONFIG || join(env.HOME || homedir(), ".docker"), "config.json");
32
+
33
+ /** The keys of a proxies entry the Docker client turns into container environment variables. */
34
+ const PROXY_KEYS = ["httpProxy", "httpsProxy", "noProxy", "ftpProxy", "allProxy"];
35
+
36
+ /** The Docker client's default daemon host, which its proxies block may key an entry by. */
37
+ const DEFAULT_DOCKER_HOST = "unix:///var/run/docker.sock";
38
+
39
+ /**
40
+ * A `proxies` block in the Docker client's config that sets a value: every container then gets HTTP_PROXY and its
41
+ * siblings. The Docker client applies the current daemon host's entry, else `default`; an entry with no value
42
+ * (or an empty block) injects nothing.
43
+ */
44
+ export function proxiesCheck(
45
+ file = dockerConfigFile(),
46
+ env: Record<string, string | undefined> = process.env,
47
+ ): Check | null {
48
+ let config: { proxies?: unknown };
49
+ try {
50
+ config = JSON.parse(readFileSync(file, "utf8")) as { proxies?: unknown };
51
+ } catch {
52
+ return null;
53
+ }
54
+ const proxies = config.proxies;
55
+ if (!proxies || typeof proxies !== "object") return null;
56
+ const entries = proxies as Record<string, unknown>;
57
+ const host = env.DOCKER_HOST || DEFAULT_DOCKER_HOST;
58
+ const entry = Object.hasOwn(entries, host) ? entries[host] : entries.default;
59
+ const sets =
60
+ !!entry &&
61
+ typeof entry === "object" &&
62
+ PROXY_KEYS.some((k) => {
63
+ const v = (entry as Record<string, unknown>)[k];
64
+ return typeof v === "string" && v.trim() !== "";
65
+ });
66
+ if (!sets) return null;
67
+ return {
68
+ id: "docker-proxies",
69
+ label: "Docker client proxies",
70
+ state: "warn",
71
+ word: "injects proxies",
72
+ detail: `${file} has a proxies block: every container gets HTTP_PROXY, so compose services cannot reach each other by name and a loopback health check goes to the proxy`,
73
+ fix: `remove the proxies block from ${file}, or add the compose service names and 127.0.0.1 to its noProxy`,
74
+ };
75
+ }
76
+
77
+ /** Two busybox services: `web` serves on 8080 with a loopback health check, `probe` fetches it by name. */
78
+ const COMPOSE = `services:
79
+ web:
80
+ image: busybox:1.36
81
+ command: ["httpd", "-f", "-p", "8080", "-h", "/etc"]
82
+ healthcheck:
83
+ test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/hostname"]
84
+ interval: 1s
85
+ retries: 30
86
+ probe:
87
+ image: busybox:1.36
88
+ depends_on:
89
+ web:
90
+ condition: service_healthy
91
+ command: ["wget", "-q", "-O", "/dev/null", "http://web:8080/hostname"]
92
+ `;
93
+
94
+ /** `docker compose up` on the two-service file, then `down`; the result of the up. */
95
+ async function composeUp(docker: string): Promise<{ ok: boolean; why: string }> {
96
+ const dir = mkdtempSync(join(realTmpdir(), "catherd-compose-"));
97
+ const file = join(dir, "compose.yaml");
98
+ writeFileSync(file, COMPOSE);
99
+ const project = ["compose", "-p", `catherd-doctor-${process.pid}`, "-f", file];
100
+ try {
101
+ const up = await runCli(
102
+ docker,
103
+ [...project, "up", "--abort-on-container-exit", "--exit-code-from", "probe"],
104
+ {
105
+ timeoutMs: composeProbe.timeoutMs,
106
+ cwd: dir,
107
+ },
108
+ );
109
+ const lines = `${up?.err ?? ""}\n${up?.out ?? ""}`
110
+ .split("\n")
111
+ .map((l) => l.trim())
112
+ .filter(Boolean);
113
+ return { ok: up?.ok === true, why: lines.at(-1) ?? "no output" };
114
+ } finally {
115
+ await runCli(docker, [...project, "down", "-v", "--remove-orphans"], { timeoutMs: 30_000, cwd: dir });
116
+ rmSync(dir, { recursive: true, force: true });
117
+ }
118
+ }
119
+
120
+ /** Free bytes in Docker's data root, when it is on this machine's disk; null when Docker runs in a VM. */
121
+ async function dockerFree(docker: string): Promise<{ root: string; free: number } | null> {
122
+ const r = await runCli(docker, ["info", "--format", "{{.DockerRootDir}}"], { timeoutMs: 15_000 });
123
+ const root = r?.ok ? r.out.trim() : "";
124
+ if (!root || !existsSync(root)) return null;
125
+ try {
126
+ const s = statfsSync(root);
127
+ return { root, free: Number(s.bavail) * Number(s.bsize) };
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ const gb = (bytes: number): string => `${(bytes / 1024 ** 3).toFixed(1)} GB`;
134
+
135
+ /**
136
+ * The Docker rows: the client's proxies, read from its config file; with `probe` (`catherd doctor --docker`), when
137
+ * a daemon answers, the compose network probe (retried once after 5 s before it calls the host blocked) and the
138
+ * free space in Docker's data root. The probes pull busybox and start containers, so a plain doctor skips them.
139
+ */
140
+ export async function dockerChecks(o: { probe: boolean }): Promise<Check[]> {
141
+ const docker = probeTargets.docker;
142
+ const checks: Check[] = [];
143
+ const proxies = proxiesCheck();
144
+ if (proxies) checks.push(proxies);
145
+ if (!o.probe) return checks;
146
+ const version = await runCli(docker, ["version"], { timeoutMs: 15_000 });
147
+ if (!version?.ok) return checks;
148
+ const compose = await runCli(docker, ["compose", "version"], { timeoutMs: 15_000 });
149
+ if (compose?.ok) {
150
+ const { result, attempts } = await probeTwice(
151
+ () => composeUp(docker),
152
+ (r) => r.ok,
153
+ );
154
+ checks.push(
155
+ result.ok
156
+ ? {
157
+ id: "docker-network",
158
+ label: "compose network",
159
+ state: "ok",
160
+ word: "ready",
161
+ detail: "two services reach each other by name, and a loopback health check passes",
162
+ }
163
+ : {
164
+ id: "docker-network",
165
+ label: "compose network",
166
+ state: "warn",
167
+ word: "blocked",
168
+ detail: `a compose service could not reach another by name, or its loopback health check failed (${attempts} tries, 5 s apart): ${result.why}`,
169
+ fix: proxies
170
+ ? (proxies.fix as string)
171
+ : "check the Docker client's proxies, a VPN or socket filter on the Docker bridge, and that busybox can be pulled",
172
+ },
173
+ );
174
+ }
175
+ const free = await dockerFree(docker);
176
+ if (free && free.free < dockerDisk.minFreeBytes)
177
+ checks.push({
178
+ id: "docker-disk",
179
+ label: "Docker disk",
180
+ state: "warn",
181
+ word: "low",
182
+ detail: `${gb(free.free)} free in Docker's data root ${free.root}; builds fail with no space left on device`,
183
+ fix: "docker image prune -f, then docker builder prune -f",
184
+ });
185
+ return checks;
186
+ }
187
+
188
+ /** Whether this user may write `dir`. */
189
+ const writable = (dir: string): boolean => {
190
+ try {
191
+ accessSync(dir, constants.W_OK);
192
+ return true;
193
+ } catch {
194
+ return false;
195
+ }
196
+ };
197
+
198
+ /**
199
+ * The toolchain caches present on the machine (Go's build and module caches, the pnpm store, Bun's and npm's
200
+ * caches), which join every workspace-write worker's writable roots; a warning for one this user cannot write.
201
+ */
202
+ export function cachesCheck(caches = toolchainCaches()): Check {
203
+ const base = { id: "caches", label: "toolchain caches" };
204
+ if (caches.length === 0)
205
+ return { ...base, state: "skip", word: "none", detail: "no Go, pnpm, Bun or npm cache on this machine" };
206
+ const blocked = caches.filter((c) => !writable(c));
207
+ return blocked.length
208
+ ? {
209
+ ...base,
210
+ state: "warn",
211
+ word: "not writable",
212
+ detail: `${blocked.join(", ")}: a worker's checks cannot write it, even with it in their writable roots`,
213
+ fix: `chown -R "$(id -un)" ${blocked.join(" ")}`,
214
+ }
215
+ : {
216
+ ...base,
217
+ state: "ok",
218
+ word: "ready",
219
+ detail: `in every workspace-write worker's writable roots: ${caches.join(", ")}`,
220
+ };
221
+ }
@@ -0,0 +1,65 @@
1
+ import { tryParseRung } from "../domain/ids.ts";
2
+ import type { Profile } from "../domain/profile.ts";
3
+ import { roleMcpTools } from "../domain/role-tools.ts";
4
+ import { ROLES } from "../domain/roles.ts";
5
+ import { ROLE_MCP_STARTUP_SEC, type RoleServerStart } from "../infra/role-mcp.ts";
6
+ import { standInFor } from "./backends.ts";
7
+ import type { Check } from "./doctor-checks.ts";
8
+
9
+ /**
10
+ * The active profile's role server, per backend that runs one (codex, claude-code), isolated or not (spec 1.5
11
+ * plan 21, #42 finding 6): the roles that get it and their tools, and, when doctor probed it, how long a cold
12
+ * start took against Codex's startup timeout. A start past half that timeout warns: on a loaded machine Codex,
13
+ * which requires the server, would abort the role.
14
+ */
15
+ export function roleMcpChecks(profile: Profile | null, start?: RoleServerStart): Check[] {
16
+ if (!profile) return [];
17
+ const checks: Check[] = [];
18
+ for (const backend of ["codex", "claude-code"] as const) {
19
+ const uses = (rung: string) => tryParseRung(rung)?.backend === backend;
20
+ const roles = ROLES.filter((role) => {
21
+ const configured = profile.roles[role];
22
+ return (
23
+ configured.enabled &&
24
+ configured.rungs.some((rung) => {
25
+ const failover = standInFor(profile.failover, rung);
26
+ return uses(rung) || (failover !== null && uses(failover));
27
+ })
28
+ );
29
+ });
30
+ if (!roles.length) continue;
31
+ const base = { id: `role-mcp:${profile.name}:${backend}`, label: `${profile.name} ${backend} role MCP` };
32
+ const tools = [...new Set(roles.flatMap(roleMcpTools))];
33
+ const configured = `${roles.join(", ")}: scoped catherd_role exposes ${tools.join(", ")}; ${backend === "codex" ? "per-tool approval_mode=approve" : "exact MCP tool allowlist"}. Model invocation not tested; managed policy may still deny tools.`;
34
+ const limit = ROLE_MCP_STARTUP_SEC * 1000;
35
+ if (start && !start.ok) {
36
+ checks.push({
37
+ ...base,
38
+ state: "fail",
39
+ word: "no start",
40
+ detail: `the role server did not start: ${start.error}`,
41
+ fix: "reinstall catherd at this version, then run catherd doctor again",
42
+ });
43
+ continue;
44
+ }
45
+ if (start?.ok && start.ms > limit / 2) {
46
+ checks.push({
47
+ ...base,
48
+ state: "warn",
49
+ word: "slow start",
50
+ detail: `the role server took ${Math.round(start.ms)} ms to start; Codex gives it ${ROLE_MCP_STARTUP_SEC} s and stops the role when it misses`,
51
+ fix: "install catherd globally (catherd init) so roles start it without resolving a package, and check the machine's load",
52
+ });
53
+ continue;
54
+ }
55
+ checks.push({
56
+ ...base,
57
+ state: "info",
58
+ word: "configured",
59
+ detail: start?.ok
60
+ ? `${configured} Starts in ${Math.round(start.ms)} ms (limit ${ROLE_MCP_STARTUP_SEC} s).`
61
+ : configured,
62
+ });
63
+ }
64
+ return checks;
65
+ }
@@ -7,11 +7,14 @@ import { BUILTIN_ROLES, type Profile } from "../domain/profile.ts";
7
7
  import { DEFAULT_ACCESS, ROLES } from "../domain/roles.ts";
8
8
  import { bunTooOld, MIN_BUN } from "../domain/runtime.ts";
9
9
  import type { JevTransport } from "../infra/jev-client.ts";
10
+ import type { RoleServerStart } from "../infra/role-mcp.ts";
10
11
  import { linkedProfiles } from "./agent-links.ts";
11
12
  import { accessChecks } from "./doctor-access.ts";
12
13
  import { backendChecks, usedBackends } from "./doctor-backends.ts";
13
14
  import { type PushProbe, pushCheck, probePush } from "./doctor-push.ts";
14
15
  import { sourcesCheck, standInsToConfirmIn } from "./doctor-sources.ts";
16
+ import { cachesCheck, dockerChecks } from "./doctor-docker.ts";
17
+ import { roleMcpChecks } from "./doctor-role-mcp.ts";
15
18
  import {
16
19
  agentsCheck,
17
20
  hostFlag,
@@ -66,14 +69,33 @@ export interface DoctorDeps {
66
69
  /** Explicit smoke test injection; default doctor never invokes it. */
67
70
  push?: () => Promise<PushProbe>;
68
71
  testPush?: boolean;
72
+ /** plan 23: `--docker`: run the Docker host probes (a compose network by service name, the free disk) */
73
+ docker?: boolean;
69
74
  env?: Record<string, string | undefined>;
70
75
  jev?: JevTransport;
76
+ /** spec 1.5 plan 21: a cold start of the role server, timed; absent, doctor does not probe it */
77
+ roleServerStart?: () => Promise<RoleServerStart>;
71
78
  }
72
79
 
73
80
  /**
74
81
  * Spec §10.3: the readiness report. Reads and probes; it writes discovery and a lock probe itself, and the
75
82
  * handshake starts `catherd mcp`, which reconciles runs as it starts. A check that throws becomes a `fail` row.
76
83
  */
84
+ /**
85
+ * Where the access probes look for a per-repo sandbox: the current repo and each repo bound to a checked
86
+ * profile. A repo bound to another profile never runs these roles, so its settings must not decide the probes.
87
+ */
88
+ export function accessRepos(
89
+ repo: string | null,
90
+ bindings: Record<string, string>,
91
+ checked: string[],
92
+ ): string[] {
93
+ const bound = Object.entries(bindings)
94
+ .filter(([, name]) => checked.includes(name))
95
+ .map(([r]) => r);
96
+ return [...new Set([...(repo ? [repo] : []), ...bound])];
97
+ }
98
+
77
99
  export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
78
100
  const checks: Check[] = [];
79
101
  checks.push(
@@ -86,7 +108,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
86
108
  detail: `${d.bunVersion}, needs ${MIN_BUN}`,
87
109
  fix: "bun upgrade",
88
110
  }
89
- : { id: "bun", label: "Bun", state: "ok", word: "ready", detail: d.bunVersion },
111
+ : {
112
+ id: "bun",
113
+ label: "Bun",
114
+ state: "ok",
115
+ word: "ready",
116
+ detail: d.bunVersion,
117
+ },
90
118
  );
91
119
 
92
120
  // every fix names the host doctor judged with: run elsewhere (a plain terminal), it must judge the same way
@@ -170,6 +198,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
170
198
  const used = usedBackends(profiles);
171
199
  const installed = new Set<string>();
172
200
  checks.push(...(await backendChecks(used, profiles, installed, d.host.host)));
201
+ checks.push(...roleMcpChecks(active, await d.roleServerStart?.()));
173
202
 
174
203
  if (active && [active, ...profiles].every((p) => p.jev.use === "off"))
175
204
  checks.push({
@@ -194,7 +223,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
194
223
  const ok = await testJevKey(key, d.jev).catch(() => false);
195
224
  checks.push(
196
225
  ok
197
- ? { id: "jev", label: "Jev", state: "ok", word: "ready", detail: "the key answers" }
226
+ ? {
227
+ id: "jev",
228
+ label: "Jev",
229
+ state: "ok",
230
+ word: "ready",
231
+ detail: "the key answers",
232
+ }
198
233
  : {
199
234
  id: "jev",
200
235
  label: "Jev",
@@ -275,7 +310,9 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
275
310
  word: !queue.cli ? "unavailable" : queue.server,
276
311
  detail: queue.reason ?? "Native server advertises queue support",
277
312
  ...(!queue.cli || queue.server === "unsupported"
278
- ? { fix: "Update native Codex/server; use peek/result while push is unavailable." }
313
+ ? {
314
+ fix: "Update native Codex/server; use peek/result while push is unavailable.",
315
+ }
279
316
  : {}),
280
317
  });
281
318
  let push: PushProbe | null = null;
@@ -291,8 +328,21 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
291
328
  }
292
329
 
293
330
  checks.push(locksCheck());
331
+ // plan 23: the toolchain caches workers write, and Docker as a gate sees it (before the access probes)
332
+ checks.push(cachesCheck());
333
+ checks.push(...(await dockerChecks({ probe: d.docker === true })));
294
334
  // spec §5 and §12: which codex sandbox form runs, and the five access probes per workspace-write backend
295
- checks.push(...(await accessChecks(profiles, installed)));
335
+ let repos: string[] = d.repo ? [d.repo] : [];
336
+ try {
337
+ repos = accessRepos(
338
+ d.repo,
339
+ readProjects().bindings,
340
+ profiles.map((p) => p.name),
341
+ );
342
+ } catch {
343
+ // the config row above already reports an unreadable projects.json
344
+ }
345
+ checks.push(...(await accessChecks(profiles, installed, repos)));
296
346
 
297
347
  // spec 1.1 §13: what the shipped defaults do is info; a warning only for what a profile changed
298
348
  const full = { changed: [] as string[], shipped: [] as string[] };
@@ -329,7 +379,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
329
379
  word: "warning",
330
380
  detail: `${what}: ${rows.changed.join(", ")}${shipped ? `; as shipped: ${shipped}` : ""}`,
331
381
  }
332
- : { id, label, state: "info", word: "default", detail: `${what}: ${shipped}` };
382
+ : {
383
+ id,
384
+ label,
385
+ state: "info",
386
+ word: "default",
387
+ detail: `${what}: ${shipped}`,
388
+ };
333
389
  };
334
390
  for (const row of [
335
391
  accessRow("access:full", "full access", full, "no sandbox for"),
@@ -372,7 +428,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
372
428
  detail: unreadable.message,
373
429
  fix: unreadable.fix ?? `fix or delete ${creds}, then catherd init`,
374
430
  }
375
- : { id: "credentials", label: "credentials.json", state: "ok", word: "ready", detail: "mode 600" },
431
+ : {
432
+ id: "credentials",
433
+ label: "credentials.json",
434
+ state: "ok",
435
+ word: "ready",
436
+ detail: "mode 600",
437
+ },
376
438
  );
377
439
  }
378
440
 
@@ -0,0 +1,157 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { errorMessage } from "../domain/errors.ts";
3
+ import type { HostSessionRef } from "../domain/host.ts";
4
+ import { envelope, formatNotices } from "../domain/notice.ts";
5
+ import { type QueueSendResult, sendToCodexQueue } from "../infra/codex-queue.ts";
6
+ import {
7
+ type DeliveryAttempt,
8
+ deliveryState,
9
+ ROLE_THREAD_REFUSAL,
10
+ writeDeliveryAttempt,
11
+ } from "../infra/delivery.ts";
12
+ import { awaitsCollect, dispatchPaths } from "../infra/dispatch-dir.ts";
13
+ import { tryLock } from "../infra/filelock.ts";
14
+ import { log } from "../infra/log.ts";
15
+ import { admitPath, listDispatches, roleThreadOf } from "./dispatches.ts";
16
+ import { finalizeDispatch } from "./finalize.ts";
17
+ import { finishedNotice, recoverSubmission } from "./notifier.ts";
18
+ import { findRun } from "./run-store.ts";
19
+ import { runOwner } from "./sessions.ts";
20
+
21
+ /**
22
+ * How long the supervisor leaves the owner's MCP server to push first (the fast path: its watcher finalizes,
23
+ * settles and sends within the notifier's coalescing window). Either path sends under the same per-event receipt,
24
+ * so a late server or a late supervisor finds the event accepted and sends nothing.
25
+ */
26
+ export const END_PUSH_GRACE_MS = 20_000;
27
+
28
+ /** `CATHERD_END_PUSH_GRACE_MS` (a test of the spawned supervisor shortens it), else END_PUSH_GRACE_MS. */
29
+ function graceFromEnv(): number {
30
+ const ms = Number(process.env.CATHERD_END_PUSH_GRACE_MS);
31
+ return process.env.CATHERD_END_PUSH_GRACE_MS !== undefined && Number.isFinite(ms) && ms >= 0
32
+ ? ms
33
+ : END_PUSH_GRACE_MS;
34
+ }
35
+
36
+ export interface EndPushOptions {
37
+ graceMs?: number;
38
+ /** the sender; tests replace it */
39
+ sendCodex?: (target: HostSessionRef, content: string) => Promise<QueueSendResult>;
40
+ now?: () => number;
41
+ }
42
+
43
+ /** Why the supervisor pushed nothing, or the receipt's status when it did. */
44
+ export type EndPushOutcome =
45
+ | "off"
46
+ | "not-a-run"
47
+ | "not-codex"
48
+ | "limit"
49
+ | "read"
50
+ | "busy"
51
+ | "delivered"
52
+ | DeliveryAttempt["status"];
53
+
54
+ /** The run's owner, when it is a Codex thread: the supervisor's push targets it. */
55
+ function codexOwner(runId: string): HostSessionRef | null {
56
+ const owner = runOwner(findRun(runId));
57
+ return owner?.host === "codex"
58
+ ? { host: "codex", sessionId: owner.sessionId, hostSessionId: null, name: null }
59
+ : null;
60
+ }
61
+
62
+ /**
63
+ * Plan 22, push from where a role ends: the detached supervisor of a dispatch whose run a Codex thread owns, once
64
+ * its worker exited, queues the role's notice to that thread with `codex queue --remote unix:// --thread <owner>`.
65
+ * Codex's app-server keeps queued input for an unloaded thread, so a result reaches a coordinator whose MCP
66
+ * server the daemon stopped (no client attached) at its next attach. The notice is the server's own text, and it
67
+ * goes under the same per-event receipt (`delivery.json`, the dispatch's notify lock), so the two paths never
68
+ * double-deliver. A usage limit is left to the owner's server, which fails it over before it announces it.
69
+ * `CATHERD_NO_END_PUSH=1` turns it off (the test preload sets it). Never throws.
70
+ */
71
+ export async function pushFromEnd(dispatchDir: string, o: EndPushOptions = {}): Promise<EndPushOutcome> {
72
+ if (process.env.CATHERD_NO_END_PUSH === "1") return "off";
73
+ try {
74
+ const runId = (JSON.parse(readFileSync(admitPath(dispatchDir), "utf8")) as { runId?: unknown }).runId;
75
+ if (typeof runId !== "string") return "not-a-run";
76
+ // read before the grace: a run no Codex thread owns costs the supervisor nothing
77
+ if (!codexOwner(runId)) return "not-codex";
78
+ await Bun.sleep(o.graceMs ?? graceFromEnv());
79
+ const run = findRun(runId);
80
+ const d = listDispatches(run).find((x) => x.dir === dispatchDir);
81
+ if (!d) return "not-a-run";
82
+ const record = await finalizeDispatch(run, d);
83
+ if (record.status === "limit") return "limit";
84
+ // the owner of record now, which a claim may have changed during the grace
85
+ const target = codexOwner(runId);
86
+ if (!target) return "not-codex";
87
+ if (!awaitsCollect(d.dir)) return "read";
88
+ const notice = finishedNotice(run, d, record);
89
+ const release = tryLock(dispatchPaths(d.dir).notified);
90
+ if (!release) return "busy";
91
+ try {
92
+ recoverSubmission(d.dir, notice.eventId);
93
+ if (deliveryState(d.dir, target, notice.eventId) !== "pending") return "delivered";
94
+ // spec 1.5 plan 21: never to a role's own thread (a `codex exec` thread exits after its turn, so what is
95
+ // queued there is lost); it fails loudly, with the notifier's reason, which `status` shows
96
+ const role = roleThreadOf(run, target.sessionId);
97
+ if (role !== null) {
98
+ const reason = `${ROLE_THREAD_REFUSAL} (${role}, ${target.host} ${target.sessionId}), not the orchestrator's; peek(run) from the orchestrator's session takes the run back`;
99
+ log("error", "notify", { from: "supervisor", run: run.id, dispatch: notice.dispatchId, reason });
100
+ writeDeliveryAttempt(d.dir, {
101
+ attemptId: crypto.randomUUID(),
102
+ target,
103
+ eventIds: [notice.eventId],
104
+ at: new Date((o.now ?? Date.now)()).toISOString(),
105
+ status: "failed",
106
+ msgId: null,
107
+ reason,
108
+ });
109
+ return "failed";
110
+ }
111
+ const attempt: DeliveryAttempt = {
112
+ attemptId: crypto.randomUUID(),
113
+ target,
114
+ eventIds: [notice.eventId],
115
+ at: new Date((o.now ?? Date.now)()).toISOString(),
116
+ status: "submitting",
117
+ msgId: null,
118
+ reason: null,
119
+ };
120
+ writeDeliveryAttempt(d.dir, attempt);
121
+ let sent: QueueSendResult;
122
+ try {
123
+ sent = await (o.sendCodex ?? ((t, c) => sendToCodexQueue(t, c, process.env)))(
124
+ target,
125
+ envelope(formatNotices([notice])),
126
+ );
127
+ } catch {
128
+ sent = { outcome: "ambiguous", reason: "Sender ended without a verified receipt; use peek/result." };
129
+ }
130
+ const receipt: DeliveryAttempt = {
131
+ ...attempt,
132
+ status:
133
+ sent.outcome === "accepted"
134
+ ? "accepted"
135
+ : sent.outcome === "not-submitted"
136
+ ? "failed"
137
+ : "ambiguous",
138
+ msgId: sent.outcome === "accepted" ? sent.msgId : null,
139
+ reason: sent.outcome === "accepted" ? null : sent.reason,
140
+ };
141
+ writeDeliveryAttempt(d.dir, receipt);
142
+ log(receipt.status === "accepted" ? "info" : "warn", "notify", {
143
+ from: "supervisor",
144
+ outcome: receipt.status,
145
+ msgId: receipt.msgId,
146
+ reason: receipt.reason,
147
+ dispatches: [notice.dispatchId],
148
+ });
149
+ return receipt.status;
150
+ } finally {
151
+ release();
152
+ }
153
+ } catch (e) {
154
+ log("warn", "notify", { from: "supervisor", dispatch: dispatchDir, error: errorMessage(e) });
155
+ return "not-a-run";
156
+ }
157
+ }
@@ -8,6 +8,7 @@ import { isCatherdError } from "../domain/errors.ts";
8
8
  import { parseRung } from "../domain/ids.ts";
9
9
  import {
10
10
  type ExitInfo,
11
+ parseReplyEnvironment,
11
12
  parseReplyStatus,
12
13
  type RunRecord,
13
14
  THREAD_HEAVY_INPUT,
@@ -25,6 +26,7 @@ import {
25
26
  writeTextAtomic,
26
27
  } from "../infra/store.ts";
27
28
  import { type Dispatch, dispatchState, listDispatches, readProc } from "./dispatches.ts";
29
+ import { currentOwns } from "./lane-edit.ts";
28
30
  import { tail } from "./run-debug.ts";
29
31
  import { appendRecord, readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
30
32
 
@@ -75,7 +77,7 @@ function othersOwns(run: Run, self: Dispatch, start: number, end: number): strin
75
77
  const to = ended.get(d.admit.dispatchId) ?? (Date.parse(readExit(d.dir)?.endedAt ?? "") || Date.now());
76
78
  return from <= end && to >= start;
77
79
  })
78
- .flatMap((d) => d.admit.owns);
80
+ .flatMap((d) => [...d.admit.owns, ...(d.admit.ownsImplicit ?? [])]);
79
81
  }
80
82
 
81
83
  /** The tree after the run, or null when git cannot say: the record is still written, its changes unknown. */
@@ -186,13 +188,20 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
186
188
  const { changedOwned, violations } = after
187
189
  ? splitChanges(
188
190
  changedPaths(a.before, after),
189
- a.owns,
191
+ // spec 1.5 owns_add: paths the lane gained while it ran are its own, not violations; a laneless
192
+ // writer is held to its implicit docs lane (plan 21)
193
+ a.lane !== null
194
+ ? [...new Set([...a.owns, ...currentOwns(run, a.lane)])]
195
+ : a.owns.length
196
+ ? a.owns
197
+ : (a.ownsImplicit ?? []),
190
198
  // from admission, when the before-snapshot was taken, not from the worker's start
191
199
  othersOwns(run, d, Date.parse(a.admittedAt), end),
192
200
  a.lane !== null || a.access === "read-only",
193
201
  )
194
202
  : { changedOwned: [], violations: [] };
195
203
  const reported = parseReplyStatus(replyText);
204
+ const environment = parseReplyEnvironment(replyText);
196
205
  return {
197
206
  schema: 1,
198
207
  runId: run.id,
@@ -219,6 +228,7 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
219
228
  ...(after ? {} : { gitUnavailable: true }),
220
229
  replyStatus: reported.status,
221
230
  replyWhy: reported.why,
231
+ ...(environment ? { environment } : {}),
222
232
  threadHeavy: o.tokens.input >= THREAD_HEAVY_INPUT,
223
233
  access: a.access,
224
234
  isolated: a.isolated,