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
@@ -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
+ }
@@ -1,109 +1,88 @@
1
- import { existsSync, readdirSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import type { HostContext } from "../domain/host.ts";
3
2
  import { envelope } from "../domain/notice.ts";
4
3
  import { readSessionEnv, sessionFileFor } from "../infra/claude-session.ts";
5
- import { claudeHome } from "../infra/paths.ts";
4
+ import { sendToCodexQueue } from "../infra/codex-queue.ts";
6
5
  import { sendToInbox } from "../infra/peer-inbox.ts";
7
6
  import type { Check } from "./doctor-checks.ts";
8
7
 
9
- /**
10
- * Spec §3.9 `doctor`'s `push` row: run from inside a Claude Code session (its Bash tool), send that session one
11
- * `later` message and look for the enqueue line its transcript gets when the message is accepted
12
- * (docs/research/2026-09-28-cross-session-messaging.md §7).
13
- */
14
-
15
- export type PushOutcome = "ok" | "no-session" | "held" | "unconfirmed" | "failed";
16
-
8
+ export type PushOutcome = "ok" | "no-session" | "unconfirmed" | "failed";
17
9
  export interface PushProbe {
18
10
  outcome: PushOutcome;
19
11
  detail: string;
12
+ enqueue: "accepted" | "ambiguous" | "not-submitted" | "no-session";
13
+ processing: "observed" | "unconfirmed";
14
+ msgId: string | null;
20
15
  }
21
16
 
22
- /** How long the probe looks for the enqueue line; tests shorten it. */
23
- export const pushLimits = { waitMs: 5_000, pollMs: 100 };
24
-
25
- /** The session's transcript, found by globbing `projects/*` (a git worktree logs under its main checkout's slug). */
26
- function transcriptOf(sessionId: string): string | null {
27
- const projects = join(claudeHome(), "projects");
28
- if (!existsSync(projects)) return null;
29
- for (const d of readdirSync(projects)) {
30
- const f = join(projects, d, `${sessionId}.jsonl`);
31
- if (existsSync(f)) return f;
32
- }
33
- return null;
34
- }
35
-
36
- const enqueued = (file: string, nonce: string): boolean => {
37
- try {
38
- return readFileSync(file, "utf8")
39
- .split("\n")
40
- .some((l) => l.includes('"operation":"enqueue"') && l.includes(nonce));
41
- } catch {
42
- return false;
43
- }
44
- };
45
-
46
- export async function probePush(env: Record<string, string | undefined> = process.env): Promise<PushProbe> {
47
- const s = readSessionEnv(env);
48
- if (!s?.socketPath)
49
- return { outcome: "no-session", detail: "run catherd doctor from a Claude Code session to test push" };
50
- const sessionId = sessionFileFor(s)?.sessionId ?? s.sessionId;
51
- const nonce = crypto.randomUUID().slice(0, 8);
52
- const r = await sendToInbox(s, envelope(`catherd doctor: push test ${nonce}, no action needed`), "later");
53
- // a socket that is gone or refuses is a shell that outlived its session, not a protocol change
54
- if (r.outcome === "no-session" || r.outcome === "refused")
17
+ /** Explicit smoke only: native receipts prove acceptance, never model processing or result collection. */
18
+ export async function probePush(
19
+ host: HostContext,
20
+ env: Record<string, string | undefined>,
21
+ ): Promise<PushProbe> {
22
+ const target = host.session;
23
+ if (host.conflict || !target || host.host === "unknown" || target.host !== host.host)
55
24
  return {
56
25
  outcome: "no-session",
57
- detail: `this session's inbox is not reachable (${r.reason ?? r.outcome}); run catherd doctor from a live Claude Code session to test push`,
58
- };
59
- if (r.outcome !== "sent")
60
- return {
61
- outcome: "failed",
62
- detail: `catherd cannot notify this Claude Code version; peek still works (${r.reason ?? r.outcome})`,
26
+ enqueue: "no-session",
27
+ processing: "unconfirmed",
28
+ msgId: null,
29
+ detail:
30
+ "No validated live host session; run doctor --test-push from the original conversation. Terminal --host selects checks only. Use peek/result.",
63
31
  };
64
- const deadline = Date.now() + pushLimits.waitMs;
65
- let file: string | null = null;
66
- for (;;) {
67
- file = sessionId ? transcriptOf(sessionId) : null;
68
- if (file && enqueued(file, nonce))
69
- return { outcome: "ok", detail: "a test message reached this session" };
70
- if (Date.now() > deadline) break;
71
- await Bun.sleep(pushLimits.pollMs);
72
- }
73
- return file
74
- ? {
75
- outcome: "held",
76
- detail: `the test message was sent but not accepted within ${pushLimits.waitMs / 1000} s: a crossSessionInbound setting holds or refuses it`,
77
- }
78
- : {
79
- outcome: "unconfirmed",
80
- detail: "sent; no transcript of this session was found to confirm it arrived",
81
- };
82
- }
83
-
84
- /** The probe as a `doctor` row. */
85
- export function pushCheck(p: PushProbe): Check {
86
- const base = { id: "push", label: "push to Claude Code", detail: p.detail };
87
- switch (p.outcome) {
88
- case "ok":
89
- return { ...base, state: "ok", word: "ready" };
90
- case "no-session":
91
- return { ...base, state: "skip", word: "no session" };
92
- case "held":
32
+ const content = envelope(`catherd doctor: push test ${crypto.randomUUID().slice(0, 8)}, no action needed`);
33
+ let enqueue: PushProbe["enqueue"];
34
+ let msgId: string | null = null;
35
+ if (host.host === "codex") {
36
+ const sent = await sendToCodexQueue(target, content, env);
37
+ enqueue = sent.outcome;
38
+ if (sent.outcome === "accepted") msgId = sent.msgId;
39
+ } else {
40
+ const s = readSessionEnv(env);
41
+ const liveId = s ? (sessionFileFor(s)?.sessionId ?? s.sessionId) : null;
42
+ if (!s || liveId !== target.sessionId)
93
43
  return {
94
- ...base,
95
- state: "warn",
96
- word: "held",
97
- fix: `set "crossSessionInbound": "accept" in ${join(claudeHome(), "settings.json")}, or remove a project setting that sets it to hold or refuse`,
44
+ outcome: "no-session",
45
+ enqueue: "no-session",
46
+ processing: "unconfirmed",
47
+ msgId: null,
48
+ detail: "Claude inbox identity does not match the validated live session; use peek/result.",
98
49
  };
99
- case "unconfirmed":
100
- return { ...base, state: "warn", word: "not confirmed" };
101
- case "failed":
50
+ const sent = await sendToInbox(s, content, "later");
51
+ if (sent.outcome === "no-session" || sent.outcome === "refused")
102
52
  return {
103
- ...base,
104
- state: "fail",
105
- word: "failed",
106
- fix: "update catherd (and Claude Code); meanwhile peek(run) shows each role and each unread record",
53
+ outcome: "no-session",
54
+ enqueue: "not-submitted",
55
+ processing: "unconfirmed",
56
+ msgId: null,
57
+ detail:
58
+ "This session's inbox is not reachable; run doctor --test-push from a live Claude Code session. Use peek/result.",
107
59
  };
60
+ enqueue = sent.outcome === "sent" && sent.msgId ? "accepted" : "ambiguous";
61
+ if (enqueue === "accepted") msgId = sent.msgId ?? null;
108
62
  }
63
+ return {
64
+ outcome: enqueue === "accepted" ? "ok" : enqueue === "ambiguous" ? "unconfirmed" : "failed",
65
+ enqueue,
66
+ processing: "unconfirmed",
67
+ msgId,
68
+ detail:
69
+ enqueue === "accepted"
70
+ ? "Smoke enqueue accepted; processing unconfirmed. Unloaded, interrupted or restarted hosts may retain queued input. Use peek/result for durable unread results."
71
+ : enqueue === "ambiguous"
72
+ ? "Smoke has no verified queue receipt; processing unconfirmed. Retrying may duplicate input. Use peek/result."
73
+ : "Smoke was not submitted; check native queue support and the existing endpoint. Use peek/result.",
74
+ };
75
+ }
76
+
77
+ export function pushCheck(p: PushProbe): Check {
78
+ const base = { id: "push", label: "host completion push", detail: p.detail };
79
+ if (p.enqueue === "accepted") return { ...base, state: "ok", word: "enqueue accepted" };
80
+ if (p.outcome === "no-session") return { ...base, state: "skip", word: "no session" };
81
+ if (p.enqueue === "ambiguous") return { ...base, state: "warn", word: "ambiguous" };
82
+ return {
83
+ ...base,
84
+ state: "fail",
85
+ word: "not submitted",
86
+ fix: "Check native host transport; peek/result keeps the unread record available.",
87
+ };
109
88
  }
@@ -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
+ }