catherd-cli 1.3.0 → 1.4.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 (70) hide show
  1. package/README.md +75 -36
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.codex-plugin/plugin.json +11 -0
  5. package/plugin/.mcp-codex.json +10 -0
  6. package/plugin/bin/catherd-mcp +1 -1
  7. package/plugin/skills/catherd/SKILL.md +44 -38
  8. package/plugin/skills/catherd-setup/SKILL.md +22 -6
  9. package/src/adapters/access.ts +7 -1
  10. package/src/adapters/cli.ts +1 -64
  11. package/src/cli.ts +48 -19
  12. package/src/domain/host.ts +17 -0
  13. package/src/domain/notice.ts +5 -2
  14. package/src/domain/profile-rules.ts +17 -0
  15. package/src/domain/profile.ts +32 -5
  16. package/src/domain/record.ts +1 -0
  17. package/src/entry/catalog-command.ts +4 -2
  18. package/src/entry/deps.ts +6 -3
  19. package/src/entry/doctor-command.ts +11 -2
  20. package/src/entry/host-arg.ts +28 -0
  21. package/src/entry/init-command.ts +18 -7
  22. package/src/entry/lock-command.ts +7 -2
  23. package/src/entry/mcp/handshake.ts +3 -1
  24. package/src/entry/mcp/run-tools.ts +5 -2
  25. package/src/entry/mcp/server.ts +129 -40
  26. package/src/entry/mcp/setup-tools.ts +7 -2
  27. package/src/entry/profile-command.ts +108 -12
  28. package/src/entry/runs-command.ts +81 -5
  29. package/src/entry/tui/effects.ts +19 -8
  30. package/src/entry/tui/fixtures.ts +33 -10
  31. package/src/entry/tui/run.tsx +10 -4
  32. package/src/entry/tui/state.ts +19 -6
  33. package/src/entry/tui/stories.ts +11 -4
  34. package/src/entry/tui/views/profile-actions.ts +4 -2
  35. package/src/entry/tui/views/profiles.tsx +2 -2
  36. package/src/entry/tui/views/save-dialog.tsx +2 -2
  37. package/src/infra/cli.ts +65 -0
  38. package/src/infra/codex-queue.ts +165 -0
  39. package/src/infra/delivery.ts +83 -0
  40. package/src/infra/dispatch-dir.ts +1 -0
  41. package/src/infra/env.ts +4 -1
  42. package/src/infra/host-context.ts +58 -0
  43. package/src/infra/peer-inbox.ts +4 -1
  44. package/src/infra/supervisor.ts +3 -2
  45. package/src/services/admission.ts +7 -3
  46. package/src/services/agent-links.ts +30 -5
  47. package/src/services/backends.ts +7 -0
  48. package/src/services/dispatch-service.ts +17 -7
  49. package/src/services/dispatches.ts +6 -1
  50. package/src/services/doctor-backends.ts +30 -7
  51. package/src/services/doctor-checks.ts +20 -5
  52. package/src/services/doctor-push.ts +69 -90
  53. package/src/services/doctor.ts +105 -33
  54. package/src/services/finalize.ts +1 -1
  55. package/src/services/lane-service.ts +1 -0
  56. package/src/services/notifier.ts +322 -70
  57. package/src/services/peek.ts +46 -9
  58. package/src/services/ports.ts +8 -2
  59. package/src/services/profile-service.ts +116 -30
  60. package/src/services/profile-store.ts +23 -6
  61. package/src/services/routing-service.ts +6 -1
  62. package/src/services/run-debug.ts +98 -4
  63. package/src/services/run-service.ts +2 -0
  64. package/src/services/run-store.ts +10 -4
  65. package/src/services/runs-page.ts +3 -2
  66. package/src/services/session-view.ts +18 -13
  67. package/src/services/sessions.ts +32 -16
  68. package/src/services/setup.ts +31 -10
  69. package/src/services/summary.ts +40 -5
  70. package/src/services/treat-likes.ts +22 -9
