catherd-cli 1.0.0 → 1.1.1

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 (83) hide show
  1. package/README.md +66 -42
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.mcp.json +2 -2
  5. package/plugin/bin/catherd-mcp +30 -0
  6. package/plugin/commands/catherd.md +1 -1
  7. package/plugin/skills/catherd/SKILL.md +112 -95
  8. package/plugin/skills/catherd-setup/SKILL.md +3 -3
  9. package/src/adapters/access.ts +118 -0
  10. package/src/adapters/backend.ts +19 -4
  11. package/src/adapters/claude-code/index.ts +95 -1
  12. package/src/adapters/codex/index.ts +78 -23
  13. package/src/adapters/opencode/index.ts +17 -0
  14. package/src/domain/errors.ts +2 -0
  15. package/src/domain/failover.ts +119 -0
  16. package/src/domain/lane.ts +23 -0
  17. package/src/domain/notice.ts +95 -0
  18. package/src/domain/profile-rules.ts +43 -9
  19. package/src/domain/profile.ts +12 -6
  20. package/src/domain/record.ts +2 -0
  21. package/src/domain/role-prompts.ts +33 -1
  22. package/src/domain/state.ts +18 -2
  23. package/src/entry/deps.ts +2 -1
  24. package/src/entry/doctor-command.ts +7 -1
  25. package/src/entry/glyphs.ts +2 -1
  26. package/src/entry/init-command.ts +41 -1
  27. package/src/entry/mcp/command.ts +5 -1
  28. package/src/entry/mcp/dispatch-tools.ts +10 -40
  29. package/src/entry/mcp/handshake.ts +19 -9
  30. package/src/entry/mcp/lane-tools.ts +7 -6
  31. package/src/entry/mcp/protocol-tools.ts +57 -0
  32. package/src/entry/mcp/run-tools.ts +4 -3
  33. package/src/entry/mcp/server.ts +17 -1
  34. package/src/entry/mcp/setup-tools.ts +1 -1
  35. package/src/entry/profile-command.ts +3 -5
  36. package/src/entry/runs-command.ts +64 -21
  37. package/src/entry/tui/commands.ts +1 -1
  38. package/src/entry/tui/effects.ts +98 -56
  39. package/src/entry/tui/fixtures.ts +352 -85
  40. package/src/entry/tui/profile-edits.ts +2 -2
  41. package/src/entry/tui/profile-tree.ts +2 -1
  42. package/src/entry/tui/providers/data.tsx +35 -8
  43. package/src/entry/tui/run.tsx +11 -1
  44. package/src/entry/tui/state.ts +26 -6
  45. package/src/entry/tui/stories.ts +36 -4
  46. package/src/entry/tui/text.ts +12 -0
  47. package/src/entry/tui/theme.ts +1 -0
  48. package/src/entry/tui/views/runs.tsx +409 -117
  49. package/src/entry/tui/views/status.tsx +5 -2
  50. package/src/entry/tui/widgets/list.tsx +4 -3
  51. package/src/infra/claude-session.ts +98 -0
  52. package/src/infra/dispatch-dir.ts +14 -6
  53. package/src/infra/env.ts +26 -2
  54. package/src/infra/peer-inbox.ts +92 -0
  55. package/src/infra/supervisor.ts +33 -3
  56. package/src/services/admission.ts +38 -12
  57. package/src/services/dispatch-service.ts +338 -197
  58. package/src/services/dispatches.ts +30 -1
  59. package/src/services/doctor-access.ts +231 -0
  60. package/src/services/doctor-backends.ts +3 -18
  61. package/src/services/doctor-checks.ts +46 -1
  62. package/src/services/doctor-push.ts +109 -0
  63. package/src/services/doctor.ts +59 -79
  64. package/src/services/finalize.ts +43 -25
  65. package/src/services/gate-service.ts +288 -0
  66. package/src/services/global-install.ts +99 -0
  67. package/src/services/lane-service.ts +150 -8
  68. package/src/services/milestones.ts +208 -0
  69. package/src/services/notifier.ts +296 -0
  70. package/src/services/peek.ts +103 -0
  71. package/src/services/ports.ts +7 -4
  72. package/src/services/preflight.ts +17 -3
  73. package/src/services/protocol.ts +212 -0
  74. package/src/services/questions.ts +102 -0
  75. package/src/services/reconcile.ts +40 -11
  76. package/src/services/reentry.ts +22 -0
  77. package/src/services/run-service.ts +42 -9
  78. package/src/services/run-store.ts +18 -0
  79. package/src/services/runs-page.ts +254 -0
  80. package/src/services/session-view.ts +152 -0
  81. package/src/services/sessions.ts +92 -0
  82. package/src/services/state.ts +10 -3
  83. package/src/services/summary.ts +14 -0
