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
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ import { errorMessage } from "../../domain/errors.ts";
3
+ import { ROLES } from "../../domain/roles.ts";
4
+ import { restoreTmpdir } from "../../infra/env.ts";
5
+ import { startRoleMcpServer } from "./role-server.ts";
6
+
7
+ if (process.argv[2] === "--probe") {
8
+ // doctor's start probe: the server's module graph is loaded by now (spec 1.5 plan 21)
9
+ console.log("ready");
10
+ process.exit(0);
11
+ }
12
+
13
+ try {
14
+ const [role, run] = z.tuple([z.enum(ROLES), z.string().min(1)]).parse(process.argv.slice(2));
15
+ restoreTmpdir(process.env);
16
+ await startRoleMcpServer({ role, run });
17
+ } catch (e) {
18
+ console.error(`catherd role MCP: ${errorMessage(e)}`);
19
+ process.exitCode = 1;
20
+ }
@@ -0,0 +1,116 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
+ import { z } from "zod";
5
+ import { ID_PATTERN } from "../../domain/ids.ts";
6
+ import { ROLE_MCP_SERVER, type RoleMcpContext, roleMcpTools } from "../../domain/role-tools.ts";
7
+ import { log } from "../../infra/log.ts";
8
+ import { gateCheckOrList, gatePass } from "../../services/gate-service.ts";
9
+ import type { Deps } from "../../services/ports.ts";
10
+ import { readKnowledge, readRunFile, writeRunFile } from "../../services/run-service.ts";
11
+ import { findRun } from "../../services/run-store.ts";
12
+ import { defaultDeps } from "../deps.ts";
13
+ import { handle, sdkToolError, toolOf } from "./result.ts";
14
+
15
+ /** A role can reach only its run artifacts and, for a verifier, that run's gate evidence. */
16
+ export function buildRoleServer(context: RoleMcpContext, deps: Deps = defaultDeps()): McpServer {
17
+ const run = findRun(context.run);
18
+ const server = new McpServer({ name: ROLE_MCP_SERVER, version: deps.version });
19
+ const tools = new Set(roleMcpTools(context.role));
20
+ const boundRun = z.literal(run.id);
21
+ const runFile = { run: boundRun, path: z.string().min(1) };
22
+ const call = async (tool: string, f: () => unknown) => {
23
+ const started = Date.now();
24
+ const result = await handle(f);
25
+ log(result.isError ? "warn" : "info", "tool", {
26
+ tool,
27
+ ms: Date.now() - started,
28
+ ok: !result.isError,
29
+ code: result.isError ? (result.structuredContent as { code?: string } | undefined)?.code : undefined,
30
+ });
31
+ return result;
32
+ };
33
+ (server as unknown as { createToolError: typeof sdkToolError }).createToolError = (message) => {
34
+ const result = sdkToolError(message);
35
+ log("warn", "tool", {
36
+ tool: toolOf(message),
37
+ ok: false,
38
+ code: (result.structuredContent as { code?: string } | undefined)?.code,
39
+ });
40
+ return result;
41
+ };
42
+
43
+ server.registerTool(
44
+ "read_run_file",
45
+ {
46
+ description: "Read an artifact from this role's run folder, using a run-relative path.",
47
+ inputSchema: runFile,
48
+ annotations: { readOnlyHint: true, openWorldHint: false },
49
+ },
50
+ (a) => call("read_run_file", () => readRunFile(a)),
51
+ );
52
+ server.registerTool(
53
+ "read_knowledge",
54
+ {
55
+ description:
56
+ "Read what past runs learned about this role's repository. Takes no argument: it reads the run's own repository.",
57
+ // #42 finding 4: `.`, the pwd or /tmp vs /private/tmp never matched a literal; any repo given is ignored
58
+ inputSchema: { repo: z.string().optional() },
59
+ annotations: { readOnlyHint: true, openWorldHint: false },
60
+ },
61
+ () => call("read_knowledge", () => readKnowledge(run.meta.repo)),
62
+ );
63
+ if (tools.has("write_run_file"))
64
+ server.registerTool(
65
+ "write_run_file",
66
+ {
67
+ description: "Write a plan, lane, dossier or note in this run; catherd's own files are protected.",
68
+ inputSchema: { ...runFile, content: z.string() },
69
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
70
+ },
71
+ (a) => call("write_run_file", () => writeRunFile(a)),
72
+ );
73
+ if (tools.has("gate_check")) {
74
+ const gate = {
75
+ run: boundRun,
76
+ item: z.string().min(1),
77
+ command: z.string().min(1),
78
+ paths: z.array(z.string().min(1)).min(1),
79
+ };
80
+ server.registerTool(
81
+ "gate_check",
82
+ {
83
+ description:
84
+ "Check whether this run's gate item passed on unchanged content; records the current step. With only run and milestone, lists the items already checked for that milestone in this run, each with its command and whether it passed: reuse those names and re-check the failed ones first.",
85
+ inputSchema: {
86
+ run: boundRun,
87
+ item: gate.item.optional(),
88
+ command: gate.command.optional(),
89
+ paths: gate.paths.optional(),
90
+ milestone: z.string().regex(ID_PATTERN).optional(),
91
+ },
92
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
93
+ },
94
+ (a) => call("gate_check", () => gateCheckOrList(deps, a)),
95
+ );
96
+ server.registerTool(
97
+ "gate_pass",
98
+ {
99
+ description: "Record this run's passed gate item with the evidence, command and content hash.",
100
+ inputSchema: { ...gate, evidence: z.string().min(1) },
101
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
102
+ },
103
+ (a) => call("gate_pass", () => gatePass(deps, a)),
104
+ );
105
+ }
106
+ return server;
107
+ }
108
+
109
+ /** Role servers have no orchestrator ownership, notifier, reconciliation or background catalog sync. */
110
+ export async function startRoleMcpServer(
111
+ context: RoleMcpContext,
112
+ o: { deps?: Deps; transport?: Transport } = {},
113
+ ): Promise<void> {
114
+ const server = buildRoleServer(context, o.deps);
115
+ await server.connect(o.transport ?? new StdioServerTransport());
116
+ }
@@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { ID_PATTERN } from "../../domain/ids.ts";
4
4
  import { ROLES } from "../../domain/roles.ts";
