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.
- package/README.md +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
);
|
package/src/entry/mcp/server.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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(
|
|
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
|
|
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
|
-
|
|
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,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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: {
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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) =>
|
|
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))),
|