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
@@ -7,7 +7,9 @@ description: Use when the user wants to tune catherd — which models and effort
7
7
 
8
8
  You tune the user's catherd profile in conversation. A profile says, per role, which rungs it may run on (`backend:model#effort`, in ladder order) and what it may touch (its access mode); whether routing favours cost or speed; how each backend is billed; whether Jev picks the rung; which rung stands in when a backend hits its usage limit; the run budget and timeouts; whether each vendor harness runs with the user's customizations or isolated; how many heavy commands run at once; and when to push.
9
9
 
10
- **You never edit a file by hand.** `profile_set` is the only writer, so this conversation, the `catherd profile` commands and the TUI cannot drift apart.
10
+ **You never edit a file by hand.** Use `profile_set` for patches and the existing reviewed CLI reset below; both go through ProfileService, as do the `catherd profile` commands and the TUI.
11
+
12
+ **Discover the host first with `status()`.** Use the host's available MCP discovery facility; names and deferred tools differ. Claude Code may use `ToolSearch`; Codex never needs invented Claude tools or native Claude agents. Pass the actual project `repo` explicitly to profile, setup and catalog calls that accept it: native Codex starts the MCP server in the installed plugin root, not the project. An unknown terminal can use `catherd init --host codex` or `--host claude-code` for setup; this does not create a conversation owner. Conflicting identity must be resolved before ownership or push.
11
13
 
12
14
  ## Rules
13
15
 
@@ -21,17 +23,19 @@ You tune the user's catherd profile in conversation. A profile says, per role, w
21
23
  Ask these, one at a time, each with its recommended answer:
22
24
 
23
25
  1. **Their order of speed, cost and quality.** Recommend cost first, the default `objective`: catherd climbs a rung when a cheap one cannot do the work, so the lanes that need speed get it anyway, and the reviewer and the verifier hold quality either way.
24
- 2. **The subscriptions they hold:** a ChatGPT plan (Codex), a Claude plan, OpenCode Go, Zen credit or API keys. They set `billing` per key (`codex`, `claude`, `claude-code`, `opencode-go`, `opencode`, `cursor`, `grok`, `antigravity`): `chatgpt-plan`, `claude-plan`, `subscription` or `metered`. Recommend leaning on subscriptions before metered spend, and on Claude last among the workers, since it spends the same quota as this conversation.
26
+ 2. **The subscriptions they hold:** a ChatGPT plan (Codex), a Claude plan, OpenCode Go, Zen credit or API keys. They set `billing` per key (`codex`, `claude`, `claude-code`, `opencode-go`, `opencode`, `cursor`, `grok`, `antigravity`): `chatgpt-plan`, `claude-plan`, `subscription` or `metered`. Recommend leaning on subscriptions before metered spend. Explain which workers share this conversation's quota from the actual host and billing facts.
25
27
  3. **The kind of work they orchestrate:** front-end screens, back-end services, terminal and ops work, docs. Recommend from their own runs when `runs_summary` has any.
26
28
 
27
29
  ## 2. Read the facts before proposing
28
30
 
29
31
  In one message, call:
30
32
 
31
- - `catalog_query({ role: "<role>" })` for each role you will discuss: the models that can fill it, their scored rungs, any "treat like", each rung's cost under their billing, and whether their backend's last listing offers it (`listed: false` means their account does not);
33
+ - `catalog_query({ repo, role: "<role>" })` for each role you will discuss: the models that can fill it, their scored rungs, any "treat like", each rung's cost under their billing, and whether their backend's last listing offers it (`listed: false` means their account does not);
32
34
  - `runs_summary({})`: how each rung has done on their own runs (runs, refusals, climbs, time) and the harness cost line, for claude-code and opencode only: Codex reports no per-request input, so it has no harness figure;
33
35
  - `profile_get({ repo })`: where they stand now, with each role's `access` and `enforcement`. Pass the user's repo: without a name, the profile tools act on the profile this repo runs on (the one bound to it, else the active one), which `here` names; `active` is the global active profile.
34
36
 
37
+ Verify the exact omitted Codex model ID `gpt-6.1-sol` against native catalog discovery and validation. If its spelling differs, report the mismatch and resolve it explicitly with the owner; never silently normalize or substitute a model. Retain architect high and verifier low effort.
38
+
35
39
  ## 3. Propose one decision at a time
36
40
 
37
41
  Go through these in order, and skip any the user does not care about:
@@ -40,7 +44,7 @@ Go through these in order, and skip any the user does not care about:
40
44
  2. `billing`, from step 1;
41
45
  3. the worker's `rungs` and its `defaultRung`;
42
46
  4. the reviewer and the UI reviewer;
43
- 5. the architect and the verifier, which spend Claude quota. A `claude:` rung runs as a native subagent in this session; a `claude-code:` rung runs headless through `dispatch`. Recommend native for these two;
47
+ 5. the architect and the verifier. Recommend omitted host defaults: on Codex, exactly `codex:gpt-6.1-sol#high` and `codex:gpt-6.1-sol#low`; on Claude Code, the existing native Claude defaults. A `claude:` rung requires a Claude Code host and its native `Agent`; `claude-code:` runs headless through `dispatch` on either host. Codex architect/verifier use process `dispatch`/`result`. Preserve explicit model IDs, efforts, ladder order and all other fields. Never silently convert `claude:` into `claude-code:`;
44
48
  6. the writer, the researcher and the artist;