@@ -1,12 +1,13 @@
1
+ import type { OrchestrationHost } from "../domain/host.ts";
1
2
  import type { Probe } from "../adapters/backend.ts";
2
3
  import { adapterFor } from "../adapters/registry.ts";
3
4
  import "../adapters/all.ts";
4
5
  import { ADAPTER_IDS, tryParseRung } from "../domain/ids.ts";
5
6
  import type { Profile } from "../domain/profile.ts";
6
7
  import { ROLES, type Role } from "../domain/roles.ts";
7
- import { probeBackend } from "./backends.ts";
8
+ import { standInFor, probeBackend } from "./backends.ts";
8
9
  import { refreshDiscovery } from "./catalog-service.ts";
9
- import { type Check, errText, fixOf } from "./doctor-checks.ts";
10
+ import { type Check, errText, fixOf, hostFlag } from "./doctor-checks.ts";
10
11
 
11
12
  // The backend rows of `catherd doctor` (spec §10.3) and the backends the linked profiles use.
12
13
 
@@ -22,7 +23,12 @@ export function usedBackends(profiles: Profile[]): Map<string, "role" | "failove
22
23
  };
23
24
  for (const p of profiles) {
24
25
  for (const role of ROLES) if (p.roles[role].enabled) for (const r of p.roles[role].rungs) note(r, "role");
25
- for (const to of Object.values(p.failover)) note(to, "failover");
26
+ for (const role of ROLES)
27
+ if (p.roles[role].enabled)
28
+ for (const r of p.roles[role].rungs) {
29
+ const to = standInFor(p.failover, r);
30
+ if (to) note(to, "failover");
31
+ }
26
32
  }
27
33
  return used;
28
34
  }
