catherd-cli 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +144 -42
  2. package/catalog/ATTRIBUTION.md +12 -4
  3. package/catalog/models.json +8 -0
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +11 -0
  7. package/plugin/.mcp-codex.json +10 -0
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +150 -79
  10. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  11. package/src/adapters/access.ts +7 -1
  12. package/src/adapters/antigravity/index.ts +18 -3
  13. package/src/adapters/backend.ts +18 -2
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/cli.ts +1 -64
  16. package/src/adapters/codex/events.ts +10 -0
  17. package/src/adapters/codex/index.ts +17 -5
  18. package/src/adapters/cursor/index.ts +7 -1
  19. package/src/adapters/grok/home.ts +75 -20
  20. package/src/adapters/grok/index.ts +27 -8
  21. package/src/adapters/opencode/index.ts +38 -1
  22. package/src/cli.ts +53 -19
  23. package/src/domain/agents.ts +3 -1
  24. package/src/domain/brief.ts +91 -0
  25. package/src/domain/calibration.ts +7 -0
  26. package/src/domain/catalog.ts +12 -1
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +8 -3
  29. package/src/domain/errors.ts +4 -0
  30. package/src/domain/failover.ts +17 -5
  31. package/src/domain/gate-brief.ts +52 -0
  32. package/src/domain/hints.ts +13 -2
  33. package/src/domain/host.ts +17 -0
  34. package/src/domain/lane.ts +99 -3
  35. package/src/domain/notice.ts +14 -4
  36. package/src/domain/profile-rules.ts +129 -16
  37. package/src/domain/profile.ts +56 -5
  38. package/src/domain/record.ts +17 -2
  39. package/src/domain/reply.ts +29 -0
  40. package/src/domain/role-prompts.ts +56 -16
  41. package/src/domain/role-scope.ts +106 -0
  42. package/src/domain/role-tools.ts +34 -0
  43. package/src/domain/route.ts +73 -0
  44. package/src/domain/select.ts +177 -25
  45. package/src/domain/state.ts +5 -0
  46. package/src/domain/util.ts +22 -0
  47. package/src/domain/workspace.ts +130 -0
  48. package/src/entry/catalog-command.ts +18 -8
  49. package/src/entry/cli-kit.ts +17 -1
  50. package/src/entry/deps.ts +8 -3
  51. package/src/entry/doctor-command.ts +29 -2
  52. package/src/entry/host-arg.ts +56 -0
  53. package/src/entry/init-command.ts +20 -8
  54. package/src/entry/knowledge-command.ts +78 -3
  55. package/src/entry/lock-command.ts +111 -10
  56. package/src/entry/mcp/dispatch-tools.ts +3 -3
  57. package/src/entry/mcp/handshake.ts +8 -2
  58. package/src/entry/mcp/lane-tools.ts +55 -8
  59. package/src/entry/mcp/protocol-tools.ts +10 -4
  60. package/src/entry/mcp/push-tools.ts +16 -0
  61. package/src/entry/mcp/role-bin.ts +20 -0
  62. package/src/entry/mcp/role-server.ts +116 -0
  63. package/src/entry/mcp/run-tools.ts +25 -6
  64. package/src/entry/mcp/server.ts +224 -44
  65. package/src/entry/mcp/setup-tools.ts +11 -5
  66. package/src/entry/mcp/workspace-tools.ts +142 -0
  67. package/src/entry/pause-command.ts +67 -0
  68. package/src/entry/profile-command.ts +131 -16
  69. package/src/entry/role-cli-command.ts +115 -0
  70. package/src/entry/runs-command.ts +157 -11
  71. package/src/entry/supervise-command.ts +30 -3
  72. package/src/entry/tui/effects.ts +23 -8
  73. package/src/entry/tui/fixtures.ts +33 -10
  74. package/src/entry/tui/run.tsx +10 -4
  75. package/src/entry/tui/state.ts +56 -16
  76. package/src/entry/tui/stories.ts +11 -4
  77. package/src/entry/tui/views/profile-actions.ts +4 -2
  78. package/src/entry/tui/views/profiles.tsx +3 -3
  79. package/src/entry/tui/views/runs.tsx +66 -20
  80. package/src/entry/tui/views/save-dialog.tsx +8 -4
  81. package/src/entry/tui/views/status.tsx +6 -2
  82. package/src/entry/workspace-command.ts +44 -0
  83. package/src/infra/boot-lock.ts +25 -0
  84. package/src/infra/cli.ts +65 -0
  85. package/src/infra/codex-queue.ts +165 -0
  86. package/src/infra/delivery.ts +86 -0
  87. package/src/infra/dispatch-dir.ts +3 -0
  88. package/src/infra/env.ts +15 -2
  89. package/src/infra/git.ts +6 -0
  90. package/src/infra/heavy-lock.ts +93 -4
  91. package/src/infra/host-context.ts +58 -0
  92. package/src/infra/host-probe.ts +16 -0
  93. package/src/infra/lock-activity.ts +88 -0
  94. package/src/infra/login-env.ts +48 -0
  95. package/src/infra/paths.ts +35 -0
  96. package/src/infra/peer-inbox.ts +4 -1
  97. package/src/infra/role-mcp.ts +69 -0
  98. package/src/infra/sources/arena.ts +11 -3
  99. package/src/infra/sources/artificial-analysis.ts +8 -1
  100. package/src/infra/sources/cache.ts +2 -0
  101. package/src/infra/supervisor.ts +110 -18
  102. package/src/services/admission.ts +117 -26
  103. package/src/services/agent-links.ts +30 -5
  104. package/src/services/backends.ts +7 -0
  105. package/src/services/budget.ts +26 -6
  106. package/src/services/catalog-service.ts +20 -12
  107. package/src/services/credentials.ts +11 -6
  108. package/src/services/dispatch-service.ts +220 -22
  109. package/src/services/dispatches.ts +36 -3
  110. package/src/services/doctor-access.ts +8 -3
  111. package/src/services/doctor-backends.ts +30 -7
  112. package/src/services/doctor-checks.ts +20 -5
  113. package/src/services/doctor-docker.ts +221 -0
  114. package/src/services/doctor-push.ts +69 -90
  115. package/src/services/doctor-role-mcp.ts +65 -0
  116. package/src/services/doctor.ts +172 -38
  117. package/src/services/end-push.ts +157 -0
  118. package/src/services/finalize.ts +13 -3
  119. package/src/services/gate-env.ts +218 -0
  120. package/src/services/gate-service.ts +216 -45
  121. package/src/services/global-install.ts +46 -13
  122. package/src/services/lane-edit.ts +154 -0
  123. package/src/services/lane-service.ts +258 -52
  124. package/src/services/milestones.ts +137 -26
  125. package/src/services/notifier.ts +377 -72
  126. package/src/services/orchestrator-wait.ts +82 -0
  127. package/src/services/pause.ts +161 -0
  128. package/src/services/peek.ts +122 -23
  129. package/src/services/ports.ts +39 -4
  130. package/src/services/preflight.ts +153 -19
  131. package/src/services/profile-service.ts +117 -30
  132. package/src/services/profile-store.ts +31 -7
  133. package/src/services/protocol.ts +119 -25
  134. package/src/services/provenance.ts +16 -12
  135. package/src/services/questions.ts +15 -0
  136. package/src/services/reconcile.ts +22 -1
  137. package/src/services/reentry.ts +7 -6
  138. package/src/services/role-access.ts +27 -0
  139. package/src/services/routing-service.ts +150 -37
  140. package/src/services/run-debug.ts +98 -4
  141. package/src/services/run-pin.ts +125 -0
  142. package/src/services/run-service.ts +153 -19
  143. package/src/services/run-store.ts +119 -19
  144. package/src/services/runs-page.ts +45 -9
  145. package/src/services/scratch.ts +60 -0
  146. package/src/services/session-view.ts +18 -13
  147. package/src/services/sessions.ts +34 -16
  148. package/src/services/setup.ts +31 -10
  149. package/src/services/source-derive.ts +28 -5
  150. package/src/services/source-sync.ts +9 -2
  151. package/src/services/standins.ts +40 -6
  152. package/src/services/state.ts +21 -0
  153. package/src/services/summary.ts +86 -11
  154. package/src/services/treat-likes.ts +41 -12
  155. package/src/services/verifier-brief.ts +23 -0
  156. package/src/services/verifier-step.ts +48 -0
  157. package/src/services/workspace-admission.ts +267 -0
  158. package/src/services/workspace-service.ts +254 -0
  159. package/src/services/workspace-store.ts +95 -0