45
49
  7. each role's `access` (below);
46
50
  8. `failover` (below);
@@ -48,6 +52,16 @@ Go through these in order, and skip any the user does not care about:
48
52
  10. harness isolation, per harness (below);
49
53
  11. `lock.heavy` and `notify`.
50
54
 
55
+ Existing materialized profiles remain unchanged on read, init, upgrade or host switch. To deliberately opt into host defaults, use the existing CLI from the project directory, naming the profile returned by `profile_get`:
56
+
57
+ ```sh
58
+ catherd profile reset-host-defaults <profile> --host codex --preview --json > /tmp/catherd-host-defaults-review.json
59
+ # Read the complete diff, effective defaults, errors and warnings before saving.
60
+ catherd profile reset-host-defaults <profile> --host codex --expect /tmp/catherd-host-defaults-review.json --json
61
+ ```
62
+
63
+ Use `--host claude-code` for that host. This reviewed reset removes only architect/verifier `rungs` and `defaultRung`, preserving access, enabled state, network, budget, isolation, billing, routing and failover. The `--expect` file guards against a profile changing after review. There is no MCP reset tool. Offer the exact headless model/effort equivalent as a separate explicit choice when a native Claude rung is incompatible with Codex.
64
+
51
65
  Each proposal has three parts: the change, a worked example from their facts, and the tradeoff in their terms. For instance: "Luna high on build lanes: about 5 min slower than Sol medium, no Claude quota, climbs on 1 in 5 of your runs so far."
52
66
 
53
67
  - Offer only the rungs `catalog_query` lists as capable for the role, written `backend:model#effort`.
@@ -59,6 +73,8 @@ Each proposal has three parts: the change, a worked example from their facts, an
59
73
 
60
74
  **Budget and timeouts.** `budget` (`minutes`, `tokens`, `usd`) is a soft cap: from 80 % routing starts at the cheapest rung that clears the bar, and at 100 % no new role starts. `timeouts.idleMin` (15) stops a role that has gone quiet, `timeouts.wallMin` (90) one that runs too long. `preflight.confirm: true` makes `preflight` show its commands for the user to approve first.
61
75
 
76
+ **Notifications and readiness.** `notify` controls milestone, finish and blocked moments through whatever notification facility the host actually exposes; Claude Code may have `PushNotification`. Do not invent a Codex tool or a scheduling promise. Completion uses Claude's peer inbox or Codex's existing native queue (`--remote unix://`); a Codex busy session receives ordered next input after its active turn, not urgent next-tool-round delivery. Queue acceptance proves neither processing nor `result` collection; unloaded/interrupted sessions may retain input. `catherd doctor --host codex` checks capability without sending; only explicit `--test-push` sends a labeled smoke input. Use the orchestration skill's `peek`/`result` recovery and current-owner, exact-event `runs retry-push` decision for ambiguity.
77
+
62
78
  **Harness isolation.** For each harness they use (`codex`, `claude-code`, `opencode`, `cursor`, `grok`, `antigravity`), offer `harness.<name>.isolated` with its harness line from `runs_summary` (Codex has none: it reports no per-request input, so say there is no figure for it instead of offering one) and this tradeoff: "native keeps your hooks, skills and AGENTS.md; isolated saves ~N tokens per run, but the role loses them." Recommend native. When they have no isolated runs yet, the number is the median first-turn input of their native runs: say that isolation would save some part of it, not all of it. When there are no runs at all, say there is no number yet, and recommend native until there is.
63
79
 
64
80
  ## 4. Write it
@@ -71,5 +87,5 @@ Each proposal has three parts: the change, a worked example from their facts, an
71
87
  ## 5. Say what applies when
72
88
 
73
89
  - Codex, claude-code, opencode, Cursor, grok and Antigravity changes, access and isolation included, apply at the next dispatch, even in a run already under way. Isolating Cursor needs `CURSOR_API_KEY` in the environment catherd runs in, isolating grok `XAI_API_KEY`, and isolating Antigravity `GEMINI_API_KEY`; `profile_validate` refuses each without. A read-only role (reviewer, architect, researcher) can run on Antigravity only isolated: agy has no read-only mode.
