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
@@ -21,9 +21,9 @@ const COMPAT = ["CLAUDE", "CURSOR"].flatMap((tool) =>
21
21
  /**
22
22
  * Spec 1.3 §5.4: an isolated run's env. GROK_HOME and the toggles alone still load the user's Claude Code
23
23
  * plugins, agents and permission rules (research §3.8 [run]), so HOME moves too, and GROK_HOME lives in it.
24
+ * `home`: another root than the isolated runs' (doctor's sandbox check runs in a scratch one).
24
25
  */
25
- export function grokHomeEnv(): Record<string, string> {
26
- const home = isolatedGrokRoot();
26
+ export function grokHomeEnv(home = isolatedGrokRoot()): Record<string, string> {
27
27
  return {
28
28
  ...movedHomeEnv(home),
29
29
  GROK_HOME: join(home, ".grok"),
@@ -82,10 +82,47 @@ const tomlError = (text: string): string | null => {
82
82
  }
83
83
  };
84
84
 
85
+ /** The `read_write` roots of each profile in catherd's block as it stands, by profile name. */
86
+ function blockRoots(block: string): Record<string, string[]> {
87
+ try {
88
+ const p =
89
+ (Bun.TOML.parse(block) as { profiles?: Record<string, { read_write?: unknown }> }).profiles ?? {};
90
+ return Object.fromEntries(
91
+ Object.entries(p).map(([k, v]) => [
92
+ k,
93
+ Array.isArray(v.read_write) ? v.read_write.filter((r): r is string => typeof r === "string") : [],
94
+ ]),
95
+ );
96
+ } catch {
97
+ return {};
98
+ }
99
+ }
100
+
101
+ /**
102
+ * `profiles` with each one's `read_write` merged into the block's current roots: those that still exist, in
103
+ * their order, then the new ones. Two catherd homes on one machine (another CATHERD_HOME, other lock dirs) then
104
+ * converge on one block instead of rewriting each other's.
105
+ */
106
+ function mergedProfiles(
107
+ profiles: Record<string, Record<string, unknown>>,
108
+ was: Record<string, string[]>,
109
+ ): Record<string, Record<string, unknown>> {
110
+ return Object.fromEntries(
111
+ Object.entries(profiles).map(([name, p]) => {
112
+ const mine = Array.isArray(p.read_write) ? (p.read_write as string[]) : [];
113
+ const kept = (was[name] ?? []).filter((r) => existsSync(r));
114
+ const roots = [...new Set([...kept, ...mine])];
115
+ return [name, { ...p, read_write: roots }];
116
+ }),
117
+ );
118
+ }
119
+
85
120
  /**
86
- * `text` (a sandbox.toml) with catherd's marked block set to `profiles`: the old block cut out, the new one
87
- * appended. Outside the block only trailing blank lines change. `why` when catherd must not write: the file
88
- * is not TOML, defines a `catherd-*` profile of its own, or has lost the block's end marker.
121
+ * `text` (a sandbox.toml) with catherd's marked block set to `profiles`: rewritten where it stands, or appended
122
+ * when there is none. A rewrite in place keeps every line outside the block, and what each line belongs to: a
123
+ * bare key the user put after the block stays in the table it was in. The block's existing roots that still
124
+ * exist stay (mergedProfiles). `why` when catherd must not write: the file is not TOML, defines a `catherd-*`
125
+ * profile of its own, or has lost the block's end marker.
89
126
  */
90
127
  export function withCatherdProfiles(
91
128
  text: string,
@@ -95,21 +132,22 @@ export function withCatherdProfiles(
95
132
  const end = start === -1 ? -1 : text.indexOf(END, start);
96
133
  if (start !== -1 && end === -1)
97
134
  return { why: `catherd's block has lost its end marker (${END}): delete the block` };
98
- // the block goes with the blank line catherd put before it
99
- const rest =
100
- start === -1
101
- ? text
102
- : text.slice(0, start).replace(/\n\n$/, "\n") + text.slice(end + END.length).replace(/^\r?\n/, "");
135
+ const after = start === -1 ? "" : text.slice(end + END.length).replace(/^\r?\n/, "");
136
+ // the file without the block, for the checks: the block goes with the blank line catherd put before it
137
+ const rest = start === -1 ? text : text.slice(0, start).replace(/\n\n$/, "\n") + after;
103
138
  const bad = tomlError(rest);
104
139
  if (bad) return { why: `it is not valid TOML (${bad})` };
105
140
  const own = Object.keys((Bun.TOML.parse(rest) as { profiles?: object }).profiles ?? {}).find((k) =>
106
141
  k.startsWith("catherd-"),
107
142
  );
108
143
  if (own) return { why: `it has a [profiles.${own}] of its own: rename it, catherd's tables are catherd-*` };
144
+ const was = start === -1 ? {} : blockRoots(text.slice(start, end));
145
+ const block = `${HEADER}\n${Bun.TOML.stringify({ profiles: mergedProfiles(profiles, was) })}${END}\n`;
109
146
  const head = rest.trimEnd();
110
- const out = `${head ? `${head}\n\n` : ""}${HEADER}\n${Bun.TOML.stringify({ profiles })}${END}\n`;
111
- const after = tomlError(out);
112
- return after ? { why: `it is not valid TOML with catherd's tables added (${after})` } : { text: out };
147
+ const out =
148
+ start === -1 ? `${head ? `${head}\n\n` : ""}${block}` : `${text.slice(0, start)}${block}${after}`;
149
+ const broken = tomlError(out);
150
+ return broken ? { why: `it is not valid TOML with catherd's tables added (${broken})` } : { text: out };
113
151
  }
114
152
 
115
153
  /**
@@ -119,14 +157,31 @@ export function withCatherdProfiles(
119
157
  */
120
158
  export function writeGrokProfiles(home: string): void {
121
159
  const path = join(home, "sandbox.toml");
122
- // a link (a dotfiles repo) stays a link: the atomic rename goes to the file it points at
123
- const file = lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink() ? realpathSync(path) : path;
124
- const was = existsSync(file) ? readFileSync(file, "utf8") : "";
125
- const next = withCatherdProfiles(was);
126
- if ("why" in next)
127
- throw new CatherdError("E_CONFIG_INVALID", `catherd will not edit ${file}: ${next.why}`, {
160
+ const refuse = (file: string, why: string) =>
161
+ new CatherdError("E_CONFIG_INVALID", `catherd will not edit ${file}: ${why}`, {
128
162
  fix: `fix ${file}, or isolate grok (catherd profile set harness.grok.isolated true)`,
129
163
  });
164
+ // a link (a dotfiles repo) stays a link: the atomic rename goes to the file it points at
165
+ let file = path;
166
+ if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink())
167
+ try {
168
+ file = realpathSync(path);
169
+ } catch (e) {
170
+ throw refuse(path, `it is a link catherd cannot follow (${(e as NodeJS.ErrnoException).code ?? e})`);
171
+ }
172
+ let was = "";
173
+ try {
174
+ was = existsSync(file) ? readFileSync(file, "utf8") : "";
175
+ } catch (e) {
176
+ throw refuse(file, `catherd cannot read it (${(e as NodeJS.ErrnoException).code ?? e})`);
177
+ }
178
+ const next = withCatherdProfiles(was);
179
+ if ("why" in next) throw refuse(file, next.why);
130
180
  if (next.text === was) return;
131
- writeTextAtomic(file, next.text, { mode: existsSync(file) ? statSync(file).mode & 0o777 : 0o600 });
181
+ try {
182
+ writeTextAtomic(file, next.text, { mode: existsSync(file) ? statSync(file).mode & 0o777 : 0o600 });
183
+ } catch (e) {
184
+ // a read-only store (a Nix home, say) or a folder catherd may not write
185
+ throw refuse(file, `catherd cannot write it (${(e as NodeJS.ErrnoException).code ?? e})`);
186
+ }
132
187
  }
@@ -1,5 +1,5 @@
1
1
  import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
  import { CatherdError } from "../../domain/errors.ts";
4
4
  import type { Rung } from "../../domain/ids.ts";
5
5
  import type { Access, RunStatus } from "../../domain/record.ts";
@@ -57,11 +57,13 @@ const NO_UPDATE = { GROK_DISABLE_AUTOUPDATER: "1" };
57
57
  export const grokShell = { timeoutMs: 15_000 };
58
58
 
59
59
  /**
60
- * A fresh run's session id (`-s`), derived from its dispatch dir so finalize knows it without the stream: a run
61
- * killed before grok's `end` (a timeout, a cancel) still records the session grok saved (plan 16 final review).
60
+ * A fresh run's session id (`-s`), derived from its dispatch id (the dispatch dir's last part) so finalize knows
61
+ * it without the stream: a run killed before grok's `end` (a timeout, a cancel) still records the session grok
62
+ * saved (plan 16 final review). The id alone, not the full path: a data dir reached by another path (a link,
63
+ * `/private/var` for `/var`) gives the same session.
62
64
  */
63
65
  export function sessionFor(r: Pick<RunRequest, "dispatchDir">): string {
64
- const h = new Bun.CryptoHasher("sha256").update(r.dispatchDir).digest("hex");
66
+ const h = new Bun.CryptoHasher("sha256").update(basename(r.dispatchDir)).digest("hex");
65
67
  const variant = ((Number.parseInt(h[16] as string, 16) & 0x3) | 0x8).toString(16);
66
68
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
67
69
  }
@@ -125,11 +127,20 @@ function shippedOnGrok(): ShippedGrok {
125
127
  return shipped;
126
128
  }
127
129
 
128
- async function listModels(): Promise<DiscoveredModel[]> {
129
- const r = await runCli("grok", ["models"], { ...grokShell, env: NO_UPDATE });
130
+ /** `grok models` under `env`: the user's GROK_HOME (its login) by default, catherd's isolated one with the key. */
131
+ async function listAs(env: Record<string, string>): Promise<DiscoveredModel[]> {
132
+ const r = await runCli("grok", ["models"], { ...grokShell, env });
130
133
  return r?.ok ? parseGrokModels(r.out, shippedOnGrok()).models : [];
131
134
  }
132
135
 
136
+ const listModels = (): Promise<DiscoveredModel[]> => listAs(NO_UPDATE);
137
+
138
+ /**
139
+ * The discovery cache an isolated run is judged by: the listing under catherd's own GROK_HOME, where only
140
+ * XAI_API_KEY signs grok in, apart from the native login's (1.3 follow-ups: the two accounts may differ).
141
+ */
142
+ export const ISOLATED_LISTING = "grok-isolated";
143
+
133
144
  /**
134
145
  * Spec 1.3 §5.3–§5.4: an isolated run needs XAI_API_KEY (its home holds no login, and the user's auth.json is
135
146
  * never copied: its refresh token rotates, research §3.9); a workspace-write run needs catherd-ws in the
@@ -150,7 +161,13 @@ async function prepare(req: {
150
161
  if (req.isolated) ensurePrivateDir(home);
151
162
  if (req.access === "workspace-write") writeGrokProfiles(home);
152
163
  const { model, effort } = req.rung;
153
- const models = await discovered("grok", listModels, { maxAgeMs: DAY_MS, need: model });
164
+ // an isolated run signs in with the key, so its model is checked against the key's own listing
165
+ const models = req.isolated
166
+ ? await discovered(ISOLATED_LISTING, () => listAs({ ...NO_UPDATE, ...grokHomeEnv() }), {
167
+ maxAgeMs: DAY_MS,
168
+ need: model,
169
+ })
170
+ : await discovered("grok", listModels, { maxAgeMs: DAY_MS, need: model });
154
171
  if (models.length === 0) return; // grok listed nothing: let the run itself say what is wrong
155
172
  const m = models.find((x) => x.id === model);
156
173
  if (!m)
@@ -241,7 +258,9 @@ async function sandboxInfo(): Promise<{ id: string; label: string; detail: strin
241
258
  const r = await runCli(
242
259
  "grok",
243
260
  ["-p", "hi", "--output-format", "streaming-json", "--sandbox", "read-only", "--no-auto-update"],
244
- { ...grokShell, env: { ...NO_UPDATE, GROK_HOME: home, XAI_API_KEY: "" } },
261
+ // a scratch HOME as well as GROK_HOME, with memory and the compat features off: nothing of the user's
262
+ // own Claude Code or Cursor setup loads into the check (1.3 follow-ups)
263
+ { ...grokShell, env: { ...NO_UPDATE, ...grokHomeEnv(home), XAI_API_KEY: "" } },
245
264
  );
246
265
  if (!r) return null;
247
266
  const refused = /could not apply the '?read-only'? sandbox profile/.test(r.err);
@@ -155,6 +155,20 @@ function finalize(run: FinishedRun): Outcome {
155
155
  const f = foldOpencodeEvents(run.eventLines);
156
156
  const stopped = run.exit.reason;
157
157
  const tooOld = f.tooOld || OPENCODE_TOO_OLD.some((r) => r.test(run.stderr));
158
+ // plan 23: the provider kept failing; failover takes it as a usage limit, onto the next backend
159
+ if (stopped === "provider-unavailable")
160
+ return {
161
+ status: "limit",
162
+ thread: f.thread ?? run.request.thread,
163
+ tokens: f.tokens,
164
+ costUsd: f.costUsd,
165
+ images: [],
166
+ error: {
167
+ code: "provider-unavailable",
168
+ message: f.error?.message ?? "the provider retried with no progress",
169
+ },
170
+ reply: f.reply,
171
+ };
158
172
  const status: RunStatus =
159
173
  stopped === "cancelled"
160
174
  ? "cancelled"
@@ -201,6 +215,8 @@ function parse(line: string): EventDelta {
201
215
  const activity = opencodeActivity(e);
202
216
  if (activity) d.activity = activity;
203
217
  if (typeof e.sessionID === "string") d.thread = e.sessionID;
218
+ // plan 23: each provider retry starts a step again with nothing between; the supervisor counts them
219
+ if (e.type === "step_start") d.step = true;
204
220
  if (e.type === "step_finish") {
205
221
  d.tokens = opencodeTokens(e.part?.tokens);
206
222
  d.requestInput = d.tokens.input;
@@ -220,6 +236,21 @@ function parse(line: string): EventDelta {
220
236
  * older messages keep their errors, so only the newest assistant message counts, and with `sinceMs` only
221
237
  * if this run wrote it.
222
238
  */
239
+ /**
240
+ * Plan 23: the provider retry the newest assistant message this run wrote is waiting out (a 503, an
241
+ * overloaded backend), by its attempt number; null when it is not retrying.
242
+ */
243
+ function providerRetryOf(messages: unknown, sinceMs: number): number | null {
244
+ if (!Array.isArray(messages)) return null;
245
+ const m = messages.find((x) => x?.type === "assistant") as Record<string, any> | undefined;
246
+ if (!m?.retry || !(m.time?.created >= sinceMs)) return null;
247
+ return typeof m.retry.attempt === "number" ? m.retry.attempt : 1;
248
+ }
249
+
250
+ async function providerRetry(thread: string, sinceMs: number): Promise<number | null> {
251
+ return providerRetryOf((await opencodeApi("GET", `/api/session/${thread}/message`))?.data, sinceMs);
252
+ }
253
+
223
254
  function limitRetry(messages: unknown, sinceMs?: number): string | null {
224
255
  if (!Array.isArray(messages)) return null;
225
256
  const m = messages.find((x) => x?.type === "assistant") as Record<string, any> | undefined;
@@ -265,7 +296,12 @@ async function isBusy(thread: string, sinceMs?: number): Promise<boolean> {
265
296
  const active = (await opencodeApi("GET", "/api/session/active"))?.data;
266
297
  if (!active || typeof active !== "object" || !(thread in active)) return false;
267
298
  const messages = (await opencodeApi("GET", `/api/session/${thread}/message`))?.data;
268
- return Array.isArray(messages) && limitRetry(messages, sinceMs) === null;
299
+ // plan 23: a session only retrying its provider is not busy, so the idle timeout still runs
300
+ return (
301
+ Array.isArray(messages) &&
302
+ limitRetry(messages, sinceMs) === null &&
303
+ providerRetryOf(messages, sinceMs ?? 0) === null
304
+ );
269
305
  }
270
306
 
271
307
  /** Killing the v2 client does not stop its session; the service must be told (research §2.4). */
@@ -327,6 +363,7 @@ export const opencodeAdapter: BackendAdapter = {
327
363
  resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
328
364
  interrupt: (thread) => interrupt(thread),
329
365
  isBusy: (thread, _cwd, sinceMs) => isBusy(thread, sinceMs),
366
+ providerRetry: (thread, _cwd, sinceMs) => providerRetry(thread, sinceMs),
330
367
  failoverFor,
331
368
  graceAfterFinalMs: null,
332
369
  reportsCost: true,
package/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { HOST_ARG } from "./entry/host-arg.ts";
2
3
  import type { ArgsDef } from "citty";
3
4
  import { errorMessage } from "./domain/errors.ts";
4
5
  import { runtimeRefusal } from "./domain/runtime.ts";
@@ -12,7 +13,7 @@ if (refusal) {
12
13
 
13
14
  const { realpathSync } = await import("node:fs");
14
15
  const { fileURLToPath } = await import("node:url");
15
- const { defineCommand, renderUsage, runCommand } = await import("citty");
16
+ const { defineCommand, renderUsage, runCommand, parseArgs } = await import("citty");
16
17
  const { CatherdError, isCatherdError } = await import("./domain/errors.ts");
17
18
  const { EXIT, exitCodeOf, printError, stripAnsi } = await import("./entry/cli-kit.ts");
18
19
  const { VERSION } = await import("./infra/version.ts");
@@ -21,6 +22,7 @@ type Command = ReturnType<typeof defineCommand>;
21
22
 
22
23
  /** The dashboard's flags, typed as any command's args so `main` stays a plain `Command`. */
23
24
  const dashboardArgs: ArgsDef = {
25
+ ...HOST_ARG,
24
26
  plain: {
25
27
  type: "boolean",
26
28
  description: "the dashboard in ASCII without colour (NO_COLOR also drops colour)",
@@ -48,7 +50,12 @@ export const main: Command = defineCommand({
48
50
  doctor: () => import("./entry/doctor-command.ts").then((m) => m.doctorCommand),
49
51
  catalog: () => import("./entry/catalog-command.ts").then((m) => m.catalogCommand),
50
52
  knowledge: () => import("./entry/knowledge-command.ts").then((m) => m.knowledgeCommand),
53
+ workspace: () => import("./entry/workspace-command.ts").then((m) => m.workspaceCommand),
51
54
  lock: () => import("./entry/lock-command.ts").then((m) => m.lockCommand),
55
+ "run-file": () => import("./entry/role-cli-command.ts").then((m) => m.runFileCommand),
56
+ gate: () => import("./entry/role-cli-command.ts").then((m) => m.gateCommand),
57
+ pause: () => import("./entry/pause-command.ts").then((m) => m.pauseCommand),
58
+ resume: () => import("./entry/pause-command.ts").then((m) => m.resumeCommand),
52
59
  "capture-fixtures": () =>
53
60
  import("./entry/capture-fixtures-command.ts").then((m) => m.captureFixturesCommand),
54
61
  mcp: () => import("./entry/mcp/command.ts").then((m) => m.mcpCommand),
@@ -56,7 +63,7 @@ export const main: Command = defineCommand({
56
63
  },
57
64
  // citty runs this after any subcommand too; only a bare `catherd` loads and opens the dashboard
58
65
  async run(ctx) {
59
- if (!ctx.rawArgs.every((a) => a.startsWith("-"))) return;
66
+ if (ctx.args._.length > 0) return;
60
67
  await (await import("./entry/tui/run.tsx")).tuiRun(ctx);
61
68
  },
62
69
  });
@@ -69,14 +76,24 @@ async function commandFor(argv: string[]): Promise<[Command, Command | undefined
69
76
  let cmd = main;
70
77
  let parent: Command | undefined;
71
78
  const path: string[] = [];
72
- for (const a of argv) {
73
- if (a.startsWith("-")) continue;
79
+ let remaining = argv;
80
+ while (true) {
81
+ const defs = await resolve(cmd.args ?? {});
82
+ const navigationArgs: ArgsDef = Object.fromEntries(
83
+ Object.entries(defs).map(([key, def]) => [
84
+ key,
85
+ { ...def, required: false, ...(def.type === "enum" ? { type: "string" as const } : {}) },
86
+ ]),
87
+ ) as ArgsDef;
88
+ const a = parseArgs(remaining, navigationArgs)._[0];
89
+ if (!a) break;
74
90
  const subs = cmd.subCommands ? await resolve(cmd.subCommands) : undefined;
75
91
  const next = subs?.[a];
76
92
  if (!next) break;
77
93
  parent = cmd;
78
94
  cmd = (await resolve(next)) as Command;
79
95
  path.push(a);
96
+ remaining = remaining.slice(remaining.indexOf(a) + 1);
80
97
  }
81
98
  return [cmd, parent, path];
82
99
  }
@@ -123,22 +140,39 @@ export async function runCli(argv: string[]): Promise<number> {
123
140
  console.log(`${await helpText(cmd, path)}\n`);
124
141
  return EXIT.ok;
125
142
  }
126
- const command = own.find((a) => !a.startsWith("-"));
127
- // a bare `catherd` opens the dashboard, which takes only its own flags: a typo is not a terminal problem
128
- const flag = (a: string) => (a.startsWith("--") ? (a.slice(2).split("=")[0] ?? "") : "");
129
- // citty negates a boolean as `--no-<name>`
130
- const known = (name: string) =>
131
- Object.hasOwn(dashboardArgs, name) ||
132
- (name.startsWith("no-") && dashboardArgs[name.slice(3)]?.type === "boolean");
133
- const unknown = command === undefined ? own.find((a) => !known(flag(a))) : undefined;
134
- if (unknown !== undefined) {
135
- printError(new CatherdError("E_INPUT_INVALID", `unknown option ${unknown}`, { fix: "catherd --help" }));
136
- return EXIT.usage;
137
- }
138
- // `lock` forwards signals to its command itself; everything else stops at once on Ctrl-C. The command is
139
- // the first word that is not a flag: `catherd --plain lock -- …` is `lock` too.
140
- if (command !== "lock") process.once("SIGINT", () => process.exit(EXIT.interrupted));
141
143
  try {
144
+ const command = parseArgs(own, dashboardArgs)._[0];
145
+ // a bare `catherd` opens the dashboard, which takes only its own flags: a typo is not a terminal problem
146
+ const flag = (a: string) => (a.startsWith("--") ? (a.slice(2).split("=")[0] ?? "") : "");
147
+ // citty negates a boolean as `--no-<name>`
148
+ const known = (name: string) =>
149
+ Object.hasOwn(dashboardArgs, name) ||
150
+ (name.startsWith("no-") && dashboardArgs[name.slice(3)]?.type === "boolean");
151
+ const unknown =
152
+ command === undefined
153
+ ? own.find((a, i) => a.startsWith("-") && !known(flag(a)) && own[i - 1] !== "--host")
154
+ : undefined;
155
+ if (unknown !== undefined) {
156
+ printError(new CatherdError("E_INPUT_INVALID", `unknown option ${unknown}`, { fix: "catherd --help" }));
157
+ return EXIT.usage;
158
+ }
159
+ // `lock` forwards signals to its command itself; everything else stops at once on Ctrl-C. The command is
160
+ // the first word that is not a flag: `catherd --plain lock -- …` is `lock` too.
161
+ if (command !== "lock") process.once("SIGINT", () => process.exit(EXIT.interrupted));
162
+ const parsed = parseArgs(own, dashboardArgs);
163
+ if (parsed.host !== undefined) {
164
+ const forwarded = own.filter(
165
+ (a, i) => a !== "--host" && own[i - 1] !== "--host" && !a.startsWith("--host="),
166
+ );
167
+ rawArgs.splice(
168
+ 0,
169
+ rawArgs.length,
170
+ ...forwarded,
171
+ "--host",
172
+ String(parsed.host),
173
+ ...(dashdash < 0 ? [] : argv.slice(dashdash)),
174
+ );
175
+ }
142
176
  await runCommand(main, { rawArgs });
143
177
  return Number(process.exitCode ?? EXIT.ok);
144
178
  } catch (e) {
@@ -1,7 +1,7 @@
1
1
  import { parseRung, tryParseRung } from "./ids.ts";
2
2
  import { agentName, type Profile } from "./profile.ts";
3
3
  import type { Access } from "./record.ts";
4
- import { nativeDisallowedTools, rolePrompt } from "./role-prompts.ts";
4
+ import { NOT_THE_ORCHESTRATOR, nativeDisallowedTools, rolePrompt } from "./role-prompts.ts";
5
5
  import { ROLES, type Role } from "./roles.ts";
6
6
 
7
7
  export interface AgentFile {
@@ -32,6 +32,8 @@ export function renderAgent(o: {
32
32
  "",
33
33
  rolePrompt(o.role, o.version),
34
34
  "",
35
+ NOT_THE_ORCHESTRATOR,
36
+ "",
35
37
  ].join("\n");
36
38
  }
37
39
 
@@ -0,0 +1,91 @@
1
+ import { replyContract } from "./role-prompts.ts";
2
+ import { ROLE_MCP_SERVER, roleMcpTools } from "./role-tools.ts";
3
+ import type { Role } from "./roles.ts";
4
+
5
+ /** What admission adds to a role's brief, besides its reply contract (spec 1.5 plan 21). */
6
+ export interface BriefContext {
7
+ run: string;
8
+ name: string;
9
+ role: Role;
10
+ /** the lane file as it stands at admission, header and body */
11
+ lane: { id: string; text: string } | null;
12
+ /** the role's scratch folder, its TMPDIR, on a backend that grants it */
13
+ scratch: string | null;
14
+ /** whether the role's harness lists the catherd_role tools */
15
+ roleServer: boolean;
16
+ }
17
+
18
+ /** The CLI form of each role server tool, which any role can run from its shell. */
19
+ function cliForm(tool: string, run: string): string {
20
+ switch (tool) {
21
+ case "read_run_file":
22
+ return `catherd run-file read ${run} <path>`;
23
+ case "write_run_file":
24
+ return `catherd run-file write ${run} <path> (the content on stdin)`;
25
+ case "read_knowledge":
26
+ return "catherd knowledge show";
27
+ case "gate_check":
28
+ return `catherd gate check ${run} --milestone <M> --item <item> --command <command> --paths <path,…>`;
29
+ case "gate_pass":
30
+ return `catherd gate pass ${run} --item <item> --command <command> --paths <path,…> --evidence <evidence>`;
31
+ default:
32
+ return tool;
33
+ }
34
+ }
35
+
36
+ const LANE_OPEN = (id: string) => `<lane-file path="lanes/${id}.md">`;
37
+ const LANE_CLOSE = "</lane-file>";
38
+
39
+ /**
40
+ * The lane file inlined (spec 1.5 plan 21): an isolated headless worker cannot read the run folder, so its Owns,
41
+ * fast check and body travel in the brief itself.
42
+ */
43
+ export function laneBlock(id: string, text: string): string {
44
+ return [
45
+ `Your lane file, lanes/${id}.md, as it stood when you were dispatched:`,
46
+ LANE_OPEN(id),
47
+ text.trimEnd(),
48
+ LANE_CLOSE,
49
+ ].join("\n");
50
+ }
51
+
52
+ /** Who the role is, where its temp files go, and how it reaches its run's files and gate. */
53
+ export function roleNotes(c: BriefContext): string {
54
+ const tools = roleMcpTools(c.role);
55
+ const lines = [`catherd: you are ${c.name} of run ${c.run}.`];
56
+ if (c.scratch)
57
+ lines.push(
58
+ `Your scratch folder is ${c.scratch}, which is your $TMPDIR: put temporary files, logs and builds there, never in /tmp. catherd removes it with the run, so anything the orchestrator must keep goes in your reply.`,
59
+ );
60
+ const cli = tools.map((t) => cliForm(t, c.run)).join("; ");
61
+ lines.push(
62
+ c.roleServer
63
+ ? `Your catherd tools: ${tools.map((t) => `mcp__${ROLE_MCP_SERVER}__${t}`).join(", ")}. If they are not listed, run the same from your shell: ${cli}.`
64
+ : `Your catherd commands, from your shell: ${cli}.`,
65
+ );
66
+ return lines.join("\n");
67
+ }
68
+
69
+ /**
70
+ * The brief admission writes: the orchestrator's text, the lane file, the role's notes, then its reply contract,
71
+ * each once. A failover stand-in that reruns an earlier brief gets the lane file as it stands now and its own notes,
72
+ * never two copies.
73
+ */
74
+ export function composeBrief(brief: string, c: BriefContext): string {
75
+ const contract = replyContract(c.role);
76
+ let body = brief.trimEnd();
77
+ if (body.endsWith(contract)) body = body.slice(0, -contract.length).trimEnd();
78
+ if (c.lane) {
79
+ const open = LANE_OPEN(c.lane.id);
80
+ const start = body.lastIndexOf(open);
81
+ const end = body.indexOf(LANE_CLOSE, start);
82
+ if (start >= 0 && end >= 0) {
83
+ const lead = body.lastIndexOf("\n\n", start);
84
+ body = `${body.slice(0, lead >= 0 ? lead : start)}${body.slice(end + LANE_CLOSE.length)}`.trimEnd();
85
+ }
86
+ }
87
+ const notesStart = body.lastIndexOf("\n\ncatherd: you are ");
88
+ if (notesStart >= 0) body = body.slice(0, notesStart).trimEnd();
89
+ const parts = [body, ...(c.lane ? [laneBlock(c.lane.id, c.lane.text)] : []), roleNotes(c), contract];
90
+ return `${parts.filter(Boolean).join("\n\n")}\n`;
91
+ }
@@ -34,6 +34,13 @@ export const DIM_SOURCES: Record<Dim, { anchor: FieldRef | "shipped"; others: Fi
34
34
  benchmark: "Artificial Analysis Coding Index",
35
35
  },
36
36
  { source: "epoch", field: "frontiercode", benchmark: "FrontierCode (Epoch AI)" },
37
+ // spec 1.5 plan 24: per model and effort, it scores a new release the day it ships (the identity run's
38
+ // GPT-6.1 Sol: medium 47.8, about Astra low)
39
+ {
40
+ source: "artificial-analysis",
41
+ field: "artificial_analysis_intelligence_index",
42
+ benchmark: "Artificial Analysis Intelligence Index",
43
+ },
37
44
  ],
38
45
  },
39
46
  // plan 14 Ruling C-2: the vendors' Terminal-Bench 4.0 values, not Epoch's Terminal-Bench 2.0, which shares
@@ -84,6 +84,11 @@ const FamilySchema = z.looseObject({
84
84
  notes: z.record(z.string(), z.string()).default({}),
85
85
  /** the day the vendor released it (models.dev), a stand-in feature (spec 1.2 §6.3) */
86
86
  releaseDate: z.iso.date().optional(),
87
+ /**
88
+ * spec 1.5 plan 24: the family this one succeeds in the same line (GPT-6.1 Sol after GPT-6 Sol): until a
89
+ * value of its own arrives, a rung takes at least its predecessor's value at the same effort
90
+ */
91
+ predecessor: z.string().min(1).optional(),
87
92
  /** a sync's speed facts (spec 1.2 §4.1), `<source>.<field>` → value; they never carry a bar */
88
93
  speed: z.record(z.string(), z.number()).optional(),
89
94
  });
@@ -211,6 +216,8 @@ export interface Catalog {
211
216
  * whose value it uses as `inferred` (spec 1.2 §6.1); filled by the stand-in ranking (services/standins.ts)
212
217
  */
213
218
  inferred: Record<string, Partial<Record<Dim, InferredStandIn>>>;
219
+ /** the thresholds the user's override set or removed, as `<kind>/<difficulty>/<dim>` (route says "your override") */
220
+ userBars?: string[];
214
221
  }
215
222
 
216
223
  /**
@@ -317,11 +324,14 @@ export function buildCatalog(o: {
317
324
  for (const [rung, like] of Object.entries(o.override?.treatLike ?? {}))
318
325
  treatLike[rung] = { like, source: "user" };
319
326
  const bars = structuredClone(o.scores.bars) as Bars;
327
+ const userBars: string[] = [];
320
328
  for (const kind of KINDS)
321
329
  for (const d of DIFFICULTIES)
322
- for (const [dim, min] of Object.entries(o.override?.bars[kind]?.[d] ?? {}) as [Dim, number | null][])
330
+ for (const [dim, min] of Object.entries(o.override?.bars[kind]?.[d] ?? {}) as [Dim, number | null][]) {
331
+ userBars.push(`${kind}/${d}/${dim}`);
323
332
  if (min === null) delete bars[kind][d][dim];
324
333
  else bars[kind][d][dim] = min;
334
+ }
325
335
  return {
326
336
  families: o.facts ? applyFacts(o.models.families, o.facts) : o.models.families,
327
337
  backends: o.models.backends,
@@ -333,6 +343,7 @@ export function buildCatalog(o: {
333
343
  secs: o.secs ?? {},
334
344
  features: o.features ?? {},
335
345
  inferred: {},
346
+ userBars,
336
347
  };
337
348
  }
338
349
 
@@ -33,6 +33,28 @@ export function changedPaths(before: Snapshot, after: Snapshot): string[] {
33
33
  .sort();
34
34
  }
35
35
 
36
+ /** Spec 1.5 plan 21: a writer's implicit docs lane, when its brief names no Owns. */
37
+ export const DOCS_OWNS = ["docs/**", "*.md"];
38
+
39
+ /** The paths of an `Owns:` line in a brief (as a lane file writes it), or null when it has none. */
40
+ export function briefOwns(brief: string): string[] | null {
41
+ const line = /^Owns:\s*(.+)$/m.exec(brief)?.[1];
42
+ const paths = line
43
+ ?.split(",")
44
+ .map((p) => p.trim().replace(/^`|`$/g, ""))
45
+ .filter(Boolean);
46
+ return paths?.length ? paths : null;
47
+ }
48
+
49
+ /**
50
+ * Whether `path` is inside an Owns entry: a path covers what is under it, and the other way round (as lanes'
51
+ * Owns always did); `dir/**` covers what is under dir; `*.ext` (no slash) is any file with that extension.
52
+ */
53
+ export function ownsPath(path: string, entry: string): boolean {
54
+ if (entry.startsWith("*.") && !entry.includes("/")) return path.endsWith(entry.slice(1));
55
+ return overlaps([path], [entry.replace(/\/\*\*$/, "")]).length > 0;
56
+ }
57
+
36
58
  /**
37
59
  * Spec §4.4: `changedOwned` are the changes inside the lane's Owns; `violations` the ones outside it
38
60
  * that no overlapping dispatch owns either. `strict` is false for a laneless dispatch that may write
@@ -44,7 +66,7 @@ export function splitChanges(
44
66
  others: string[],
45
67
  strict: boolean,
46
68
  ): { changedOwned: string[]; violations: string[] } {
47
- const inside = (p: string, paths: string[]) => overlaps([p], paths).length > 0;
69
+ const inside = (p: string, paths: string[]) => paths.some((entry) => ownsPath(p, entry));
48
70
  return {
49
71
  changedOwned: changed.filter((p) => inside(p, owns)),
50
72
  violations: strict ? changed.filter((p) => !inside(p, owns) && !inside(p, others)) : [],
@@ -51,19 +51,24 @@ export const CHATGPT_UNIT_USD = 0.019;
51
51
  export interface Cost {
52
52
  /** 0: paid from a subscription; 1: metered. Every tier-0 rung ranks before any tier-1 rung. */
53
53
  tier: 0 | 1;
54
- /** comparable across billing modes, in list-price dollars per task; null when unknown */
54
+ /**
55
+ * comparable across billing modes, in list-price dollars per task; 0 for an unpriced rung paid from a plan or
56
+ * subscription (it spends nothing beyond the flat fee); null for an unpriced metered one
57
+ */
55
58
  value: number | null;
56
59
  mode: BillingMode;
57
60
  }
58
61
 
59
62
  /**
60
- * Spec §5.3. Every mode yields list-price dollars per task, so plans compare with each other:
63
+ * Spec §5.3. Every mode yields list-price dollars per task, so plans compare with each other. Spec 1.5 plan 24:
64
+ * a rung catherd has no price for costs 0 within its tier when a plan or subscription pays for it, so it can
65
+ * start a lane (the profile's ladder order then breaks the tie); a metered one stays unknown, last in its tier.
61
66
  * chatgpt-plan scales the official quota weight (Luna 1, Sol 20, Astra 60) by the effort factor;
62
67
  * claude-plan uses API prices as the proxy, with Fable metered (catherd cannot tell Pro from Max);
63
68
  * Go's share of its monthly dollar limit is dollars / limit, which orders the same as dollars.
64
69
  */
65
70
  export function costOf(family: Family | null, effort: string, mode: BillingMode): Cost {
66
- if (!family) return { tier: mode === "metered" ? 1 : 0, value: null, mode };
71
+ if (!family) return mode === "metered" ? { tier: 1, value: null, mode } : { tier: 0, value: 0, mode };
67
72
  const usd = taskUsd(family.price, effort);
68
73
  switch (mode) {
69
74
  case "chatgpt-plan":