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
package/src/services/doctor.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { queueCapability, type QueueCapability } from "../infra/codex-queue.ts";
|
|
2
|
+
import type { HostContext } from "../domain/host.ts";
|
|
1
3
|
import { existsSync, statSync } from "node:fs";
|
|
2
4
|
import { adapterFor } from "../adapters/registry.ts";
|
|
3
5
|
import "../adapters/all.ts";
|
|
@@ -5,13 +7,17 @@ import { BUILTIN_ROLES, type Profile } from "../domain/profile.ts";
|
|
|
5
7
|
import { DEFAULT_ACCESS, ROLES } from "../domain/roles.ts";
|
|
6
8
|
import { bunTooOld, MIN_BUN } from "../domain/runtime.ts";
|
|
7
9
|
import type { JevTransport } from "../infra/jev-client.ts";
|
|
10
|
+
import type { RoleServerStart } from "../infra/role-mcp.ts";
|
|
8
11
|
import { linkedProfiles } from "./agent-links.ts";
|
|
9
12
|
import { accessChecks } from "./doctor-access.ts";
|
|
10
13
|
import { backendChecks, usedBackends } from "./doctor-backends.ts";
|
|
11
|
-
import { type PushProbe, pushCheck } from "./doctor-push.ts";
|
|
14
|
+
import { type PushProbe, pushCheck, probePush } from "./doctor-push.ts";
|
|
12
15
|
import { sourcesCheck, standInsToConfirmIn } from "./doctor-sources.ts";
|
|
16
|
+
import { cachesCheck, dockerChecks } from "./doctor-docker.ts";
|
|
17
|
+
import { roleMcpChecks } from "./doctor-role-mcp.ts";
|
|
13
18
|
import {
|
|
14
19
|
agentsCheck,
|
|
20
|
+
hostFlag,
|
|
15
21
|
type Check,
|
|
16
22
|
errText,
|
|
17
23
|
fixOf,
|
|
@@ -31,9 +37,13 @@ import {
|
|
|
31
37
|
readProjects,
|
|
32
38
|
validateNamed,
|
|
33
39
|
} from "./profile-store.ts";
|
|
40
|
+
import { inspectionHost } from "./run-debug.ts";
|
|
34
41
|
import { listRuns } from "./run-store.ts";
|
|
35
42
|
|
|
36
43
|
export interface DoctorReport {
|
|
44
|
+
host: HostContext;
|
|
45
|
+
queue: QueueCapability | null;
|
|
46
|
+
push: PushProbe | null;
|
|
37
47
|
/** false when any check failed: `catherd doctor` exits 3 */
|
|
38
48
|
ready: boolean;
|
|
39
49
|
version: string;
|
|
@@ -49,20 +59,43 @@ export interface Handshake {
|
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
export interface DoctorDeps {
|
|
62
|
+
host: HostContext;
|
|
63
|
+
repo: string | null;
|
|
52
64
|
bunVersion: string;
|
|
53
65
|
/** the package version, which the Claude Code plugin must pin */
|
|
54
66
|
version: string;
|
|
55
67
|
/** starts the MCP server as the plugin does (its launcher) over stdio and asks it for tools/list */
|
|
56
68
|
handshake: () => Promise<Handshake>;
|
|
57
|
-
/**
|
|
69
|
+
/** Explicit smoke test injection; default doctor never invokes it. */
|
|
58
70
|
push?: () => Promise<PushProbe>;
|
|
71
|
+
testPush?: boolean;
|
|
72
|
+
/** plan 23: `--docker`: run the Docker host probes (a compose network by service name, the free disk) */
|
|
73
|
+
docker?: boolean;
|
|
74
|
+
env?: Record<string, string | undefined>;
|
|
59
75
|
jev?: JevTransport;
|
|
76
|
+
/** spec 1.5 plan 21: a cold start of the role server, timed; absent, doctor does not probe it */
|
|
77
|
+
roleServerStart?: () => Promise<RoleServerStart>;
|
|
60
78
|
}
|
|
61
79
|
|
|
62
80
|
/**
|
|
63
81
|
* Spec §10.3: the readiness report. Reads and probes; it writes discovery and a lock probe itself, and the
|
|
64
82
|
* handshake starts `catherd mcp`, which reconciles runs as it starts. A check that throws becomes a `fail` row.
|
|
65
83
|
*/
|
|
84
|
+
/**
|
|
85
|
+
* Where the access probes look for a per-repo sandbox: the current repo and each repo bound to a checked
|
|
86
|
+
* profile. A repo bound to another profile never runs these roles, so its settings must not decide the probes.
|
|
87
|
+
*/
|
|
88
|
+
export function accessRepos(
|
|
89
|
+
repo: string | null,
|
|
90
|
+
bindings: Record<string, string>,
|
|
91
|
+
checked: string[],
|
|
92
|
+
): string[] {
|
|
93
|
+
const bound = Object.entries(bindings)
|
|
94
|
+
.filter(([, name]) => checked.includes(name))
|
|
95
|
+
.map(([r]) => r);
|
|
96
|
+
return [...new Set([...(repo ? [repo] : []), ...bound])];
|
|
97
|
+
}
|
|
98
|
+
|
|
66
99
|
export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
67
100
|
const checks: Check[] = [];
|
|
68
101
|
checks.push(
|
|
@@ -75,16 +108,24 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
75
108
|
detail: `${d.bunVersion}, needs ${MIN_BUN}`,
|
|
76
109
|
fix: "bun upgrade",
|
|
77
110
|
}
|
|
78
|
-
: {
|
|
111
|
+
: {
|
|
112
|
+
id: "bun",
|
|
113
|
+
label: "Bun",
|
|
114
|
+
state: "ok",
|
|
115
|
+
word: "ready",
|
|
116
|
+
detail: d.bunVersion,
|
|
117
|
+
},
|
|
79
118
|
);
|
|
80
119
|
|
|
120
|
+
// every fix names the host doctor judged with: run elsewhere (a plain terminal), it must judge the same way
|
|
121
|
+
const onHost = hostFlag(d.host.host);
|
|
81
122
|
let profiles: Profile[] = [];
|
|
82
123
|
let linked: string[] = [];
|
|
83
124
|
let active: Profile | null = null;
|
|
84
125
|
try {
|
|
85
126
|
readConfig();
|
|
86
127
|
readProjects();
|
|
87
|
-
active = getProfile(activeName());
|
|
128
|
+
active = getProfile(activeName(d.repo), d.host.host);
|
|
88
129
|
linked = linkedProfiles();
|
|
89
130
|
checks.push({
|
|
90
131
|
id: "config",
|
|
@@ -106,7 +147,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
106
147
|
// one unreadable linked profile must not hide the others' backends: its own row below reports it
|
|
107
148
|
profiles = linked.flatMap((n) => {
|
|
108
149
|
try {
|
|
109
|
-
return [getProfile(n)];
|
|
150
|
+
return [getProfile(n, d.host.host)];
|
|
110
151
|
} catch {
|
|
111
152
|
return [];
|
|
112
153
|
}
|
|
@@ -118,7 +159,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
118
159
|
checks.push({
|
|
119
160
|
id: `binding:${repo}`,
|
|
120
161
|
label: `binding ${repo}`,
|
|
121
|
-
state: "fail",
|
|
162
|
+
state: repo === d.repo ? "fail" : "warn",
|
|
122
163
|
word: "missing",
|
|
123
164
|
detail: `bound to profile ${name}, which does not exist`,
|
|
124
165
|
fix: `cd ${repo} && catherd profile use --repo --clear`,
|
|
@@ -131,30 +172,33 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
131
172
|
const names = active ? [active.name, ...linked.filter((n) => n !== active?.name)] : [];
|
|
132
173
|
for (const name of names) {
|
|
133
174
|
const id = name === active?.name ? "profile" : `profile:${name}`;
|
|
134
|
-
const validate = `catherd profile validate ${name}`;
|
|
175
|
+
const validate = `catherd profile validate ${name}${onHost}`;
|
|
135
176
|
const named = (fix: string) =>
|
|
136
|
-
fix.replaceAll("catherd profile set ", `catherd profile set --profile ${name} `);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
);
|
|
177
|
+
fix.replaceAll("catherd profile set ", `catherd profile set --profile ${name}${onHost} `);
|
|
178
|
+
const row = guarded(id, `profile ${name}`, validate, () => {
|
|
179
|
+
const v = validateNamed(name, d.repo, d.host.host);
|
|
180
|
+
const first = v.errors[0] ?? v.warnings[0];
|
|
181
|
+
return {
|
|
182
|
+
id,
|
|
183
|
+
label: `profile ${name}`,
|
|
184
|
+
state: v.errors.length ? "fail" : v.warnings.length ? "warn" : "ok",
|
|
185
|
+
word: v.errors.length ? "invalid" : v.warnings.length ? "warning" : "ready",
|
|
186
|
+
detail: first
|
|
187
|
+
? `${first.path}: ${first.message}${v.errors.length + v.warnings.length > 1 ? " (and more)" : ""}`
|
|
188
|
+
: "valid",
|
|
189
|
+
...(first ? { fix: first.fix ? named(first.fix) : validate } : {}),
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
if (name !== active?.name && row.state === "fail") row.state = "warn";
|
|
193
|
+
checks.push(row);
|
|
153
194
|
}
|
|
154
195
|
|
|
196
|
+
const diagnostics = profiles;
|
|
197
|
+
profiles = active ? [active] : [];
|
|
155
198
|
const used = usedBackends(profiles);
|
|
156
199
|
const installed = new Set<string>();
|
|
157
|
-
checks.push(...(await backendChecks(used, profiles, installed)));
|
|
200
|
+
checks.push(...(await backendChecks(used, profiles, installed, d.host.host)));
|
|
201
|
+
checks.push(...roleMcpChecks(active, await d.roleServerStart?.()));
|
|
158
202
|
|
|
159
203
|
if (active && [active, ...profiles].every((p) => p.jev.use === "off"))
|
|
160
204
|
checks.push({
|
|
@@ -179,7 +223,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
179
223
|
const ok = await testJevKey(key, d.jev).catch(() => false);
|
|
180
224
|
checks.push(
|
|
181
225
|
ok
|
|
182
|
-
? {
|
|
226
|
+
? {
|
|
227
|
+
id: "jev",
|
|
228
|
+
label: "Jev",
|
|
229
|
+
state: "ok",
|
|
230
|
+
word: "ready",
|
|
231
|
+
detail: "the key answers",
|
|
232
|
+
}
|
|
183
233
|
: {
|
|
184
234
|
id: "jev",
|
|
185
235
|
label: "Jev",
|
|
@@ -194,18 +244,44 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
194
244
|
|
|
195
245
|
// spec 1.2 §9: the public sources' ages and errors, the Artificial Analysis key, and the active and linked
|
|
196
246
|
// profiles' stand-ins to confirm
|
|
197
|
-
const mine = [...(active ? [active] : []), ...
|
|
247
|
+
const mine = [...(active ? [active] : []), ...diagnostics.filter((p) => p.name !== active?.name)];
|
|
198
248
|
checks.push(
|
|
199
249
|
guarded("sources", "sources", "catherd catalog sync --force", () =>
|
|
200
250
|
sourcesCheck(Date.now(), standInsToConfirmIn(mine)),
|
|
201
251
|
),
|
|
202
252
|
);
|
|
203
253
|
|
|
204
|
-
|
|
254
|
+
const native = used.get("claude");
|
|
255
|
+
const plugin = pluginCheck(d.version);
|
|
256
|
+
if (d.host.host !== "claude-code" && native !== "role") {
|
|
257
|
+
plugin.state = native === "failover" ? "warn" : "skip";
|
|
258
|
+
plugin.word = native === "failover" ? "optional" : "not required";
|
|
259
|
+
}
|
|
260
|
+
checks.push(plugin);
|
|
205
261
|
|
|
262
|
+
// relinks the profile doctor checked: this repo's binding when it has one, else the active profile
|
|
263
|
+
let bound = false;
|
|
264
|
+
try {
|
|
265
|
+
bound = d.repo !== null && readProjects().bindings[d.repo] === active?.name;
|
|
266
|
+
} catch {
|
|
267
|
+
// the config row above already reports an unreadable projects.json
|
|
268
|
+
}
|
|
269
|
+
const relink = active
|
|
270
|
+
? `${bound ? `cd ${d.repo} && ` : ""}catherd profile use ${active.name}${bound ? " --repo" : ""}${onHost}`
|
|
271
|
+
: "";
|
|
206
272
|
checks.push(
|
|
207
273
|
active
|
|
208
|
-
? guarded("agents", "Claude agents",
|
|
274
|
+
? guarded("agents", "Claude agents", relink, () =>
|
|
275
|
+
native
|
|
276
|
+
? agentsCheck(d.host.host, [active!.name], relink)
|
|
277
|
+
: {
|
|
278
|
+
id: "agents",
|
|
279
|
+
label: "Claude agents",
|
|
280
|
+
state: "skip",
|
|
281
|
+
word: "not required",
|
|
282
|
+
detail: "selected profile has no native Claude roles",
|
|
283
|
+
},
|
|
284
|
+
)
|
|
209
285
|
: {
|
|
210
286
|
id: "agents",
|
|
211
287
|
label: "Claude agents",
|
|
@@ -215,19 +291,58 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
215
291
|
},
|
|
216
292
|
);
|
|
217
293
|
|
|
294
|
+
const agents = checks.find((c) => c.id === "agents");
|
|
295
|
+
if (native === "failover" && agents?.state === "fail") agents.state = "warn";
|
|
296
|
+
|
|
218
297
|
const h = await d
|
|
219
298
|
.handshake()
|
|
220
299
|
.catch((e: unknown): Handshake => ({ ok: false, tools: [], error: errText(e) }));
|
|
221
300
|
checks.push(mcpCheck(h, d.version));
|
|
222
301
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
302
|
+
const queue =
|
|
303
|
+
d.host.host === "codex" && !d.host.conflict ? await queueCapability(d.env ?? process.env) : null;
|
|
304
|
+
if (queue)
|
|
305
|
+
checks.push({
|
|
306
|
+
id: "push-capability",
|
|
307
|
+
label: "native Codex queue",
|
|
308
|
+
state:
|
|
309
|
+
!queue.cli || queue.server === "unsupported" ? "fail" : queue.server === "unverified" ? "warn" : "ok",
|
|
310
|
+
word: !queue.cli ? "unavailable" : queue.server,
|
|
311
|
+
detail: queue.reason ?? "Native server advertises queue support",
|
|
312
|
+
...(!queue.cli || queue.server === "unsupported"
|
|
313
|
+
? {
|
|
314
|
+
fix: "Update native Codex/server; use peek/result while push is unavailable.",
|
|
315
|
+
}
|
|
316
|
+
: {}),
|
|
317
|
+
});
|
|
318
|
+
let push: PushProbe | null = null;
|
|
319
|
+
if (d.testPush) {
|
|
320
|
+
push = await (d.push ?? (() => probePush(d.host, d.env ?? process.env)))().catch((): PushProbe => ({
|
|
321
|
+
outcome: "failed",
|
|
322
|
+
enqueue: "ambiguous",
|
|
323
|
+
processing: "unconfirmed",
|
|
324
|
+
msgId: null,
|
|
325
|
+
detail: "Smoke has no verified receipt; retry may duplicate input. Use peek/result.",
|
|
326
|
+
}));
|
|
327
|
+
checks.push(pushCheck(push));
|
|
328
|
+
}
|
|
227
329
|
|
|
228
330
|
checks.push(locksCheck());
|
|
331
|
+
// plan 23: the toolchain caches workers write, and Docker as a gate sees it (before the access probes)
|
|
332
|
+
checks.push(cachesCheck());
|
|
333
|
+
checks.push(...(await dockerChecks({ probe: d.docker === true })));
|
|
229
334
|
// spec §5 and §12: which codex sandbox form runs, and the five access probes per workspace-write backend
|
|
230
|
-
|
|
335
|
+
let repos: string[] = d.repo ? [d.repo] : [];
|
|
336
|
+
try {
|
|
337
|
+
repos = accessRepos(
|
|
338
|
+
d.repo,
|
|
339
|
+
readProjects().bindings,
|
|
340
|
+
profiles.map((p) => p.name),
|
|
341
|
+
);
|
|
342
|
+
} catch {
|
|
343
|
+
// the config row above already reports an unreadable projects.json
|
|
344
|
+
}
|
|
345
|
+
checks.push(...(await accessChecks(profiles, installed, repos)));
|
|
231
346
|
|
|
232
347
|
// spec 1.1 §13: what the shipped defaults do is info; a warning only for what a profile changed
|
|
233
348
|
const full = { changed: [] as string[], shipped: [] as string[] };
|
|
@@ -264,7 +379,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
264
379
|
word: "warning",
|
|
265
380
|
detail: `${what}: ${rows.changed.join(", ")}${shipped ? `; as shipped: ${shipped}` : ""}`,
|
|
266
381
|
}
|
|
267
|
-
: {
|
|
382
|
+
: {
|
|
383
|
+
id,
|
|
384
|
+
label,
|
|
385
|
+
state: "info",
|
|
386
|
+
word: "default",
|
|
387
|
+
detail: `${what}: ${shipped}`,
|
|
388
|
+
};
|
|
268
389
|
};
|
|
269
390
|
for (const row of [
|
|
270
391
|
accessRow("access:full", "full access", full, "no sandbox for"),
|
|
@@ -307,7 +428,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
307
428
|
detail: unreadable.message,
|
|
308
429
|
fix: unreadable.fix ?? `fix or delete ${creds}, then catherd init`,
|
|
309
430
|
}
|
|
310
|
-
: {
|
|
431
|
+
: {
|
|
432
|
+
id: "credentials",
|
|
433
|
+
label: "credentials.json",
|
|
434
|
+
state: "ok",
|
|
435
|
+
word: "ready",
|
|
436
|
+
detail: "mode 600",
|
|
437
|
+
},
|
|
311
438
|
);
|
|
312
439
|
}
|
|
313
440
|
|
|
@@ -322,7 +449,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
322
449
|
detail: "the plugin's MCP launcher runs bunx when no global catherd is installed",
|
|
323
450
|
fix: "put Bun's bin folder (~/.bun/bin) on PATH",
|
|
324
451
|
});
|
|
325
|
-
const browser = uiBrowserCheck(mine, onPath);
|
|
452
|
+
const browser = uiBrowserCheck(mine, onPath, d.host.host);
|
|
326
453
|
if (browser) checks.push(browser);
|
|
327
454
|
let corrupt: ReturnType<typeof listRuns>["corrupt"] = [];
|
|
328
455
|
try {
|
|
@@ -347,5 +474,12 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
347
474
|
fix: `fix or delete ${corrupt[0]?.dir}`,
|
|
348
475
|
});
|
|
349
476
|
|
|
350
|
-
return {
|
|
477
|
+
return {
|
|
478
|
+
host: inspectionHost(d.host),
|
|
479
|
+
queue,
|
|
480
|
+
push,
|
|
481
|
+
ready: !checks.some((c) => c.state === "fail"),
|
|
482
|
+
version: d.version,
|
|
483
|
+
checks,
|
|
484
|
+
};
|
|
351
485
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { errorMessage } from "../domain/errors.ts";
|
|
3
|
+
import type { HostSessionRef } from "../domain/host.ts";
|
|
4
|
+
import { envelope, formatNotices } from "../domain/notice.ts";
|
|
5
|
+
import { type QueueSendResult, sendToCodexQueue } from "../infra/codex-queue.ts";
|
|
6
|
+
import {
|
|
7
|
+
type DeliveryAttempt,
|
|
8
|
+
deliveryState,
|
|
9
|
+
ROLE_THREAD_REFUSAL,
|
|
10
|
+
writeDeliveryAttempt,
|
|
11
|
+
} from "../infra/delivery.ts";
|
|
12
|
+
import { awaitsCollect, dispatchPaths } from "../infra/dispatch-dir.ts";
|
|
13
|
+
import { tryLock } from "../infra/filelock.ts";
|
|
14
|
+
import { log } from "../infra/log.ts";
|
|
15
|
+
import { admitPath, listDispatches, roleThreadOf } from "./dispatches.ts";
|
|
16
|
+
import { finalizeDispatch } from "./finalize.ts";
|
|
17
|
+
import { finishedNotice, recoverSubmission } from "./notifier.ts";
|
|
18
|
+
import { findRun } from "./run-store.ts";
|
|
19
|
+
import { runOwner } from "./sessions.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How long the supervisor leaves the owner's MCP server to push first (the fast path: its watcher finalizes,
|
|
23
|
+
* settles and sends within the notifier's coalescing window). Either path sends under the same per-event receipt,
|
|
24
|
+
* so a late server or a late supervisor finds the event accepted and sends nothing.
|
|
25
|
+
*/
|
|
26
|
+
export const END_PUSH_GRACE_MS = 20_000;
|
|
27
|
+
|
|
28
|
+
/** `CATHERD_END_PUSH_GRACE_MS` (a test of the spawned supervisor shortens it), else END_PUSH_GRACE_MS. */
|
|
29
|
+
function graceFromEnv(): number {
|
|
30
|
+
const ms = Number(process.env.CATHERD_END_PUSH_GRACE_MS);
|
|
31
|
+
return process.env.CATHERD_END_PUSH_GRACE_MS !== undefined && Number.isFinite(ms) && ms >= 0
|
|
32
|
+
? ms
|
|
33
|
+
: END_PUSH_GRACE_MS;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface EndPushOptions {
|
|
37
|
+
graceMs?: number;
|
|
38
|
+
/** the sender; tests replace it */
|
|
39
|
+
sendCodex?: (target: HostSessionRef, content: string) => Promise<QueueSendResult>;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Why the supervisor pushed nothing, or the receipt's status when it did. */
|
|
44
|
+
export type EndPushOutcome =
|
|
45
|
+
| "off"
|
|
46
|
+
| "not-a-run"
|
|
47
|
+
| "not-codex"
|
|
48
|
+
| "limit"
|
|
49
|
+
| "read"
|
|
50
|
+
| "busy"
|
|
51
|
+
| "delivered"
|
|
52
|
+
| DeliveryAttempt["status"];
|
|
53
|
+
|
|
54
|
+
/** The run's owner, when it is a Codex thread: the supervisor's push targets it. */
|
|
55
|
+
function codexOwner(runId: string): HostSessionRef | null {
|
|
56
|
+
const owner = runOwner(findRun(runId));
|
|
57
|
+
return owner?.host === "codex"
|
|
58
|
+
? { host: "codex", sessionId: owner.sessionId, hostSessionId: null, name: null }
|
|
59
|
+
: null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Plan 22, push from where a role ends: the detached supervisor of a dispatch whose run a Codex thread owns, once
|
|
64
|
+
* its worker exited, queues the role's notice to that thread with `codex queue --remote unix:// --thread <owner>`.
|
|
65
|
+
* Codex's app-server keeps queued input for an unloaded thread, so a result reaches a coordinator whose MCP
|
|
66
|
+
* server the daemon stopped (no client attached) at its next attach. The notice is the server's own text, and it
|
|
67
|
+
* goes under the same per-event receipt (`delivery.json`, the dispatch's notify lock), so the two paths never
|
|
68
|
+
* double-deliver. A usage limit is left to the owner's server, which fails it over before it announces it.
|
|
69
|
+
* `CATHERD_NO_END_PUSH=1` turns it off (the test preload sets it). Never throws.
|
|
70
|
+
*/
|
|
71
|
+
export async function pushFromEnd(dispatchDir: string, o: EndPushOptions = {}): Promise<EndPushOutcome> {
|
|
72
|
+
if (process.env.CATHERD_NO_END_PUSH === "1") return "off";
|
|
73
|
+
try {
|
|
74
|
+
const runId = (JSON.parse(readFileSync(admitPath(dispatchDir), "utf8")) as { runId?: unknown }).runId;
|
|
75
|
+
if (typeof runId !== "string") return "not-a-run";
|
|
76
|
+
// read before the grace: a run no Codex thread owns costs the supervisor nothing
|
|
77
|
+
if (!codexOwner(runId)) return "not-codex";
|
|
78
|
+
await Bun.sleep(o.graceMs ?? graceFromEnv());
|
|
79
|
+
const run = findRun(runId);
|
|
80
|
+
const d = listDispatches(run).find((x) => x.dir === dispatchDir);
|
|
81
|
+
if (!d) return "not-a-run";
|
|
82
|
+
const record = await finalizeDispatch(run, d);
|
|
83
|
+
if (record.status === "limit") return "limit";
|
|
84
|
+
// the owner of record now, which a claim may have changed during the grace
|
|
85
|
+
const target = codexOwner(runId);
|
|
86
|
+
if (!target) return "not-codex";
|
|
87
|
+
if (!awaitsCollect(d.dir)) return "read";
|
|
88
|
+
const notice = finishedNotice(run, d, record);
|
|
89
|
+
const release = tryLock(dispatchPaths(d.dir).notified);
|
|
90
|
+
if (!release) return "busy";
|
|
91
|
+
try {
|
|
92
|
+
recoverSubmission(d.dir, notice.eventId);
|
|
93
|
+
if (deliveryState(d.dir, target, notice.eventId) !== "pending") return "delivered";
|
|
94
|
+
// spec 1.5 plan 21: never to a role's own thread (a `codex exec` thread exits after its turn, so what is
|
|
95
|
+
// queued there is lost); it fails loudly, with the notifier's reason, which `status` shows
|
|
96
|
+
const role = roleThreadOf(run, target.sessionId);
|
|
97
|
+
if (role !== null) {
|
|
98
|
+
const reason = `${ROLE_THREAD_REFUSAL} (${role}, ${target.host} ${target.sessionId}), not the orchestrator's; peek(run) from the orchestrator's session takes the run back`;
|
|
99
|
+
log("error", "notify", { from: "supervisor", run: run.id, dispatch: notice.dispatchId, reason });
|
|
100
|
+
writeDeliveryAttempt(d.dir, {
|
|
101
|
+
attemptId: crypto.randomUUID(),
|
|
102
|
+
target,
|
|
103
|
+
eventIds: [notice.eventId],
|
|
104
|
+
at: new Date((o.now ?? Date.now)()).toISOString(),
|
|
105
|
+
status: "failed",
|
|
106
|
+
msgId: null,
|
|
107
|
+
reason,
|
|
108
|
+
});
|
|
109
|
+
return "failed";
|
|
110
|
+
}
|
|
111
|
+
const attempt: DeliveryAttempt = {
|
|
112
|
+
attemptId: crypto.randomUUID(),
|
|
113
|
+
target,
|
|
114
|
+
eventIds: [notice.eventId],
|
|
115
|
+
at: new Date((o.now ?? Date.now)()).toISOString(),
|
|
116
|
+
status: "submitting",
|
|
117
|
+
msgId: null,
|
|
118
|
+
reason: null,
|
|
119
|
+
};
|
|
120
|
+
writeDeliveryAttempt(d.dir, attempt);
|
|
121
|
+
let sent: QueueSendResult;
|
|
122
|
+
try {
|
|
123
|
+
sent = await (o.sendCodex ?? ((t, c) => sendToCodexQueue(t, c, process.env)))(
|
|
124
|
+
target,
|
|
125
|
+
envelope(formatNotices([notice])),
|
|
126
|
+
);
|
|
127
|
+
} catch {
|
|
128
|
+
sent = { outcome: "ambiguous", reason: "Sender ended without a verified receipt; use peek/result." };
|
|
129
|
+
}
|
|
130
|
+
const receipt: DeliveryAttempt = {
|
|
131
|
+
...attempt,
|
|
132
|
+
status:
|
|
133
|
+
sent.outcome === "accepted"
|
|
134
|
+
? "accepted"
|
|
135
|
+
: sent.outcome === "not-submitted"
|
|
136
|
+
? "failed"
|
|
137
|
+
: "ambiguous",
|
|
138
|
+
msgId: sent.outcome === "accepted" ? sent.msgId : null,
|
|
139
|
+
reason: sent.outcome === "accepted" ? null : sent.reason,
|
|
140
|
+
};
|
|
141
|
+
writeDeliveryAttempt(d.dir, receipt);
|
|
142
|
+
log(receipt.status === "accepted" ? "info" : "warn", "notify", {
|
|
143
|
+
from: "supervisor",
|
|
144
|
+
outcome: receipt.status,
|
|
145
|
+
msgId: receipt.msgId,
|
|
146
|
+
reason: receipt.reason,
|
|
147
|
+
dispatches: [notice.dispatchId],
|
|
148
|
+
});
|
|
149
|
+
return receipt.status;
|
|
150
|
+
} finally {
|
|
151
|
+
release();
|
|
152
|
+
}
|
|
153
|
+
} catch (e) {
|
|
154
|
+
log("warn", "notify", { from: "supervisor", dispatch: dispatchDir, error: errorMessage(e) });
|
|
155
|
+
return "not-a-run";
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/services/finalize.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { isCatherdError } from "../domain/errors.ts";
|
|
|
8
8
|
import { parseRung } from "../domain/ids.ts";
|
|
9
9
|
import {
|
|
10
10
|
type ExitInfo,
|
|
11
|
+
parseReplyEnvironment,
|
|
11
12
|
parseReplyStatus,
|
|
12
13
|
type RunRecord,
|
|
13
14
|
THREAD_HEAVY_INPUT,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
writeTextAtomic,
|
|
26
27
|
} from "../infra/store.ts";
|
|
27
28
|
import { type Dispatch, dispatchState, listDispatches, readProc } from "./dispatches.ts";
|
|
29
|
+
import { currentOwns } from "./lane-edit.ts";
|
|
28
30
|
import { tail } from "./run-debug.ts";
|
|
29
31
|
import { appendRecord, readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
|
|
30
32
|
|
|
@@ -75,7 +77,7 @@ function othersOwns(run: Run, self: Dispatch, start: number, end: number): strin
|
|
|
75
77
|
const to = ended.get(d.admit.dispatchId) ?? (Date.parse(readExit(d.dir)?.endedAt ?? "") || Date.now());
|
|
76
78
|
return from <= end && to >= start;
|
|
77
79
|
})
|
|
78
|
-
.flatMap((d) => d.admit.owns);
|
|
80
|
+
.flatMap((d) => [...d.admit.owns, ...(d.admit.ownsImplicit ?? [])]);
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
/** The tree after the run, or null when git cannot say: the record is still written, its changes unknown. */
|
|
@@ -186,13 +188,20 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
|
186
188
|
const { changedOwned, violations } = after
|
|
187
189
|
? splitChanges(
|
|
188
190
|
changedPaths(a.before, after),
|
|
189
|
-
|
|
191
|
+
// spec 1.5 owns_add: paths the lane gained while it ran are its own, not violations; a laneless
|
|
192
|
+
// writer is held to its implicit docs lane (plan 21)
|
|
193
|
+
a.lane !== null
|
|
194
|
+
? [...new Set([...a.owns, ...currentOwns(run, a.lane)])]
|
|
195
|
+
: a.owns.length
|
|
196
|
+
? a.owns
|
|
197
|
+
: (a.ownsImplicit ?? []),
|
|
190
198
|
// from admission, when the before-snapshot was taken, not from the worker's start
|
|
191
199
|
othersOwns(run, d, Date.parse(a.admittedAt), end),
|
|
192
200
|
a.lane !== null || a.access === "read-only",
|
|
193
201
|
)
|
|
194
202
|
: { changedOwned: [], violations: [] };
|
|
195
203
|
const reported = parseReplyStatus(replyText);
|
|
204
|
+
const environment = parseReplyEnvironment(replyText);
|
|
196
205
|
return {
|
|
197
206
|
schema: 1,
|
|
198
207
|
runId: run.id,
|
|
@@ -219,6 +228,7 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
|
219
228
|
...(after ? {} : { gitUnavailable: true }),
|
|
220
229
|
replyStatus: reported.status,
|
|
221
230
|
replyWhy: reported.why,
|
|
231
|
+
...(environment ? { environment } : {}),
|
|
222
232
|
threadHeavy: o.tokens.input >= THREAD_HEAVY_INPUT,
|
|
223
233
|
access: a.access,
|
|
224
234
|
isolated: a.isolated,
|
|
@@ -226,7 +236,7 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
|
226
236
|
images: o.images,
|
|
227
237
|
error: o.error,
|
|
228
238
|
replyPath: relative(run.dir, p.reply),
|
|
229
|
-
...(a.sessionId ? { sessionId: a.sessionId } : {}),
|
|
239
|
+
...(a.sessionId ? { sessionId: a.sessionId, host: a.host ?? "claude-code" } : {}),
|
|
230
240
|
};
|
|
231
241
|
}
|
|
232
242
|
|