74
- - An agent listed in `newSessionNeededFor` applies from the next Claude Code session: Claude Code reads agent files when a session starts. Other Claude changes apply now.
75
- - Editing a profile that is not the active one writes its agent files but links none; they apply once it becomes active (`catherd profile use <name>`), or in a repo it is bound to (`catherd profile use <name> --repo`).
90
+ - For native Claude roles, an agent listed in `newSessionNeededFor` applies from the next Claude Code session: Claude Code reads agent files when a session starts. Other Claude changes apply now. `record_agent_run` accounts only for native Claude Agent results on Claude Code; process records are automatic.
91
+ - ProfileService manages Claude agent files/links only when native Claude roles are used. Codex-only setup and profile operations do not write under `~/.claude`; Claude CLI/login is needed only by an enabled selected role or reachable failover that uses it. Editing an inactive Claude-dependent profile writes its managed files but links none; they apply once active or repo-bound. Keep role-disabled, access, isolation, budget and failover choices intact.
@@ -1,3 +1,4 @@
1
+ import { scrubSecrets } from "../infra/env.ts";
1
2
  import { existsSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
2
3
  import { homedir, tmpdir } from "node:os";
3
4
  import { join } from "node:path";
@@ -53,7 +54,12 @@ export type ToolQuery = (bin: string, args: string[]) => string[];
53
54
 
54
55
  const queryTool: ToolQuery = (bin, args) => {
55
56
  try {
56
- const r = Bun.spawnSync([bin, ...args], { stdout: "pipe", stderr: "ignore", timeout: 5_000 });
57
+ const r = Bun.spawnSync([bin, ...args], {
58
+ env: scrubSecrets(process.env),
59
+ stdout: "pipe",
60
+ stderr: "ignore",
61
+ timeout: 5_000,
62
+ });
57
63
  return r.exitCode === 0
58
64
  ? r.stdout
59
65
  .toString()
@@ -1,67 +1,4 @@
1
- import { scrubSecrets } from "../infra/env.ts";
2
- import { log } from "../infra/log.ts";
3
- import { killGroup } from "../infra/proc.ts";
4
-
5
- export interface CliResult {
6
- ok: boolean;
7
- out: string;
8
- err: string;
9
- }
10
-
11
- /** Process groups of CLI calls still running: an exiting catherd (Ctrl-C is exit 130) kills them. */
12
- const live = new Set<number>();
13
- let hooked = false;
14
- function track(pid: number): void {
15
- live.add(pid);
16
- if (hooked) return;
17
- hooked = true;
18
- // a detached group gets no terminal SIGINT, and process.exit skips the timeout, so kill it on the way out
19
- process.on("exit", () => {
20
- for (const g of live) killGroup(g, "SIGKILL");
21
- });
22
- }
23
-
24
- /**
25
- * Runs `bin <args>` without catherd's secrets and with no stdin; null when `bin` is not on PATH. A call
26
- * past `timeoutMs` is killed with every process it started (it runs in its own process group) and is
27
- * reported as failed, so neither a wedged CLI nor a child holding its pipes stalls the caller.
28
- */
29
- export async function runCli(
30
- bin: string,
31
- args: string[],
32
- o: { timeoutMs: number; env?: Record<string, string>; cwd?: string },
33
- ): Promise<CliResult | null> {
34
- if (!Bun.which(bin, { PATH: process.env.PATH ?? "" })) return null;
35
- log("debug", "spawn", { argv: [bin, ...args], env: o.env ?? {} });
36
- const p = Bun.spawn([bin, ...args], {
37
- cwd: o.cwd,
38
- env: { ...scrubSecrets(process.env), ...o.env },
39
- stdin: "ignore",
40
- stdout: "pipe",
41
- stderr: "pipe",
42
- detached: true,
43
- });
44
- track(p.pid);
45
- let timer: ReturnType<typeof setTimeout> | undefined;
46
- const late = new Promise<null>((resolve) => {
47
- timer = setTimeout(() => resolve(null), o.timeoutMs);
48
- });
49
- try {
50
- const done = await Promise.race([
51
- Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]),
52
- late,
53
- ]);
54
- if (!done) {
55
- killGroup(p.pid, "SIGKILL");
56
- return { ok: false, out: "", err: `${bin} ${args.join(" ")} timed out after ${o.timeoutMs} ms` };
57
- }
58
- const [code, out, err] = done;
59
- return { ok: code === 0, out, err };
60
- } finally {
61
- clearTimeout(timer);
62
- live.delete(p.pid);
63
- }
64
- }
1
+ export { runCli, type CliResult } from "../infra/cli.ts";
65
2
 
66
3
  /** The JSON a CLI printed, or null for empty or unreadable output. */
67
4
  export function jsonOf(out: string | undefined): unknown {
package/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { HOST_ARG } from "./entry/host-arg.ts";
2
3
  import type { ArgsDef } from "citty";
3
4
  import { errorMessage } from "./domain/errors.ts";
4
5
  import { runtimeRefusal } from "./domain/runtime.ts";
@@ -12,7 +13,7 @@ if (refusal) {
12
13
 
13
14
  const { realpathSync } = await import("node:fs");
14
15
  const { fileURLToPath } = await import("node:url");
15
- const { defineCommand, renderUsage, runCommand } = await import("citty");
16
+ const { defineCommand, renderUsage, runCommand, parseArgs } = await import("citty");
16
17
  const { CatherdError, isCatherdError } = await import("./domain/errors.ts");
17
18
  const { EXIT, exitCodeOf, printError, stripAnsi } = await import("./entry/cli-kit.ts");
18
19
  const { VERSION } = await import("./infra/version.ts");
@@ -21,6 +22,7 @@ type Command = ReturnType<typeof defineCommand>;
21
22
 
22
23
  /** The dashboard's flags, typed as any command's args so `main` stays a plain `Command`. */
23
24
  const dashboardArgs: ArgsDef = {
25
+ ...HOST_ARG,
24
26
  plain: {
25
27
  type: "boolean",
26
28
  description: "the dashboard in ASCII without colour (NO_COLOR also drops colour)",
@@ -56,7 +58,7 @@ export const main: Command = defineCommand({
56
58
  },
57
59
  // citty runs this after any subcommand too; only a bare `catherd` loads and opens the dashboard
58
60
  async run(ctx) {
59
- if (!ctx.rawArgs.every((a) => a.startsWith("-"))) return;
61
+ if (ctx.args._.length > 0) return;
60
62
  await (await import("./entry/tui/run.tsx")).tuiRun(ctx);
61
63
  },
62
64
  });
@@ -69,14 +71,24 @@ async function commandFor(argv: string[]): Promise<[Command, Command | undefined
69
71
  let cmd = main;
70
72
  let parent: Command | undefined;
71
73
  const path: string[] = [];
72
- for (const a of argv) {
73
- if (a.startsWith("-")) continue;
74
+ let remaining = argv;
75
+ while (true) {
76
+ const defs = await resolve(cmd.args ?? {});
77
+ const navigationArgs: ArgsDef = Object.fromEntries(
78
+ Object.entries(defs).map(([key, def]) => [
79
+ key,
80
+ { ...def, required: false, ...(def.type === "enum" ? { type: "string" as const } : {}) },
81
+ ]),
82
+ ) as ArgsDef;
83
+ const a = parseArgs(remaining, navigationArgs)._[0];
84
+ if (!a) break;
74
85
  const subs = cmd.subCommands ? await resolve(cmd.subCommands) : undefined;
75
86
  const next = subs?.[a];
76
87
  if (!next) break;
77
88
  parent = cmd;
78
89
  cmd = (await resolve(next)) as Command;
79
90
  path.push(a);
91
+ remaining = remaining.slice(remaining.indexOf(a) + 1);
80
92
  }
81
93
  return [cmd, parent, path];
82
94
  }
@@ -123,22 +135,39 @@ export async function runCli(argv: string[]): Promise<number> {
123
135
  console.log(`${await helpText(cmd, path)}\n`);
124
136
  return EXIT.ok;
125
137
  }
126
- const command = own.find((a) => !a.startsWith("-"));
127
- // a bare `catherd` opens the dashboard, which takes only its own flags: a typo is not a terminal problem
128
- const flag = (a: string) => (a.startsWith("--") ? (a.slice(2).split("=")[0] ?? "") : "");
129
- // citty negates a boolean as `--no-<name>`
130
- const known = (name: string) =>
131
- Object.hasOwn(dashboardArgs, name) ||
132
- (name.startsWith("no-") && dashboardArgs[name.slice(3)]?.type === "boolean");
133
- const unknown = command === undefined ? own.find((a) => !known(flag(a))) : undefined;
134
- if (unknown !== undefined) {
135
- printError(new CatherdError("E_INPUT_INVALID", `unknown option ${unknown}`, { fix: "catherd --help" }));
136
- return EXIT.usage;
137
- }
138
- // `lock` forwards signals to its command itself; everything else stops at once on Ctrl-C. The command is
139
- // the first word that is not a flag: `catherd --plain lock -- …` is `lock` too.
140
- if (command !== "lock") process.once("SIGINT", () => process.exit(EXIT.interrupted));
141
138
  try {
139
+ const command = parseArgs(own, dashboardArgs)._[0];
140
+ // a bare `catherd` opens the dashboard, which takes only its own flags: a typo is not a terminal problem
141
+ const flag = (a: string) => (a.startsWith("--") ? (a.slice(2).split("=")[0] ?? "") : "");
142
+ // citty negates a boolean as `--no-<name>`
143
+ const known = (name: string) =>
144
+ Object.hasOwn(dashboardArgs, name) ||
145
+ (name.startsWith("no-") && dashboardArgs[name.slice(3)]?.type === "boolean");
146
+ const unknown =
147
+ command === undefined
148
+ ? own.find((a, i) => a.startsWith("-") && !known(flag(a)) && own[i - 1] !== "--host")
149
+ : undefined;
150
+ if (unknown !== undefined) {
151
+ printError(new CatherdError("E_INPUT_INVALID", `unknown option ${unknown}`, { fix: "catherd --help" }));
152
+ return EXIT.usage;
153
+ }
154
+ // `lock` forwards signals to its command itself; everything else stops at once on Ctrl-C. The command is
155
+ // the first word that is not a flag: `catherd --plain lock -- …` is `lock` too.
156
+ if (command !== "lock") process.once("SIGINT", () => process.exit(EXIT.interrupted));
157
+ const parsed = parseArgs(own, dashboardArgs);
158
+ if (parsed.host !== undefined) {
159
+ const forwarded = own.filter(
160
+ (a, i) => a !== "--host" && own[i - 1] !== "--host" && !a.startsWith("--host="),
161
+ );
162
+ rawArgs.splice(
163
+ 0,
164
+ rawArgs.length,
165
+ ...forwarded,
166
+ "--host",
167
+ String(parsed.host),
168
+ ...(dashdash < 0 ? [] : argv.slice(dashdash)),
169
+ );
170
+ }
142
171
  await runCommand(main, { rawArgs });
143
172
  return Number(process.exitCode ?? EXIT.ok);
144
173
  } catch (e) {
@@ -0,0 +1,17 @@
1
+ export type OrchestrationHost = "claude-code" | "codex" | "unknown";
2
+ export type KnownHost = Exclude<OrchestrationHost, "unknown">;
3
+ export interface HostSessionRef {
4
+ host: KnownHost;
5
+ sessionId: string;
6
+ hostSessionId: string | null;
7
+ name: string | null;
8
+ }
9
+ export interface HostContext {
10
+ host: OrchestrationHost;
11
+ session: HostSessionRef | null;
12
+ conflict: string | null;
13
+ }
14
+ export function sessionKey(s: Pick<HostSessionRef, "host" | "sessionId">): string {
15
+ // a Codex thread is a UUID, whatever its casing: an owner stored before normalization still matches
16
+ return JSON.stringify([s.host, s.host === "codex" ? s.sessionId.toLowerCase() : s.sessionId]);
17
+ }
@@ -9,6 +9,7 @@ export type NoticePriority = "later" | "next";
9
9
  /** One role's news: it finished (`record`), or an event while it runs (`stall`). */
10
10
  export interface Notice {
11
11
  kind: "finished" | "stalled";
12
+ eventId: string;
12
13
  runId: string;
13
14
  runTitle: string;
14
15
  dispatchId: string;
@@ -62,9 +63,11 @@ const neutral = (s: string) => s.replace(/(cross)-(session)-(message)/gi, "$1 $2
62
63
 
63
64
  function block(n: Notice): string {
64
65
  const call = `run: "${n.runId}", name: "${n.name}"`;
65
- if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})`;
66
+ if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})\nEvent: ${n.eventId}`;
66
67
  const reply = capNoticeReply(n.reply);
67
- return [noticeHeader(n), ...(reply ? [reply] : []), `Record: result(${call})`].join("\n");
68
+ return [noticeHeader(n), ...(reply ? [reply] : []), `Record: result(${call})`, `Event: ${n.eventId}`].join(
69
+ "\n",
70
+ );
68
71
  }
69
72
 
70
73
  /** The preview line of a message that carries several roles: `catherd · <title> · 3 roles finished: …`. */
@@ -1,3 +1,4 @@
1
+ import type { OrchestrationHost } from "./host.ts";
1
2
  import {
2
3
  type Catalog,
3
4
  type Dim,
@@ -145,6 +146,7 @@ export function validateProfile(
145
146
  c: Catalog,
146
147
  backends: readonly string[],
147
148
  doc?: ProfileDoc,
149
+ host: OrchestrationHost = "unknown",
148
150
  ): Validation {
149
151
  const errors: Issue[] = [];
150
152
  const warnings: Issue[] = [];
@@ -171,6 +173,8 @@ export function validateProfile(
171
173
  });
172
174
  if (!rc.enabled) continue;
173
175
  for (const rung of rc.rungs) {
176
+ const native = nativeClaudeIssue(rung, host, `${at}.rungs`);
177
+ if (native) errors.push(native);
174
178
  const r = tryParseRung(rung);
175
179
  if (!r) {
176
180
  errors.push({
@@ -274,6 +278,8 @@ export function validateProfile(
274
278
  });
275
279
  for (const [from, to] of Object.entries(p.failover)) {
276
280
  const at = `failover.${from}`;
281
+ const native = ladders.has(from) ? nativeClaudeIssue(to, host, at) : null;
282
+ if (native) errors.push(native);
277
283
  const a = tryParseRung(from);
278
284
  const b = tryParseRung(to);
279
285
  if (!a || !b) {
@@ -327,3 +333,14 @@ export function validateProfile(
327
333
  warnings.push({ path: x.path, message: standInMessage(x), fix: SUGGEST_FIX(x.rungs[0] as string) });
328
334
  return { errors, warnings };
329
335
  }
336
+
337
+ export function nativeClaudeIssue(rung: string, host: OrchestrationHost, path: string): Issue | null {
338
+ const r = tryParseRung(rung);
339
+ return host !== "claude-code" && r?.backend === "claude"
340
+ ? {
341
+ path,
342
+ message: `${rung}: native Claude subagents require the claude-code orchestration host`,
343
+ fix: `use claude-code:${r.model}#${r.effort} for headless execution, or --host claude-code`,
344
+ }
345
+ : null;
346
+ }
@@ -1,3 +1,4 @@
1
+ import type { OrchestrationHost } from "./host.ts";
1
2
  import { z } from "zod";
2
3
  import type { Budget } from "./budget.ts";
3
4
  import { BILLING_KEYS, type BillingKey } from "./catalog.ts";
@@ -229,7 +230,7 @@ export const PAIRED_FAILOVER: Record<string, string> = Object.fromEntries(
229
230
  /** The five billing keys spec §7.1 writes out; cursor and grok arrive with their backends. */
230
231
  const WRITTEN_BILLING: BillingKey[] = ["codex", "claude", "claude-code", "opencode-go", "opencode"];
231
232
 
232
- /** The full default profile document, every field written out: what `init` and `profile new` save. */
233
+ /** Stored defaults for init/new; architect/verifier rungs resolve from the current host. */
233
234
  export function defaultProfileDoc(name = "default"): ProfileDoc {
234
235
  return {
235
236
  schema: PROFILE_SCHEMA,
@@ -237,7 +238,7 @@ export function defaultProfileDoc(name = "default"): ProfileDoc {
237
238
  objective: "cost",
238
239
  jev: { use: "auto" },
239
240
  billing: Object.fromEntries(WRITTEN_BILLING.map((k) => [k, DEFAULT_BILLING[k]])),
240
- roles: structuredClone(BUILTIN_ROLES),
241
+ roles: hostDefaultsDoc({ schema: PROFILE_SCHEMA, roles: structuredClone(BUILTIN_ROLES) }).roles,
241
242
  harness: {
242
243
  codex: { isolated: false },
243
244
  "claude-code": { isolated: false },
@@ -252,12 +253,38 @@ export function defaultProfileDoc(name = "default"): ProfileDoc {
252
253
  };
253
254
  }
254
255
 
255
- /** Spec §7.1: every field a document leaves out takes its default; a missing role takes the built-in one. */
256
- export function resolveProfile(doc: ProfileDoc, name: string): Profile {
256
+ /** Remove only the four architect/verifier execution choices, preserving other stored fields. */
257
+ export function hostDefaultsDoc(doc: ProfileDoc): ProfileDoc {
258
+ const next = structuredClone(doc);
259
+ for (const role of ["architect", "verifier"] as const) {
260
+ const r = next.roles?.[role];
261
+ if (r) {
262
+ delete r.rungs;
263
+ delete r.defaultRung;
264
+ }
265
+ }
266
+ return next;
267
+ }
268
+
269
+ /** Resolve omitted role execution choices from the explicit orchestration host. */
270
+ export function resolveProfile(doc: ProfileDoc, name: string, host: OrchestrationHost): Profile {
257
271
  const roles = {} as Record<Role, RoleConfig>;
258
272
  for (const role of ROLES) {
259
273
  const d = doc.roles?.[role];
260
274
  const b = BUILTIN_ROLES[role];
275
+ let omitted = b.rungs;
276
+ if ((role === "architect" || role === "verifier") && d?.rungs === undefined) {
277
+ if (host === "unknown" && (d?.enabled ?? b.enabled))
278
+ throw new CatherdError("E_CONFIG_INVALID", `roles.${role}.rungs needs an orchestration host`, {
279
+ fix: "pass --host codex or --host claude-code, or set explicit role rungs",
280
+ });
281
+ omitted =
282
+ host === "codex"
283
+ ? [`codex:gpt-6.1-sol#${role === "architect" ? "high" : "low"}`]
284
+ : host === "unknown"
285
+ ? []
286
+ : b.rungs;
287
+ }
261
288
  // a role that lists its own rungs never inherits the built-in default rung, which may not be among them
262
289
  const defaultRung = d?.defaultRung ?? (d?.rungs === undefined ? b.defaultRung : undefined);
263
290
  roles[role] = {
@@ -268,7 +295,7 @@ export function resolveProfile(doc: ProfileDoc, name: string): Profile {
268
295
  : known(ACCESS, d.access)
269
296
  ? d.access
270
297
  : STORED_FALLBACK.access,
271
- rungs: [...(d?.rungs ?? b.rungs)],
298
+ rungs: [...(d?.rungs ?? omitted)],
272
299
  ...(defaultRung ? { defaultRung } : {}),
273
300
  ...(d?.network === false ? { network: false as const } : {}),
274
301
  };
@@ -75,6 +75,7 @@ export const RunRecordSchema = z.looseObject({
75
75
  replyPath: z.string(),
76
76
  /** the Claude Code session that dispatched it, copied from admit.json (spec §3.3); absent before 1.1 */
77
77
  sessionId: z.string().optional(),
78
+ host: z.enum(["claude-code", "codex"]).optional(),
78
79
  });
79
80
  export type RunRecord = z.infer<typeof RunRecordSchema>;
80
81
 
@@ -1,3 +1,4 @@
1
+ import { HOST_ARG, terminalHost } from "./host-arg.ts";
1
2
  import { defineCommand } from "citty";
2
3
  import { CatherdError, isCatherdError } from "../domain/errors.ts";
3
4
  import { ROLES, type Role } from "../domain/roles.ts";
@@ -194,6 +195,7 @@ const treatLike = defineCommand({
194
195
  "Score an unscored rung as a scored one; --suggest ranks stand-ins, --clear and --reset remove your mappings",
195
196
  },
196
197
  args: {
198
+ ...HOST_ARG,
197
199
  rung: {
198
200
  type: "positional",
199
201
  required: false,
@@ -228,14 +230,14 @@ const treatLike = defineCommand({
228
230
  return;
229
231
  }
230
232
  if (args.clear !== undefined) {
231
- const r = await clearTreatLike(args.clear);
233
+ const r = await clearTreatLike(args.clear, terminalHost(args.host).host);
232
234
  if (args.json) return console.log(JSON.stringify(r, null, 2));
233
235
  for (const l of leftLines(r.left)) console.log(l);
234
236
  console.log(`${mark("ok")} ${r.rung} is no longer treated like ${r.like}`);
235
237
  return;
236
238
  }
237
239
  if (args.reset) {
238
- const r = await resetTreatLikes();
240
+ const r = await resetTreatLikes(terminalHost(args.host).host);
239
241
  if (args.json) return console.log(JSON.stringify(r, null, 2));
240
242
  for (const l of leftLines(r.left)) console.log(l);
241
243
  console.log(
package/src/entry/deps.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { HostContext } from "../domain/host.ts";
1
2
  import { readSessionEnv } from "../infra/claude-session.ts";
2
3
  import { VERSION } from "../infra/version.ts";
3
4
  import type { Deps } from "../services/ports.ts";
@@ -5,13 +6,15 @@ import { profileService } from "../services/profile-service.ts";
5
6
  import { routingService } from "../services/routing-service.ts";
6
7
 
7
8
  /** The services every entry point (MCP server, CLI commands) runs on. */
8
- export function defaultDeps(): Deps {
9
- return {
10
- profiles: profileService(),
9
+ export function defaultDeps(host: HostContext = { host: "unknown", session: null, conflict: null }): Deps {
10
+ const deps: Deps = {
11
+ host,
12
+ profiles: profileService(() => deps.host),
11
13
  routing: routingService(),
12
14
  version: VERSION,
13
15
  pollMs: 250,
14
16
  session: readSessionEnv(process.env),
15
17
  now: Date.now,
16
18
  };
19
+ return deps;
17
20
  }
@@ -1,7 +1,8 @@
1
+ import { gitToplevel } from "../infra/git.ts";
2
+ import { HOST_ARG, terminalHost } from "./host-arg.ts";
1
3
  import { defineCommand } from "citty";
2
4
  import { VERSION } from "../infra/version.ts";
3
5
  import { type DoctorReport, doctor } from "../services/doctor.ts";
4
- import { probePush } from "../services/doctor-push.ts";
5
6
  import type { Check } from "../services/doctor-checks.ts";
6
7
  import { EXIT, JSON_ARG, mark, printJson } from "./cli-kit.ts";
7
8
  import { mcpHandshake } from "./mcp/handshake.ts";
@@ -34,15 +35,23 @@ export const doctorCommand = defineCommand({
34
35
  description: "Readiness report: Bun, backends, Jev, the plugin, agents, the MCP server, locks",
35
36
  },
36
37
  args: {
38
+ ...HOST_ARG,
37
39
  ...JSON_ARG,
40
+ "test-push": {
41
+ type: "boolean",
42
+ description:
43
+ "send one labeled smoke to the validated original session (receipt does not prove processing)",
44
+ },
38
45
  plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
39
46
  },
40
47
  async run({ args }) {
41
48
  const r = await doctor({
49
+ host: terminalHost(args.host),
50
+ repo: await gitToplevel(process.cwd()),
42
51
  bunVersion: Bun.version,
43
52
  version: VERSION,
44
53
  handshake: () => mcpHandshake(),
45
- push: () => probePush(),
54
+ testPush: args["test-push"] === true,
46
55
  });
47
56
  if (args.json) printJson(r);
48
57
  else for (const l of formatReport(r, args.plain === true)) console.log(l);
@@ -0,0 +1,28 @@
1
+ import { CatherdError } from "../domain/errors.ts";
2
+ import type { HostContext } from "../domain/host.ts";
3
+ import { resolveHost } from "../infra/host-context.ts";
4
+
5
+ export const HOST_ARG = {
6
+ host: {
7
+ type: "enum" as const,
8
+ options: ["auto", "codex", "claude-code"],
9
+ description: "orchestration host (auto uses session evidence)",
10
+ },
11
+ };
12
+
13
+ export function terminalHost(value: string | undefined, env = process.env): HostContext {
14
+ if (value === undefined || value === "auto") return resolveHost({ env });
15
+ if (value !== "codex" && value !== "claude-code")
16
+ throw new CatherdError("E_INPUT_INVALID", `unknown host ${value}`, {
17
+ fix: "use --host auto, codex or claude-code",
18
+ });
19
+ const resolved = resolveHost({ env, terminalHost: value });
20
+ if (resolved.conflict) return resolved;
21
+ if (resolved.host !== value)
22
+ return {
23
+ host: "unknown",
24
+ session: null,
25
+ conflict: "Terminal host and launcher session evidence disagree; remove the other host’s environment",
26
+ };
27
+ return { ...resolved, session: null };
28
+ }
@@ -1,3 +1,5 @@
1
+ import { gitToplevel } from "../infra/git.ts";
2
+ import { HOST_ARG, terminalHost } from "./host-arg.ts";
1
3
  import { defineCommand } from "citty";
2
4
  import { assertProfileName } from "../domain/profile.ts";
3
5
  import { errorMessage, isCatherdError } from "../domain/errors.ts";
@@ -11,6 +13,7 @@ import { type SyncReport, syncSources } from "../services/source-sync.ts";
11
13
  import { reinstallCommand } from "../services/doctor-checks.ts";
12
14
  import { ensureGlobal, type GlobalInstallDeps, realGlobalInstall } from "../services/global-install.ts";
13
15
  import { hasProfileFile, type InitResult, initSetup, moveLegacy } from "../services/setup.ts";
16
+ import { activeName } from "../services/profile-store.ts";
14
17
  import { formatRefreshed, syncLines } from "./catalog-command.ts";
15
18
  import { mark as markOf } from "./cli-kit.ts";
16
19
  import { formatReport } from "./doctor-command.ts";
@@ -195,6 +198,7 @@ export const initCommand = defineCommand({
195
198
  "First run: the global catherd command, the Jev key, the Artificial Analysis key, a sync of the public model sources, the default profile, its agents, and a readiness report. Piped, it reads the answers from stdin one per line, a line per question even when this machine skips it (the Jev key, the Artificial Analysis key, the profile, whether to replace it), and waits for stdin to close; --no-input asks nothing",
196
199
  },
197
200
  args: {
201
+ ...HOST_ARG,
198
202
  // citty reads --no-input as input: false, whatever the flag is named; naming it no-input shows it as is
199
203
  "no-input": { type: "boolean", description: "ask nothing: keep what exists, else write the defaults" },
200
204
  // read as global: false, like no-input above
@@ -202,7 +206,10 @@ export const initCommand = defineCommand({
202
206
  type: "boolean",
203
207
  description: "do not install the global catherd command (bun add -g catherd-cli@<this version>)",
204
208
  },
205
- profile: { type: "string", description: "the profile to set up and make active (default: default)" },
209
+ profile: {
210
+ type: "string",
211
+ description: "the profile to set up and make active (default: current repo profile)",
212
+ },
206
213
  plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
207
214
  },
208
215
  async run({ args }) {
@@ -218,19 +225,21 @@ export const initCommand = defineCommand({
218
225
  await jevStep(ask, {}, plain);
219
226
  await aaStep(ask, {}, plain);
220
227
  await syncStep({ plain });
221
- if (args.profile !== undefined) ask?.skip?.();
222
- const name = assertProfileName(
223
- args.profile ?? (ask ? (await ask.ask("Profile to set up [default]: ")) || "default" : "default"),
224
- );
228
+ const repo = await gitToplevel(process.cwd());
225
229
  // 0.x files go first, so a legacy profile about to be moved aside is never asked about
226
230
  const moved = moveLegacy();
231
+ const current = activeName(repo);
232
+ if (args.profile !== undefined) ask?.skip?.();
233
+ const selected =
234
+ args.profile ?? (ask ? (await ask.ask(`Profile to set up [${current}]: `)) || undefined : undefined);
235
+ const name = assertProfileName(selected ?? current);
227
236
  const exists = hasProfileFile(name);
228
237
  if (!exists) ask?.skip?.();
229
238
  const overwrite =
230
239
  ask !== null &&
231
240
  exists &&
232
241
  /^y(es)?$/i.test(await ask.ask(`Replace profile ${name} with the default profile? [y/N] `));
233
- const r = await initSetup({ profile: name, overwrite });
242
+ const r = await initSetup({ profile: selected, repo, overwrite, host: terminalHost(args.host) });
234
243
  for (const f of [...moved, ...r.moved]) console.log(`${mark("ok")} moved a 0.x file aside: ${f}`);
235
244
  for (const l of profileLines(r, plain)) console.log(l);
236
245
  if (r.synced?.linked.length)
@@ -238,13 +247,15 @@ export const initCommand = defineCommand({
238
247
  for (const x of r.refreshed) console.log(formatRefreshed(x, plain));
239
248
  console.log("");
240
249
  const report = await doctor({
250
+ host: terminalHost(args.host),
251
+ repo,
241
252
  bunVersion: Bun.version,
242
253
  version: VERSION,
243
254
  handshake: () => mcpHandshake(),
244
255
  });
245
256
  for (const l of formatReport(report, plain)) console.log(l);
246
257
  console.log("");
247
- for (const l of PLUGIN_STEPS) console.log(l);
258
+ for (const l of terminalHost(args.host).host === "claude-code" ? PLUGIN_STEPS : []) console.log(l);
248
259
  } finally {
249
260
  ask?.close();
250
261
  }