catherd-cli 1.4.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 (139) hide show
  1. package/README.md +93 -30
  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 +1 -1
  7. package/plugin/bin/catherd-mcp +1 -1
  8. package/plugin/skills/catherd/SKILL.md +110 -45
  9. package/plugin/skills/catherd-setup/SKILL.md +2 -2
  10. package/src/adapters/antigravity/index.ts +18 -3
  11. package/src/adapters/backend.ts +18 -2
  12. package/src/adapters/claude-code/index.ts +126 -14
  13. package/src/adapters/codex/events.ts +10 -0
  14. package/src/adapters/codex/index.ts +17 -5
  15. package/src/adapters/cursor/index.ts +7 -1
  16. package/src/adapters/grok/home.ts +75 -20
  17. package/src/adapters/grok/index.ts +27 -8
  18. package/src/adapters/opencode/index.ts +38 -1
  19. package/src/cli.ts +5 -0
  20. package/src/domain/agents.ts +3 -1
  21. package/src/domain/brief.ts +91 -0
  22. package/src/domain/calibration.ts +7 -0
  23. package/src/domain/catalog.ts +12 -1
  24. package/src/domain/changes.ts +23 -1
  25. package/src/domain/cost.ts +8 -3
  26. package/src/domain/errors.ts +4 -0
  27. package/src/domain/failover.ts +17 -5
  28. package/src/domain/gate-brief.ts +52 -0
  29. package/src/domain/hints.ts +13 -2
  30. package/src/domain/lane.ts +99 -3
  31. package/src/domain/notice.ts +9 -2
  32. package/src/domain/profile-rules.ts +112 -16
  33. package/src/domain/profile.ts +24 -0
  34. package/src/domain/record.ts +16 -2
  35. package/src/domain/reply.ts +29 -0
  36. package/src/domain/role-prompts.ts +56 -16
  37. package/src/domain/role-scope.ts +106 -0
  38. package/src/domain/role-tools.ts +34 -0
  39. package/src/domain/route.ts +73 -0
  40. package/src/domain/select.ts +177 -25
  41. package/src/domain/state.ts +5 -0
  42. package/src/domain/util.ts +22 -0
  43. package/src/domain/workspace.ts +130 -0
  44. package/src/entry/catalog-command.ts +14 -6
  45. package/src/entry/cli-kit.ts +17 -1
  46. package/src/entry/deps.ts +2 -0
  47. package/src/entry/doctor-command.ts +20 -2
  48. package/src/entry/host-arg.ts +28 -0
  49. package/src/entry/init-command.ts +2 -1
  50. package/src/entry/knowledge-command.ts +78 -3
  51. package/src/entry/lock-command.ts +104 -8
  52. package/src/entry/mcp/dispatch-tools.ts +3 -3
  53. package/src/entry/mcp/handshake.ts +6 -2
  54. package/src/entry/mcp/lane-tools.ts +55 -8
  55. package/src/entry/mcp/protocol-tools.ts +10 -4
  56. package/src/entry/mcp/push-tools.ts +16 -0
  57. package/src/entry/mcp/role-bin.ts +20 -0
  58. package/src/entry/mcp/role-server.ts +116 -0
  59. package/src/entry/mcp/run-tools.ts +20 -4
  60. package/src/entry/mcp/server.ts +98 -7
  61. package/src/entry/mcp/setup-tools.ts +4 -3
  62. package/src/entry/mcp/workspace-tools.ts +142 -0
  63. package/src/entry/pause-command.ts +67 -0
  64. package/src/entry/profile-command.ts +23 -4
  65. package/src/entry/role-cli-command.ts +115 -0
  66. package/src/entry/runs-command.ts +78 -8
  67. package/src/entry/supervise-command.ts +30 -3
  68. package/src/entry/tui/effects.ts +4 -0
  69. package/src/entry/tui/state.ts +37 -10
  70. package/src/entry/tui/views/profiles.tsx +1 -1
  71. package/src/entry/tui/views/runs.tsx +66 -20
  72. package/src/entry/tui/views/save-dialog.tsx +6 -2
  73. package/src/entry/tui/views/status.tsx +6 -2
  74. package/src/entry/workspace-command.ts +44 -0
  75. package/src/infra/boot-lock.ts +25 -0
  76. package/src/infra/delivery.ts +3 -0
  77. package/src/infra/dispatch-dir.ts +2 -0
  78. package/src/infra/env.ts +11 -1
  79. package/src/infra/git.ts +6 -0
  80. package/src/infra/heavy-lock.ts +93 -4
  81. package/src/infra/host-probe.ts +16 -0
  82. package/src/infra/lock-activity.ts +88 -0
  83. package/src/infra/login-env.ts +48 -0
  84. package/src/infra/paths.ts +35 -0
  85. package/src/infra/role-mcp.ts +69 -0
  86. package/src/infra/sources/arena.ts +11 -3
  87. package/src/infra/sources/artificial-analysis.ts +8 -1
  88. package/src/infra/sources/cache.ts +2 -0
  89. package/src/infra/supervisor.ts +107 -16
  90. package/src/services/admission.ts +110 -23
  91. package/src/services/budget.ts +26 -6
  92. package/src/services/catalog-service.ts +20 -12
  93. package/src/services/credentials.ts +11 -6
  94. package/src/services/dispatch-service.ts +204 -16
  95. package/src/services/dispatches.ts +30 -2
  96. package/src/services/doctor-access.ts +8 -3
  97. package/src/services/doctor-docker.ts +221 -0
  98. package/src/services/doctor-role-mcp.ts +65 -0
  99. package/src/services/doctor.ts +68 -6
  100. package/src/services/end-push.ts +157 -0
  101. package/src/services/finalize.ts +12 -2
  102. package/src/services/gate-env.ts +218 -0
  103. package/src/services/gate-service.ts +216 -45
  104. package/src/services/global-install.ts +46 -13
  105. package/src/services/lane-edit.ts +154 -0
  106. package/src/services/lane-service.ts +257 -52
  107. package/src/services/milestones.ts +137 -26
  108. package/src/services/notifier.ts +56 -3
  109. package/src/services/orchestrator-wait.ts +82 -0
  110. package/src/services/pause.ts +161 -0
  111. package/src/services/peek.ts +79 -17
  112. package/src/services/ports.ts +31 -2
  113. package/src/services/preflight.ts +153 -19
  114. package/src/services/profile-service.ts +1 -0
  115. package/src/services/profile-store.ts +10 -3
  116. package/src/services/protocol.ts +119 -25
  117. package/src/services/provenance.ts +16 -12
  118. package/src/services/questions.ts +15 -0
  119. package/src/services/reconcile.ts +22 -1
  120. package/src/services/reentry.ts +7 -6
  121. package/src/services/role-access.ts +27 -0
  122. package/src/services/routing-service.ts +144 -36
  123. package/src/services/run-pin.ts +125 -0
  124. package/src/services/run-service.ts +151 -19
  125. package/src/services/run-store.ts +109 -15
  126. package/src/services/runs-page.ts +42 -7
  127. package/src/services/scratch.ts +60 -0
  128. package/src/services/sessions.ts +3 -1
  129. package/src/services/source-derive.ts +28 -5
  130. package/src/services/source-sync.ts +9 -2
  131. package/src/services/standins.ts +40 -6
  132. package/src/services/state.ts +21 -0
  133. package/src/services/summary.ts +49 -9
  134. package/src/services/treat-likes.ts +19 -3
  135. package/src/services/verifier-brief.ts +23 -0
  136. package/src/services/verifier-step.ts +48 -0
  137. package/src/services/workspace-admission.ts +267 -0
  138. package/src/services/workspace-service.ts +254 -0
  139. package/src/services/workspace-store.ts +95 -0
