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
@@ -0,0 +1,165 @@
1
+ import type { HostSessionRef } from "../domain/host.ts";
2
+ import { runCli } from "./cli.ts";
3
+ import { scrubSecrets } from "./env.ts";
4
+
5
+ export interface QueueCapability {
6
+ cli: boolean;
7
+ server: "supported" | "unsupported" | "unverified";
8
+ reason: string | null;
9
+ /** false when nothing was probed yet: `cli` then says nothing about the CLI */
10
+ checked?: false;
11
+ }
12
+ export type QueueSendResult =
13
+ | { outcome: "accepted"; msgId: string }
14
+ | { outcome: "not-submitted"; reason: string }
15
+ | { outcome: "ambiguous"; reason: string };
16
+
17
+ const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
18
+ const uuid = new RegExp(`^${UUID}$`, "i");
19
+ const receipt = new RegExp(`^Queued message (${UUID}) for thread (${UUID})\\.$`);
20
+ const TIMEOUT_MS = 10000;
21
+
22
+ export async function queueCapability(
23
+ env: Record<string, string | undefined>,
24
+ run: typeof runCli = runCli,
25
+ ): Promise<QueueCapability> {
26
+ try {
27
+ const help = await run("codex", ["queue", "--help"], {
28
+ timeoutMs: TIMEOUT_MS,
29
+ env: scrubSecrets({ ...process.env, ...env }),
30
+ });
31
+ if (
32
+ !help?.ok ||
33
+ !/\bqueue\b/.test(help.out) ||
34
+ !["--thread", "--message", "--remote"].every((flag) => new RegExp(`${flag}\\s`).test(help.out))
35
+ )
36
+ return {
37
+ cli: false,
38
+ server: "unverified",
39
+ reason: "Native codex queue with --remote is unavailable; update Codex and use peek/result.",
40
+ };
41
+ return {
42
+ cli: true,
43
+ server: "unverified",
44
+ reason: "Native server queue method enumeration is unavailable; help proves CLI support only.",
45
+ };
46
+ } catch {
47
+ return {
48
+ cli: false,
49
+ server: "unverified",
50
+ reason: "Could not inspect native codex queue; use peek/result.",
51
+ };
52
+ }
53
+ }
54
+
55
+ const PROBE_TTL_MS = 60_000;
56
+ let known: { at: number; capability: QueueCapability } | null = null;
57
+ let probing: Promise<void> | null = null;
58
+
59
+ /** The capability a one-shot command reports without probing: it never waits, and doctor checks it. */
60
+ export const UNCHECKED_QUEUE: QueueCapability = {
61
+ cli: false,
62
+ server: "unverified",
63
+ reason: "Native codex queue is not checked by this command; catherd doctor checks it.",
64
+ checked: false,
65
+ };
66
+
67
+ /**
68
+ * The last known queue capability, without waiting on the Codex CLI: peek and status never wait (spec §3.7).
69
+ * A missing or stale answer starts one probe in the background; until it lands the capability reads unchecked.
70
+ */
71
+ export function knownQueueCapability(
72
+ env: Record<string, string | undefined>,
73
+ run: typeof runCli = runCli,
74
+ now: number = Date.now(),
75
+ ): QueueCapability {
76
+ if (!probing && (!known || now - known.at >= PROBE_TTL_MS))
77
+ probing = queueCapability(env, run)
78
+ .then((capability) => {
79
+ known = { at: now, capability };
80
+ })
81
+ .finally(() => {
82
+ probing = null;
83
+ });
84
+ return (
85
+ known?.capability ?? {
86
+ cli: false,
87
+ server: "unverified",
88
+ reason: "Native codex queue is being checked; peek again for the result.",
89
+ checked: false,
90
+ }
91
+ );
92
+ }
93
+
94
+ /** Settles the background probe knownQueueCapability started, if any (a test seam). */
95
+ export async function queueProbeSettled(): Promise<void> {
96
+ await probing;
97
+ }
98
+
99
+ /** Settles any background probe and forgets its answer (a test seam). */
100
+ export async function resetQueueProbe(): Promise<void> {
101
+ await probing;
102
+ known = null;
103
+ }
104
+
105
+ export async function sendToCodexQueue(
106
+ target: HostSessionRef,
107
+ content: string,
108
+ env: Record<string, string | undefined>,
109
+ run: typeof runCli = runCli,
110
+ ): Promise<QueueSendResult> {
111
+ if (target.host !== "codex" || target.sessionId.length !== 36 || !uuid.test(target.sessionId))
112
+ return {
113
+ outcome: "not-submitted",
114
+ reason: "Queue requires a validated original Codex thread UUID; use peek/result.",
115
+ };
116
+ if (!content.trim()) return { outcome: "not-submitted", reason: "Queue message is empty." };
117
+ const capability = await queueCapability(env, run);
118
+ if (!capability.cli || capability.server === "unsupported")
119
+ return {
120
+ outcome: "not-submitted",
121
+ reason: capability.reason ?? "Native queue is unavailable; use peek/result.",
122
+ };
123
+ try {
124
+ const args = ["queue", "--remote", "unix://", "--thread", target.sessionId, "--message", content];
125
+ const result = await run("codex", args, {
126
+ timeoutMs: TIMEOUT_MS,
127
+ env: scrubSecrets({ ...process.env, ...env }),
128
+ logArgs: [...args.slice(0, -1), "[redacted]"],
129
+ });
130
+ if (result === null)
131
+ return { outcome: "not-submitted", reason: "Codex executable is unavailable; use peek/result." };
132
+ const match = result.ok ? receipt.exec(result.out.trim()) : null;
133
+ if (match && match[2]?.toLowerCase() === target.sessionId.toLowerCase())
134
+ return { outcome: "accepted", msgId: match[1]! };
135
+ if (
136
+ !result.ok &&
137
+ !result.out.trim() &&
138
+ (/^Error: the remote app server does not support thread\/queue\/add; update or restart the remote app server(?:\n|$)/.test(
139
+ result.err,
140
+ ) ||
141
+ /^error: (?:unrecognized subcommand 'queue'|unexpected argument '--remote' found)(?:\n|$)/.test(
142
+ result.err,
143
+ ) ||
144
+ /^Error: failed to connect to remote app server at `unix:\/\/[^`\n]+`: [^\n]+(?:\n|$)/.test(
145
+ result.err,
146
+ ))
147
+ )
148
+ return {
149
+ outcome: "not-submitted",
150
+ reason:
151
+ "Native queue refused before submission; check CLI/server support and the existing native endpoint, then use peek/result.",
152
+ };
153
+ return {
154
+ outcome: "ambiguous",
155
+ reason:
156
+ "Native queue invocation has no verified receipt; use peek/result. Retrying may duplicate input.",
157
+ };
158
+ } catch {
159
+ return {
160
+ outcome: "ambiguous",
161
+ reason:
162
+ "Native queue invocation failed without a verified receipt; use peek/result. Retrying may duplicate input.",
163
+ };
164
+ }
165
+ }
@@ -0,0 +1,83 @@
1
+ import { existsSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import { CatherdError } from "../domain/errors.ts";
4
+ import { sessionKey, type HostSessionRef } from "../domain/host.ts";
5
+ import { awaitsCollect, dispatchPaths } from "./dispatch-dir.ts";
6
+ import { withFileLockSync } from "./filelock.ts";
7
+ import { readVersioned, writeJsonAtomic } from "./store.ts";
8
+
9
+ const AttemptSchema = z
10
+ .looseObject({
11
+ attemptId: z.string().min(1),
12
+ target: z.object({
13
+ host: z.enum(["codex", "claude-code"]),
14
+ sessionId: z.string().min(1),
15
+ hostSessionId: z.string().nullable(),
16
+ name: z.string().nullable(),
17
+ }),
18
+ eventIds: z.array(z.string().min(1)).min(1),
19
+ at: z.iso.datetime(),
20
+ status: z.enum(["submitting", "accepted", "ambiguous", "failed"]),
21
+ msgId: z.string().nullable(),
22
+ reason: z.string().nullable(),
23
+ })
24
+ .refine((a) => a.status !== "accepted" || Boolean(a.msgId?.trim()), "accepted attempt needs a receipt");
25
+ const DeliverySchema = z.looseObject({ schema: z.literal(1), attempts: z.array(AttemptSchema) });
26
+ export type DeliveryAttempt = z.infer<typeof AttemptSchema>;
27
+ export type DeliveryState = "pending" | "enqueue-accepted" | "ambiguous" | "collected";
28
+
29
+ export function readDelivery(dir: string): DeliveryAttempt[] {
30
+ const file = dispatchPaths(dir).delivery;
31
+ return existsSync(file)
32
+ ? readVersioned(file, DeliverySchema, 1, {
33
+ fix: "Use peek/result; delivery evidence is unreadable and retry may duplicate input.",
34
+ }).attempts
35
+ : [];
36
+ }
37
+
38
+ /** Short shared lock: distinct stalled/finished event claims must not lose each other's updates. */
39
+ export function writeDeliveryAttempt(dir: string, attempt: DeliveryAttempt): void {
40
+ const parsed = AttemptSchema.safeParse(attempt);
41
+ if (!parsed.success) throw new CatherdError("E_INPUT_INVALID", "Invalid delivery attempt");
42
+ const file = dispatchPaths(dir).delivery;
43
+ withFileLockSync(file, () => {
44
+ const document = existsSync(file)
45
+ ? readVersioned(file, DeliverySchema, 1)
46
+ : { schema: 1 as const, attempts: [] as DeliveryAttempt[] };
47
+ const prior = document.attempts.findIndex((a) => a.attemptId === attempt.attemptId);
48
+ if (prior < 0) document.attempts.push(parsed.data);
49
+ else {
50
+ const sent = document.attempts[prior]!;
51
+ if (
52
+ JSON.stringify(sent.target) !== JSON.stringify(parsed.data.target) ||
53
+ JSON.stringify(sent.eventIds) !== JSON.stringify(attempt.eventIds) ||
54
+ sent.at !== attempt.at
55
+ )
56
+ throw new CatherdError(
57
+ "E_INPUT_INVALID",
58
+ "Delivery target, events and submission time are immutable",
59
+ );
60
+ document.attempts[prior] = { ...sent, ...parsed.data };
61
+ }
62
+ writeJsonAtomic(file, document);
63
+ });
64
+ }
65
+
66
+ export function deliveryState(dir: string, target: HostSessionRef, eventId: string): DeliveryState {
67
+ try {
68
+ const event = JSON.parse(eventId) as unknown;
69
+ if (Array.isArray(event) && event[2] === "finished" && !awaitsCollect(dir)) return "collected";
70
+ } catch {
71
+ /* Invalid event text cannot imply collection. */
72
+ }
73
+ try {
74
+ const attempts = readDelivery(dir).filter(
75
+ (a) => sessionKey(a.target) === sessionKey(target) && a.eventIds.includes(eventId),
76
+ );
77
+ if (attempts.some((a) => a.status === "accepted")) return "enqueue-accepted";
78
+ if (attempts.some((a) => a.status === "submitting" || a.status === "ambiguous")) return "ambiguous";
79
+ return "pending";
80
+ } catch {
81
+ return "ambiguous";
82
+ }
83
+ }
@@ -40,6 +40,7 @@ export function dispatchPaths(dir: string) {
40
40
  failover: join(dir, "failover.json"),
41
41
  /** the message that announced this dispatch's record to its session, once sent (plan 10) */
42
42
  notified: join(dir, "notified.json"),
43
+ delivery: join(dir, "delivery.json"),
43
44
  /** written once by the supervisor when the worker went quiet for half its idle timeout, not busy (spec §3.6) */
44
45
  stall: join(dir, "stall.json"),
45
46
  /** the message that announced the stall, once sent */
package/src/infra/env.ts CHANGED
@@ -5,6 +5,9 @@
5
5
  * runs, a headless claude-code worker would inherit it): no process catherd starts gets them.
6
6
  */
7
7
  const SECRET_ENV = new Set([
8
+ "CODEX_THREAD_ID",
9
+ "CODEX_SESSION_ID",
10
+ "CATHERD_ORCHESTRATION_HOST",
8
11
  "TYPESAFE_API_KEY",
9
12
  // spec 1.2 §9: the user's Artificial Analysis key reads scores for catherd alone
10
13
  "ARTIFICIAL_ANALYSIS_API_KEY",
@@ -39,7 +42,7 @@ export function workerEnv(
39
42
  overrides: Record<string, string>,
40
43
  cwd: string,
41
44
  ): Record<string, string> {
42
- return { ...scrubSecrets(base), ...overrides, PWD: cwd };
45
+ return { ...scrubSecrets({ ...base, ...overrides }), PWD: cwd };
43
46
  }
44
47
 
45
48
  /** What a preflight check may see; everything else, credentials included, stays out (spec §4.7). */
@@ -0,0 +1,58 @@
1
+ import type { HostContext, KnownHost, OrchestrationHost } from "../domain/host.ts";
2
+ import { readSessionEnv, sessionFileFor } from "./claude-session.ts";
3
+
4
+ export function resolveHost(e: {
5
+ clientName?: string;
6
+ env: Record<string, string | undefined>;
7
+ terminalHost?: OrchestrationHost;
8
+ }): HostContext {
9
+ const conflict = (message: string): HostContext => ({ host: "unknown", session: null, conflict: message });
10
+ const hosts = new Set<KnownHost>();
11
+ const client = e.clientName;
12
+ if (client === "codex-mcp-client") hosts.add("codex");
13
+ if (client === "claude-code") hosts.add("claude-code");
14
+ const launcher = e.env.CATHERD_ORCHESTRATION_HOST;
15
+ if (launcher === "codex" || launcher === "claude-code") hosts.add(launcher);
16
+ const claude = readSessionEnv(e.env);
17
+ if (claude) hosts.add("claude-code");
18
+ // UUIDs are case-insensitive: one spelling, so the launcher's and a request's casing name the same thread
19
+ const thread = e.env.CODEX_THREAD_ID?.toLowerCase();
20
+ const session = e.env.CODEX_SESSION_ID?.toLowerCase();
21
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ if (thread !== undefined || session !== undefined) {
23
+ hosts.add("codex");
24
+ if ((thread !== undefined && !uuid.test(thread)) || (session !== undefined && !uuid.test(session)))
25
+ return conflict("Codex session IDs must be UUIDs; remove invalid CODEX_THREAD_ID / CODEX_SESSION_ID");
26
+ if (thread && session && thread !== session)
27
+ return conflict("CODEX_THREAD_ID and CODEX_SESSION_ID disagree; correct the launcher environment");
28
+ }
29
+ if (hosts.size > 1)
30
+ return conflict(
31
+ "Initialized client and launcher session evidence disagree; remove the other host's environment",
32
+ );
33
+ const host = [...hosts][0] ?? e.terminalHost ?? "unknown";
34
+ if (host === "codex")
35
+ return {
36
+ host,
37
+ session:
38
+ thread || session ? { host, sessionId: thread || session!, hostSessionId: null, name: null } : null,
39
+ conflict: null,
40
+ };
41
+ if (host === "claude-code" && claude) {
42
+ const file = sessionFileFor(claude);
43
+ const id = file?.sessionId ?? claude.sessionId;
44
+ return {
45
+ host,
46
+ session: id
47
+ ? {
48
+ host,
49
+ sessionId: id,
50
+ hostSessionId: file?.hostSessionId ?? claude.hostSessionId,
51
+ name: file?.name ?? null,
52
+ }
53
+ : null,
54
+ conflict: null,
55
+ };
56
+ }
57
+ return { host, session: null, conflict: null };
58
+ }
@@ -51,10 +51,12 @@ export function sendToInbox(
51
51
  const socketPath = target.socketPath;
52
52
  return new Promise<SendResult>((resolve) => {
53
53
  let settled = false;
54
+ // once the frame is being written a failure may still have delivered it: such a result carries its msg_id
55
+ let writing = false;
54
56
  const done = (r: SendResult) => {
55
57
  if (settled) return;
56
58
  settled = true;
57
- resolve(r);
59
+ resolve(writing && r.outcome === "error" ? { ...r, msgId } : r);
58
60
  };
59
61
  let s: ReturnType<typeof createConnection>;
60
62
  try {
@@ -77,6 +79,7 @@ export function sendToInbox(
77
79
  });
78
80
  s.on("connect", () => {
79
81
  clearTimeout(timer);
82
+ writing = true;
80
83
  s.write(frameText(content, priority, target.token, msgId), (err) => {
81
84
  if (err) {
82
85
  s.destroy();
@@ -1,3 +1,4 @@
1
+ import { scrubSecrets } from "./env.ts";
1
2
  import type { Subprocess } from "bun";
2
3
  import { appendFileSync, closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
3
4
  import { z } from "zod";
@@ -139,7 +140,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
139
140
  fds.push(stderr);
140
141
  child = Bun.spawn([spec.cmd, ...spec.args], {
141
142
  cwd: spec.cwd,
142
- env: spec.env,
143
+ env: scrubSecrets(spec.env),
143
144
  stdin,
144
145
  stdout,
145
146
  stderr,
@@ -149,7 +150,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
149
150
  backend: spec.backend,
150
151
  dispatch: spec.dispatchDir,
151
152
  argv: [spec.cmd, ...spec.args],
152
- env: spec.env,
153
+ env: scrubSecrets(spec.env),
153
154
  cwd: spec.cwd,
154
155
  pid: child.pid,
155
156
  });
@@ -1,3 +1,4 @@
1
+ import type { KnownHost } from "../domain/host.ts";
1
2
  import { appendFileSync, existsSync, readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import type { BackendAdapter } from "../adapters/backend.ts";
@@ -45,6 +46,7 @@ export interface AdmitInput {
45
46
  failoverOf?: string;
46
47
  /** the dispatching session (spec §3.3); absent means the session this process serves */
47
48
  sessionId?: string | null;
49
+ host?: KnownHost;
48
50
  }
49
51
 
50
52
  /** Spec §3.3: SIGTERM, then SIGKILL this long after. */
@@ -205,7 +207,6 @@ export async function admit(
205
207
  });
206
208
 
207
209
  await finalizeFinished(run, deps.now(), onRecorded);
208
- const sessionId = i.sessionId !== undefined ? i.sessionId : (currentSession(deps)?.sessionId ?? null);
209
210
  return withFileLock(runPaths(run.dir).admission, async () => {
210
211
  // A dispatch blocks until its record is written, not only while it runs: its finalizer diffs the
211
212
  // tree after the exit, so a later dispatch's writes must not land in between. A finished one here
@@ -245,6 +246,9 @@ export async function admit(
245
246
  throw new CatherdError("E_RUN_BUDGET", `the run budget is spent: ${formatBudget(budget)}`, {
246
247
  fix: "ask the user to raise profile.budget, or finish the run with what landed",
247
248
  });
249
+ const before = await statusSnapshot(run.meta.repo);
250
+ const session = currentSession(deps);
251
+ const sessionId = i.sessionId !== undefined ? i.sessionId : (session?.sessionId ?? null);
248
252
  const admitted: Admit = {
249
253
  schema: 1,
250
254
  runId: run.id,
@@ -265,8 +269,8 @@ export async function admit(
265
269
  cliVersion: probe.version,
266
270
  admittedAt: new Date(deps.now()).toISOString(),
267
271
  repo: run.meta.repo,
268
- before: await statusSnapshot(run.meta.repo),
269
- ...(sessionId ? { sessionId } : {}),
272
+ before,
273
+ ...(sessionId ? { sessionId, host: i.host ?? session?.host ?? "claude-code" } : {}),
270
274
  };
271
275
  ensurePrivateDir(dir);
272
276
  // spec 1.1 §6: every brief ends with its role's reply contract, failover stand-ins' included
@@ -1,3 +1,4 @@
1
+ import type { OrchestrationHost } from "../domain/host.ts";
1
2
  import {
2
3
  existsSync,
3
4
  lstatSync,
@@ -44,10 +45,10 @@ export interface Planned {
44
45
  links: Map<string, string>;
45
46
  }
46
47
 
47
- export function plan(extra: string[] = []): Planned {
48
+ export function plan(host: OrchestrationHost, extra: string[] = [], selected?: string[]): Planned {
48
49
  const files = new Map<string, AgentFile[]>();
49
- for (const name of new Set([...linkedProfiles(), ...extra.filter(profileExists)]))
50
- files.set(name, agentFiles(getProfile(name), VERSION));
50
+ for (const name of new Set(selected ?? [...linkedProfiles(), ...extra.filter(profileExists)]))
51
+ files.set(name, agentFiles(getProfile(name, host), VERSION));
51
52
  const links = new Map<string, string>();
52
53
  for (const name of linkedProfiles())
53
54
  for (const f of files.get(name) ?? [])
@@ -71,6 +72,27 @@ export interface Synced {
71
72
  newSessionNeededFor: string[];
72
73
  }
73
74
 
75
+ export function pruneProfileAgents(name: string): Synced {
76
+ const dir = join(agentsRoot(), name);
77
+ const pruned: string[] = [];
78
+ const target = claudeAgentsDir();
79
+ if (existsSync(target))
80
+ for (const entry of readdirSync(target)) {
81
+ const link = join(target, entry);
82
+ if (isOurLink(link) && readlinkSync(link).startsWith(`${dir}${sep}`)) {
83
+ rmSync(link);
84
+ pruned.push(entry);
85
+ }
86
+ }
87
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
88
+ pruned.sort();
89
+ return {
90
+ linked: [],
91
+ pruned,
92
+ newSessionNeededFor: pruned.map((entry) => basename(entry, ".md")),
93
+ };
94
+ }
95
+
74
96
  /**
75
97
  * Writes the planned agent files and links, prunes catherd's stale ones, and says what changed. The
76
98
  * ownership check runs before the first write, so no caller can replace a file catherd does not own.
@@ -122,8 +144,11 @@ export function apply(p: Planned, removed: string[] = []): Synced {
122
144
  }
123
145
 
124
146
  /** What `doctor` compares: each link that should exist, and whether it and its file are current. */
125
- export function agentLinkState(): { missing: string[]; stale: string[]; ok: string[] } {
126
- const p = plan();
147
+ export function agentLinkState(
148
+ host: OrchestrationHost,
149
+ selected?: string[],
150
+ ): { missing: string[]; stale: string[]; ok: string[] } {
151
+ const p = plan(host, [], selected);
127
152
  const out = { missing: [] as string[], stale: [] as string[], ok: [] as string[] };
128
153
  for (const [name, files] of p.files)
129
154
  for (const f of files) {
@@ -1,3 +1,5 @@
1
+ import type { OrchestrationHost } from "../domain/host.ts";
2
+ import { nativeClaudeIssue } from "../domain/profile-rules.ts";
1
3
  import type { BackendAdapter, Probe } from "../adapters/backend.ts";
2
4
  import { adapterFor } from "../adapters/registry.ts";
3
5
  import "../adapters/all.ts";
@@ -80,3 +82,8 @@ export function standInFor(failover: Record<string, string>, rung: string, repo?
80
82
  const byAdapter = adapterFor(r.backend)?.failoverFor?.(r, repo) ?? null;
81
83
  return byAdapter && formatRung(byAdapter);
82
84
  }
85
+
86
+ export function assertNativeHost(rung: string, host: OrchestrationHost): void {
87
+ const issue = nativeClaudeIssue(rung, host, "rung");
88
+ if (issue) throw new CatherdError("E_CONFIG_INVALID", `${issue.message}; ${issue.fix}`, { fix: issue.fix });
89
+ }
@@ -1,3 +1,4 @@
1
+ import { assertNativeHost } from "./backends.ts";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { CatherdError, errorMessage, isCatherdError } from "../domain/errors.ts";
3
4
  import { assertId, parseRung } from "../domain/ids.ts";
@@ -34,7 +35,7 @@ import { finalizeDispatch, finalizingElsewhere, waitForFinish } from "./finalize
34
35
  import type { Deps } from "./ports.ts";
35
36
  import { route } from "./lane-service.ts";
36
37
  import { findRun, readRecords, readRoutes, type Run } from "./run-store.ts";
37
- import { claimRun, ownsRun } from "./sessions.ts";
38
+ import { claimRun, ownsRun, runOwner } from "./sessions.ts";
38
39
  import { type NotesPatch, refreshState } from "./state.ts";
39
40
 
40
41
  export interface DispatchInput {
@@ -101,17 +102,21 @@ export interface Stalled {
101
102
  export const stallHooks = new Set<(s: Stalled) => void>();
102
103
 
103
104
  /** A watcher's poll: the first time the dispatch's stall.json is there, every stall hook hears of it. */
105
+ /** A stall's quiet time; 0 when stall.json is being written or damaged, so one bad file never blocks a notice. */
106
+ export function readStallQuietMs(dir: string): number {
107
+ try {
108
+ return Number(JSON.parse(readFileSync(dispatchPaths(dir).stall, "utf8")).quietMs) || 0;
109
+ } catch {
110
+ return 0;
111
+ }
112
+ }
113
+
104
114
  export function stallPoll(run: Run, d: Dispatch): () => void {
105
115
  let seen = false;
106
116
  return () => {
107
117
  if (seen || !existsSync(dispatchPaths(d.dir).stall)) return;
108
118
  seen = true;
109
- let quietMs = 0;
110
- try {
111
- quietMs = Number(JSON.parse(readFileSync(dispatchPaths(d.dir).stall, "utf8")).quietMs) || 0;
112
- } catch {
113
- // being written: the stall is reported without its length
114
- }
119
+ const quietMs = readStallQuietMs(d.dir);
115
120
  for (const hook of stallHooks) {
116
121
  try {
117
122
  hook({ run, d, quietMs });
@@ -294,6 +299,8 @@ export async function dispatch(deps: Deps, i: DispatchInput): Promise<DispatchSt
294
299
  hints.push(`${i.rung} is not on ${i.lane}'s routed ladder: dispatched at ${routed.rung}`);
295
300
  }
296
301
  }
302
+ // the rung that runs, after routing: an off-ladder native rung routing replaced is never launched
303
+ assertNativeHost(rung, deps.host.host);
297
304
  const { d, specPath } = await admit(
298
305
  deps,
299
306
  run,
@@ -493,6 +500,8 @@ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord):
493
500
  // else that stand-in never ran and is over (recorded as lost, or past its start grace): admit a new one
494
501
  const standIn = standInFor(deps.profiles.forRepo(run.meta.repo).failover, limited.rung, run.meta.repo);
495
502
  if (!standIn) return { hints, started: null, pause: paused };
503
+ // the stand-in answers to whoever owns the run now, which a claim from another host may have changed
504
+ assertNativeHost(standIn, runOwner(run)?.host ?? deps.host.host);
496
505
  if (parseRung(standIn).backend === "claude") {
497
506
  const agent = deps.profiles.agentFor(run.meta.repo, d.admit.role, standIn);
498
507
  return {
@@ -517,6 +526,7 @@ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord):
517
526
  failoverOf: d.admit.dispatchId,
518
527
  // the stand-in answers to the session that dispatched the limited role, whoever fails it over
519
528
  sessionId: d.admit.sessionId ?? null,
529
+ host: d.admit.host,
520
530
  });
