catherd-cli 0.2.1 → 1.1.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 (207) hide show
  1. package/README.md +141 -33
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: catherd-setup
3
- description: Use when the user wants to tune catherd — which models and efforts each role may use, cost versus speed, which subscriptions to lean on, harness isolation — /catherd-setup, "set up catherd", "tune my catherd profile". Not for running a task; that is the catherd skill.
3
+ description: Use when the user wants to tune catherd — which models and efforts each role may use, how much each role may touch, cost versus speed, which subscriptions to lean on, failover, budget, harness isolation — /catherd-setup, "set up catherd", "tune my catherd profile". Not for running a task; that is the catherd skill.
4
4
  ---
5
5
 
6
6
  # catherd setup
7
7
 
8
- You tune the user's catherd profile in conversation. A profile says, per role, which models and efforts it may run on; whether routing favours cost or speed; whether each vendor harness runs with the user's customizations or isolated; how many heavy commands run at once; and when to push.
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 and the TUI (`bunx catherd-cli`) cannot drift apart.
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.
11
11
 
12
12
  ## Rules
13
13
 
@@ -21,45 +21,55 @@ You tune the user's catherd profile in conversation. A profile says, per role, w
21
21
  Ask these, one at a time, each with its recommended answer:
22
22
 
23
23
  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, OpenRouter or other opencode credit. Recommend leaning on subscriptions before metered spend, and on Claude last among the workers, since it spends the same quota as this conversation.
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`): `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.
25
25
  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
26
 
27
27
  ## 2. Read the facts before proposing
28
28
 
29
29
  In one message, call:
30
30
 
31
- - `catalog_query({ role: "<role>" })` for each role you will discuss: what their installed backends can run, with capabilities and scores. A model whose `installed` is false is not offered;
32
- - `runs_summary({})`: how each rung has done on their own runs (runs, refusals, climbs, time) and the harness cost line;
33
- - `profile_get()`: where they stand now.
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);
32
+ - `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
+ - `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
34
 
35
35
  ## 3. Propose one decision at a time
36
36
 
37
37
  Go through these in order, and skip any the user does not care about:
38
38
 