@@ -2,7 +2,7 @@ import type { ErrorCode } from "../domain/errors.ts";
2
2
  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
6
  type AdapterId = (typeof ADAPTER_IDS)[number];
7
7
 
8
8
  export interface Probe {
@@ -18,6 +18,14 @@ export interface Probe {
18
18
  problems: { code: ErrorCode; message: string; fix: string }[];
19
19
  }
20
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
+
21
29
  export interface DiscoveredModel {
22
30
  id: string;
23
31
  efforts: string[];
@@ -28,6 +36,8 @@ export interface DiscoveredModel {
28
36
  export interface RunRequest {
29
37
  rung: Rung;
30
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;
31
41
  thread: string | null;
32
42
  isolated: boolean;
33
43
  repo: string;
@@ -56,6 +66,11 @@ export interface EventDelta {
56
66
  requestInput?: number;
57
67
  costUsd?: number;
58
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;
59
74
  failure?: string;
60
75
  limit?: boolean;
61
76
  tooOld?: boolean;
@@ -120,10 +135,10 @@ export interface BackendAdapter {
120
135
  /** Spec §4.5: this backend's own stand-in for a rung on a usage limit, when the profile names none. */
121
136
  failoverFor?(rung: Rung, repo?: string): Rung | null;
122
137
  /**
123
- * Spec §10.3: whether a workspace-write worker of this backend can write `dir` (the heavy-lock dir, so
124
- * `catherd lock` works inside it); null when it cannot be tested on this machine.
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.
125
140
  */
126
- canWrite?(dir: string): Promise<{ ok: boolean; fix?: string } | null>;
141
+ accessShell?(o: { network: boolean }): Promise<AccessShell | string>;
127
142
  /** Spec §10.3: why this backend's isolation is weak; doctor warns when a profile uses it. */
128
143
  isolationNote?: string;
129
144
  graceAfterFinalMs: number | null;
@@ -2,6 +2,7 @@ import { CatherdError } from "../../domain/errors.ts";
2
2
  import type { Rung } from "../../domain/ids.ts";
3
3
  import type { Access, RunStatus } from "../../domain/record.ts";
4
4
  import {
5
+ type AccessShell,
5
6
  type BackendAdapter,
6
7
  compareVersions,
7
8
  type EventDelta,
@@ -12,6 +13,10 @@ import {
12
13
  type RunRequest,
13
14
  type SpawnPlan,
14
15
  } from "../backend.ts";
16
+ import { readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { claudeHome } from "../../infra/paths.ts";
19
+ import { dockerSocket, scratchShell, writableRoots } from "../access.ts";
15
20
  import { jsonOf, runCli } from "../cli.ts";
16
21
  import {
17
22
  CLAUDE_LIMIT,
@@ -60,6 +65,75 @@ export const CLAUDE_ACCESS: Record<Access, string[]> = {
60
65
  full: ["--permission-mode", "bypassPermissions"],
61
66
  };
62
67
 
68
+ /**
69
+ * Spec §5 for headless Claude Code. Its Bash runs unsandboxed unless the user turned Claude Code's own
70
+ * sandbox on (`sandbox.enabled`); then these settings, merged over the user's, add the lock and temp dirs,
71
+ * loopback binds, the Docker socket and `docker` itself (which cannot run inside that sandbox). Outbound
72
+ * domains stay the user's `sandbox.network.allowedDomains`: doctor's `access:claude-code` row says when
73
+ * the registry is not among them. `network: false` drops the network grants and the web tools (and with
74
+ * them Docker: the socket and `docker *` are network grants) and sets `allowLocalBinding: false`, so the
75
+ * user's own `true` does not carry over (their allowedDomains and other array grants merge in and stay;
76
+ * 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.
78
+ */
79
+ export function claudeAccessArgs(access: Access, network = true, repo?: string): string[] {
80
+ const base = CLAUDE_ACCESS[access];
81
+ if (access !== "workspace-write") return base;
82
+ const sock = dockerSocket();
83
+ const sandbox = {
84
+ ...(claudeSandboxOn(repo) ? { enabled: true } : {}),
85
+ filesystem: { allowWrite: writableRoots() },
86
+ ...(network
87
+ ? {
88
+ network: { allowLocalBinding: true, ...(sock ? { allowUnixSockets: [sock] } : {}) },
89
+ excludedCommands: ["docker *"],
90
+ }
91
+ : // a boolean overrides the user's own `allowLocalBinding: true`; their array grants cannot be revoked here
92
+ { network: { allowLocalBinding: false } }),
93
+ };
94
+ const args = [...base, "--settings", JSON.stringify({ sandbox })];
95
+ if (!network) {
96
+ const i = args.indexOf("--disallowedTools") + 1;
97
+ args[i] = [args[i], "WebFetch", "WebSearch"].join(",");
98
+ }
99
+ return args;
100
+ }
101
+
102
+ /** `sandbox.enabled` in one settings file, when it says true or false; undefined when absent or unreadable. */
103
+ function sandboxEnabledIn(file: string): boolean | undefined {
104
+ try {
105
+ const on = JSON.parse(readFileSync(file, "utf8"))?.sandbox?.enabled;
106
+ return typeof on === "boolean" ? on : undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Whether Claude Code's Bash sandbox is on for a worker in `repo` (`sandbox.enabled`): the most specific
114
+ * settings file that says wins, as Claude Code layers them: the user's settings.json, then the project's
115
+ * .claude/settings.json and .claude/settings.local.json (Claude Code has no user-level settings.local.json).
116
+ */
117
+ export function claudeSandboxOn(repo: string = process.cwd()): boolean {
118
+ const files = [
119
+ join(claudeHome(), "settings.json"),
120
+ join(repo, ".claude", "settings.json"),
121
+ join(repo, ".claude", "settings.local.json"),
122
+ ];
123
+ return files.map(sandboxEnabledIn).findLast((on) => on !== undefined) ?? false;
124
+ }
125
+
126
+ /**
127
+ * Spec §5: with Claude Code's own sandbox off (its default) a headless worker's Bash is an unsandboxed
128
+ * shell, which doctor probes as is. With it on, only a model turn runs inside it, so doctor says what to
129
+ * check instead of spending one.
130
+ */
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";
134
+ return scratchShell("an unsandboxed shell (Claude Code's sandbox is off)", []);
135
+ }
136
+
63
137
  function plan(r: RunRequest): SpawnPlan {
64
138
  if (r.thread !== null && !THREAD.test(r.thread))
65
139
  throw new CatherdError("E_ADMIT_THREAD", `"${r.thread}" is not a Claude Code session id`, {
@@ -80,7 +154,7 @@ function plan(r: RunRequest): SpawnPlan {
80
154
  ...(r.thread === null ? ["--session-id", crypto.randomUUID()] : ["--resume", r.thread]),
81
155
  "--permission-prompts",
82
156
  "none",
83
- ...CLAUDE_ACCESS[r.access],
157
+ ...claudeAccessArgs(r.access, r.network, r.repo),
84
158
  // --bare would also drop OAuth, so a Claude plan could not log in; --safe-mode keeps auth
85
159
  ...(r.isolated ? ["--safe-mode"] : []),
86
160
  ],
@@ -147,10 +221,29 @@ function finalize(run: FinishedRun): Outcome {
147
221
  };
148
222
  }
149
223
 
224
+ const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
225
+
226
+ /** Spec §3.7: the main thread's last tool call (a command, a file edit, another tool) or its text. */
227
+ function claudeActivity(e: Record<string, any>): string | undefined {
228
+ if (e.type !== "assistant" || e.parent_tool_use_id) return undefined;
229
+ const c = (e.message?.content ?? []).at(-1);
230
+ if (c?.type === "tool_use") {
231
+ const input = c.input ?? {};
232
+ if (typeof input.command === "string") return `$ ${input.command}`;
233
+ if (typeof input.file_path === "string")
234
+ return `${EDIT_TOOLS.has(c.name) ? "edit" : String(c.name)} ${input.file_path}`;
235
+ return String(c.name ?? "tool");
236
+ }
237
+ if (c?.type === "text" && typeof c.text === "string") return c.text;
238
+ return undefined;
239
+ }
240
+
150
241
  function parse(line: string): EventDelta {
151
242
  const e = parseClaudeLine(line);
152
243
  if (!e) return {};
153
244
  const d: EventDelta = { lastEvent: eventName(e) };
245
+ const activity = claudeActivity(e);
246
+ if (activity) d.activity = activity;
154
247
  if (typeof e.session_id === "string" && e.session_id) d.thread = e.session_id;
155
248
  if (e.type === "system" && e.subtype === "api_retry") d.retrying = true;
156
249
  if (e.type === "rate_limit_event" && e.rate_limit_info?.status === "rejected") d.limit = true;
@@ -218,6 +311,7 @@ export const claudeCodeAdapter: BackendAdapter = {
218
311
  enforcement: { "read-only": "advisory", "workspace-write": "advisory", full: "advisory" },
219
312
  errors: { limit: CLAUDE_LIMIT, tooOld: CLAUDE_TOO_OLD },
220
313
  resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
314
+ accessShell,
221
315
  // the `result` event is the last thing claude prints; a CLI still running 30 s later is stuck
222
316
  graceAfterFinalMs: 30_000,
223
317
  };
@@ -1,9 +1,9 @@
1
- import { mkdtempSync, rmSync } from "node:fs";
2
- import { tmpdir } from "node:os";
1
+ import { existsSync, readFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
  import { CatherdError } from "../../domain/errors.ts";
5
4
  import type { Access, RunStatus } from "../../domain/record.ts";
6
5
  import {
6
+ type AccessShell,
7
7
  type BackendAdapter,
8
8
  compareVersions,
9
9
  type DiscoveredModel,
@@ -15,6 +15,7 @@ import {
15
15
  type SpawnPlan,
16
16
  } from "../backend.ts";
17
17
  import { type CliResult, runCli } from "../cli.ts";
18
+ import { scratchShell, writableRoots } from "../access.ts";
18
19
  import { CODEX_LIMIT, CODEX_TOO_OLD, foldCodexEvents, parseCodexLine } from "./events.ts";
19
20
 
20
21
  /** The item types that are a tool call running, as the idle watchdog counts them. */
@@ -30,6 +31,43 @@ const SANDBOX: Record<Access, string> = {
30
31
  full: "danger-full-access",
31
32
  };
32
33
 
34
+ /**
35
+ * The `writable_roots` the user's Codex config lists under its top-level `[sandbox_workspace_write]`
36
+ * (`$CODEX_HOME/config.toml`). A `-c` override replaces that array, so catherd passes the union. A
37
+ * `--profile`'s or a managed requirements file's roots are not read. Unreadable or absent: none.
38
+ */
39
+ export function userWritableRoots(): string[] {
40
+ const file = join(userCodexHome(), "config.toml");
41
+ if (!existsSync(file)) return [];
42
+ try {
43
+ const roots = (Bun.TOML.parse(readFileSync(file, "utf8")) as Record<string, unknown>)
44
+ .sandbox_workspace_write as { writable_roots?: unknown } | undefined;
45
+ const list = roots?.writable_roots;
46
+ return Array.isArray(list) ? list.filter((r): r is string => typeof r === "string" && r !== "") : [];
47
+ } catch {
48
+ // a config Codex cannot parse either: Codex reports it on the run itself
49
+ return [];
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Spec §5: a workspace-write worker also writes the lock and temp dirs, and (unless the role says
55
+ * `network: false`) reaches the network and binds loopback. `network_access` is always set, to false for
56
+ * `network: false`, since leaving it out would keep a `network_access = true` from the user's config.toml.
57
+ * 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
+ * user's own roots, so theirs go in too; an isolated run ignores the user's config, so it has none.
59
+ */
60
+ export function codexGrants(access: Access, network = true, isolated = false): string[] {
61
+ if (access !== "workspace-write") return [];
62
+ const roots = [...new Set([...(isolated ? [] : userWritableRoots()), ...writableRoots()])];
63
+ return [
64
+ "-c",
65
+ `sandbox_workspace_write.network_access=${network}`,
66
+ "-c",
67
+ `sandbox_workspace_write.writable_roots=${JSON.stringify(roots)}`,
68
+ ];
69
+ }
70
+
33
71
  /** How long a `codex` query (version, login, models) may take before it counts as failed. */
34
72
  export const codexShell = { timeoutMs: 15_000 };
35
73
 
@@ -51,6 +89,7 @@ function plan(r: RunRequest): SpawnPlan {
51
89
  "--json",
52
90
  "-o",
53
91
  r.replyPath,
92
+ ...codexGrants(r.access, r.network, r.isolated),
54
93
  ];
55
94
  const sandbox = SANDBOX[r.access];
56
95
  const args =
@@ -169,29 +208,44 @@ async function listModels(): Promise<DiscoveredModel[]> {
169
208
  }
170
209
 
171
210
  /**
172
- * Spec §10.3: runs a write into `dir` under `codex sandbox <os> --full-auto`, the workspace-write sandbox,
173
- * from a scratch folder. null when this machine has no Codex sandbox to test with (the control fails).
211
+ * Spec §12: `codex sandbox [-c …] -- <cmd>` in the workspace-write sandbox with the grants a worker gets
212
+ * (codexGrants), else the old `codex sandbox <os> --full-auto` form. Each form is tried with `true` first;
213
+ * a string says why neither runs here.
174
214
  */
175
- async function canWrite(dir: string): Promise<{ ok: boolean; fix?: string } | null> {
215
+ async function accessShell(o: { network: boolean }): Promise<AccessShell | string> {
216
+ const grants = codexGrants("workspace-write", o.network);
176
217
  const os = process.platform === "darwin" ? "macos" : process.platform === "linux" ? "linux" : null;
177
- if (!os) return null;
178
- const cwd = mkdtempSync(join(tmpdir(), "catherd-sandbox-"));
179
- try {
180
- const run = (...script: string[]) => sh(["sandbox", os, "--full-auto", "--", "sh", "-c", ...script], cwd);
181
- if (!(await run("true"))?.ok) return null;
182
- const probe = join(dir, `.doctor-${process.pid}`);
183
- // the path goes in as $1, never into the script text
184
- const r = await run('touch "$1" && rm -f "$1"', "_", probe);
185
- if (!r) return null;
186
- return r.ok
187
- ? { ok: true }
188
- : {
189
- ok: false,
190
- fix: `add "${dir}" to writable_roots under [sandbox_workspace_write] in ~/.codex/config.toml`,
191
- };
192
- } finally {
193
- rmSync(cwd, { recursive: true, force: true });
218
+ const forms: [string, string[]][] = [
219
+ ["codex sandbox", ["codex", "sandbox", "-c", "sandbox_mode=workspace-write", ...grants, "--"]],
220
+ ...(os
221
+ ? [
222
+ [
223
+ `codex sandbox ${os} --full-auto (the old form)`,
224
+ ["codex", "sandbox", os, "--full-auto", ...grants, "--"],
225
+ ],
226
+ ]
227
+ : []),
228
+ ] as [string, string[]][];
229
+ for (const [how, prefix] of forms) {
230
+ const shell = scratchShell(how, prefix);
231
+ if ((await shell.run("true", []))?.ok) return shell;
232
+ shell.close();
194
233
  }
234
+ return "no codex sandbox to test with on this machine (codex sandbox did not run `true`)";
235
+ }
236
+
237
+ /** Spec §3.7: a command Codex runs, the files it changes, or its message. */
238
+ function codexActivity(e: Record<string, any>): string | undefined {
239
+ const it = e.item;
240
+ if (!it || (e.type !== "item.started" && e.type !== "item.completed")) return undefined;
241
+ if (it.type === "command_execution" && typeof it.command === "string") return `$ ${it.command}`;
242
+ if (it.type === "file_change" && Array.isArray(it.changes))
243
+ return `edit ${it.changes
244
+ .map((c: { path?: unknown }) => c.path)
245
+ .filter((p: unknown) => typeof p === "string")
246
+ .join(", ")}`;
247
+ if (it.type === "agent_message" && typeof it.text === "string") return it.text;
248
+ return undefined;
195
249
  }
196
250
 
197
251
  export const codexAdapter: BackendAdapter = {
@@ -213,6 +267,7 @@ export const codexAdapter: BackendAdapter = {
213
267
  ...(id !== null && open !== null ? { item: { id, open } } : {}),
214
268
  ...(e.type === "turn.completed" ? { tokens: f.tokens } : {}),
215
269
  lastEvent: f.lastEvent ?? undefined,
270
+ ...(codexActivity(e) ? { activity: codexActivity(e) } : {}),
216
271
  ...(f.turnFailed ? { failure: f.failure ?? "turn failed" } : {}),
217
272
  ...(f.limit ? { limit: true } : {}),
218
273
  ...(f.tooOld ? { tooOld: true } : {}),
@@ -223,5 +278,5 @@ export const codexAdapter: BackendAdapter = {
223
278
  errors: { limit: CODEX_LIMIT, tooOld: CODEX_TOO_OLD },
224
279
  resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
225
280
  graceAfterFinalMs: null,
226
- canWrite,
281
+ accessShell,
227
282
  };
@@ -17,6 +17,7 @@ import {
17
17
  type SpawnPlan,
18
18
  type Spent,
19
19
  } from "../backend.ts";
20
+ import { scratchShell } from "../access.ts";
20
21
  import { jsonOf, runCli } from "../cli.ts";
21
22
  import { configDir } from "../../infra/paths.ts";
22
23
  import { discovered, readDiscovery } from "../discovery.ts";
@@ -181,10 +182,24 @@ function finalize(run: FinishedRun): Outcome {
181
182
  };
182
183
  }
183
184
 
185
+ /** Spec §3.7: the tool opencode ran (a command, a file edit, another tool with its first argument) or its text. */
186
+ function opencodeActivity(e: Record<string, any>): string | undefined {
187
+ if (e.type === "text" && typeof e.part?.text === "string") return e.part.text;
188
+ if (e.type !== "tool_use" || typeof e.part?.tool !== "string") return undefined;
189
+ const input = e.part.state?.input ?? {};
190
+ if (typeof input.command === "string") return `$ ${input.command}`;
191
+ const path = input.filePath ?? input.path;
192
+ if (/^(edit|write|patch)$/.test(e.part.tool) && typeof path === "string") return `edit ${path}`;
193
+ const first = Object.values(input).find((v) => typeof v === "string");
194
+ return `${e.part.tool}${typeof first === "string" ? ` ${first}` : ""}`;
195
+ }
196
+
184
197
  function parse(line: string): EventDelta {
185
198
  const e = parseOpencodeLine(line);
186
199
  if (!e) return {};
187
200
  const d: EventDelta = { lastEvent: eventName(e) };
201
+ const activity = opencodeActivity(e);
202
+ if (activity) d.activity = activity;
188
203
  if (typeof e.sessionID === "string") d.thread = e.sessionID;
189
204
  if (e.type === "step_finish") {
190
205
  d.tokens = opencodeTokens(e.part?.tokens);
@@ -313,4 +328,6 @@ export const opencodeAdapter: BackendAdapter = {
313
328
  isBusy: (thread, _cwd, sinceMs) => isBusy(thread, sinceMs),
314
329
  failoverFor,
315
330
  graceAfterFinalMs: null,
331
+ // research 2026-09-25 §2.6: opencode has no OS sandbox; its shell runs with the user's own authority
332
+ accessShell: async () => scratchShell("an unsandboxed shell (opencode has no sandbox)", []),
316
333
  };
@@ -17,6 +17,8 @@ export type ErrorCode =
17
17
  | "E_ADMIT_ID"
18
18
  | "E_ADMIT_THREAD"
19
19
  | "E_LANE_INVALID"
20
+ | "E_LAND_GATE"
21
+ | "E_CLIMB_DESIGN"
20
22
  | "E_JEV_KEY"
21
23
  | "E_JEV_NETWORK"
22
24
  | "E_JEV_RESPONSE"
@@ -0,0 +1,119 @@
1
+ import { billingKeyOf, type Catalog, DIMS, type Dim, rungInfo, scoresOf } from "./catalog.ts";
2
+ import { type BillingMode, compareCost, type Cost, costOf, DEFAULT_BILLING } from "./cost.ts";
3
+ import { type Rung, tryParseRung } from "./ids.ts";
4
+ import { DIFFICULTIES, KINDS } from "./lane.ts";
5
+
6
+ /**
7
+ * Spec §7.1 and §4.5: a stand-in on the same quota would be out of quota too. The billing key names the
8
+ * quota, except that the native `claude` path and headless `claude-code` both draw on the Claude plan.
9
+ */
10
+ export const quotaOf = (r: Rung): string => (billingKeyOf(r) === "claude" ? "claude-code" : billingKeyOf(r));
11
+
12
+ /** Whether a rung draws on the Claude plan (native `claude:` or headless `claude-code:`). */
13
+ export function claudeBilled(rung: string): boolean {
14
+ const r = tryParseRung(rung);
15
+ return r !== null && quotaOf(r) === "claude-code";
16
+ }
17
+
18
+ /** A rung's scores by dim, its own or borrowed through a treat-like; null when unscored or not a rung. */
19
+ export function valuesOf(c: Catalog, rung: string): Partial<Record<Dim, number>> | null {
20
+ try {
21
+ return scoresOf(c, rungInfo(c, rung).canonical)?.values ?? null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /** The dims used by the routing bars (kind × difficulty) the rung clears: what makes it fit its lanes. */
28
+ export function barDims(c: Catalog, rung: string): Dim[] {
29
+ const v = valuesOf(c, rung);
30
+ if (!v) return [];
31
+ const used = new Set<Dim>();
32
+ for (const kind of KINDS)
33
+ for (const d of DIFFICULTIES) {
34
+ const bar = Object.entries(c.bars[kind][d]).filter(([, min]) => min !== undefined) as [Dim, number][];
35
+ if (bar.every(([dim, min]) => (v[dim] ?? Number.NEGATIVE_INFINITY) >= min))
36
+ for (const [dim] of bar) used.add(dim);
37
+ }
38
+ return DIMS.filter((d) => used.has(d));
39
+ }
40
+
41
+ /**
42
+ * Spec 1.1 §11: the dims, among those the rung's bars use, on which `standIn` scores below `rung` (a
43
+ * missing score counts as below). Empty means the stand-in clears every bar the rung clears, at least as
44
+ * well: no downgrade.
45
+ */
46
+ export function downgradeDims(c: Catalog, rung: string, standIn: string): Dim[] {
47
+ const a = valuesOf(c, rung);
48
+ if (!a) return [];
49
+ const b = valuesOf(c, standIn) ?? {};
50
+ return barDims(c, rung).filter((d) => (b[d] ?? Number.NEGATIVE_INFINITY) < (a[d] as number));
51
+ }
52
+
53
+ /**
54
+ * Spec 1.1 §11 "the ladder goes down": the dims both rungs are scored on where `upper` (the later rung)
55
+ * scores below `lower`, when it scores above it on none of them. Empty when it does not go down.
56
+ */
57
+ export function ladderDropDims(c: Catalog, lower: string, upper: string): Dim[] {
58
+ const a = valuesOf(c, lower);
59
+ const b = valuesOf(c, upper);
60
+ if (!a || !b) return [];
61
+ const shared = DIMS.filter((d) => a[d] !== undefined && b[d] !== undefined);
62
+ if (shared.some((d) => (b[d] as number) > (a[d] as number))) return [];
63
+ return shared.filter((d) => (b[d] as number) < (a[d] as number));
64
+ }
65
+
66
+ const costFor = (c: Catalog, billing: Partial<Record<string, BillingMode>>, rung: string): Cost => {
67
+ const info = rungInfo(c, rung);
68
+ return costOf(info.family, info.parsed.effort, billing[info.key] ?? DEFAULT_BILLING[info.key]);
69
+ };
70
+
71
+ /**
72
+ * Spec 1.1 §11: the rungs of `pool` that can stand in for `rung`, best first. A stand-in is on another
73
+ * quota, scored, paid from a plan or subscription under `billing` (a metered one spends money nobody chose
74
+ * to), startable by `dispatch` (not a native `claude:` subagent), and no downgrade on the rung's bar dims.
75
+ * Claude-billed stand-ins rank last, then the cheapest first.
76
+ */
77
+ export function rankStandIns(
78
+ c: Catalog,
79
+ billing: Partial<Record<string, BillingMode>>,
80
+ rung: string,
81
+ pool: readonly string[],
82
+ ): string[] {
83
+ const from = tryParseRung(rung);
84
+ if (!from) return [];
85
+ const fits = [...new Set(pool)].filter((x) => {
86
+ const r = tryParseRung(x);
87
+ if (!r || r.backend === "claude" || quotaOf(r) === quotaOf(from) || !valuesOf(c, x)) return false;
88
+ if (costFor(c, billing, x).tier === 1) return false;
89
+ return downgradeDims(c, rung, x).length === 0;
90
+ });
91
+ return fits.sort(
92
+ (a, b) =>
93
+ Number(claudeBilled(a)) - Number(claudeBilled(b)) ||
94
+ compareCost(costFor(c, billing, a), costFor(c, billing, b)) ||
95
+ a.localeCompare(b),
96
+ );
97
+ }
98
+
99
+ /** How a family's `on` key or a model id's prefix maps to the backend that runs it. */
100
+ const BACKEND_OF_KEY: Record<string, string> = { "opencode-go": "opencode" };
101
+
102
+ /**
103
+ * Every rung the catalog knows how to name: each shipped family's model on each backend at each effort,
104
+ * each listed model at each effort, and each treat-like whose model is an opencode id (`opencode-go/…`,
105
+ * `opencode/…`), such as the shipped Kimi K3 stand-in. Sorted, without duplicates.
106
+ */
107
+ export function catalogRungs(c: Catalog): string[] {
108
+ const out = new Set<string>();
109
+ for (const f of c.families)
110
+ for (const [key, m] of Object.entries(f.on)) {
111
+ if (!m) continue;
112
+ for (const e of m.efforts) out.add(`${BACKEND_OF_KEY[key] ?? key}:${m.id}#${e}`);
113
+ }
114
+ for (const [backend, l] of Object.entries(c.listed))
115
+ for (const m of l.models) for (const e of m.efforts) out.add(`${backend}:${m.id}#${e}`);
116
+ for (const canonical of Object.keys(c.treatLike))
117
+ if (/^opencode(-go)?\//.test(canonical)) out.add(`opencode:${canonical}`);
118
+ return [...out].sort();
119
+ }
@@ -60,6 +60,29 @@ export function parseLaneHeader(text: string): LaneHeader {
60
60
  };
61
61
  }
62
62
 
63
+ /** The fix every lane-header refusal carries: the values the catalog knows. */
64
+ export const LANE_HEADER_FIX = `write the lane's header lines as Kind: ${KINDS.join("|")} and Difficulty: ${DIFFICULTIES.join("|")}`;
65
+
66
+ /**
67
+ * Spec 1.1 §6: a lane's `Kind:` and `Difficulty:` must be values the catalog knows, so routing never falls
68
+ * back on a typo. `where` names the lane file in the message. Throws E_LANE_INVALID; returns the header.
69
+ */
70
+ export function assertLaneHeader(text: string, where: string): LaneHeader {
71
+ const h = parseLaneHeader(text);
72
+ const problems: string[] = [];
73
+ const check = (label: string, value: string | null, ok: boolean) => {
74
+ if (ok) return;
75
+ problems.push(
76
+ value === null ? `no ${label}: line` : `${label} "${unquote(value)}" is not one the catalog knows`,
77
+ );
78
+ };
79
+ check("Kind", field(text, "kind"), h.kind !== null);
80
+ check("Difficulty", field(text, "difficulty"), h.difficulty !== null);
81
+ if (problems.length)
82
+ throw new CatherdError("E_LANE_INVALID", `${where}: ${problems.join("; ")}`, { fix: LANE_HEADER_FIX });
83
+ return h;
84
+ }
85
+
63
86
  const bare = (p: string) => p.replace(/\/+$/, "");
64
87
  const covers = (outer: string, inner: string) => inner === outer || inner.startsWith(`${outer}/`);
65
88
 
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Spec §3.5: the message catherd pushes to the main thread when roles finish or need attention. Pure text; the
3
+ * notifier (services/notifier.ts) gathers the facts and infra/peer-inbox.ts sends the result.
4
+ */
5
+
6
+ /** Spec §3.6: `later` waits for the receiver's turn to end, `next` drains at its next tool round. Never `now`. */
7
+ export type NoticePriority = "later" | "next";
8
+
9
+ /** One role's news: it finished (`record`), or an event while it runs (`stall`). */
10
+ export interface Notice {
11
+ kind: "finished" | "stalled";
12
+ runId: string;
13
+ runTitle: string;
14
+ dispatchId: string;
15
+ name: string;
16
+ role: string;
17
+ /** the lane it worked, when it had one: the multi-role preview names it */
18
+ lane: string | null;
19
+ rung: string;
20
+ /** the record's status, or for a limit what failover did ("limit on X; failed over to Y"), or the event */
21
+ status: string;
22
+ /** the reply's STATUS word, null without a STATUS line */
23
+ replyStatus: string | null;
24
+ secs: number;
25
+ changedOwned: number;
26
+ /** the worker's reply (finished notices), capped when the message is written */
27
+ reply: string;
28
+ priority: NoticePriority;
29
+ }
30
+
31
+ /** The body of a reply a message carries at most (spec §3.5). */
32
+ export const REPLY_CAP = 2_000;
33
+
34
+ const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
35
+
36
+ /** The first line of one role's block: it must stand alone, since the Desktop preview shows only it. */
37
+ export function noticeHeader(n: Notice): string {
38
+ const head = `catherd · ${n.runTitle} · ${n.name} ${n.role} · ${n.rung} · ${n.status}`;
39
+ if (n.kind === "stalled") return `${head} · running ${n.secs}s`;
40
+ return [
41
+ head,
42
+ n.replyStatus ? `STATUS: ${n.replyStatus}` : "no STATUS",
43
+ `${n.secs}s`,
44
+ plural(n.changedOwned, "owned file changed", "owned files changed"),
45
+ ].join(" · ");
46
+ }
47
+
48
+ /** The reply, at most `cap` characters, cut on a line boundary, saying where the rest is. */
49
+ export function capNoticeReply(reply: string, cap = REPLY_CAP): string {
50
+ const text = reply.trimEnd();
51
+ if (text.length <= cap) return text;
52
+ const cut = text.slice(0, cap);
53
+ const at = cut.lastIndexOf("\n");
54
+ return `${at > 0 ? cut.slice(0, at) : cut}\n…(cut; result(run, name) has the rest)`;
55
+ }
56
+
57
+ /**
58
+ * The envelope's own tags are neutralised in a body, in any case (a parser may not care), so a reply can never
59
+ * close the envelope early.
60
+ */
61
+ const neutral = (s: string) => s.replace(/(cross)-(session)-(message)/gi, "$1 $2 $3");
62
+
63
+ function block(n: Notice): string {
64
+ const call = `run: "${n.runId}", name: "${n.name}"`;
65
+ if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})`;
66
+ const reply = capNoticeReply(n.reply);
67
+ return [noticeHeader(n), ...(reply ? [reply] : []), `Record: result(${call})`].join("\n");
68
+ }
69
+
70
+ /** The preview line of a message that carries several roles: `catherd · <title> · 3 roles finished: …`. */
71
+ function previewOf(ns: Notice[]): string {
72
+ const titles = [...new Set(ns.map((n) => n.runTitle))];
73
+ const where = titles.length === 1 ? titles[0] : plural(titles.length, "run", "runs");
74
+ const what = ns.every((n) => n.kind === "finished")
75
+ ? `${ns.length} roles finished`
76
+ : `${plural(ns.length, "role", "roles")} to look at`;
77
+ const each = ns.map(
78
+ (n) => `${n.lane ?? n.name} ${n.kind === "stalled" ? "stalled" : n.status.split(";")[0]}`,
79
+ );
80
+ return `catherd · ${where} · ${what}: ${each.join(", ")}`;
81
+ }
82
+
83
+ /** The message body for one or more notices: one block per role, a preview line first when there are several. */
84
+ export function formatNotices(ns: Notice[]): string {
85
+ if (ns.length === 1) return neutral(block(ns[0] as Notice));
86
+ return neutral([previewOf(ns), ...ns.map(block)].join("\n\n"));
87
+ }
88
+
89
+ /** The message's priority: the most urgent of its notices. */
90
+ export const priorityOf = (ns: Notice[]): NoticePriority =>
91
+ ns.some((n) => n.priority === "next") ? "next" : "later";
92
+
93
+ /** The envelope catherd sends: from-name only, no `from` (the MCP server has no reply address). */
94
+ export const envelope = (body: string): string =>
95
+ `<cross-session-message from-name="catherd">\n${body}\n</cross-session-message>`;