@@ -16,6 +16,8 @@ import {
16
16
  import { readFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
  import { claudeHome } from "../../infra/paths.ts";
19
+ import { ROLE_MCP_SERVER, roleMcpTools } from "../../domain/role-tools.ts";
20
+ import { roleMcpConfig } from "../../infra/role-mcp.ts";
19
21
  import { dockerSocket, scratchShell, writableRoots } from "../access.ts";
20
22
  import { jsonOf, runCli } from "../cli.ts";
21
23
  import {
@@ -74,15 +76,21 @@ export const CLAUDE_ACCESS: Record<Access, string[]> = {
74
76
  * them Docker: the socket and `docker *` are network grants) and sets `allowLocalBinding: false`, so the
75
77
  * user's own `true` does not carry over (their allowedDomains and other array grants merge in and stay;
76
78
  * doctor says `network: false` is not enforced by claude-code's shell). With the user's sandbox on, `enabled: true`
77
- * goes in too, so a merge that replaces the whole `sandbox` object cannot turn it off.
79
+ * goes in too, so a merge that replaces the whole `sandbox` object cannot turn it off. A `repo` of null reads the
80
+ * user's settings only: a run that drops the project's settings files (`--setting-sources ""`).
78
81
  */
79
- export function claudeAccessArgs(access: Access, network = true, repo?: string): string[] {
82
+ export function claudeAccessArgs(
83
+ access: Access,
84
+ network = true,
85
+ repo?: string | null,
86
+ extra: string[] = [],
87
+ ): string[] {
80
88
  const base = CLAUDE_ACCESS[access];
81
89
  if (access !== "workspace-write") return base;
82
90
  const sock = dockerSocket();
83
91
  const sandbox = {
84
92
  ...(claudeSandboxOn(repo) ? { enabled: true } : {}),
85
- filesystem: { allowWrite: writableRoots() },
93
+ filesystem: { allowWrite: [...new Set([...writableRoots(), ...extra])] },
86
94
  ...(network
87
95
  ? {
88
96
  network: { allowLocalBinding: true, ...(sock ? { allowUnixSockets: [sock] } : {}) },
@@ -113,12 +121,14 @@ function sandboxEnabledIn(file: string): boolean | undefined {
113
121
  * Whether Claude Code's Bash sandbox is on for a worker in `repo` (`sandbox.enabled`): the most specific
114
122
  * settings file that says wins, as Claude Code layers them: the user's settings.json, then the project's
115
123
  * .claude/settings.json and .claude/settings.local.json (Claude Code has no user-level settings.local.json).
124
+ * A `repo` of null reads the user's settings.json only.
116
125
  */
117
- export function claudeSandboxOn(repo: string = process.cwd()): boolean {
126
+ export function claudeSandboxOn(repo: string | null = process.cwd()): boolean {
118
127
  const files = [
119
128
  join(claudeHome(), "settings.json"),
120
- join(repo, ".claude", "settings.json"),
121
- join(repo, ".claude", "settings.local.json"),
129
+ ...(repo === null
130
+ ? []
131
+ : [join(repo, ".claude", "settings.json"), join(repo, ".claude", "settings.local.json")]),
122
132
  ];
123
133
  return files.map(sandboxEnabledIn).findLast((on) => on !== undefined) ?? false;
124
134
  }
@@ -128,17 +138,114 @@ export function claudeSandboxOn(repo: string = process.cwd()): boolean {
128
138
  * shell, which doctor probes as is. With it on, only a model turn runs inside it, so doctor says what to
129
139
  * check instead of spending one.
130
140
  */
131
- async function accessShell(): Promise<AccessShell | string> {
132
- if (claudeSandboxOn())
133
- return "Claude Code's own sandbox is on (sandbox.enabled): catherd passes the lock, temp, loopback and Docker grants in --settings; outbound HTTPS reaches only sandbox.network.allowedDomains, so add registry.npmjs.org and the hosts your checks need there";
141
+ async function accessShell(o: { repos?: string[] } = {}): Promise<AccessShell | string> {
142
+ // the setting is per repo (its .claude/settings*.json over the user's): each repo a worker runs in counts,
143
+ // not only the directory doctor runs in
144
+ const repos = o.repos?.length ? o.repos : [process.cwd()];
145
+ const on = repos.filter((r) => claudeSandboxOn(r));
146
+ if (on.length)
147
+ return `Claude Code's own sandbox is on (sandbox.enabled)${o.repos?.length ? ` in ${on.join(", ")}` : ""}: catherd passes the lock, temp, loopback and Docker grants in --settings; outbound HTTPS reaches only sandbox.network.allowedDomains, so add registry.npmjs.org and the hosts your checks need there`;
134
148
  return scratchShell("an unsandboxed shell (Claude Code's sandbox is off)", []);
135
149
  }
136
150
 
151
+ /**
152
+ * An isolated run without the user's customizations. --bare would also drop OAuth, so a Claude plan could not
153
+ * log in; --safe-mode keeps auth, but drops every --mcp-config server too (Claude Code 2.1.287: "--mcp-config:
154
+ * … ignored (safe mode)"). A run with the role server (spec 1.5 plan 21, #42 finding 1) gets the same isolation
155
+ * piece by piece instead: only the --mcp-config servers, no settings file (so no hooks, no enabled plugins), no
156
+ * skills, CLAUDE.md and auto-memory off through the env, and the user's auth, provider, model, deny rules and
157
+ * allowed domains carried into --settings (carriedUserSettings).
158
+ */
159
+ /** The user settings keys --setting-sources "" would drop that a run needs: auth, provider and model. */
160
+ const CARRIED_SETTINGS = [
161
+ "apiKeyHelper",
162
+ "env",
163
+ "awsAuthRefresh",
164
+ "awsCredentialExport",
165
+ "gcpAuthRefresh",
166
+ "model",
167
+ ] as const;
168
+
169
+ /**
170
+ * What an isolated run with the role server keeps of the user's settings.json (plan 21 review, finding 1):
171
+ * --safe-mode keeps the settings files and drops only customizations, while `--setting-sources ""` drops the
172
+ * files, so a Bedrock, Vertex, gateway or apiKeyHelper login would fail. Carried: the auth and provider keys,
173
+ * `model`, `permissions.deny` and `sandbox.network`; never hooks, plugins, allow rules or the rest.
174
+ */
175
+ function carriedUserSettings(): Record<string, unknown> {
176
+ let user: Record<string, any>;
177
+ try {
178
+ user = JSON.parse(readFileSync(join(claudeHome(), "settings.json"), "utf8"));
179
+ } catch {
180
+ return {};
181
+ }
182
+ if (!user || typeof user !== "object") return {};
183
+ const out: Record<string, unknown> = {};
184
+ for (const k of CARRIED_SETTINGS) if (user[k] !== undefined) out[k] = user[k];
185
+ const deny = user.permissions?.deny;
186
+ if (Array.isArray(deny) && deny.length) out.permissions = { deny };
187
+ const network = user.sandbox?.network;
188
+ if (network && typeof network === "object") out.sandbox = { network };
189
+ return out;
190
+ }
191
+
192
+ /** `--settings` with the user's carried keys merged under catherd's own: catherd's sandbox grants win. */
193
+ function withCarriedSettings(args: string[]): string[] {
194
+ const carried = carriedUserSettings();
195
+ if (!Object.keys(carried).length) return args;
196
+ const at = args.indexOf("--settings");
197
+ const own: Record<string, any> = at >= 0 ? JSON.parse(args[at + 1] as string) : {};
198
+ const userNet: Record<string, unknown> = (carried.sandbox as any)?.network ?? {};
199
+ const ownNet: Record<string, unknown> = own.sandbox?.network ?? {};
200
+ const sockets = [
201
+ ...((userNet.allowUnixSockets as string[] | undefined) ?? []),
202
+ ...((ownNet.allowUnixSockets as string[] | undefined) ?? []),
203
+ ];
204
+ const sandbox =
205
+ carried.sandbox || own.sandbox
206
+ ? {
207
+ sandbox: {
208
+ ...own.sandbox,
209
+ network: {
210
+ ...userNet,
211
+ ...ownNet,
212
+ ...(sockets.length ? { allowUnixSockets: [...new Set(sockets)] } : {}),
213
+ },
214
+ },
215
+ }
216
+ : {};
217
+ const json = JSON.stringify({ ...carried, ...own, ...sandbox });
218
+ if (at < 0) return [...args, "--settings", json];
219
+ const out = [...args];
220
+ out[at + 1] = json;
221
+ return out;
222
+ }
223
+
224
+ function isolationArgs(r: RunRequest): string[] {
225
+ if (!r.roleMcp) return ["--safe-mode"];
226
+ return ["--strict-mcp-config", "--setting-sources", "", "--disable-slash-commands"];
227
+ }
228
+
137
229
  function plan(r: RunRequest): SpawnPlan {
138
230
  if (r.thread !== null && !THREAD.test(r.thread))
139
231
  throw new CatherdError("E_ADMIT_THREAD", `"${r.thread}" is not a Claude Code session id`, {
140
232
  fix: "pass the thread from the earlier RunRecord",
141
233
  });
234
+ // isolated with the role server, `--setting-sources ""` drops the project's settings (its network grants too),
235
+ // so only the user's carried settings.json decides whether the sandbox is on (PR #46)
236
+ const carried = r.isolated && !!r.roleMcp;
237
+ const access = claudeAccessArgs(r.access, r.network, carried ? null : r.repo, r.scratch ? [r.scratch] : []);
238
+ const accessArgs = carried ? withCarriedSettings(access) : [...access];
239
+ if (r.roleMcp) {
240
+ const tools = roleMcpTools(r.roleMcp.role).map((tool) => `mcp__${ROLE_MCP_SERVER}__${tool}`);
241
+ const allowed = accessArgs.indexOf("--allowedTools");
242
+ if (allowed >= 0) accessArgs[allowed + 1] = [accessArgs[allowed + 1], ...tools].join(",");
243
+ else accessArgs.push("--allowedTools", tools.join(","));
244
+ accessArgs.push(
245
+ "--mcp-config",
246
+ JSON.stringify({ mcpServers: { [ROLE_MCP_SERVER]: roleMcpConfig(r.roleMcp) } }),
247
+ );
248
+ }
142
249
  return {
143
250
  cmd: "claude",
144
251
  args: [
@@ -154,11 +261,14 @@ function plan(r: RunRequest): SpawnPlan {
154
261
  ...(r.thread === null ? ["--session-id", crypto.randomUUID()] : ["--resume", r.thread]),
155
262
  "--permission-prompts",
156
263
  "none",
157
- ...claudeAccessArgs(r.access, r.network, r.repo),
158
- // --bare would also drop OAuth, so a Claude plan could not log in; --safe-mode keeps auth
159
- ...(r.isolated ? ["--safe-mode"] : []),
264
+ ...accessArgs,
265
+ ...(r.isolated ? isolationArgs(r) : []),
160
266
  ],
161
- env: {},
267
+ // CLAUDE.md and auto-memory off, as --safe-mode turns them off (spec 1.5 plan 21)
268
+ env:
269
+ r.isolated && r.roleMcp
270
+ ? { CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1", CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1" }
271
+ : {},
162
272
  cwd: r.repo,
163
273
  stdinPath: r.briefPath,
164
274
  };
@@ -232,7 +342,9 @@ function claudeActivity(e: Record<string, any>): string | undefined {
232
342
  if (typeof input.command === "string") return `$ ${input.command}`;
233
343
  if (typeof input.file_path === "string")
234
344
  return `${EDIT_TOOLS.has(c.name) ? "edit" : String(c.name)} ${input.file_path}`;
235
- return String(c.name ?? "tool");
345
+ // another tool shows its first string argument, as opencode's activity does
346
+ const first = Object.values(input).find((v) => typeof v === "string");
347
+ return `${String(c.name ?? "tool")}${typeof first === "string" ? ` ${first}` : ""}`;
236
348
  }
237
349
  if (c?.type === "text" && typeof c.text === "string") return c.text;
238
350
  return undefined;
@@ -8,6 +8,8 @@ export interface CodexFold {
8
8
  limit: boolean;
9
9
  tooOld: boolean;
10
10
  lastEvent: string | null;
11
+ /** plan 22: the agent message that ended each completed turn, in order */
12
+ finals: string[];
11
13
  }
12
14
 
13
15
  /**
@@ -43,10 +45,18 @@ export function foldCodexEvents(lines: string[]): CodexFold {
43
45
  limit: false,
44
46
  tooOld: false,
45
47
  lastEvent: null,
48
+ finals: [],
46
49
  };
50
+ let said: string | null = null;
47
51
  for (const line of lines) {
48
52
  const e = parseCodexLine(line);
49
53
  if (!e) continue;
54
+ if (e.type === "item.completed" && e.item?.type === "agent_message" && typeof e.item.text === "string")
55
+ said = e.item.text;
56
+ if (e.type === "turn.completed" && said !== null) {
57
+ f.finals.push(said);
58
+ said = null;
59
+ }
50
60
  f.lastEvent = e.item?.type ? `${e.type}/${e.item.type}` : e.type;
51
61
  const msg: string = e.message ?? e.error?.message ?? e.item?.message ?? "";
52
62
  if (CODEX_TOO_OLD.some((r) => r.test(msg))) f.tooOld = true;
@@ -1,7 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { codexRoleMcpArgs } from "../../infra/role-mcp.ts";
3
4
  import { CatherdError } from "../../domain/errors.ts";
4
5
  import type { Access, RunStatus } from "../../domain/record.ts";
6
+ import { composeReply } from "../../domain/reply.ts";
5
7
  import {
6
8
  type AccessShell,
7
9
  type BackendAdapter,
@@ -57,9 +59,14 @@ export function userWritableRoots(): string[] {
57
59
  * The same `-c` overrides go to `codex sandbox` in doctor's probes, so doctor tests exactly what a worker gets. `-c …writable_roots=` replaces the
58
60
  * user's own roots, so theirs go in too; an isolated run ignores the user's config, so it has none.
59
61
  */
60
- export function codexGrants(access: Access, network = true, isolated = false): string[] {
62
+ export function codexGrants(
63
+ access: Access,
64
+ network = true,
65
+ isolated = false,
66
+ extra: string[] = [],
67
+ ): string[] {
61
68
  if (access !== "workspace-write") return [];
62
- const roots = [...new Set([...(isolated ? [] : userWritableRoots()), ...writableRoots()])];
69
+ const roots = [...new Set([...(isolated ? [] : userWritableRoots()), ...writableRoots(), ...extra])];
63
70
  return [
64
71
  "-c",
65
72
  `sandbox_workspace_write.network_access=${network}`,
@@ -89,7 +96,8 @@ function plan(r: RunRequest): SpawnPlan {
89
96
  "--json",
90
97
  "-o",
91
98
  r.replyPath,
92
- ...codexGrants(r.access, r.network, r.isolated),
99
+ ...codexGrants(r.access, r.network, r.isolated, r.scratch ? [r.scratch] : []),
100
+ ...(r.roleMcp ? codexRoleMcpArgs(r.roleMcp) : []),
93
101
  ];
94
102
  const sandbox = SANDBOX[r.access];
95
103
  const args =
@@ -125,6 +133,7 @@ function finalize(run: FinishedRun): Outcome {
125
133
  : "ok";
126
134
  const lastErr = run.stderr.trim().split("\n").at(-1) ?? "";
127
135
  const home = run.request.isolated ? isolatedCodexHomePath() : userCodexHome();
136
+ const reply = composeReply(f.finals, run.reply);
128
137
  return {
129
138
  status,
130
139
  thread: f.thread ?? run.request.thread,
@@ -138,6 +147,8 @@ function finalize(run: FinishedRun): Outcome {
138
147
  code: status,
139
148
  message: f.failure ?? (lastErr || `${stopped}, exit ${run.exit.code ?? run.exit.signal}`),
140
149
  },
150
+ // plan 22: -o keeps the last turn's message; a later turn never overwrites the report
151
+ ...(reply !== run.reply ? { reply } : {}),
141
152
  };
142
153
  }
143
154
 
@@ -243,7 +254,7 @@ function codexActivity(e: Record<string, any>): string | undefined {
243
254
  return `edit ${it.changes
244
255
  .map((c: { path?: unknown }) => c.path)
245
256
  .filter((p: unknown) => typeof p === "string")
246
- .join(", ")}`;
257
+ .join(", ")}`.trimEnd();
247
258
  if (it.type === "agent_message" && typeof it.text === "string") return it.text;
248
259
  return undefined;
249
260
  }
@@ -263,12 +274,13 @@ export const codexAdapter: BackendAdapter = {
263
274
  // Not a todo_list: update_plan opens one that only completes with the turn, and would mute the watchdog
264
275
  const id = typeof e.item?.id === "string" && CODEX_TOOL_ITEMS.has(e.item.type) ? e.item.id : null;
265
276
  const open = e.type === "item.started" ? true : e.type === "item.completed" ? false : null;
277
+ const activity = codexActivity(e);
266
278
  return {
267
279
  ...(f.thread ? { thread: f.thread } : {}),
268
280
  ...(id !== null && open !== null ? { item: { id, open } } : {}),
269
281
  ...(e.type === "turn.completed" ? { tokens: f.tokens } : {}),
270
282
  lastEvent: f.lastEvent ?? undefined,
271
- ...(codexActivity(e) ? { activity: codexActivity(e) } : {}),
283
+ ...(activity ? { activity } : {}),
272
284
  ...(f.turnFailed ? { failure: f.failure ?? "turn failed" } : {}),
273
285
  ...(f.limit ? { limit: true } : {}),
274
286
  ...(f.tooOld ? { tooOld: true } : {}),
@@ -100,7 +100,13 @@ function plan(r: RunRequest): SpawnPlan {
100
100
  }
101
101
 
102
102
  async function listModels(): Promise<DiscoveredModel[]> {
103
- const r = await runCli(cursorBin(), ["models"], { ...cursorShell, env: { NO_OPEN_BROWSER: "1" } });
103
+ const bin = cursorBin();
104
+ // an `agent` that is not Cursor's (grok's, say) is never asked for models: nothing to list, as when absent
105
+ if (bin === "agent") {
106
+ const v = await runCli(bin, ["--version"], cursorShell).catch(() => null);
107
+ if (!v || !CURSOR_VERSION.test(v.out)) return [];
108
+ }
109
+ const r = await runCli(bin, ["models"], { ...cursorShell, env: { NO_OPEN_BROWSER: "1" } });
104
110
  return r?.ok ? parseCursorModels(r.out) : [];
105
111
  }
106
112
 
@@ -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
@@ -50,7 +50,12 @@ export const main: Command = defineCommand({
50
50
  doctor: () => import("./entry/doctor-command.ts").then((m) => m.doctorCommand),
51
51
  catalog: () => import("./entry/catalog-command.ts").then((m) => m.catalogCommand),
52
52
  knowledge: () => import("./entry/knowledge-command.ts").then((m) => m.knowledgeCommand),
53
+ workspace: () => import("./entry/workspace-command.ts").then((m) => m.workspaceCommand),
53
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),
54
59
  "capture-fixtures": () =>
55
60
  import("./entry/capture-fixtures-command.ts").then((m) => m.captureFixturesCommand),
56
61
  mcp: () => import("./entry/mcp/command.ts").then((m) => m.mcpCommand),
@@ -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