39
- 1. `objective`;
40
- 2. the worker's ladder: its enabled models and efforts, and its `defaultRung`;
41
- 3. the reviewer and the UI reviewer;
42
- 4. the architect and the verifier, which spend Claude quota;
43
- 5. the writer, the researcher and the artist;
44
- 6. harness isolation, per harness (below);
45
- 7. `lock.heavy` and `notify`.
39
+ 1. `objective`, and `jev.use` (`auto` asks Jev when a key exists; `off` routes on each lane's `Kind:` and `Difficulty:` lines);
40
+ 2. `billing`, from step 1;
41
+ 3. the worker's `rungs` and its `defaultRung`;
42
+ 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;
44
+ 6. the writer, the researcher and the artist;
45
+ 7. each role's `access` (below);
46
+ 8. `failover` (below);
47
+ 9. `budget`, `timeouts` and `preflight.confirm`;
48
+ 10. harness isolation, per harness (below);
49
+ 11. `lock.heavy` and `notify`.
46
50
 
47
51
  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."
48
52
 
49
- - Offer only the models `catalog_query` lists as capable for the role.
50
- - An unscored model can be enabled only once it has a "treat like <scored model#effort>". That lives in the catalog override, which the TUI edits: send the user to `bunx catherd-cli` for it, then come back.
53
+ - Offer only the rungs `catalog_query` lists as capable for the role, written `backend:model#effort`.
54
+ - An unscored model can be enabled only once it has a "treat like <scored rung>". There is no tool for that: give the user the command to run in a terminal, `catherd catalog treat-like <rung> <scored rung>`, then come back and propose again.
51
55
 
52
- **Harness isolation.** For each harness they use (`codex`, `opencode`), offer `harness.<name>.isolated` with its harness line from `runs_summary` 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.
56
+ **Access.** Each role runs `read-only`, `workspace-write` or `full`. The defaults: architect, reviewer and researcher `read-only`; worker, writer and artist `workspace-write`; verifier and UI reviewer `full`. `profile_get` says per role whether its backend holds it to that mode (`enforced`: Codex's sandbox) or only asks (`advisory`: claude-code, opencode and native subagents, where the model can still reach past it). Recommend the defaults; when the user wants a role tighter or looser, say what it can no longer do (a read-only reviewer on claude-code or opencode has no shell, so it cannot run `git diff`) and that `profile_validate` will warn about it. A `workspace-write` role also writes catherd's lock dir and the temp dir, reaches the network, binds loopback ports and talks to a local Docker socket, so it runs its own installs and tests; `roles.<role>.network: false` takes the network, loopback and Docker away from that role (Codex enforces it; claude-code drops its web tools; opencode has no sandbox to enforce it). `catherd doctor` probes each of these per backend.
57
+
58
+ **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
+
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.
61
+
62
+ **Harness isolation.** For each harness they use (`codex`, `claude-code`, `opencode`), 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.
53
63
 
54
64
  ## 4. Write it
55
65
 
56
- - Call `profile_set({ patch })` with only what changed; pass `name` only to edit a profile other than the active one. It validates before it writes:
57
- - `saved: false` comes with `errors`. Explain each in their terms, fix the patch, and propose again.
58
- - `saved: true` comes with the `diff`. Read it back to the user, one line per change.
59
- - Then call `profile_validate()`. It checks that every enabled role has a capable model, that the worker's ladder is non-empty for every kind, and that no unscored model is enabled without its "treat like".
66
+ - Call `profile_set({ repo, patch })` with only what changed; pass `name` only to edit a profile other than the one this repo runs on (a name that does not exist yet starts a new profile from the default one). Lists (`rungs`, `notify`) replace, maps (`billing`, `failover`, `budget`) merge, and `null` removes a key. A key it does not know is refused with `E_INPUT_INVALID`. It validates before it writes:
67
+ - `saved: false` comes with `errors`, each with a `path`, a `message` and often a `fix`. Explain each in their terms, fix the patch, and propose again.
68
+ - `saved: true` comes with the `diff` (`path`, `before`, `after`) and any `warnings`. Read the diff back to the user, one line per change, and each warning with it.
69
+ - Then call `profile_validate({ repo })`. `errors` block a save: the worker disabled, an enabled role with no usable rung, an unscored rung without a "treat like", a failover stand-in unscored or on the same quota, a backend catherd cannot run. `warnings` do not: an access mode other than the role's default, a model the backend's listing lacks, a stand-in that never runs, a stand-in that scores below its rung ("downgrade"), a Claude stand-in while another plan could stand in ("spends Claude quota"), a ladder whose rung scores below the one before it ("the ladder goes down"). Each has a `fix`; offer it.
60
70
 
61
71
  ## 5. Say what applies when
62
72
 
63
- - Codex and opencode changes, isolation included, apply at the next dispatch, even in a run already under way.
64
- - A Claude agent listed in `new_session_needed_for` applies from the next Claude Code session: Claude Code registers agent files when a session starts. Other Claude changes apply now.
65
- - Editing a profile that is not the active one writes its agent files but links none; they apply once it becomes active.
73
+ - Codex, claude-code and opencode changes, access and isolation included, apply at the next dispatch, even in a run already under way.
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`).
@@ -0,0 +1,63 @@
1
+ import { existsSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { locksDir } from "../infra/paths.ts";
5
+ import type { AccessShell } from "./backend.ts";
6
+ import { runCli } from "./cli.ts";
7
+
8
+ /** How long one access probe may run: an HTTPS fetch through a slow proxy included. */
9
+ export const probeShell = { timeoutMs: 20_000 };
10
+
11
+ /**
12
+ * A probe shell from a fresh scratch dir: `prefix` is the argv before `sh -c <script> _ <args…>` (empty for
13
+ * a backend whose worker shell runs unsandboxed).
14
+ */
15
+ export function scratchShell(how: string, prefix: string[]): AccessShell {
16
+ const cwd = mkdtempSync(join(realTmpdir(), "catherd-probe-"));
17
+ const [bin, ...rest] = prefix.length ? prefix : ["sh"];
18
+ const sh = prefix.length ? ["sh"] : [];
19
+ return {
20
+ how,
21
+ run: (script, args) =>
22
+ runCli(bin as string, [...rest, ...sh, "-c", script, "_", ...args], { ...probeShell, cwd }),
23
+ close: () => rmSync(cwd, { recursive: true, force: true }),
24
+ };
25
+ }
26
+
27
+ // Spec §5: what a workspace-write worker may reach besides the repo, as each backend's own flags grant it.
28
+
29
+ /** `p` by its real path when it exists (macOS: /var/folders/… is /private/var/folders/…), else as given. */
30
+ function realOr(p: string): string {
31
+ try {
32
+ return realpathSync(p);
33
+ } catch {
34
+ return p;
35
+ }
36
+ }
37
+
38
+ /** The temp dir by its real path (macOS: /var/folders/… is /private/var/folders/…); sandboxes compare real paths. */
39
+ export const realTmpdir = (): string => realOr(tmpdir());
40
+
41
+ /** The directories a workspace-write worker writes besides the repo: the heavy-lock dir and the temp dir, real paths. */
42
+ export const writableRoots = (): string[] => [realOr(locksDir()), realTmpdir()];
43
+
44
+ /** The usual local Docker sockets, in the order they are tried (OrbStack and Docker Desktop link /var/run/docker.sock). */
45
+ export const dockerSocketCandidates = (home = homedir()): string[] => [
46
+ "/var/run/docker.sock",
47
+ join(home, ".orbstack", "run", "docker.sock"),
48
+ join(home, ".docker", "run", "docker.sock"),
49
+ join(home, ".colima", "default", "docker.sock"),
50
+ ];
51
+
52
+ /**
53
+ * The local Docker socket a worker may talk to, by its real path: the one `DOCKER_HOST` names when it is a
54
+ * unix socket; none when it names a remote daemon (tcp://, ssh://…), which the user's docker client uses
55
+ * instead of any local socket; else the first of `candidates` that exists.
56
+ */
57
+ export function dockerSocket(candidates = dockerSocketCandidates()): string | null {
58
+ const host = process.env.DOCKER_HOST;
59
+ if (host?.startsWith("unix://")) return realOr(host.slice("unix://".length));
60
+ if (host) return null;
61
+ const found = candidates.find((p) => existsSync(p));
62
+ return found ? realOr(found) : null;
63
+ }
@@ -0,0 +1,8 @@
1
+ import { claudeCodeAdapter } from "./claude-code/index.ts";
2
+ import { codexAdapter } from "./codex/index.ts";
3
+ import { opencodeAdapter } from "./opencode/index.ts";
4
+ import { registerAdapter } from "./registry.ts";
5
+
6
+ registerAdapter(codexAdapter);
7
+ registerAdapter(claudeCodeAdapter);
8
+ registerAdapter(opencodeAdapter);
@@ -0,0 +1,159 @@
1
+ import type { ErrorCode } from "../domain/errors.ts";
2
+ import type { ADAPTER_IDS, Rung } from "../domain/ids.ts";
3
+ import type { BillingMode } from "../domain/cost.ts";
4
+ import type { Access, ExitInfo, RunStatus, Tokens } from "../domain/record.ts";
5
+ import type { CliResult } from "./cli.ts";
6
+ type AdapterId = (typeof ADAPTER_IDS)[number];
7
+
8
+ export interface Probe {
9
+ installed: boolean;
10
+ version: string | null;
11
+ versionOk: boolean;
12
+ /** null when the CLI offers no way to ask */
13
+ loggedIn: boolean | null;
14
+ /** how the CLI is logged in, in a word or two ("ChatGPT", "API key"), when it says */
15
+ login?: string;
16
+ /** the billing mode that login implies, when it implies one: doctor compares it with the profiles' */
17
+ billing?: BillingMode;
18
+ problems: { code: ErrorCode; message: string; fix: string }[];
19
+ }
20
+
21
+ /** A worker's shell for doctor's probes: `run` is `sh -c <script> _ <args…>`; `close` removes its scratch dir. */
22
+ export interface AccessShell {
23
+ /** how it runs, for the doctor rows: "codex sandbox", "an unsandboxed shell" */
24
+ how: string;
25
+ run(script: string, args: string[]): Promise<CliResult | null>;
26
+ close(): void;
27
+ }
28
+
29
+ export interface DiscoveredModel {
30
+ id: string;
31
+ efforts: string[];
32
+ context: number | null;
33
+ imageIn: boolean;
34
+ }
35
+
36
+ export interface RunRequest {
37
+ rung: Rung;
38
+ access: Access;
39
+ /** spec §5: a workspace-write role's network and loopback grants; false only when the profile says `network: false` */
40
+ network?: boolean;
41
+ thread: string | null;
42
+ isolated: boolean;
43
+ repo: string;
44
+ briefPath: string;
45
+ replyPath: string;
46
+ dispatchDir: string;
47
+ }
48
+
49
+ /**
50
+ * `env` holds only the adapter's overrides, never PWD. Admission writes them to spec.json with PWD added
51
+ * and nothing else, so no credential reaches disk; the `_supervise` process builds the worker's env at
52
+ * spawn time with `workerEnv(process.env, spec.env, spec.cwd)`, the only place secrets are scrubbed.
53
+ */
54
+ export interface SpawnPlan {
55
+ cmd: string;
56
+ args: string[];
57
+ env: Record<string, string>;
58
+ cwd: string;
59
+ stdinPath: string | null;
60
+ }
61
+
62
+ export interface EventDelta {
63
+ thread?: string;
64
+ tokens?: Tokens;
65
+ /** the input tokens of one model request, from an event that reports them per request (the harness cost) */
66
+ requestInput?: number;
67
+ costUsd?: number;
68
+ lastEvent?: string;
69
+ /**
70
+ * what the worker is doing, in words, when the line says (spec §3.7 `peek`): a command (`$ bun test`), a file
71
+ * edit (`edit src/a.ts`) or a message line
72
+ */
73
+ activity?: string;
74
+ failure?: string;
75
+ limit?: boolean;
76
+ tooOld?: boolean;
77
+ retrying?: boolean;
78
+ /** the stream's terminal event: the supervisor may kill a CLI that lingers after it */
79
+ final?: boolean;
80
+ /** a tool call starting (`open`) or ending: while one is open the run is busy, however quiet */
81
+ item?: { id: string; open: boolean };
82
+ }
83
+
84
+ export interface FinishedRun {
85
+ request: RunRequest;
86
+ eventLines: string[];
87
+ reply: string;
88
+ stderr: string;
89
+ exit: ExitInfo;
90
+ startedAtMs: number;
91
+ }
92
+
93
+ export interface Outcome {
94
+ status: RunStatus;
95
+ thread: string | null;
96
+ tokens: Tokens;
97
+ costUsd: number | null;
98
+ images: string[];
99
+ error: { code: string; message: string } | null;
100
+ /** The role's reply, for a CLI that streams it rather than writing the reply file (absent: the file is the reply). */
101
+ reply?: string;
102
+ }
103
+
104
+ /** What earlier records on the same thread already counted, so a session total becomes this run's share. */
105
+ export interface Spent {
106
+ tokens: Tokens;
107
+ costUsd: number;
108
+ }
109
+
110
+ export interface BackendAdapter {
111
+ id: AdapterId;
112
+ minVersion: string;
113
+ probe(): Promise<Probe>;
114
+ /** The models the backend offers; in `repo` when given, for a backend whose listing depends on it. */
115
+ listModels(repo?: string): Promise<DiscoveredModel[]>;
116
+ /**
117
+ * Refuses a rung this backend cannot run and readies the backend's own config, before admission writes
118
+ * anything (spec §6.3: variants are validated before dispatch). Throws a CatherdError.
119
+ */
120
+ prepare?(req: { rung: Rung; access: Access; isolated: boolean; repo: string }): Promise<void>;
121
+ plan(req: RunRequest): SpawnPlan;
122
+ parse(line: string): EventDelta;
123
+ finalize(run: FinishedRun): Outcome;
124
+ /**
125
+ * Spec §6.3: the backend's own account of a finished session, for a stream that undercounts. Returns
126
+ * `o` refined; never throws (the caller also cuts it off after a timeout and keeps `o`).
127
+ */
128
+ settle?(o: Outcome, run: FinishedRun, prior: Spent): Promise<Outcome>;
129
+ enforcement: Record<Access, "enforced" | "advisory">;
130
+ errors: { limit: RegExp[]; tooOld: RegExp[] };
131
+ resume: { supported: boolean; sameAccessOnly: boolean; threadPattern: RegExp };
132
+ interrupt?(thread: string, cwd: string): Promise<void>;
133
+ /** `sinceMs`: when this run started, so what an earlier run on the thread left behind does not count */
134
+ isBusy?(thread: string, cwd: string, sinceMs?: number): Promise<boolean>;
135
+ /** Spec §4.5: this backend's own stand-in for a rung on a usage limit, when the profile names none. */
136
+ failoverFor?(rung: Rung, repo?: string): Rung | null;
137
+ /**
138
+ * Spec §5 and §12: a shell that runs a command the way this backend's workspace-write worker runs one,
139
+ * with the grants the worker gets, for doctor's access probes; a string says why it cannot be tested here.
140
+ */
141
+ accessShell?(o: { network: boolean }): Promise<AccessShell | string>;
142
+ /** Spec §10.3: why this backend's isolation is weak; doctor warns when a profile uses it. */
143
+ isolationNote?: string;
144
+ graceAfterFinalMs: number | null;
145
+ }
146
+
147
+ export function extractVersion(s: string): string | null {
148
+ return /(\d+)\.(\d+)\.(\d+)/.exec(s)?.[0] ?? null;
149
+ }
150
+
151
+ export function compareVersions(a: string, b: string): number {
152
+ const pa = a.split(".").map(Number);
153
+ const pb = b.split(".").map(Number);
154
+ for (let i = 0; i < 3; i++) {
155
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
156
+ if (d !== 0) return d;
157
+ }
158
+ return 0;
159
+ }
@@ -0,0 +1,86 @@
1
+ import { type Tokens, ZERO_TOKENS } from "../../domain/record.ts";
2
+
3
+ interface ClaudeResult {
4
+ isError: boolean;
5
+ text: string;
6
+ apiStatus: number | null;
7
+ tokens: Tokens;
8
+ costUsd: number | null;
9
+ }
10
+
11
+ export interface ClaudeFold {
12
+ thread: string | null;
13
+ result: ClaudeResult | null;
14
+ limit: boolean;
15
+ lastEvent: string | null;
16
+ }
17
+
18
+ /** A usage or rate limit, in the result text Claude Code prints. */
19
+ export const CLAUDE_LIMIT = [/usage limit/i, /hit your (?:usage )?limit/i, /rate[ _-]?limit/i];
20
+ /** What an older `claude` prints for a flag catherd passes (commander's messages). */
21
+ export const CLAUDE_TOO_OLD = [
22
+ /unknown option '--/i,
23
+ /option '--[\w-]+ <[\w.]+>' argument '[^']*' is invalid/i,
24
+ ];
25
+
26
+ type Event = Record<string, any>;
27
+
28
+ export function parseClaudeLine(line: string): Event | null {
29
+ if (!line.trim().startsWith("{")) return null;
30
+ try {
31
+ const e = JSON.parse(line) as unknown;
32
+ return e && typeof e === "object" && !Array.isArray(e) ? (e as Event) : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /** Spec §4.3's convention: input counts cached and freshly cached tokens too. */
39
+ export function claudeTokens(u: Event | undefined): Tokens {
40
+ if (!u) return { ...ZERO_TOKENS };
41
+ const n = (k: string) => (typeof u[k] === "number" ? (u[k] as number) : 0);
42
+ const cached = n("cache_read_input_tokens");
43
+ return {
44
+ input: n("input_tokens") + n("cache_creation_input_tokens") + cached,
45
+ cached,
46
+ output: n("output_tokens"),
47
+ };
48
+ }
49
+
50
+ export function eventName(e: Event): string {
51
+ if (e.type === "system") return e.subtype === "api_retry" ? "retrying" : `system/${e.subtype ?? "?"}`;
52
+ if (e.type === "assistant") {
53
+ const c = (e.message?.content ?? []).at(-1) as Event | undefined;
54
+ return c?.type === "tool_use" ? `tool_use/${c.name}` : `assistant/${c?.type ?? "?"}`;
55
+ }
56
+ if (e.type === "user") return "tool_result";
57
+ return String(e.type ?? "?");
58
+ }
59
+
60
+ const limited = (e: Event): boolean =>
61
+ (e.type === "rate_limit_event" && e.rate_limit_info?.status === "rejected") ||
62
+ (e.type === "assistant" && e.error === "rate_limit") ||
63
+ (e.type === "result" &&
64
+ e.is_error === true &&
65
+ (e.api_error_status === 429 || CLAUDE_LIMIT.some((r) => r.test(String(e.result ?? "")))));
66
+
67
+ /** Tokens and cost come from the final `result` event only: assistant events repeat one message's usage. */
68
+ export function foldClaudeEvents(lines: string[]): ClaudeFold {
69
+ const f: ClaudeFold = { thread: null, result: null, limit: false, lastEvent: null };
70
+ for (const line of lines) {
71
+ const e = parseClaudeLine(line);
72
+ if (!e) continue;
73
+ f.lastEvent = eventName(e);
74
+ if (typeof e.session_id === "string" && e.session_id) f.thread ??= e.session_id;
75
+ if (limited(e)) f.limit = true;
76
+ if (e.type === "result")
77
+ f.result = {
78
+ isError: e.is_error === true,
79
+ text: typeof e.result === "string" ? e.result : "",
80
+ apiStatus: typeof e.api_error_status === "number" ? e.api_error_status : null,
81
+ tokens: claudeTokens(e.usage),
82
+ costUsd: typeof e.total_cost_usd === "number" ? e.total_cost_usd : null,
83
+ };
84
+ }
85
+ return f;
86
+ }