catherd-cli 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +144 -42
  2. package/catalog/ATTRIBUTION.md +12 -4
  3. package/catalog/models.json +8 -0
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +11 -0
  7. package/plugin/.mcp-codex.json +10 -0
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +150 -79
  10. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  11. package/src/adapters/access.ts +7 -1
  12. package/src/adapters/antigravity/index.ts +18 -3
  13. package/src/adapters/backend.ts +18 -2
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/cli.ts +1 -64
  16. package/src/adapters/codex/events.ts +10 -0
  17. package/src/adapters/codex/index.ts +17 -5
  18. package/src/adapters/cursor/index.ts +7 -1
  19. package/src/adapters/grok/home.ts +75 -20
  20. package/src/adapters/grok/index.ts +27 -8
  21. package/src/adapters/opencode/index.ts +38 -1
  22. package/src/cli.ts +53 -19
  23. package/src/domain/agents.ts +3 -1
  24. package/src/domain/brief.ts +91 -0
  25. package/src/domain/calibration.ts +7 -0
  26. package/src/domain/catalog.ts +12 -1
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +8 -3
  29. package/src/domain/errors.ts +4 -0
  30. package/src/domain/failover.ts +17 -5
  31. package/src/domain/gate-brief.ts +52 -0
  32. package/src/domain/hints.ts +13 -2
  33. package/src/domain/host.ts +17 -0
  34. package/src/domain/lane.ts +99 -3
  35. package/src/domain/notice.ts +14 -4
  36. package/src/domain/profile-rules.ts +129 -16
  37. package/src/domain/profile.ts +56 -5
  38. package/src/domain/record.ts +17 -2
  39. package/src/domain/reply.ts +29 -0
  40. package/src/domain/role-prompts.ts +56 -16
  41. package/src/domain/role-scope.ts +106 -0
  42. package/src/domain/role-tools.ts +34 -0
  43. package/src/domain/route.ts +73 -0
  44. package/src/domain/select.ts +177 -25
  45. package/src/domain/state.ts +5 -0
  46. package/src/domain/util.ts +22 -0
  47. package/src/domain/workspace.ts +130 -0
  48. package/src/entry/catalog-command.ts +18 -8
  49. package/src/entry/cli-kit.ts +17 -1
  50. package/src/entry/deps.ts +8 -3
  51. package/src/entry/doctor-command.ts +29 -2
  52. package/src/entry/host-arg.ts +56 -0
  53. package/src/entry/init-command.ts +20 -8
  54. package/src/entry/knowledge-command.ts +78 -3
  55. package/src/entry/lock-command.ts +111 -10
  56. package/src/entry/mcp/dispatch-tools.ts +3 -3
  57. package/src/entry/mcp/handshake.ts +8 -2
  58. package/src/entry/mcp/lane-tools.ts +55 -8
  59. package/src/entry/mcp/protocol-tools.ts +10 -4
  60. package/src/entry/mcp/push-tools.ts +16 -0
  61. package/src/entry/mcp/role-bin.ts +20 -0
  62. package/src/entry/mcp/role-server.ts +116 -0
  63. package/src/entry/mcp/run-tools.ts +25 -6
  64. package/src/entry/mcp/server.ts +224 -44
  65. package/src/entry/mcp/setup-tools.ts +11 -5
  66. package/src/entry/mcp/workspace-tools.ts +142 -0
  67. package/src/entry/pause-command.ts +67 -0
  68. package/src/entry/profile-command.ts +131 -16
  69. package/src/entry/role-cli-command.ts +115 -0
  70. package/src/entry/runs-command.ts +157 -11
  71. package/src/entry/supervise-command.ts +30 -3
  72. package/src/entry/tui/effects.ts +23 -8
  73. package/src/entry/tui/fixtures.ts +33 -10
  74. package/src/entry/tui/run.tsx +10 -4
  75. package/src/entry/tui/state.ts +56 -16
  76. package/src/entry/tui/stories.ts +11 -4
  77. package/src/entry/tui/views/profile-actions.ts +4 -2
  78. package/src/entry/tui/views/profiles.tsx +3 -3
  79. package/src/entry/tui/views/runs.tsx +66 -20
  80. package/src/entry/tui/views/save-dialog.tsx +8 -4
  81. package/src/entry/tui/views/status.tsx +6 -2
  82. package/src/entry/workspace-command.ts +44 -0
  83. package/src/infra/boot-lock.ts +25 -0
  84. package/src/infra/cli.ts +65 -0
  85. package/src/infra/codex-queue.ts +165 -0
  86. package/src/infra/delivery.ts +86 -0
  87. package/src/infra/dispatch-dir.ts +3 -0
  88. package/src/infra/env.ts +15 -2
  89. package/src/infra/git.ts +6 -0
  90. package/src/infra/heavy-lock.ts +93 -4
  91. package/src/infra/host-context.ts +58 -0
  92. package/src/infra/host-probe.ts +16 -0
  93. package/src/infra/lock-activity.ts +88 -0
  94. package/src/infra/login-env.ts +48 -0
  95. package/src/infra/paths.ts +35 -0
  96. package/src/infra/peer-inbox.ts +4 -1
  97. package/src/infra/role-mcp.ts +69 -0
  98. package/src/infra/sources/arena.ts +11 -3
  99. package/src/infra/sources/artificial-analysis.ts +8 -1
  100. package/src/infra/sources/cache.ts +2 -0
  101. package/src/infra/supervisor.ts +110 -18
  102. package/src/services/admission.ts +117 -26
  103. package/src/services/agent-links.ts +30 -5
  104. package/src/services/backends.ts +7 -0
  105. package/src/services/budget.ts +26 -6
  106. package/src/services/catalog-service.ts +20 -12
  107. package/src/services/credentials.ts +11 -6
  108. package/src/services/dispatch-service.ts +220 -22
  109. package/src/services/dispatches.ts +36 -3
  110. package/src/services/doctor-access.ts +8 -3
  111. package/src/services/doctor-backends.ts +30 -7
  112. package/src/services/doctor-checks.ts +20 -5
  113. package/src/services/doctor-docker.ts +221 -0
  114. package/src/services/doctor-push.ts +69 -90
  115. package/src/services/doctor-role-mcp.ts +65 -0
  116. package/src/services/doctor.ts +172 -38
  117. package/src/services/end-push.ts +157 -0
  118. package/src/services/finalize.ts +13 -3
  119. package/src/services/gate-env.ts +218 -0
  120. package/src/services/gate-service.ts +216 -45
  121. package/src/services/global-install.ts +46 -13
  122. package/src/services/lane-edit.ts +154 -0
  123. package/src/services/lane-service.ts +258 -52
  124. package/src/services/milestones.ts +137 -26
  125. package/src/services/notifier.ts +377 -72
  126. package/src/services/orchestrator-wait.ts +82 -0
  127. package/src/services/pause.ts +161 -0
  128. package/src/services/peek.ts +122 -23
  129. package/src/services/ports.ts +39 -4
  130. package/src/services/preflight.ts +153 -19
  131. package/src/services/profile-service.ts +117 -30
  132. package/src/services/profile-store.ts +31 -7
  133. package/src/services/protocol.ts +119 -25
  134. package/src/services/provenance.ts +16 -12
  135. package/src/services/questions.ts +15 -0
  136. package/src/services/reconcile.ts +22 -1
  137. package/src/services/reentry.ts +7 -6
  138. package/src/services/role-access.ts +27 -0
  139. package/src/services/routing-service.ts +150 -37
  140. package/src/services/run-debug.ts +98 -4
  141. package/src/services/run-pin.ts +125 -0
  142. package/src/services/run-service.ts +153 -19
  143. package/src/services/run-store.ts +119 -19
  144. package/src/services/runs-page.ts +45 -9
  145. package/src/services/scratch.ts +60 -0
  146. package/src/services/session-view.ts +18 -13
  147. package/src/services/sessions.ts +34 -16
  148. package/src/services/setup.ts +31 -10
  149. package/src/services/source-derive.ts +28 -5
  150. package/src/services/source-sync.ts +9 -2
  151. package/src/services/standins.ts +40 -6
  152. package/src/services/state.ts +21 -0
  153. package/src/services/summary.ts +86 -11
  154. package/src/services/treat-likes.ts +41 -12
  155. package/src/services/verifier-brief.ts +23 -0
  156. package/src/services/verifier-step.ts +48 -0
  157. package/src/services/workspace-admission.ts +267 -0
  158. package/src/services/workspace-service.ts +254 -0
  159. package/src/services/workspace-store.ts +95 -0