521
531
  } catch (e) {
522
532
  if (!isCatherdError(e)) throw e;
@@ -36,6 +36,7 @@ const AdmitSchema = z.looseObject({
36
36
  before: z.record(z.string(), z.string()),
37
37
  /** the Claude Code session that dispatched it (spec §3.3); absent on 1.0 dispatches and outside Claude Code */
38
38
  sessionId: z.string().optional(),
39
+ host: z.enum(["claude-code", "codex"]).optional(),
39
40
  });
40
41
  export type Admit = z.infer<typeof AdmitSchema>;
41
42
 
@@ -65,7 +66,7 @@ export function listDispatches(run: Run): Dispatch[] {
65
66
  if (!id.isDirectory()) continue;
66
67
  const dir = join(roles, name.name, id.name);
67
68
  try {
68
- out.push({ dir, admit: readVersioned(admitPath(dir), AdmitSchema, 1) });
69
+ out.push({ dir, admit: decodeAdmit(readVersioned(admitPath(dir), AdmitSchema, 1)) });
69
70
  } catch {
70
71
  // admission never finished writing this folder
71
72
  }
@@ -174,3 +175,7 @@ export function readFailover(dir: string): FailoverFile | null {
174
175
  export function recordHints(run: Run, d: Dispatch, r: RunRecord): string[] {
175
176
  return readFailover(d.dir)?.hints ?? dispatchHints(r, d.admit.owns, relative(run.dir, d.dir));
176
177
  }
178
+
179
+ function decodeAdmit(a: Admit): Admit {
180
+ return a.sessionId && !a.host ? { ...a, host: "claude-code" } : a;
181
+ }