5
+ import { knownQueueCapability } from "../../infra/codex-queue.ts";
5
6
  import { redact } from "../../infra/log.ts";
6
7
  import { registerSavedSecrets } from "../../services/jev-service.ts";
7
8
  import type { Deps } from "../../services/ports.ts";
@@ -14,6 +15,7 @@ import {
14
15
  startRun,
15
16
  writeRunFile,
16
17
  } from "../../services/run-service.ts";
18
+ import { repin } from "../../services/run-pin.ts";
17
19
  import { runsSummary, status } from "../../services/summary.ts";
18
20
  import { handle } from "./result.ts";
19
21
 
@@ -22,14 +24,25 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
22
24
  "run_start",
23
25
  {
24
26
  description:
25
- "Start a catherd run for a git repository: creates its run folder (outside the repo) and state.md. Returns the run id, the folder, protocol (the next step of the milestone loop and its six-line checklist), and hints when state.md could not be written yet.",
27
+ "Start a catherd run for a git repository: creates its run folder (outside the repo) and state.md. Returns the run id, the folder, protocol (the next step of the milestone loop and its six-line checklist), and hints when state.md could not be written yet. from: the run this one takes over (a planning run handed to the execution run in a worktree): it is closed with a pointer here, and status hides it.",
26
28
  inputSchema: {
27
29
  repo: z.string().min(1),
28
30
  title: z.string().min(1),
29
31
  a_lines: z.array(z.string().min(1)).min(1),
32
+ from: z.string().regex(ID_PATTERN).optional(),
30
33
  },
31
34
  },
