catherd-cli 1.2.1 → 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 (104) hide show
  1. package/README.md +146 -36
  2. package/catalog/models.json +148 -5
  3. package/catalog/scores.json +24 -0
  4. package/catalog/sources.json +12 -2
  5. package/package.json +3 -2
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +11 -0
  8. package/plugin/.mcp-codex.json +10 -0
  9. package/plugin/bin/catherd-mcp +1 -1
  10. package/plugin/skills/catherd/SKILL.md +45 -39
  11. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  12. package/src/adapters/access.ts +61 -18
  13. package/src/adapters/all.ts +6 -0
  14. package/src/adapters/antigravity/events.ts +131 -0
  15. package/src/adapters/antigravity/home.ts +46 -0
  16. package/src/adapters/antigravity/index.ts +320 -0
  17. package/src/adapters/antigravity/models.ts +37 -0
  18. package/src/adapters/backend.ts +34 -1
  19. package/src/adapters/claude-code/index.ts +2 -0
  20. package/src/adapters/cli.ts +1 -64
  21. package/src/adapters/codex/index.ts +2 -0
  22. package/src/adapters/cursor/events.ts +120 -0
  23. package/src/adapters/cursor/home.ts +79 -0
  24. package/src/adapters/cursor/index.ts +331 -0
  25. package/src/adapters/cursor/models.ts +30 -0
  26. package/src/adapters/discovery.ts +21 -0
  27. package/src/adapters/grok/events.ts +112 -0
  28. package/src/adapters/grok/home.ts +132 -0
  29. package/src/adapters/grok/index.ts +339 -0
  30. package/src/adapters/grok/models.ts +48 -0
  31. package/src/adapters/opencode/index.ts +2 -0
  32. package/src/cli.ts +49 -19
  33. package/src/domain/catalog.ts +10 -1
  34. package/src/domain/cost.ts +1 -0
  35. package/src/domain/errors.ts +1 -0
  36. package/src/domain/failover.ts +5 -2
  37. package/src/domain/host.ts +17 -0
  38. package/src/domain/ids.ts +2 -2
  39. package/src/domain/notice.ts +5 -2
  40. package/src/domain/profile-rules.ts +17 -0
  41. package/src/domain/profile.ts +89 -5
  42. package/src/domain/record.ts +3 -0
  43. package/src/domain/util.ts +3 -0
  44. package/src/entry/catalog-command.ts +4 -2
  45. package/src/entry/deps.ts +6 -3
  46. package/src/entry/doctor-command.ts +11 -2
  47. package/src/entry/host-arg.ts +28 -0
  48. package/src/entry/init-command.ts +18 -7
  49. package/src/entry/knowledge-command.ts +50 -0
  50. package/src/entry/lock-command.ts +7 -2
  51. package/src/entry/mcp/handshake.ts +3 -1
  52. package/src/entry/mcp/run-tools.ts +5 -2
  53. package/src/entry/mcp/server.ts +129 -40
  54. package/src/entry/mcp/setup-tools.ts +7 -2
  55. package/src/entry/profile-command.ts +108 -12
  56. package/src/entry/runs-command.ts +81 -5
  57. package/src/entry/tui/effects.ts +30 -10
  58. package/src/entry/tui/fixtures.ts +34 -10
  59. package/src/entry/tui/profile-tree.ts +10 -1
  60. package/src/entry/tui/run.tsx +10 -4
  61. package/src/entry/tui/state.ts +19 -6
  62. package/src/entry/tui/stories.ts +11 -4
  63. package/src/entry/tui/views/profile-actions.ts +4 -2
  64. package/src/entry/tui/views/profiles.tsx +16 -3
  65. package/src/entry/tui/views/save-dialog.tsx +2 -2
  66. package/src/infra/cli.ts +65 -0
  67. package/src/infra/codex-queue.ts +165 -0
  68. package/src/infra/delivery.ts +83 -0
  69. package/src/infra/dispatch-dir.ts +1 -0
  70. package/src/infra/env.ts +4 -1
  71. package/src/infra/host-context.ts +58 -0
  72. package/src/infra/paths.ts +3 -1
  73. package/src/infra/peer-inbox.ts +4 -1
  74. package/src/infra/supervisor.ts +3 -2
  75. package/src/services/admission.ts +34 -7
  76. package/src/services/agent-links.ts +30 -5
  77. package/src/services/backends.ts +49 -4
  78. package/src/services/capture.ts +112 -54
  79. package/src/services/catalog-refresh.ts +2 -1
  80. package/src/services/dispatch-service.ts +17 -7
  81. package/src/services/dispatches.ts +8 -1
  82. package/src/services/doctor-access.ts +14 -7
  83. package/src/services/doctor-backends.ts +39 -7
  84. package/src/services/doctor-checks.ts +43 -3
  85. package/src/services/doctor-push.ts +69 -90
  86. package/src/services/doctor.ts +107 -32
  87. package/src/services/finalize.ts +2 -1
  88. package/src/services/lane-service.ts +4 -10
  89. package/src/services/notifier.ts +322 -70
  90. package/src/services/peek.ts +46 -9
  91. package/src/services/ports.ts +8 -2
  92. package/src/services/profile-service.ts +117 -31
  93. package/src/services/profile-store.ts +126 -5
  94. package/src/services/routing-service.ts +6 -1
  95. package/src/services/run-debug.ts +98 -4
  96. package/src/services/run-service.ts +39 -4
  97. package/src/services/run-store.ts +21 -5
  98. package/src/services/runs-page.ts +3 -2
  99. package/src/services/session-view.ts +18 -13
  100. package/src/services/sessions.ts +32 -16
  101. package/src/services/setup.ts +31 -10
  102. package/src/services/source-derive.ts +32 -7
  103. package/src/services/summary.ts +40 -5
  104. package/src/services/treat-likes.ts +22 -9