@@ -1,3 +1,5 @@
1
+ import { queueCapability, type QueueCapability } from "../infra/codex-queue.ts";
2
+ import type { HostContext } from "../domain/host.ts";
1
3
  import { existsSync, statSync } from "node:fs";
2
4
  import { adapterFor } from "../adapters/registry.ts";
3
5
  import "../adapters/all.ts";
@@ -5,13 +7,17 @@ import { BUILTIN_ROLES, type Profile } from "../domain/profile.ts";
5
7
  import { DEFAULT_ACCESS, ROLES } from "../domain/roles.ts";
6
8
  import { bunTooOld, MIN_BUN } from "../domain/runtime.ts";
7
9
  import type { JevTransport } from "../infra/jev-client.ts";
10
+ import type { RoleServerStart } from "../infra/role-mcp.ts";
8
11
  import { linkedProfiles } from "./agent-links.ts";
9
12
  import { accessChecks } from "./doctor-access.ts";
10
13
  import { backendChecks, usedBackends } from "./doctor-backends.ts";
11
- import { type PushProbe, pushCheck } from "./doctor-push.ts";
14
+ import { type PushProbe, pushCheck, probePush } from "./doctor-push.ts";
12
15
  import { sourcesCheck, standInsToConfirmIn } from "./doctor-sources.ts";