32
- (a) => handle(() => startRun(deps, { repo: a.repo, title: a.title, aLines: a.a_lines })),
35
+ (a) => handle(() => startRun(deps, { repo: a.repo, title: a.title, aLines: a.a_lines, from: a.from })),
36
+ );
37
+
38
+ server.registerTool(
39
+ "run_pin",
40
+ {
41
+ description:
42
+ "Re-pin a run to what its repo runs on now: the profile, each role's access and each backend's isolation. run_start pins them; while they differ, dispatch keeps the pinned values and status and state.md say what changed. Only on the owner's word. Returns the new pin and what it changed. Orchestrator only.",
43
+ inputSchema: { run: z.string().regex(ID_PATTERN) },
44
+ },
45
+ (a) => handle(() => repin(deps, a)),
33
46
  );
34
47
 
35
48
  server.registerTool(
@@ -55,13 +68,15 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
55
68
  "status",
56
69
  {
57
70
  description:
58
- "catherd's version, and one screen per run: the state.md tail, live roles, totals, reported Claude subagents, Jev fallbacks, per backend how many dispatches ran native and isolated, budget and landed milestones. Without a run: every run with live roles, else the newest. Reads only.",
71
+ "catherd's version, and one screen per run: the state.md tail, live roles, unread results waiting for the orchestrator, totals, reported Claude subagents, Jev fallbacks, per backend how many dispatches ran native and isolated, budget and landed milestones. Without a run: every run with live roles or unread completions waiting for its orchestrator, else the newest. Reads only.",
59
72
  inputSchema: { run: z.string().optional() },
60
73
  },
61
74
  (a) =>
62
- handle(() => {
75
+ handle(async () => {
63
76
  registerSavedSecrets();
64
- return redact(status(deps, a.run));
77
+ const queue =
78
+ deps.host.host === "codex" && !deps.host.conflict ? knownQueueCapability(process.env) : null;
79
+ return redact(status(deps, a.run, queue));
65
80
  }),
66
81
  );
67
82
 
@@ -89,7 +104,7 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
89
104
  "record_agent_run",
90
105
  {
91
106
  description:
92
- "After every native Claude subagent (Agent tool) of a run, record what the Agent result reported: total_tokens and duration_ms. The budget counts it. Pass lane (e.g. M1.L1) when the subagent worked a lane, so its time counts toward that lane's kind in the catalog timings. status says how the subagent's work ended: for a verifier it is the verdict, so pass status: \"failed\" when its verdict is FAIL; only a PASS is recorded ok.",
107
+ 'After every native Claude subagent (Agent tool) of a run, record what the Agent result reported: total_tokens and duration_ms. The budget counts it. Pass lane (e.g. M1.L1) when the subagent worked a lane, so its time counts toward that lane\'s kind in the catalog timings. status says how the subagent\'s work ended: for a verifier it is the verdict, so pass status: "failed" when its verdict is FAIL; only a PASS is recorded ok. For a verifier, pass verdict: the first line of its reply; a VERDICT: BLOCKED: environment — <probe> (status "failed") is then a blocker to surface to the owner, not a fix round. Pass reply_status, the STATUS word its reply ends with: a reviewer that stopped partial is not the milestone review.',
93
108
  inputSchema: {
94
109
  run: z.string(),
95
110
  name: z.string().regex(ID_PATTERN),
@@ -100,6 +115,8 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
100
115
  cost_usd: z.number().nonnegative().optional(),
101
116
  status: z.enum(["ok", "failed", "cancelled"]).default("ok"),
102
117
  lane: z.string().regex(ID_PATTERN).optional(),
118
+ verdict: z.string().min(1).optional(),
119
+ reply_status: z.enum(["complete", "partial", "blocked", "refused", "flaky"]).optional(),
103
120
  },
104
121
  },
105
122
  (a) =>
@@ -114,6 +131,8 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
114
131
  durationMs: a.duration_ms,
115
132
  costUsd: a.cost_usd,
116
133
  status: a.status,
134
+ verdict: a.verdict,
135
+ replyStatus: a.reply_status,
117
136
  }),
118
137
  ),
119
138
  );