@@ -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`.
@@ -57,7 +71,9 @@ Each proposal has three parts: the change, a worked example from their facts, an
57
71
 
58
72
  **Failover.** `failover` maps a rung to its stand-in when that rung's backend hits a usage limit. A stand-in must be scored and on another quota (Go and Zen bill apart; native `claude` and `claude-code` share the Claude plan). The default profile fails a Codex rung over only to a stand-in that clears the same bars at least as well: Go's GPT-6 Luna for Luna high, and Kimi K3 (its scores borrowed from Sol medium) for Sol medium. Sol high and xhigh have none, so a limit there pauses the lane rather than dropping it a tier. Say so, and offer to change it when they have no Go subscription. `null` removes an entry.
59
73
 
60
- **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.
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; `roles.<role>.timeouts.idleMin` and `wallMin` override them for one role (a verifier with a long gate), and `null` removes the override. `preflight.confirm: true` makes `preflight` show its commands for the user to approve first.
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.
61
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
 
@@ -70,6 +86,6 @@ Each proposal has three parts: the change, a worked example from their facts, an
70
86
 
71
87
  ## 5. Say what applies when
72
88
 
73
- - 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`).
89
+ - Codex, claude-code, opencode, Cursor, grok and Antigravity rung and ladder changes apply at the next dispatch, even in a run already under way. Access and isolation are pinned per run when it starts: a run under way keeps them (its `state.md` and `status` name the change) until its orchestrator re-pins it with `run_pin` (`catherd runs pin <run>`). 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.
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()
@@ -102,11 +102,20 @@ function plan(r: RunRequest): SpawnPlan {
102
102
  };