16
+ import { cachesCheck, dockerChecks } from "./doctor-docker.ts";
17
+ import { roleMcpChecks } from "./doctor-role-mcp.ts";
13
18
  import {
14
19
  agentsCheck,
20
+ hostFlag,
15
21
  type Check,
16
22
  errText,
17
23
  fixOf,
@@ -31,9 +37,13 @@ import {
31
37
  readProjects,
32
38
  validateNamed,
33
39
  } from "./profile-store.ts";
40
+ import { inspectionHost } from "./run-debug.ts";
34
41
  import { listRuns } from "./run-store.ts";
35
42
 
36
43
  export interface DoctorReport {
44
+ host: HostContext;
45
+ queue: QueueCapability | null;
46
+ push: PushProbe | null;
37
47
  /** false when any check failed: `catherd doctor` exits 3 */
38
48
  ready: boolean;
39
49
  version: string;
@@ -49,20 +59,43 @@ export interface Handshake {
49
59
  }
50
60
 
51
61
  export interface DoctorDeps {
62
+ host: HostContext;
63
+ repo: string | null;
52
64
  bunVersion: string;
53
65
  /** the package version, which the Claude Code plugin must pin */
54
66
  version: string;
55
67
  /** starts the MCP server as the plugin does (its launcher) over stdio and asks it for tools/list */
56
68
  handshake: () => Promise<Handshake>;
57
- /** spec §3.9: sends this Claude Code session a test message; without it (the dashboard) there is no `push` row */
69
+ /** Explicit smoke test injection; default doctor never invokes it. */
58
70
  push?: () => Promise<PushProbe>;
71
+ testPush?: boolean;
72
+ /** plan 23: `--docker`: run the Docker host probes (a compose network by service name, the free disk) */
73
+ docker?: boolean;
74
+ env?: Record<string, string | undefined>;
59
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>;
60
78
  }
61
79
 
62
80
  /**
63
81
  * Spec §10.3: the readiness report. Reads and probes; it writes discovery and a lock probe itself, and the
64
82
  * handshake starts `catherd mcp`, which reconciles runs as it starts. A check that throws becomes a `fail` row.
65
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
+
66
99
  export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
67
100
  const checks: Check[] = [];
68
101
  checks.push(
@@ -75,16 +108,24 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
75
108
  detail: `${d.bunVersion}, needs ${MIN_BUN}`,
76
109
  fix: "bun upgrade",
77
110
  }
78
- : { 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
+ },
79
118
  );
80
119
 
120
+ // every fix names the host doctor judged with: run elsewhere (a plain terminal), it must judge the same way
121
+ const onHost = hostFlag(d.host.host);
81
122
  let profiles: Profile[] = [];
82
123
  let linked: string[] = [];
83
124
  let active: Profile | null = null;
84
125
  try {
85
126
  readConfig();
86
127
  readProjects();
87
- active = getProfile(activeName());
128
+ active = getProfile(activeName(d.repo), d.host.host);
88
129
  linked = linkedProfiles();
89
130
  checks.push({
90
131
  id: "config",
@@ -106,7 +147,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
106
147
  // one unreadable linked profile must not hide the others' backends: its own row below reports it
107
148
  profiles = linked.flatMap((n) => {
108
149
  try {
109
- return [getProfile(n)];
150
+ return [getProfile(n, d.host.host)];
110
151
  } catch {
111
152
  return [];
112
153
  }
@@ -118,7 +159,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
118
159
  checks.push({
119
160
  id: `binding:${repo}`,
120
161
  label: `binding ${repo}`,
121
- state: "fail",
162
+ state: repo === d.repo ? "fail" : "warn",
122
163
  word: "missing",
123
164
  detail: `bound to profile ${name}, which does not exist`,
124
165
  fix: `cd ${repo} && catherd profile use --repo --clear`,
@@ -131,30 +172,33 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
131
172
  const names = active ? [active.name, ...linked.filter((n) => n !== active?.name)] : [];
132
173
  for (const name of names) {
133
174
  const id = name === active?.name ? "profile" : `profile:${name}`;
134
- const validate = `catherd profile validate ${name}`;
175
+ const validate = `catherd profile validate ${name}${onHost}`;
135
176
  const named = (fix: string) =>
136
- fix.replaceAll("catherd profile set ", `catherd profile set --profile ${name} `);
137
- checks.push(
138
- guarded(id, `profile ${name}`, validate, () => {
139
- const v = validateNamed(name);
140
- const first = v.errors[0] ?? v.warnings[0];
141
- return {
142
- id,
143
- label: `profile ${name}`,
144
- state: v.errors.length ? "fail" : v.warnings.length ? "warn" : "ok",
145
- word: v.errors.length ? "invalid" : v.warnings.length ? "warning" : "ready",
146
- detail: first
147
- ? `${first.path}: ${first.message}${v.errors.length + v.warnings.length > 1 ? " (and more)" : ""}`
148
- : "valid",
149
- ...(first ? { fix: first.fix ? named(first.fix) : validate } : {}),
150
- };
151
- }),
152
- );
177
+ fix.replaceAll("catherd profile set ", `catherd profile set --profile ${name}${onHost} `);
178
+ const row = guarded(id, `profile ${name}`, validate, () => {
179
+ const v = validateNamed(name, d.repo, d.host.host);
180
+ const first = v.errors[0] ?? v.warnings[0];
181
+ return {
182
+ id,
183
+ label: `profile ${name}`,
184
+ state: v.errors.length ? "fail" : v.warnings.length ? "warn" : "ok",
185
+ word: v.errors.length ? "invalid" : v.warnings.length ? "warning" : "ready",
186
+ detail: first
187
+ ? `${first.path}: ${first.message}${v.errors.length + v.warnings.length > 1 ? " (and more)" : ""}`
188
+ : "valid",
189
+ ...(first ? { fix: first.fix ? named(first.fix) : validate } : {}),
190
+ };
191
+ });
192
+ if (name !== active?.name && row.state === "fail") row.state = "warn";
193
+ checks.push(row);
153
194
  }
154
195
 
196
+ const diagnostics = profiles;
197
+ profiles = active ? [active] : [];
155
198
  const used = usedBackends(profiles);
156
199
  const installed = new Set<string>();
157
- checks.push(...(await backendChecks(used, profiles, installed)));
200
+ checks.push(...(await backendChecks(used, profiles, installed, d.host.host)));
201
+ checks.push(...roleMcpChecks(active, await d.roleServerStart?.()));
158
202
 
159
203
  if (active && [active, ...profiles].every((p) => p.jev.use === "off"))
160
204
  checks.push({
@@ -179,7 +223,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
179
223
  const ok = await testJevKey(key, d.jev).catch(() => false);
180
224
  checks.push(
181
225
  ok
182
- ? { 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
+ }
183
233
  : {
184
234
  id: "jev",
185
235
  label: "Jev",
@@ -194,18 +244,44 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
194
244
 
195
245
  // spec 1.2 §9: the public sources' ages and errors, the Artificial Analysis key, and the active and linked
196
246
  // profiles' stand-ins to confirm
197
- const mine = [...(active ? [active] : []), ...profiles.filter((p) => p.name !== active?.name)];
247
+ const mine = [...(active ? [active] : []), ...diagnostics.filter((p) => p.name !== active?.name)];
198
248
  checks.push(
199
249
  guarded("sources", "sources", "catherd catalog sync --force", () =>
200
250
  sourcesCheck(Date.now(), standInsToConfirmIn(mine)),
201
251
  ),
202
252
  );
203
253
 
204
- checks.push(pluginCheck(d.version));
254
+ const native = used.get("claude");
255
+ const plugin = pluginCheck(d.version);
256
+ if (d.host.host !== "claude-code" && native !== "role") {
257
+ plugin.state = native === "failover" ? "warn" : "skip";
258
+ plugin.word = native === "failover" ? "optional" : "not required";
259
+ }
260
+ checks.push(plugin);
205
261
 
262
+ // relinks the profile doctor checked: this repo's binding when it has one, else the active profile
263
+ let bound = false;
264
+ try {
265
+ bound = d.repo !== null && readProjects().bindings[d.repo] === active?.name;
266
+ } catch {
267
+ // the config row above already reports an unreadable projects.json
268
+ }
269
+ const relink = active
270
+ ? `${bound ? `cd ${d.repo} && ` : ""}catherd profile use ${active.name}${bound ? " --repo" : ""}${onHost}`
271
+ : "";
206
272
  checks.push(
207
273
  active
208
- ? guarded("agents", "Claude agents", `catherd profile use ${activeName()}`, agentsCheck)
274
+ ? guarded("agents", "Claude agents", relink, () =>
275
+ native
276
+ ? agentsCheck(d.host.host, [active!.name], relink)
277
+ : {
278
+ id: "agents",
279
+ label: "Claude agents",
280
+ state: "skip",
281
+ word: "not required",
282
+ detail: "selected profile has no native Claude roles",
283
+ },
284
+ )
209
285
  : {
210
286
  id: "agents",
211
287
  label: "Claude agents",
@@ -215,19 +291,58 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
215
291
  },
216
292
  );
217
293
 
294
+ const agents = checks.find((c) => c.id === "agents");
295
+ if (native === "failover" && agents?.state === "fail") agents.state = "warn";
296
+
218
297
  const h = await d
219
298
  .handshake()
220
299
  .catch((e: unknown): Handshake => ({ ok: false, tools: [], error: errText(e) }));
221
300
  checks.push(mcpCheck(h, d.version));
222
301
 
223
- if (d.push)
224
- checks.push(
225
- pushCheck(await d.push().catch((e: unknown): PushProbe => ({ outcome: "failed", detail: errText(e) }))),
226
- );
302
+ const queue =
303
+ d.host.host === "codex" && !d.host.conflict ? await queueCapability(d.env ?? process.env) : null;
304
+ if (queue)
305
+ checks.push({
306
+ id: "push-capability",
307
+ label: "native Codex queue",
308
+ state:
309
+ !queue.cli || queue.server === "unsupported" ? "fail" : queue.server === "unverified" ? "warn" : "ok",
310
+ word: !queue.cli ? "unavailable" : queue.server,
311
+ detail: queue.reason ?? "Native server advertises queue support",
312
+ ...(!queue.cli || queue.server === "unsupported"
313
+ ? {
314
+ fix: "Update native Codex/server; use peek/result while push is unavailable.",
315
+ }
316
+ : {}),
317
+ });
318
+ let push: PushProbe | null = null;
319
+ if (d.testPush) {
320
+ push = await (d.push ?? (() => probePush(d.host, d.env ?? process.env)))().catch((): PushProbe => ({
321
+ outcome: "failed",
322
+ enqueue: "ambiguous",
323
+ processing: "unconfirmed",
324
+ msgId: null,
325
+ detail: "Smoke has no verified receipt; retry may duplicate input. Use peek/result.",
326
+ }));
327
+ checks.push(pushCheck(push));
328
+ }
227
329
 
228
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 })));
229
334
  // spec §5 and §12: which codex sandbox form runs, and the five access probes per workspace-write backend
230
- 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)));
231
346
 
232
347
  // spec 1.1 §13: what the shipped defaults do is info; a warning only for what a profile changed
233
348
  const full = { changed: [] as string[], shipped: [] as string[] };
@@ -264,7 +379,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
264
379
  word: "warning",
265
380
  detail: `${what}: ${rows.changed.join(", ")}${shipped ? `; as shipped: ${shipped}` : ""}`,
266
381
  }
267
- : { 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
+ };
268
389
  };
269
390
  for (const row of [
270
391
  accessRow("access:full", "full access", full, "no sandbox for"),
@@ -307,7 +428,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
307
428
  detail: unreadable.message,
308
429
  fix: unreadable.fix ?? `fix or delete ${creds}, then catherd init`,
309
430
  }
310
- : { 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
+ },
311
438
  );
312
439
  }
313
440
 
@@ -322,7 +449,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
322
449
  detail: "the plugin's MCP launcher runs bunx when no global catherd is installed",
323
450
  fix: "put Bun's bin folder (~/.bun/bin) on PATH",
324
451
  });
325
- const browser = uiBrowserCheck(mine, onPath);
452
+ const browser = uiBrowserCheck(mine, onPath, d.host.host);
326
453
  if (browser) checks.push(browser);
327
454
  let corrupt: ReturnType<typeof listRuns>["corrupt"] = [];
328
455
  try {
@@ -347,5 +474,12 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
347
474
  fix: `fix or delete ${corrupt[0]?.dir}`,
348
475
  });
349
476
 
350
- return { ready: !checks.some((c) => c.state === "fail"), version: d.version, checks };
477
+ return {
478
+ host: inspectionHost(d.host),
479
+ queue,
480
+ push,
481
+ ready: !checks.some((c) => c.state === "fail"),
482
+ version: d.version,
483
+ checks,
484
+ };
351
485
  }
@@ -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,
@@ -226,7 +236,7 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
226
236
  images: o.images,
227
237
  error: o.error,
228
238
  replyPath: relative(run.dir, p.reply),
229
- ...(a.sessionId ? { sessionId: a.sessionId } : {}),
239
+ ...(a.sessionId ? { sessionId: a.sessionId, host: a.host ?? "claude-code" } : {}),
230
240
  };
231
241
  }
232
242