@@ -1,6 +1,8 @@
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";
5
+ import type { Profile } from "../domain/profile.ts";
4
6
  import { claudeHome, locksDir } from "../infra/paths.ts";
5
7
  import { ensurePrivateDir, PRIVATE_FILE } from "../infra/store.ts";
6
8
  import { agentLinkState } from "./agent-links.ts";
@@ -130,9 +132,13 @@ export function locksCheck(): Check {
130
132
  }
131
133
  }
132
134
 
133
- export function agentsCheck(): Check {
135
+ export function agentsCheck(
136
+ host: OrchestrationHost,
137
+ selected?: string[],
138
+ relink = `catherd profile use ${activeName()}`,
139
+ ): Check {
134
140
  const base = { id: "agents", label: "Claude agents" };
135
- const links = agentLinkState();
141
+ const links = agentLinkState(host, selected);
136
142
  const broken = [...links.missing, ...links.stale];
137
143
  if (broken.length)
138
144
  return {
@@ -140,9 +146,43 @@ export function agentsCheck(): Check {
140
146
  state: "fail",
141
147
  word: links.missing.length ? "missing" : "stale",
142
148
  detail: broken.join(", "),
143
- fix: `catherd profile use ${activeName()}`,
149
+ fix: relink,
144
150
  };
145
151
  return links.ok.length
146
152
  ? { ...base, state: "ok", word: "ready", detail: `${links.ok.length} linked` }
147
153
  : { ...base, state: "skip", word: "none", detail: "no profile uses a native Claude rung" };
148
154
  }
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
+
162
+ /** The browser CLI the ui-reviewer's prompt takes its screenshots with. */
163
+ const UI_BROWSER = "agent-browser";
164
+
165
+ /**
166
+ * The ui-reviewer takes screenshots with agent-browser: a profile that turns it on, on a machine without it,
167
+ * dispatches a role that cannot do its job. Nothing to say when it is on PATH or no profile turns the role on.
168
+ */
169
+ export function uiBrowserCheck(
170
+ profiles: Profile[],
171
+ onPath: (bin: string) => string | null,
172
+ host: OrchestrationHost = "unknown",
173
+ ): Check | null {
174
+ const on = profiles.filter((p) => p.roles["ui-reviewer"].enabled).map((p) => p.name);
175
+ if (!on.length || onPath(UI_BROWSER)) return null;
176
+ return {
177
+ id: "ui-browser",
178
+ label: UI_BROWSER,
179
+ state: "warn",
180
+ word: "missing",
181
+ detail: `${UI_BROWSER} is not on PATH, so ui-reviewer (${on.join(", ")}) cannot take screenshots`,
182
+ fix: [
183
+ `npm i -g ${UI_BROWSER}`,
184
+ "or turn the role off:",
185
+ ...on.map((n) => `catherd profile set roles.ui-reviewer.enabled false --profile ${n}${hostFlag(host)}`),
186
+ ].join("\n"),
187
+ };
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,
@@ -19,6 +22,7 @@ import {
19
22
  locksCheck,
20
23
  mcpCheck,
21
24
  pluginCheck,
25
+ uiBrowserCheck,
22
26
  } from "./doctor-checks.ts";
23
27
  import { credentialsPath, jevKey, savedJevKey, testJevKey } from "./jev-service.ts";
24
28
  import {
@@ -30,9 +34,13 @@ import {
30
34
  readProjects,
31
35
  validateNamed,
32
36
  } from "./profile-store.ts";
37
+ import { inspectionHost } from "./run-debug.ts";
33
38
  import { listRuns } from "./run-store.ts";
34
39
 
35
40
  export interface DoctorReport {
41
+ host: HostContext;
42
+ queue: QueueCapability | null;
43
+ push: PushProbe | null;
36
44
  /** false when any check failed: `catherd doctor` exits 3 */
37
45
  ready: boolean;
38
46
  version: string;
@@ -48,13 +56,17 @@ export interface Handshake {
48
56
  }
49
57
 
50
58
  export interface DoctorDeps {
59
+ host: HostContext;
60
+ repo: string | null;
51
61
  bunVersion: string;
52
62
  /** the package version, which the Claude Code plugin must pin */
53
63
  version: string;
54
64
  /** starts the MCP server as the plugin does (its launcher) over stdio and asks it for tools/list */
55
65
  handshake: () => Promise<Handshake>;
56
- /** 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. */
57
67
  push?: () => Promise<PushProbe>;
68
+ testPush?: boolean;
69
+ env?: Record<string, string | undefined>;
58
70
  jev?: JevTransport;
59
71
  }
60
72
 
@@ -77,13 +89,15 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
77
89
  : { id: "bun", label: "Bun", state: "ok", word: "ready", detail: d.bunVersion },
78
90
  );
79
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);
80
94
  let profiles: Profile[] = [];
81
95
  let linked: string[] = [];
82
96
  let active: Profile | null = null;
83
97
  try {
84
98
  readConfig();
85
99
  readProjects();
86
- active = getProfile(activeName());
100
+ active = getProfile(activeName(d.repo), d.host.host);
87
101
  linked = linkedProfiles();
88
102
  checks.push({
89
103
  id: "config",
@@ -105,7 +119,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
105
119
  // one unreadable linked profile must not hide the others' backends: its own row below reports it
106
120
  profiles = linked.flatMap((n) => {
107
121
  try {
108
- return [getProfile(n)];
122
+ return [getProfile(n, d.host.host)];
109
123
  } catch {
110
124
  return [];
111
125
  }
@@ -117,7 +131,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
117
131
  checks.push({
118
132
  id: `binding:${repo}`,
119
133
  label: `binding ${repo}`,
120
- state: "fail",
134
+ state: repo === d.repo ? "fail" : "warn",
121
135
  word: "missing",
122
136
  detail: `bound to profile ${name}, which does not exist`,
123
137
  fix: `cd ${repo} && catherd profile use --repo --clear`,
@@ -130,30 +144,32 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
130
144
  const names = active ? [active.name, ...linked.filter((n) => n !== active?.name)] : [];
131
145
  for (const name of names) {
132
146
  const id = name === active?.name ? "profile" : `profile:${name}`;
133
- const validate = `catherd profile validate ${name}`;
147
+ const validate = `catherd profile validate ${name}${onHost}`;
134
148
  const named = (fix: string) =>
135
- fix.replaceAll("catherd profile set ", `catherd profile set --profile ${name} `);
136
- checks.push(
137
- guarded(id, `profile ${name}`, validate, () => {
138
- const v = validateNamed(name);
139
- const first = v.errors[0] ?? v.warnings[0];
140
- return {
141
- id,
142
- label: `profile ${name}`,
143
- state: v.errors.length ? "fail" : v.warnings.length ? "warn" : "ok",
144
- word: v.errors.length ? "invalid" : v.warnings.length ? "warning" : "ready",
145
- detail: first
146
- ? `${first.path}: ${first.message}${v.errors.length + v.warnings.length > 1 ? " (and more)" : ""}`
147
- : "valid",
148
- ...(first ? { fix: first.fix ? named(first.fix) : validate } : {}),
149
- };
150
- }),
151
- );
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);
152
166
  }
153
167
 
168
+ const diagnostics = profiles;
169
+ profiles = active ? [active] : [];
154
170
  const used = usedBackends(profiles);
155
171
  const installed = new Set<string>();
156
- checks.push(...(await backendChecks(used, profiles, installed)));
172
+ checks.push(...(await backendChecks(used, profiles, installed, d.host.host)));
157
173
 
158
174
  if (active && [active, ...profiles].every((p) => p.jev.use === "off"))
159
175
  checks.push({
@@ -193,18 +209,44 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
193
209
 
194
210
  // spec 1.2 §9: the public sources' ages and errors, the Artificial Analysis key, and the active and linked
195
211
  // profiles' stand-ins to confirm
196
- const mine = [...(active ? [active] : []), ...profiles.filter((p) => p.name !== active?.name)];
212
+ const mine = [...(active ? [active] : []), ...diagnostics.filter((p) => p.name !== active?.name)];
197
213
  checks.push(
198
214
  guarded("sources", "sources", "catherd catalog sync --force", () =>
199
215
  sourcesCheck(Date.now(), standInsToConfirmIn(mine)),
200
216
  ),
201
217
  );
202
218
 
203
- 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);
204
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
+ : "";
205
237
  checks.push(
206
238
  active
207
- ? 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
+ )
208
250
  : {
209
251
  id: "agents",
210
252
  label: "Claude agents",
@@ -214,15 +256,39 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
214
256
  },
215
257
  );
216
258
 
259
+ const agents = checks.find((c) => c.id === "agents");
260
+ if (native === "failover" && agents?.state === "fail") agents.state = "warn";
261
+
217
262
  const h = await d
218
263
  .handshake()
219
264
  .catch((e: unknown): Handshake => ({ ok: false, tools: [], error: errText(e) }));
220
265
  checks.push(mcpCheck(h, d.version));
221
266
 
222
- if (d.push)
223
- checks.push(
224
- pushCheck(await d.push().catch((e: unknown): PushProbe => ({ outcome: "failed", detail: errText(e) }))),
225
- );
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
+ }
226
292
 
227
293
  checks.push(locksCheck());
228
294
  // spec §5 and §12: which codex sandbox form runs, and the five access probes per workspace-write backend
@@ -321,6 +387,8 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
321
387
  detail: "the plugin's MCP launcher runs bunx when no global catherd is installed",
322
388
  fix: "put Bun's bin folder (~/.bun/bin) on PATH",
323
389
  });
390
+ const browser = uiBrowserCheck(mine, onPath, d.host.host);
391
+ if (browser) checks.push(browser);
324
392
  let corrupt: ReturnType<typeof listRuns>["corrupt"] = [];
325
393
  try {
326
394
  corrupt = listRuns().corrupt;
@@ -344,5 +412,12 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
344
412
  fix: `fix or delete ${corrupt[0]?.dir}`,
345
413
  });
346
414
 
347
- 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
+ };
348
423
  }
@@ -222,10 +222,11 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
222
222
  threadHeavy: o.tokens.input >= THREAD_HEAVY_INPUT,
223
223
  access: a.access,
224
224
  isolated: a.isolated,
225
+ ...(a.network === undefined ? {} : { network: a.network }),
225
226
  images: o.images,
226
227
  error: o.error,
227
228
  replyPath: relative(run.dir, p.reply),
228
- ...(a.sessionId ? { sessionId: a.sessionId } : {}),
229
+ ...(a.sessionId ? { sessionId: a.sessionId, host: a.host ?? "claude-code" } : {}),
229
230
  };
230
231
  }
231
232
 
@@ -4,6 +4,7 @@ import { CatherdError } from "../domain/errors.ts";
4
4
  import { assertId, ID_PATTERN, parseRung } from "../domain/ids.ts";
5
5
  import { assertLaneHeader, type Difficulty, type Kind } from "../domain/lane.ts";
6
6
  import type { Role } from "../domain/roles.ts";
7
+ import { cell } from "../domain/util.ts";
7
8
  import {
8
9
  type ClimbReason,
9
10
  currentRoute,
@@ -28,11 +29,11 @@ import {
28
29
  } from "./milestones.ts";
29
30
  import type { Deps, Verdict } from "./ports.ts";
30
31
  import {
32
+ appendKnowledge,
31
33
  appendLedger,
32
34
  appendOutcome,
33
35
  appendRoute,
34
36
  findRun,
35
- knowledgeFile,
36
37
  readRoutes,
37
38
  type Run,
38
39
  runFile,
@@ -41,7 +42,6 @@ import {
41
42
  import { writeDigest } from "./protocol.ts";
42
43
  import { openQuestions } from "./questions.ts";
43
44
  import { type Notes, type NotesPatch, refreshState } from "./state.ts";
44
- import { appendPrivate } from "../infra/store.ts";
45
45
 
46
46
  const withHints = (hints: string[]) => (hints.length ? { hints } : {});
47
47
 
@@ -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,
@@ -212,8 +213,6 @@ export async function climb(
212
213
  };
213
214
  }
214
215
 
215
- const cell = (s: string) => s.replace(/[|\n]/g, "/").replace(/\s+/g, " ").trim();
216
-
217
216
  export const LAND_SKIPS = ["docs-only", "no-code"] as const;
218
217
  export type LandSkip = (typeof LAND_SKIPS)[number];
219
218
 
@@ -348,12 +347,7 @@ export async function land(
348
347
  hints.push(
349
348
  `land: no routed lane is in milestone "${i.milestone}" (routed: ${routed.slice(0, 5).join(", ")}${routed.length > 5 ? ", …" : ""}); check its name: no lane outcome was recorded`,
350
349
  );
351
- if (i.learned) {
352
- appendPrivate(
353
- knowledgeFile(run.meta.repo),
354
- `- ${now.toISOString().slice(0, 10)} ${run.meta.title} ${i.milestone}: ${cell(i.learned)}\n`,
355
- );
356
- }
350
+ if (i.learned) appendKnowledge(run.meta.repo, now, `${run.meta.title} ${i.milestone}`, i.learned);
357
351
  // spec 1.1 §10: the milestone's digest, which the milestone push links
358
352
  const digest = writeDigest(run, {
359
353
  milestone: i.milestone,