@@ -52,10 +58,16 @@ const shellWord = (w: string): string => (/^[\w./:=@,+-]+$/.test(w) ? w : `'${w.
52
58
  * each linked profile, any default rung the new ladder would not hold reset first. The artist draws, which only Codex does, so
53
59
  * off Codex it is turned off instead.
54
60
  */
55
- export function moveRolesFix(install: string, id: string, to: string, profiles: Profile[]): string {
61
+ export function moveRolesFix(
62
+ install: string,
63
+ id: string,
64
+ to: string,
65
+ profiles: Profile[],
66
+ host: OrchestrationHost = "unknown",
67
+ ): string {
56
68
  const onIt = (r: string | undefined) => r !== undefined && backendOf(r) === id;
57
69
  const set = (p: Profile, path: string, v: string) =>
58
- `catherd profile set ${path} ${shellWord(v)} --profile ${shellWord(p.name)}`;
70
+ `catherd profile set ${path} ${shellWord(v)} --profile ${shellWord(p.name)}${hostFlag(host)}`;
59
71
  const stuck = new Set<Role>();
60
72
  const commands: string[] = [];
61
73
  for (const p of profiles)
@@ -87,10 +99,21 @@ export async function backendChecks(
87
99
  used: Map<string, "role" | "failover">,
88
100
  profiles: Profile[],
89
101
  installed?: Set<string>,
102
+ host: OrchestrationHost = "unknown",
90
103
  ): Promise<Check[]> {
91
104
  const checks: Check[] = [];
92
105
  const ready: string[] = [];
93
106
  for (const id of ADAPTER_IDS) {
107
+ if (host === "codex" && id === "claude-code" && !used.has(id)) {
108
+ checks.push({
109
+ id: `backend:${id}`,
110
+ label: id,
111
+ state: "skip",
112
+ word: "not required",
113
+ detail: "not probed: the selected profile has no headless Claude role or reachable failover",
114
+ });
115
+ continue;
116
+ }
94
117
  const a = adapterFor(id);
95
118
  if (!a) continue;
96
119
  const probe: Probe = await probeBackend(a).catch((e: unknown) => ({
@@ -124,7 +147,7 @@ export async function backendChecks(
124
147
  state: "warn",
125
148
  word: "billing",
126
149
  detail: `${detail} · profile ${billed.name} bills ${id} as ${billed.billing[id]}, but this login is ${probe.billing}`,
127
- fix: `catherd profile set billing.${id} ${probe.billing} --profile ${billed.name}`,
150
+ fix: `catherd profile set billing.${id} ${probe.billing} --profile ${billed.name}${hostFlag(host)}`,
128
151
  }
129
152
  : { id: `backend:${id}`, label: id, state: "ok", word: "ready", detail },
130
153
  );
@@ -147,7 +170,7 @@ export async function backendChecks(
147
170
  for (const c of checks) {
148
171
  const id = c.id.slice("backend:".length);
149
172
  if (to && c.state === "fail" && c.word === PROBLEM_WORD.E_BACKEND_MISSING && c.fix && id !== to)
150
- c.fix = moveRolesFix(c.fix, id, to, profiles);
173
+ c.fix = moveRolesFix(c.fix, id, to, profiles, host);
151
174
  }
152
175
  // spec §5.2: doctor refreshes discovery; a listing that fails keeps the last one
153
176
  let refreshed: Awaited<ReturnType<typeof refreshDiscovery>> = [];
@@ -1,3 +1,4 @@
1
+ import type { OrchestrationHost } from "../domain/host.ts";
1
2
  import { readFileSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { errorMessage, isCatherdError } from "../domain/errors.ts";
@@ -131,9 +132,13 @@ export function locksCheck(): Check {
131
132
  }
132
133
  }
133
134
 
134
- export function agentsCheck(): Check {
135
+ export function agentsCheck(
136
+ host: OrchestrationHost,
137
+ selected?: string[],
138
+ relink = `catherd profile use ${activeName()}`,
139
+ ): Check {
135
140
  const base = { id: "agents", label: "Claude agents" };
136
- const links = agentLinkState();
141
+ const links = agentLinkState(host, selected);
137
142
  const broken = [...links.missing, ...links.stale];
138
143
  if (broken.length)
139
144
  return {
@@ -141,13 +146,19 @@ export function agentsCheck(): Check {
141
146
  state: "fail",
142
147
  word: links.missing.length ? "missing" : "stale",
143
148
  detail: broken.join(", "),
144
- fix: `catherd profile use ${activeName()}`,
149
+ fix: relink,
145
150
  };
146
151
  return links.ok.length
147
152
  ? { ...base, state: "ok", word: "ready", detail: `${links.ok.length} linked` }
148
153
  : { ...base, state: "skip", word: "none", detail: "no profile uses a native Claude rung" };
149
154
  }
150
155
 
156
+ /**
157
+ * ` --host <host>` for a fix doctor prints, when it judged on a known host: run from a plain terminal, the fix
158
+ * must judge the profile the same way (omitted architect/verifier rungs resolve per host).
159
+ */
160
+ export const hostFlag = (host: OrchestrationHost): string => (host === "unknown" ? "" : ` --host ${host}`);
161
+
151
162
  /** The browser CLI the ui-reviewer's prompt takes its screenshots with. */
152
163
  const UI_BROWSER = "agent-browser";
153
164
 
@@ -155,7 +166,11 @@ const UI_BROWSER = "agent-browser";
155
166
  * The ui-reviewer takes screenshots with agent-browser: a profile that turns it on, on a machine without it,
156
167
  * dispatches a role that cannot do its job. Nothing to say when it is on PATH or no profile turns the role on.
157
168
  */
158
- export function uiBrowserCheck(profiles: Profile[], onPath: (bin: string) => string | null): Check | null {
169
+ export function uiBrowserCheck(
170
+ profiles: Profile[],
171
+ onPath: (bin: string) => string | null,
172
+ host: OrchestrationHost = "unknown",
173
+ ): Check | null {
159
174
  const on = profiles.filter((p) => p.roles["ui-reviewer"].enabled).map((p) => p.name);
160
175
  if (!on.length || onPath(UI_BROWSER)) return null;
161
176
  return {
@@ -167,7 +182,7 @@ export function uiBrowserCheck(profiles: Profile[], onPath: (bin: string) => str
167
182
  fix: [
168
183
  `npm i -g ${UI_BROWSER}`,
169
184
  "or turn the role off:",
170
- ...on.map((n) => `catherd profile set roles.ui-reviewer.enabled false --profile ${n}`),
185
+ ...on.map((n) => `catherd profile set roles.ui-reviewer.enabled false --profile ${n}${hostFlag(host)}`),
171
186
  ].join("\n"),
172
187
  };
173
188
  }
@@ -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
  }
@@ -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";
@@ -8,10 +10,11 @@ import type { JevTransport } from "../infra/jev-client.ts";
8
10
  import { linkedProfiles } from "./agent-links.ts";
9
11
  import { accessChecks } from "./doctor-access.ts";
10
12
  import { backendChecks, usedBackends } from "./doctor-backends.ts";
11
- import { type PushProbe, pushCheck } from "./doctor-push.ts";
13
+ import { type PushProbe, pushCheck, probePush } from "./doctor-push.ts";
12
14
  import { sourcesCheck, standInsToConfirmIn } from "./doctor-sources.ts";
13
15
  import {
14
16
  agentsCheck,
17
+ hostFlag,
15
18
  type Check,
16
19
  errText,
17
20
  fixOf,
@@ -31,9 +34,13 @@ import {
31
34
  readProjects,
32
35
  validateNamed,
33
36
  } from "./profile-store.ts";
37
+ import { inspectionHost } from "./run-debug.ts";
34
38
  import { listRuns } from "./run-store.ts";
35
39
 
36
40
  export interface DoctorReport {
41
+ host: HostContext;
42
+ queue: QueueCapability | null;
43
+ push: PushProbe | null;
37
44
  /** false when any check failed: `catherd doctor` exits 3 */
38
45
  ready: boolean;
39
46
  version: string;
@@ -49,13 +56,17 @@ export interface Handshake {
49
56
  }
50
57
 
51
58
  export interface DoctorDeps {
59
+ host: HostContext;
60
+ repo: string | null;
52
61
  bunVersion: string;
53
62
  /** the package version, which the Claude Code plugin must pin */
54
63
  version: string;
55
64
  /** starts the MCP server as the plugin does (its launcher) over stdio and asks it for tools/list */
56
65
  handshake: () => Promise<Handshake>;
57
- /** spec §3.9: sends this Claude Code session a test message; without it (the dashboard) there is no `push` row */
66
+ /** Explicit smoke test injection; default doctor never invokes it. */
58
67
  push?: () => Promise<PushProbe>;
68
+ testPush?: boolean;
69
+ env?: Record<string, string | undefined>;
59
70
  jev?: JevTransport;
60
71
  }
61
72
 
@@ -78,13 +89,15 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
78
89
  : { id: "bun", label: "Bun", state: "ok", word: "ready", detail: d.bunVersion },
79
90
  );
80
91
 
92
+ // every fix names the host doctor judged with: run elsewhere (a plain terminal), it must judge the same way
93
+ const onHost = hostFlag(d.host.host);
81
94
  let profiles: Profile[] = [];
82
95
  let linked: string[] = [];
83
96
  let active: Profile | null = null;
84
97
  try {
85
98
  readConfig();
86
99
  readProjects();
87
- active = getProfile(activeName());
100
+ active = getProfile(activeName(d.repo), d.host.host);
88
101
  linked = linkedProfiles();
89
102
  checks.push({
90
103
  id: "config",
@@ -106,7 +119,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
106
119
  // one unreadable linked profile must not hide the others' backends: its own row below reports it
107
120
  profiles = linked.flatMap((n) => {
108
121
  try {
109
- return [getProfile(n)];
122
+ return [getProfile(n, d.host.host)];
110
123
  } catch {
111
124
  return [];
112
125
  }
@@ -118,7 +131,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
118
131
  checks.push({
119
132
  id: `binding:${repo}`,
120
133
  label: `binding ${repo}`,
121
- state: "fail",
134
+ state: repo === d.repo ? "fail" : "warn",
122
135
  word: "missing",
123
136
  detail: `bound to profile ${name}, which does not exist`,
124
137
  fix: `cd ${repo} && catherd profile use --repo --clear`,
@@ -131,30 +144,32 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
131
144
  const names = active ? [active.name, ...linked.filter((n) => n !== active?.name)] : [];
132
145
  for (const name of names) {
133
146
  const id = name === active?.name ? "profile" : `profile:${name}`;
134
- const validate = `catherd profile validate ${name}`;
147
+ const validate = `catherd profile validate ${name}${onHost}`;
135
148
  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
- );
149
+ fix.replaceAll("catherd profile set ", `catherd profile set --profile ${name}${onHost} `);
150
+ const row = guarded(id, `profile ${name}`, validate, () => {
151
+ const v = validateNamed(name, d.repo, d.host.host);
152
+ const first = v.errors[0] ?? v.warnings[0];
153
+ return {
154
+ id,
155
+ label: `profile ${name}`,
156
+ state: v.errors.length ? "fail" : v.warnings.length ? "warn" : "ok",
157
+ word: v.errors.length ? "invalid" : v.warnings.length ? "warning" : "ready",
158
+ detail: first
159
+ ? `${first.path}: ${first.message}${v.errors.length + v.warnings.length > 1 ? " (and more)" : ""}`
160
+ : "valid",
161
+ ...(first ? { fix: first.fix ? named(first.fix) : validate } : {}),
162
+ };
163
+ });
164
+ if (name !== active?.name && row.state === "fail") row.state = "warn";
165
+ checks.push(row);
153
166
  }
154
167
 
168
+ const diagnostics = profiles;
169
+ profiles = active ? [active] : [];
155
170
  const used = usedBackends(profiles);
156
171
  const installed = new Set<string>();
157
- checks.push(...(await backendChecks(used, profiles, installed)));
172
+ checks.push(...(await backendChecks(used, profiles, installed, d.host.host)));
158
173
 
159
174
  if (active && [active, ...profiles].every((p) => p.jev.use === "off"))
160
175
  checks.push({
@@ -194,18 +209,44 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
194
209
 
195
210
  // spec 1.2 §9: the public sources' ages and errors, the Artificial Analysis key, and the active and linked
196
211
  // profiles' stand-ins to confirm
197
- const mine = [...(active ? [active] : []), ...profiles.filter((p) => p.name !== active?.name)];
212
+ const mine = [...(active ? [active] : []), ...diagnostics.filter((p) => p.name !== active?.name)];
198
213
  checks.push(
199
214
  guarded("sources", "sources", "catherd catalog sync --force", () =>
200
215
  sourcesCheck(Date.now(), standInsToConfirmIn(mine)),
201
216
  ),
202
217
  );
203
218
 
204
- checks.push(pluginCheck(d.version));
219
+ const native = used.get("claude");
220
+ const plugin = pluginCheck(d.version);
221
+ if (d.host.host !== "claude-code" && native !== "role") {
222
+ plugin.state = native === "failover" ? "warn" : "skip";
223
+ plugin.word = native === "failover" ? "optional" : "not required";
224
+ }
225
+ checks.push(plugin);
205
226
 
227
+ // relinks the profile doctor checked: this repo's binding when it has one, else the active profile
228
+ let bound = false;
229
+ try {
230
+ bound = d.repo !== null && readProjects().bindings[d.repo] === active?.name;
231
+ } catch {
232
+ // the config row above already reports an unreadable projects.json
233
+ }
234
+ const relink = active
235
+ ? `${bound ? `cd ${d.repo} && ` : ""}catherd profile use ${active.name}${bound ? " --repo" : ""}${onHost}`
236
+ : "";
206
237
  checks.push(
207
238
  active
208
- ? guarded("agents", "Claude agents", `catherd profile use ${activeName()}`, agentsCheck)
239
+ ? guarded("agents", "Claude agents", relink, () =>
240
+ native
241
+ ? agentsCheck(d.host.host, [active!.name], relink)
242
+ : {
243
+ id: "agents",
244
+ label: "Claude agents",
245
+ state: "skip",
246
+ word: "not required",
247
+ detail: "selected profile has no native Claude roles",
248
+ },
249
+ )
209
250
  : {
210
251
  id: "agents",
211
252
  label: "Claude agents",
@@ -215,15 +256,39 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
215
256
  },
216
257
  );
217
258
 
259
+ const agents = checks.find((c) => c.id === "agents");
260
+ if (native === "failover" && agents?.state === "fail") agents.state = "warn";
261
+
218
262
  const h = await d
219
263
  .handshake()
220
264
  .catch((e: unknown): Handshake => ({ ok: false, tools: [], error: errText(e) }));
221
265
  checks.push(mcpCheck(h, d.version));
222
266
 
223
- if (d.push)
224
- checks.push(
225
- pushCheck(await d.push().catch((e: unknown): PushProbe => ({ outcome: "failed", detail: errText(e) }))),
226
- );
267
+ const queue =
268
+ d.host.host === "codex" && !d.host.conflict ? await queueCapability(d.env ?? process.env) : null;
269
+ if (queue)
270
+ checks.push({
271
+ id: "push-capability",
272
+ label: "native Codex queue",
273
+ state:
274
+ !queue.cli || queue.server === "unsupported" ? "fail" : queue.server === "unverified" ? "warn" : "ok",
275
+ word: !queue.cli ? "unavailable" : queue.server,
276
+ detail: queue.reason ?? "Native server advertises queue support",
277
+ ...(!queue.cli || queue.server === "unsupported"
278
+ ? { fix: "Update native Codex/server; use peek/result while push is unavailable." }
279
+ : {}),
280
+ });
281
+ let push: PushProbe | null = null;
282
+ if (d.testPush) {
283
+ push = await (d.push ?? (() => probePush(d.host, d.env ?? process.env)))().catch((): PushProbe => ({
284
+ outcome: "failed",
285
+ enqueue: "ambiguous",
286
+ processing: "unconfirmed",
287
+ msgId: null,
288
+ detail: "Smoke has no verified receipt; retry may duplicate input. Use peek/result.",
289
+ }));
290
+ checks.push(pushCheck(push));
291
+ }
227
292
 
228
293
  checks.push(locksCheck());
229
294
  // spec §5 and §12: which codex sandbox form runs, and the five access probes per workspace-write backend
@@ -322,7 +387,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
322
387
  detail: "the plugin's MCP launcher runs bunx when no global catherd is installed",
323
388
  fix: "put Bun's bin folder (~/.bun/bin) on PATH",
324
389
  });
325
- const browser = uiBrowserCheck(mine, onPath);
390
+ const browser = uiBrowserCheck(mine, onPath, d.host.host);
326
391
  if (browser) checks.push(browser);
327
392
  let corrupt: ReturnType<typeof listRuns>["corrupt"] = [];
328
393
  try {
@@ -347,5 +412,12 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
347
412
  fix: `fix or delete ${corrupt[0]?.dir}`,
348
413
  });
349
414
 
350
- return { ready: !checks.some((c) => c.state === "fail"), version: d.version, checks };
415
+ return {
416
+ host: inspectionHost(d.host),
417
+ queue,
418
+ push,
419
+ ready: !checks.some((c) => c.state === "fail"),
420
+ version: d.version,
421
+ checks,
422
+ };
351
423
  }
@@ -226,7 +226,7 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
226
226
  images: o.images,
227
227
  error: o.error,
228
228
  replyPath: relative(run.dir, p.reply),
229
- ...(a.sessionId ? { sessionId: a.sessionId } : {}),
229
+ ...(a.sessionId ? { sessionId: a.sessionId, host: a.host ?? "claude-code" } : {}),
230
230
  };
231
231
  }
232
232
 
@@ -88,6 +88,7 @@ export async function route(
88
88
  const lane = i.laneFile === undefined ? null : readLaneFile(run, i.laneFile);
89
89
  const profile = deps.profiles.forRepo(run.meta.repo);
90
90
  const a = await deps.routing.route({
91
+ host: deps.host.host,
91
92
  runDir: run.dir,
92
93
  repo: run.meta.repo,
93
94
  profile,