@@ -1,34 +1,134 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
4
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
5
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
5
- import { errorMessage } from "../../domain/errors.ts";
6
+ import { sessionKey, type HostContext } from "../../domain/host.ts";
7
+ import { type CatherdError, errorMessage, isCatherdError } from "../../domain/errors.ts";
8
+ import {
9
+ isCoordinatorTool,
10
+ refusedForRole,
11
+ type RoleScope,
12
+ roleScopeError,
13
+ } from "../../domain/role-scope.ts";
14
+ import { takeBootLock } from "../../infra/boot-lock.ts";
15
+ import { resolveHost } from "../../infra/host-context.ts";
6
16
  import { log } from "../../infra/log.ts";
17
+ import { assertRoleMay } from "../../services/role-access.ts";
18
+ import { currentSession } from "../../services/sessions.ts";
7
19
  import { startNotifier } from "../../services/notifier.ts";
8
20
  import type { Deps } from "../../services/ports.ts";
9
- import { reconcileAll } from "../../services/reconcile.ts";
21
+ import { adoptOwned, reconcileAll } from "../../services/reconcile.ts";
10
22
  import { backgroundSync } from "../../services/source-sync.ts";
11
23
  import { defaultDeps } from "../deps.ts";
12
24
  import { registerDispatchTools } from "./dispatch-tools.ts";
13
25
  import { registerLaneTools } from "./lane-tools.ts";
14
26
  import { registerProtocolTools } from "./protocol-tools.ts";
15
- import { sdkToolError, toolOf } from "./result.ts";
27
+ import { registerPushTools } from "./push-tools.ts";
28
+ import { handle, sdkToolError, toolOf } from "./result.ts";
16
29
  import { registerRunTools } from "./run-tools.ts";
17
30
  import { registerSetupTools } from "./setup-tools.ts";