103
103
  }
104
104
 
105
- async function listModels(): Promise<DiscoveredModel[]> {
106
- const r = await runCli("agy", ["models"], { ...agyShell, env: ENV });
105
+ /** `agy models` under `env`: the user's HOME (its Google login) by default, an isolated one with the key. */
106
+ async function listAs(env: Record<string, string> = {}): Promise<DiscoveredModel[]> {
107
+ const r = await runCli("agy", ["models"], { ...agyShell, env: { ...ENV, ...env } });
107
108
  return r?.ok ? parseAgyModels(r.out) : [];
108
109
  }
109
110
 
111
+ const listModels = (): Promise<DiscoveredModel[]> => listAs();
112
+
113
+ /**
114
+ * The discovery cache an isolated run is judged by: the listing under catherd's isolated HOME, where
115
+ * GEMINI_API_KEY signs agy in, apart from the native login's (1.3 follow-ups: the two may serve differently).
116
+ */
117
+ export const ISOLATED_LISTING = "antigravity-isolated";
118
+
110
119
  /**
111
120
  * Spec 1.3 §6.3, §6.4, §9 Q2: read-only runs only isolated; an isolated run needs GEMINI_API_KEY and gets its
112
121
  * home and settings; a native one needs agy's own login; the rung's model and effort must be ones agy lists.
@@ -137,7 +146,13 @@ async function prepare(req: {
137
146
  { fix: LOGIN_FIX },
138
147
  );
139
148
  const { model, effort } = req.rung;
140
- const models = await discovered("antigravity", listModels, { maxAgeMs: DAY_MS, need: model });
149
+ // an isolated run signs in with the key, so its model is checked against the key's own listing
150
+ const models = req.isolated
151
+ ? await discovered(ISOLATED_LISTING, () => listAs(agyHomeEnv(req.access, req.network !== false)), {
152
+ maxAgeMs: DAY_MS,
153
+ need: model,
154
+ })
155
+ : await discovered("antigravity", listModels, { maxAgeMs: DAY_MS, need: model });
141
156
  if (models.length === 0) return; // agy listed nothing: let the run itself say what is wrong
142
157
  const m = models.find((x) => x.id === model);
143
158
  if (!m)
@@ -3,6 +3,7 @@ import type { ADAPTER_IDS, Rung } from "../domain/ids.ts";
3
3
  import type { BillingMode } from "../domain/cost.ts";
4
4
  import type { Access, ExitInfo, RunStatus, Tokens } from "../domain/record.ts";
5
5
  import type { CliResult } from "./cli.ts";
6
+ import type { RoleMcpContext } from "../domain/role-tools.ts";
6
7
  type AdapterId = (typeof ADAPTER_IDS)[number];
7
8
 
8
9
  export interface Probe {
@@ -43,6 +44,10 @@ export interface DiscoveredModel {
43
44
  export interface RunRequest {
44
45
  rung: Rung;
45
46
  access: Access;
47
+ /** Native catherd roles receive a dedicated, run-bound control-plane server. */
48
+ roleMcp?: RoleMcpContext;
49
+ /** spec 1.5 plan 21: the role's TMPDIR (`<run>/scratch/<name>`), a write root besides the repo */
50
+ scratch?: string;
46
51
  /** spec §5: a workspace-write role's network and loopback grants; false only when the profile says `network: false` */
47
52
  network?: boolean;
48
53
  thread: string | null;
@@ -82,6 +87,11 @@ export interface EventDelta {
82
87
  limit?: boolean;
83
88
  tooOld?: boolean;
84
89
  retrying?: boolean;
90
+ /**
91
+ * plan 23: a model step started (opencode's step_start). Two in a row with nothing between are a provider retry,
92
+ * which the supervisor never counts as progress
93
+ */
94
+ step?: boolean;
85
95
  /** the stream's terminal event: the supervisor may kill a CLI that lingers after it */
86
96
  final?: boolean;
87
97
  /** a tool call starting (`open`) or ending: while one is open the run is busy, however quiet */
@@ -147,14 +157,20 @@ export interface BackendAdapter {
147
157
  interrupt?(thread: string, cwd: string): Promise<void>;
148
158
  /** `sinceMs`: when this run started, so what an earlier run on the thread left behind does not count */
149
159
  isBusy?(thread: string, cwd: string, sinceMs?: number): Promise<boolean>;
160
+ /**
161
+ * Plan 23: the provider retry the session is waiting out, by its attempt number, when its newest message since
162
+ * `sinceMs` is retrying (a 503, an overloaded backend); null when it is not
163
+ */
164
+ providerRetry?(thread: string, cwd: string, sinceMs: number): Promise<number | null>;
150
165
  /** Spec §4.5: this backend's own stand-in for a rung on a usage limit, when the profile names none. */
151
166
  failoverFor?(rung: Rung, repo?: string): Rung | null;
152
167
  /**
153
168
  * Spec §5 and §12: a shell that runs a command the way this backend's workspace-write worker runs one,
154
169
  * with the grants the worker gets, for doctor's access probes; a string says why it cannot be tested here.
155
- * Absent: the CLI has no way to run a shell in its sandbox without a model turn (spec 1.3 §3.4).
170
+ * Absent: the CLI has no way to run a shell in its sandbox without a model turn (spec 1.3 §3.4). `repos`: the
171
+ * repositories workers run in (doctor's own and every bound one), for a backend whose sandbox is set per repo.
156
172
  */
157
- accessShell?(o: { network: boolean }): Promise<AccessShell | string>;
173
+ accessShell?(o: { network: boolean; repos?: string[] }): Promise<AccessShell | string>;
158
174
  /** Spec §10.3: why this backend's isolation is weak; doctor warns when a profile uses it. */
159
175
  isolationNote?: string;
160
176
  /**
@@ -16,6 +16,8 @@ import {
16
16
  import { readFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
  import { claudeHome } from "../../infra/paths.ts";
19
+ import { ROLE_MCP_SERVER, roleMcpTools } from "../../domain/role-tools.ts";
20
+ import { roleMcpConfig } from "../../infra/role-mcp.ts";
19
21
  import { dockerSocket, scratchShell, writableRoots } from "../access.ts";
20
22
  import { jsonOf, runCli } from "../cli.ts";
21
23
  import {
@@ -74,15 +76,21 @@ export const CLAUDE_ACCESS: Record<Access, string[]> = {
74
76
  * them Docker: the socket and `docker *` are network grants) and sets `allowLocalBinding: false`, so the
75
77
  * user's own `true` does not carry over (their allowedDomains and other array grants merge in and stay;
76
78
  * doctor says `network: false` is not enforced by claude-code's shell). With the user's sandbox on, `enabled: true`
77
- * goes in too, so a merge that replaces the whole `sandbox` object cannot turn it off.
79
+ * goes in too, so a merge that replaces the whole `sandbox` object cannot turn it off. A `repo` of null reads the
80
+ * user's settings only: a run that drops the project's settings files (`--setting-sources ""`).
78
81
  */
79
- export function claudeAccessArgs(access: Access, network = true, repo?: string): string[] {
82
+ export function claudeAccessArgs(
83
+ access: Access,
84
+ network = true,
85
+ repo?: string | null,
86
+ extra: string[] = [],
87
+ ): string[] {
80
88
  const base = CLAUDE_ACCESS[access];
81
89
  if (access !== "workspace-write") return base;
82
90
  const sock = dockerSocket();
83
91
  const sandbox = {
84
92
  ...(claudeSandboxOn(repo) ? { enabled: true } : {}),
85
- filesystem: { allowWrite: writableRoots() },
93
+ filesystem: { allowWrite: [...new Set([...writableRoots(), ...extra])] },
86
94
  ...(network
87
95
  ? {
88
96
  network: { allowLocalBinding: true, ...(sock ? { allowUnixSockets: [sock] } : {}) },
@@ -113,12 +121,14 @@ function sandboxEnabledIn(file: string): boolean | undefined {
113
121
  * Whether Claude Code's Bash sandbox is on for a worker in `repo` (`sandbox.enabled`): the most specific
114
122
  * settings file that says wins, as Claude Code layers them: the user's settings.json, then the project's
115
123
  * .claude/settings.json and .claude/settings.local.json (Claude Code has no user-level settings.local.json).
124
+ * A `repo` of null reads the user's settings.json only.
116
125
  */
117
- export function claudeSandboxOn(repo: string = process.cwd()): boolean {
126
+ export function claudeSandboxOn(repo: string | null = process.cwd()): boolean {
118
127
  const files = [
119
128
  join(claudeHome(), "settings.json"),
120
- join(repo, ".claude", "settings.json"),
121
- join(repo, ".claude", "settings.local.json"),
129
+ ...(repo === null
130
+ ? []
131
+ : [join(repo, ".claude", "settings.json"), join(repo, ".claude", "settings.local.json")]),
122
132
  ];
123
133
  return files.map(sandboxEnabledIn).findLast((on) => on !== undefined) ?? false;
124
134
  }
@@ -128,17 +138,114 @@ export function claudeSandboxOn(repo: string = process.cwd()): boolean {
128
138
  * shell, which doctor probes as is. With it on, only a model turn runs inside it, so doctor says what to
129
139
  * check instead of spending one.
130
140
  */
131
- async function accessShell(): Promise<AccessShell | string> {
132
- if (claudeSandboxOn())
133
- return "Claude Code's own sandbox is on (sandbox.enabled): catherd passes the lock, temp, loopback and Docker grants in --settings; outbound HTTPS reaches only sandbox.network.allowedDomains, so add registry.npmjs.org and the hosts your checks need there";
141
+ async function accessShell(o: { repos?: string[] } = {}): Promise<AccessShell | string> {
142
+ // the setting is per repo (its .claude/settings*.json over the user's): each repo a worker runs in counts,
143
+ // not only the directory doctor runs in
144
+ const repos = o.repos?.length ? o.repos : [process.cwd()];
145
+ const on = repos.filter((r) => claudeSandboxOn(r));
146
+ if (on.length)
147
+ return `Claude Code's own sandbox is on (sandbox.enabled)${o.repos?.length ? ` in ${on.join(", ")}` : ""}: catherd passes the lock, temp, loopback and Docker grants in --settings; outbound HTTPS reaches only sandbox.network.allowedDomains, so add registry.npmjs.org and the hosts your checks need there`;
134
148
  return scratchShell("an unsandboxed shell (Claude Code's sandbox is off)", []);
135
149
  }
136
150
 
151
+ /**
152
+ * An isolated run without the user's customizations. --bare would also drop OAuth, so a Claude plan could not
153
+ * log in; --safe-mode keeps auth, but drops every --mcp-config server too (Claude Code 2.1.287: "--mcp-config:
154
+ * … ignored (safe mode)"). A run with the role server (spec 1.5 plan 21, #42 finding 1) gets the same isolation
155
+ * piece by piece instead: only the --mcp-config servers, no settings file (so no hooks, no enabled plugins), no
156
+ * skills, CLAUDE.md and auto-memory off through the env, and the user's auth, provider, model, deny rules and
157
+ * allowed domains carried into --settings (carriedUserSettings).
158
+ */
159
+ /** The user settings keys --setting-sources "" would drop that a run needs: auth, provider and model. */
160
+ const CARRIED_SETTINGS = [
161
+ "apiKeyHelper",
162
+ "env",
163
+ "awsAuthRefresh",
164
+ "awsCredentialExport",
165
+ "gcpAuthRefresh",
166
+ "model",
167
+ ] as const;
168
+
169
+ /**
170
+ * What an isolated run with the role server keeps of the user's settings.json (plan 21 review, finding 1):
171
+ * --safe-mode keeps the settings files and drops only customizations, while `--setting-sources ""` drops the
172
+ * files, so a Bedrock, Vertex, gateway or apiKeyHelper login would fail. Carried: the auth and provider keys,
173
+ * `model`, `permissions.deny` and `sandbox.network`; never hooks, plugins, allow rules or the rest.
174
+ */
175
+ function carriedUserSettings(): Record<string, unknown> {
176
+ let user: Record<string, any>;
177
+ try {
178
+ user = JSON.parse(readFileSync(join(claudeHome(), "settings.json"), "utf8"));
179
+ } catch {
180
+ return {};
181
+ }
182
+ if (!user || typeof user !== "object") return {};
183
+ const out: Record<string, unknown> = {};
184
+ for (const k of CARRIED_SETTINGS) if (user[k] !== undefined) out[k] = user[k];
185
+ const deny = user.permissions?.deny;
186
+ if (Array.isArray(deny) && deny.length) out.permissions = { deny };
187
+ const network = user.sandbox?.network;
188
+ if (network && typeof network === "object") out.sandbox = { network };
189
+ return out;
190
+ }
191
+
192
+ /** `--settings` with the user's carried keys merged under catherd's own: catherd's sandbox grants win. */
193
+ function withCarriedSettings(args: string[]): string[] {
194
+ const carried = carriedUserSettings();
195
+ if (!Object.keys(carried).length) return args;
196
+ const at = args.indexOf("--settings");
197
+ const own: Record<string, any> = at >= 0 ? JSON.parse(args[at + 1] as string) : {};
198
+ const userNet: Record<string, unknown> = (carried.sandbox as any)?.network ?? {};
199
+ const ownNet: Record<string, unknown> = own.sandbox?.network ?? {};
200
+ const sockets = [
201
+ ...((userNet.allowUnixSockets as string[] | undefined) ?? []),
202
+ ...((ownNet.allowUnixSockets as string[] | undefined) ?? []),
203
+ ];
204
+ const sandbox =
205
+ carried.sandbox || own.sandbox
206
+ ? {
207
+ sandbox: {
208
+ ...own.sandbox,
209
+ network: {
210
+ ...userNet,
211
+ ...ownNet,
212
+ ...(sockets.length ? { allowUnixSockets: [...new Set(sockets)] } : {}),
213
+ },
214
+ },
215
+ }
216
+ : {};
217
+ const json = JSON.stringify({ ...carried, ...own, ...sandbox });
218
+ if (at < 0) return [...args, "--settings", json];
219
+ const out = [...args];
220
+ out[at + 1] = json;
221
+ return out;
222
+ }
223
+
224
+ function isolationArgs(r: RunRequest): string[] {
225
+ if (!r.roleMcp) return ["--safe-mode"];
226
+ return ["--strict-mcp-config", "--setting-sources", "", "--disable-slash-commands"];
227
+ }
228
+
137
229
  function plan(r: RunRequest): SpawnPlan {
138
230
  if (r.thread !== null && !THREAD.test(r.thread))
139
231
  throw new CatherdError("E_ADMIT_THREAD", `"${r.thread}" is not a Claude Code session id`, {
140
232
  fix: "pass the thread from the earlier RunRecord",
141
233
  });
234
+ // isolated with the role server, `--setting-sources ""` drops the project's settings (its network grants too),
235
+ // so only the user's carried settings.json decides whether the sandbox is on (PR #46)
236
+ const carried = r.isolated && !!r.roleMcp;
237
+ const access = claudeAccessArgs(r.access, r.network, carried ? null : r.repo, r.scratch ? [r.scratch] : []);
238
+ const accessArgs = carried ? withCarriedSettings(access) : [...access];
239
+ if (r.roleMcp) {
240
+ const tools = roleMcpTools(r.roleMcp.role).map((tool) => `mcp__${ROLE_MCP_SERVER}__${tool}`);
241
+ const allowed = accessArgs.indexOf("--allowedTools");
242
+ if (allowed >= 0) accessArgs[allowed + 1] = [accessArgs[allowed + 1], ...tools].join(",");
243
+ else accessArgs.push("--allowedTools", tools.join(","));
244
+ accessArgs.push(
245
+ "--mcp-config",
246
+ JSON.stringify({ mcpServers: { [ROLE_MCP_SERVER]: roleMcpConfig(r.roleMcp) } }),
247
+ );
248
+ }
142
249
  return {
143
250
  cmd: "claude",
144
251
  args: [
@@ -154,11 +261,14 @@ function plan(r: RunRequest): SpawnPlan {
154
261
  ...(r.thread === null ? ["--session-id", crypto.randomUUID()] : ["--resume", r.thread]),
155
262
  "--permission-prompts",
156
263
  "none",
157
- ...claudeAccessArgs(r.access, r.network, r.repo),
158
- // --bare would also drop OAuth, so a Claude plan could not log in; --safe-mode keeps auth
159
- ...(r.isolated ? ["--safe-mode"] : []),
264
+ ...accessArgs,
265
+ ...(r.isolated ? isolationArgs(r) : []),
160
266
  ],
161
- env: {},
267
+ // CLAUDE.md and auto-memory off, as --safe-mode turns them off (spec 1.5 plan 21)
268
+ env:
269
+ r.isolated && r.roleMcp
270
+ ? { CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1", CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1" }
271
+ : {},
162
272
  cwd: r.repo,
163
273
  stdinPath: r.briefPath,
164
274
  };
@@ -232,7 +342,9 @@ function claudeActivity(e: Record<string, any>): string | undefined {
232
342
  if (typeof input.command === "string") return `$ ${input.command}`;
233
343
  if (typeof input.file_path === "string")
234
344
  return `${EDIT_TOOLS.has(c.name) ? "edit" : String(c.name)} ${input.file_path}`;
235
- return String(c.name ?? "tool");
345
+ // another tool shows its first string argument, as opencode's activity does
346
+ const first = Object.values(input).find((v) => typeof v === "string");
347
+ return `${String(c.name ?? "tool")}${typeof first === "string" ? ` ${first}` : ""}`;
236
348
  }
237
349
  if (c?.type === "text" && typeof c.text === "string") return c.text;
238
350
  return undefined;
@@ -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 {
@@ -8,6 +8,8 @@ export interface CodexFold {
8
8
  limit: boolean;
9
9
  tooOld: boolean;
10
10
  lastEvent: string | null;
11
+ /** plan 22: the agent message that ended each completed turn, in order */
12
+ finals: string[];
11
13
  }
12
14
 
13
15
  /**
@@ -43,10 +45,18 @@ export function foldCodexEvents(lines: string[]): CodexFold {
43
45
  limit: false,
44
46
  tooOld: false,
45
47
  lastEvent: null,
48
+ finals: [],
46
49
  };
50
+ let said: string | null = null;
47
51
  for (const line of lines) {
48
52
  const e = parseCodexLine(line);
49
53
  if (!e) continue;
54
+ if (e.type === "item.completed" && e.item?.type === "agent_message" && typeof e.item.text === "string")
55
+ said = e.item.text;
56
+ if (e.type === "turn.completed" && said !== null) {
57
+ f.finals.push(said);
58
+ said = null;
59
+ }
50
60
  f.lastEvent = e.item?.type ? `${e.type}/${e.item.type}` : e.type;
51
61
  const msg: string = e.message ?? e.error?.message ?? e.item?.message ?? "";
52
62
  if (CODEX_TOO_OLD.some((r) => r.test(msg))) f.tooOld = true;
@@ -1,7 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { codexRoleMcpArgs } from "../../infra/role-mcp.ts";
3
4
  import { CatherdError } from "../../domain/errors.ts";
4
5
  import type { Access, RunStatus } from "../../domain/record.ts";
6
+ import { composeReply } from "../../domain/reply.ts";
5
7
  import {
6
8
  type AccessShell,
7
9
  type BackendAdapter,
@@ -57,9 +59,14 @@ export function userWritableRoots(): string[] {
57
59
  * The same `-c` overrides go to `codex sandbox` in doctor's probes, so doctor tests exactly what a worker gets. `-c …writable_roots=` replaces the
58
60
  * user's own roots, so theirs go in too; an isolated run ignores the user's config, so it has none.
59
61
  */
60
- export function codexGrants(access: Access, network = true, isolated = false): string[] {
62
+ export function codexGrants(
63
+ access: Access,
64
+ network = true,
65
+ isolated = false,
66
+ extra: string[] = [],
67
+ ): string[] {
61
68
  if (access !== "workspace-write") return [];
62
- const roots = [...new Set([...(isolated ? [] : userWritableRoots()), ...writableRoots()])];
69
+ const roots = [...new Set([...(isolated ? [] : userWritableRoots()), ...writableRoots(), ...extra])];
63
70
  return [
64
71
  "-c",
65
72
  `sandbox_workspace_write.network_access=${network}`,
@@ -89,7 +96,8 @@ function plan(r: RunRequest): SpawnPlan {
89
96
  "--json",
90
97
  "-o",
91
98
  r.replyPath,
92
- ...codexGrants(r.access, r.network, r.isolated),
99
+ ...codexGrants(r.access, r.network, r.isolated, r.scratch ? [r.scratch] : []),
100
+ ...(r.roleMcp ? codexRoleMcpArgs(r.roleMcp) : []),
93
101
  ];
94
102
  const sandbox = SANDBOX[r.access];
95
103
  const args =
@@ -125,6 +133,7 @@ function finalize(run: FinishedRun): Outcome {
125
133
  : "ok";
126
134
  const lastErr = run.stderr.trim().split("\n").at(-1) ?? "";
127
135
  const home = run.request.isolated ? isolatedCodexHomePath() : userCodexHome();
136
+ const reply = composeReply(f.finals, run.reply);
128
137
  return {
129
138
  status,
130
139
  thread: f.thread ?? run.request.thread,
@@ -138,6 +147,8 @@ function finalize(run: FinishedRun): Outcome {
138
147
  code: status,
139
148
  message: f.failure ?? (lastErr || `${stopped}, exit ${run.exit.code ?? run.exit.signal}`),
140
149
  },
150
+ // plan 22: -o keeps the last turn's message; a later turn never overwrites the report
151
+ ...(reply !== run.reply ? { reply } : {}),
141
152
  };
142
153
  }
143
154
 
@@ -243,7 +254,7 @@ function codexActivity(e: Record<string, any>): string | undefined {
243
254
  return `edit ${it.changes
244
255
  .map((c: { path?: unknown }) => c.path)
245
256
  .filter((p: unknown) => typeof p === "string")
246
- .join(", ")}`;
257
+ .join(", ")}`.trimEnd();
247
258
  if (it.type === "agent_message" && typeof it.text === "string") return it.text;
248
259
  return undefined;
249
260
  }
@@ -263,12 +274,13 @@ export const codexAdapter: BackendAdapter = {
263
274
  // Not a todo_list: update_plan opens one that only completes with the turn, and would mute the watchdog
264
275
  const id = typeof e.item?.id === "string" && CODEX_TOOL_ITEMS.has(e.item.type) ? e.item.id : null;
265
276
  const open = e.type === "item.started" ? true : e.type === "item.completed" ? false : null;
277
+ const activity = codexActivity(e);
266
278
  return {
267
279
  ...(f.thread ? { thread: f.thread } : {}),
268
280
  ...(id !== null && open !== null ? { item: { id, open } } : {}),
269
281
  ...(e.type === "turn.completed" ? { tokens: f.tokens } : {}),
270
282
  lastEvent: f.lastEvent ?? undefined,
271
- ...(codexActivity(e) ? { activity: codexActivity(e) } : {}),
283
+ ...(activity ? { activity } : {}),
272
284
  ...(f.turnFailed ? { failure: f.failure ?? "turn failed" } : {}),
273
285
  ...(f.limit ? { limit: true } : {}),
274
286
  ...(f.tooOld ? { tooOld: true } : {}),
@@ -100,7 +100,13 @@ function plan(r: RunRequest): SpawnPlan {
100
100
  }
101
101
 
102
102
  async function listModels(): Promise<DiscoveredModel[]> {
103
- const r = await runCli(cursorBin(), ["models"], { ...cursorShell, env: { NO_OPEN_BROWSER: "1" } });
103
+ const bin = cursorBin();
104
+ // an `agent` that is not Cursor's (grok's, say) is never asked for models: nothing to list, as when absent
105
+ if (bin === "agent") {
106
+ const v = await runCli(bin, ["--version"], cursorShell).catch(() => null);
107
+ if (!v || !CURSOR_VERSION.test(v.out)) return [];
108
+ }
109
+ const r = await runCli(bin, ["models"], { ...cursorShell, env: { NO_OPEN_BROWSER: "1" } });
104
110
  return r?.ok ? parseCursorModels(r.out) : [];
105
111
  }
106
112