31
+ import { registerWorkspaceTools } from "./workspace-tools.ts";
32
+
33
+ type ObserveSession = (context: Deps) => (() => void) | undefined;
34
+
35
+ /** Spec 1.5 plan 21: a coordinator tool's description says, first, that only the orchestrator calls it. */
36
+ function orchestratorOnly(name: string, config: unknown): unknown {
37
+ const c = config as { description?: string };
38
+ return isCoordinatorTool(name) && c.description
39
+ ? { ...c, description: `Orchestrator only (a role process gets E_ROLE_SCOPE). ${c.description}` }
40
+ : config;
41
+ }
42
+
43
+ type RequestHandler = (
44
+ request: { params?: { name?: unknown; arguments?: unknown } },
45
+ extra: unknown,
46
+ ) => unknown;
47
+
48
+ /** The role server's tools that act on a run: in a role they are bound as their CLI forms are (assertRoleMay). */
49
+ const RUN_BOUND_TOOLS = new Set(["read_run_file", "write_run_file", "gate_check", "gate_pass"]);
50
+
51
+ /**
52
+ * Why a role process may not make this call on the full server, else null (plan 21 review, finding 3): a
53
+ * coordinator tool; `record_agent_run`, which could fabricate a verifier's ok; or a run-bound tool on another
54
+ * run or outside its role's tool set, as `catherd run-file` and `catherd gate` refuse it.
55
+ */
56
+ function roleRefusal(name: string, args: unknown, role: RoleScope): CatherdError | null {
57
+ if (refusedForRole(name, args, role) || name === "record_agent_run") return roleScopeError(name, role);
58
+ if (!RUN_BOUND_TOOLS.has(name)) return null;
59
+ const run = (args as { run?: unknown } | undefined)?.run;
60
+ try {
61
+ assertRoleMay(role, typeof run === "string" ? run : "", name);
62
+ return null;
63
+ } catch (e) {
64
+ if (isCatherdError(e)) return e;
65
+ throw e;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Spec 1.5 plan 21: in a role's process (`deps.role`) a coordinator tool call is refused with E_ROLE_SCOPE
71
+ * before anything else, its input check included, so a role learns at once that the tool is not its own. The
72
+ * SDK keeps its request handlers in a private map; test/entry/role-scope-mcp.test.ts fails if it moves.
73
+ */
74
+ function refuseCoordinatorTools(server: McpServer, deps: Deps): void {
75
+ const handlers = (server.server as unknown as { _requestHandlers: Map<string, RequestHandler> })
76
+ ._requestHandlers;
77
+ const callTool = handlers.get("tools/call");
78
+ if (!callTool) return;
79
+ handlers.set("tools/call", async (request, extra) => {
80
+ const role = deps.role;
81
+ const name = String(request.params?.name ?? "");
82
+ const refusal = role ? roleRefusal(name, request.params?.arguments, role) : null;
83
+ if (!refusal) return callTool(request, extra);
84
+ const r = await handle(() => {
85
+ throw refusal;
86
+ });
87
+ log("warn", "tool", { tool: name, ok: false, code: "E_ROLE_SCOPE" });
88
+ return r;
89
+ });
90
+ }
18
91
 
19
92
  type Handler = (...args: unknown[]) => CallToolResult | Promise<CallToolResult>;
20
93
 
21
94
  /** Spec §10.2: every tool call is logged with its duration and outcome (the error code), never its input. */
22
- function logToolCalls(server: McpServer): void {
95
+ function logToolCalls(
96
+ server: McpServer,
97
+ deps: Deps,
98
+ scope: AsyncLocalStorage<Deps>,
99
+ generation: () => number,
100
+ observe?: ObserveSession,
101
+ ): void {
23
102
  const register = server.registerTool.bind(server) as unknown as (
24
103
  n: string,
25
104
  c: unknown,
26
105
  h: Handler,
27
106
  ) => unknown;
28
107
  (server as unknown as { registerTool: typeof register }).registerTool = (name, config, handler) =>
29
- register(name, config, async (...args: unknown[]) => {
108
+ register(name, orchestratorOnly(name, config), async (...args: unknown[]) => {
30
109
  const started = Date.now();
31
- const r = await handler(...args);
110
+ const epoch = generation();
111
+ const extra = args.at(-1) as { _meta?: Record<string, unknown> } | undefined;
112
+ let host = deps.host;
113
+ if (host.host === "codex" && !host.conflict && extra?._meta && "threadId" in extra._meta) {
114
+ const threadId = extra._meta.threadId;
115
+ host =
116
+ typeof threadId === "string"
117
+ ? resolveHost({
118
+ clientName: "codex-mcp-client",
119
+ env: { CODEX_THREAD_ID: threadId, CODEX_SESSION_ID: host.session?.sessionId },
120
+ })
121
+ : { host: "unknown", session: null, conflict: "Codex _meta.threadId must be a UUID string" };
122
+ }
123
+ const context: Deps = {
124
+ ...deps,
125
+ get host(): HostContext {
126
+ return epoch === generation() ? host : { host: "unknown", session: null, conflict: null };
127
+ },
128
+ };
129
+ const after = observe?.(context);
130
+ const r = await scope.run(context, () => handler(...args));
131
+ after?.();
32
132
  const code = r.isError ? (r.structuredContent as { code?: string } | undefined)?.code : undefined;
33
133
  log(r.isError ? "warn" : "info", "tool", {
34
134
  tool: name,
@@ -40,9 +140,25 @@ function logToolCalls(server: McpServer): void {
40
140
  });
41
141
  }
42
142
 
43
- export function buildServer(deps: Deps = defaultDeps()): McpServer {
143
+ export function buildServer(deps: Deps = defaultDeps(), observe?: ObserveSession): McpServer {
44
144
  const server = new McpServer({ name: "catherd", version: deps.version });
45
- logToolCalls(server);
145
+ const scope = new AsyncLocalStorage<Deps>();
146
+ const scoped: Deps = new Proxy(deps, {
147
+ get: (target, key) => (key === "profiles" ? profiles : Reflect.get(scope.getStore() ?? target, key)),
148
+ });
149
+ const profiles = deps.profiles.withHost?.(() => scoped.host) ?? deps.profiles;
150
+ let generation = 0;
151
+ const invalidate = () => {
152
+ generation++;
153
+ deps.host = { host: "unknown", session: null, conflict: null };
154
+ };
155
+ logToolCalls(server, deps, scope, () => generation, observe);
156
+ deps.host = { host: "unknown", session: null, conflict: null };
157
+ server.server.oninitialized = () => {
158
+ invalidate();
159
+ deps.host = resolveHost({ clientName: server.server.getClientVersion()?.name, env: process.env });
160
+ };
161
+ server.server.onclose = invalidate;
46
162
  // The SDK validates input before a tool's `handle` runs and reports a failure through this (private)
47
163
  // method as plain text; test/entry/mcp.test.ts fails loudly if an SDK upgrade renames it. The wrapper
48
164
  // above never sees such a call, so it is logged here (spec §10.2: every call), by the tool it names.
@@ -52,47 +168,111 @@ export function buildServer(deps: Deps = defaultDeps()): McpServer {
52
168
  log("warn", "tool", { tool: toolOf(message), ok: false, code });
53
169
  return r;
54
170
  };
55
- registerRunTools(server, deps);
56
- registerLaneTools(server, deps);
57
- registerDispatchTools(server, deps);
58
- registerSetupTools(server, deps);
59
- registerProtocolTools(server, deps);
171
+ registerRunTools(server, scoped);
172
+ registerWorkspaceTools(server, scoped);
173
+ registerLaneTools(server, scoped);
174
+ registerDispatchTools(server, scoped);
175
+ registerSetupTools(server, scoped);
176
+ registerProtocolTools(server, scoped);
177
+ registerPushTools(server, scoped);
178
+ refuseCoordinatorTools(server, deps);
60
179
  return server;
61
180
  }
62
181
 
63
- /**
64
- * Spec §4.7: connect first, so the client never waits on a scan; then reconcile every run. Spec §3.4: the notifier
65
- * starts before reconcile, so what reconcile settles is announced, and scans for the rest once it is done. Spec
66
- * 1.2 §3.2: the source sync starts once connected and is never awaited, so neither the handshake nor a tool call
67
- * waits on the network. Tests pass their own transport and sync.
68
- */
182
+ /** Connect immediately; initialized triggers recovery without delaying the handshake. */
69
183
  export async function startMcpServer(
70
- o: { transport?: Transport; sync?: () => Promise<unknown> } = {},
184
+ o: {
185
+ transport?: Transport;
186
+ sync?: () => Promise<unknown>;
187
+ deps?: Deps;
188
+ /** plan 22: the boot lock (tests replace it); null while another live server holds it */
189
+ bootLock?: () => (() => void) | null;
190
+ } = {},
71
191
  ): Promise<void> {
72
- const deps = defaultDeps();
73
- // which of the session's variables this server got (never their values): the live check of spec §3.9
74
- log("info", "session", {
75
- sessionId: Boolean(deps.session?.sessionId),
76
- hostSessionId: Boolean(deps.session?.hostSessionId),
77
- socket: Boolean(deps.session?.socketPath),
78
- token: Boolean(deps.session?.token),
192
+ const deps = o.deps ?? defaultDeps();
193
+ const observed = new Map<string, ReturnType<typeof startNotifier>>();
194
+ const server = buildServer(deps, (context) => {
195
+ const target = currentSession(context);
196
+ if (!target) return;
197
+ const key = sessionKey(target);
198
+ let active = observed.get(key);
199
+ if (!active) {
200
+ active = startNotifier(context);
201
+ observed.set(key, active);
202
+ }
203
+ const n = active;
204
+ return () => {
205
+ if (observed.get(key) === n) void n.scan();
206
+ };
79
207
  });
80
- const notifier = startNotifier(deps);
81
- await buildServer(deps).connect(o.transport ?? new StdioServerTransport());
82
- const sync = o.sync ?? (() => backgroundSync());
83
- void Promise.resolve()
84
- .then(sync)
85
- .catch((e: unknown) => log("debug", "sources", { error: errorMessage(e) }));
86
- try {
87
- const r = await reconcileAll(deps);
88
- const shown = r.warnings.length;
89
- for (const w of r.warnings) console.error(`catherd: ${w}`);
90
- void r.done.then(() => {
91
- for (const w of r.warnings.slice(shown)) console.error(`catherd: ${w}`);
208
+ const initialized = server.server.oninitialized!;
209
+ const closed = server.server.onclose!;
210
+ let generation = 0;
211
+ let notifier: ReturnType<typeof startNotifier> | undefined;
212
+ let recovery: Deps | undefined;
213
+ // plan 22: taken at the first initialize, held until this server closes; null: another server leads the boot
214
+ let boot: (() => void) | null | undefined;
215
+ const invalidate = () => {
216
+ generation++;
217
+ if (recovery) recovery.host = { host: "unknown", session: null, conflict: null };
218
+ notifier?.stop();
219
+ notifier = undefined;
220
+ for (const n of observed.values()) n.stop();
221
+ observed.clear();
222
+ closed();
223
+ };
224
+ server.server.onclose = () => {
225
+ invalidate();
226
+ boot?.();
227
+ boot = undefined;
228
+ };
229
+ server.server.oninitialized = () => {
230
+ invalidate();
231
+ initialized();
232
+ boot ??= (o.bootLock ?? takeBootLock)();
233
+ const lead = boot !== null;
234
+ if (!lead) log("info", "boot", { skipped: "another catherd server runs the boot sync and reconcile" });
235
+ log("info", "session", {
236
+ host: deps.host.host,
237
+ conflict: deps.host.conflict,
238
+ sessionId: Boolean(deps.session?.sessionId),
239
+ hostSessionId: Boolean(deps.session?.hostSessionId),
240
+ socket: Boolean(deps.session?.socketPath),
241
+ token: Boolean(deps.session?.token),
92
242
  });
93
- } catch (e) {
94
- console.error(`catherd: reconcile failed: ${errorMessage(e)}`);
95
- }
96
- // whatever finished unread while no server ran, or finished under another server (spec §3.4)
97
- void notifier.scan();
243
+ const epoch = generation;
244
+ recovery = { ...deps };
245
+ const context = recovery;
246
+ notifier = startNotifier(context);
247
+ const active = notifier;
248
+ const target = currentSession(context);
249
+ if (target) observed.set(sessionKey(target), active);
250
+ void Promise.resolve()
251
+ .then(() => {
252
+ if (lead && epoch === generation) return (o.sync ?? (() => backgroundSync()))();
253
+ })
254
+ .catch((e: unknown) => log("debug", "sources", { error: errorMessage(e) }));
255
+ void (async () => {
256
+ try {
257
+ if (epoch !== generation) return;
258
+ // the leading server reconciles every run; this one still watches the live roles of the runs its own
259
+ // session owns (a coordinator's server restarted mid-run) and tells its session what it owns
260
+ if (!lead) {
261
+ adoptOwned(context);
262
+ return void active.scan();
263
+ }
264
+ const r = await reconcileAll(context);
265
+ if (epoch !== generation) return;
266
+ const shown = r.warnings.length;
267
+ for (const w of r.warnings) console.error(`catherd: ${w}`);
268
+ void r.done.then(() => {
269
+ if (epoch === generation) for (const w of r.warnings.slice(shown)) console.error(`catherd: ${w}`);
270
+ });
271
+ if (epoch === generation) void active.scan();
272
+ } catch (e) {
273
+ console.error(`catherd: reconcile failed: ${errorMessage(e)}`);
274
+ }
275
+ })();
276
+ };
277
+ await server.connect(o.transport ?? new StdioServerTransport());
98
278
  }
@@ -59,7 +59,8 @@ export function registerSetupTools(server: McpServer, deps: Deps): void {
59
59
  },
60
60
  (a) =>
61
61
  handle(async () => {
62
- const r = await (deps.sync ?? ((o) => syncSources(o)))({ force: a.force });
62
+ // (1.2 minor) a sync already running (the boot sync) answers busy at once, never a 120 s wait
63
+ const r = await (deps.sync ?? ((o) => syncSources(o)))({ force: a.force, wait: false });
63
64
  return {
64
65
  newlyScored: r.newlyScored,
65
66
  standInsNoLongerNeeded: r.noLongerNeeded,
@@ -76,16 +77,21 @@ export function registerSetupTools(server: McpServer, deps: Deps): void {
76
77
  {
77
78
  description:
78
79
  "A profile (without a name: the profile this repo runs on, i.e. the one bound to `repo`, default this server's directory, else the active one) as the run engine reads it, with every default filled in: per role its access mode, rungs and default rung, and whether its backend enforces the access (enforcement); billing, jev, harness isolation, failover, budget, timeouts, preflight, lock and notify. Also `active` (the global active profile), `here` (the profile this repo runs on) and every name.",
79
- inputSchema: { name: PROFILE, repo: REPO },
80
+ inputSchema: { name: PROFILE, repo: REPO, raw: z.boolean().optional() },
80
81
  },
81
- (a) => handle(async () => deps.profiles.get(a.name, await toplevel(a.repo))),
82
+ (a) =>
83
+ handle(async () =>
84
+ a.raw && deps.profiles.raw
85
+ ? deps.profiles.raw(a.name, await toplevel(a.repo))
86
+ : deps.profiles.get(a.name, await toplevel(a.repo)),
87
+ ),
82
88
  );
83
89
 
84
90
  server.registerTool(
85
91
  "profile_validate",
86
92
  {
87
93
  description:
88
- "Check a profile (without a name: the profile this repo runs on, as in profile_get). errors block a save: the worker disabled, an enabled role with no usable rung, an unscored rung without a 'treat like', a failover stand-in unscored or on the same quota, a backend catherd cannot run. warnings do not: an access mode other than the role's default, a model the backend's listing lacks, a stand-in that never runs. Each has a path, a message and often a fix.",
94
+ "Check a profile (without a name: the profile this repo runs on, as in profile_get). errors block a save: the worker disabled, an enabled role with no usable rung, an unscored rung without a 'treat like', a failover stand-in unscored or on the same quota, a backend catherd cannot run. warnings do not: an access mode other than the role's default, a model the backend's listing lacks, a stand-in that never runs, a quota none of whose rungs ever starts a lane, and every kind and difficulty no worker rung clears. Each has a path, a message and often a fix.",
89
95
  inputSchema: { name: PROFILE, repo: REPO },
90
96
  },
91
97
  (a) => handle(async () => deps.profiles.validate(a.name, await toplevel(a.repo))),
@@ -95,7 +101,7 @@ export function registerSetupTools(server: McpServer, deps: Deps): void {
95
101
  "profile_set",
96
102
  {
97
103
  description:
98
- "Apply a patch to a profile (without a name: the profile this repo runs on, as in profile_get; a new name starts from the default profile): objective, jev.use, billing, roles (enabled, access, network, rungs, defaultRung), harness isolation per backend, failover, budget, timeouts, preflight.confirm, lock.heavy and notify. Lists replace, maps merge, and null removes a key. An unknown key is refused. It validates first and writes nothing when invalid, unless the patch repairs an invalid profile: it fixes at least one of its errors and adds none, and then it saves with `errors` listing those still open. A save rewrites the agent files and relinks them. Returns saved, the errors and warnings, the diff, and newSessionNeededFor: the agents that apply from the next Claude Code session.",
104
+ "Apply a patch to a profile (without a name: the profile this repo runs on, as in profile_get; a new name starts from the default profile): objective, jev.use, billing, roles (enabled, access, network, rungs, defaultRung, timeouts: a role's own idleMin and wallMin over the profile's), harness isolation per backend, failover, budget, timeouts, preflight.confirm, lock.heavy and notify. Lists replace, maps merge, and null removes a key. An unknown key is refused. It validates first and writes nothing when invalid, unless the patch repairs an invalid profile: it fixes at least one of its errors and adds none, and then it saves with `errors` listing those still open. A save rewrites the agent files and relinks them. Returns saved, the errors and warnings, the diff, and newSessionNeededFor: the agents that apply from the next Claude Code session.",
99
105
  inputSchema: { name: PROFILE, repo: REPO, patch: ProfilePatchSchema },
100
106
  },
101
107
  (a) => handle(async () => deps.profiles.set(a.name, a.patch, await toplevel(a.repo))),