catherd-cli 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -30
- 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 +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +110 -45
- package/plugin/skills/catherd-setup/SKILL.md +2 -2
- 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/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 +5 -0
- 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/lane.ts +99 -3
- package/src/domain/notice.ts +9 -2
- package/src/domain/profile-rules.ts +112 -16
- package/src/domain/profile.ts +24 -0
- package/src/domain/record.ts +16 -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 +14 -6
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +2 -1
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +104 -8
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +6 -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 +20 -4
- package/src/entry/mcp/server.ts +98 -7
- package/src/entry/mcp/setup-tools.ts +4 -3
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +23 -4
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +78 -8
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +4 -0
- package/src/entry/tui/state.ts +37 -10
- package/src/entry/tui/views/profiles.tsx +1 -1
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +6 -2
- 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/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -1
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- 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/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 +107 -16
- package/src/services/admission.ts +110 -23
- 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 +204 -16
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +68 -6
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +12 -2
- 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 +257 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +56 -3
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/ports.ts +31 -2
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +1 -0
- package/src/services/profile-store.ts +10 -3
- 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 +144 -36
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +151 -19
- package/src/services/run-store.ts +109 -15
- package/src/services/runs-page.ts +42 -7
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- 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 +49 -9
- package/src/services/treat-likes.ts +19 -3
- 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
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type MutableRefObject, useEffect, useRef, useState } from "react";
|
|
1
|
+
import { type MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { waitingLine } from "../../../services/orchestrator-wait.ts";
|
|
2
3
|
import { useApp, useBack, useNow } from "../providers/app.tsx";
|
|
3
4
|
import { useData, usePoll } from "../providers/data.tsx";
|
|
4
5
|
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
@@ -7,7 +8,7 @@ import { useUi } from "../providers/theme.tsx";
|
|
|
7
8
|
import { isArmed } from "../state.ts";
|
|
8
9
|
import { ago, clock, plural, shortRung, wrap, wrapHanging } from "../text.ts";
|
|
9
10
|
import { glyph, mascot, type Token } from "../theme.ts";
|
|
10
|
-
import type { RoleRow, SessionRow, SessionRun } from "../effects.ts";
|
|
11
|
+
import type { RoleRow, SessionDetail, SessionRow, SessionRun } from "../effects.ts";
|
|
11
12
|
import { Line, type Part } from "../widgets/line.tsx";
|
|
12
13
|
import { List, type ListItem, useSelected } from "../widgets/list.tsx";
|
|
13
14
|
|
|
@@ -166,6 +167,8 @@ function roleMark(r: RoleRow, plain: boolean): Part {
|
|
|
166
167
|
function runHeading(r: SessionRun, now: number, plain: boolean): Part[] {
|
|
167
168
|
const bits = [r.repo, `started ${ago(now - Date.parse(r.createdAt))}`];
|
|
168
169
|
if (r.budget !== null) bits.push(`budget ${Math.round(r.budget * 100)}%`);
|
|
170
|
+
const waiting = waitingLine(r.waiting, now);
|
|
171
|
+
if (waiting) bits.push(waiting);
|
|
169
172
|
const moved =
|
|
170
173
|
r.continued === "here" ? "continued here" : r.continuedIn ? `continued in ${r.continuedIn}` : null;
|
|
171
174
|
return [
|
|
@@ -181,8 +184,11 @@ function useLiveRead<T extends { dirs: string[] }>(read: () => T, key: string) {
|
|
|
181
184
|
const app = useApp();
|
|
182
185
|
const [watching, setWatching] = useState(false);
|
|
183
186
|
const polled = usePoll(read, watching ? WATCHED_EVERY_MS : RUN_EVERY_MS, { paused: app.state.paused, key });
|
|
187
|
+
// the watch calls the latest refresh; the ref moves after each commit, never during a render
|
|
184
188
|
const refresh = useRef(polled.refresh);
|
|
185
|
-
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
refresh.current = polled.refresh;
|
|
191
|
+
});
|
|
186
192
|
const dirs = polled.value?.dirs.join("\n") ?? "";
|
|
187
193
|
useEffect(() => {
|
|
188
194
|
if (app.state.paused || dirs === "") {
|
|
@@ -196,8 +202,26 @@ function useLiveRead<T extends { dirs: string[] }>(read: () => T, key: string) {
|
|
|
196
202
|
return { ...polled, watching };
|
|
197
203
|
}
|
|
198
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The row a session's screen starts on before the cursor moves: the first row of the run it was opened for,
|
|
207
|
+
* else its first live role (live roles can sit below many landed milestones), else its first row.
|
|
208
|
+
*/
|
|
209
|
+
function startRow(d: SessionDetail, run: string | undefined): string | null {
|
|
210
|
+
const rows = (r: SessionDetail["runs"][number]) => [
|
|
211
|
+
...r.milestones.map((m) => milestoneKey(r.id, m.name)),
|
|
212
|
+
...r.roles.map((x) => roleKey(x.run, x.dispatchId)),
|
|
213
|
+
];
|
|
214
|
+
const asked = run === undefined ? undefined : d.runs.find((r) => r.id === run);
|
|
215
|
+
const first = asked ? rows(asked)[0] : undefined;
|
|
216
|
+
if (first) return first;
|
|
217
|
+
const live = d.runs.flatMap((r) => r.roles).find((x) => x.live);
|
|
218
|
+
if (live) return roleKey(live.run, live.dispatchId);
|
|
219
|
+
return d.runs.flatMap(rows)[0] ?? null;
|
|
220
|
+
}
|
|
221
|
+
|
|
199
222
|
function SessionView(props: {
|
|
200
223
|
sessionKey: string | null;
|
|
224
|
+
run: string | undefined;
|
|
201
225
|
width: number;
|
|
202
226
|
height: number;
|
|
203
227
|
initial: string | null;
|
|
@@ -206,17 +230,26 @@ function SessionView(props: {
|
|
|
206
230
|
const app = useApp();
|
|
207
231
|
const ui = useUi();
|
|
208
232
|
const now = useNow(1_000);
|
|
209
|
-
const [
|
|
233
|
+
const [chosen, setSelected, chosenNow] = useSelection(props.initial);
|
|
210
234
|
const polled = useLiveRead(() => app.effects.session(props.sessionKey), String(props.sessionKey));
|
|
211
235
|
useRefresher(props.refresher, polled.refresh);
|
|
212
236
|
useBack(true, "view", () => app.dispatch({ type: "up" }));
|
|
213
237
|
const d = polled.value;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
238
|
+
// each row's role or milestone by its key, built once per read rather than on every key
|
|
239
|
+
const byKey = useMemo(() => {
|
|
240
|
+
const roles = new Map<string, RoleRow>();
|
|
241
|
+
const milestones = new Map<string, { run: string; name: string }>();
|
|
242
|
+
for (const r of d?.runs ?? []) {
|
|
243
|
+
for (const m of r.milestones) milestones.set(milestoneKey(r.id, m.name), { run: r.id, name: m.name });
|
|
244
|
+
for (const x of r.roles) roles.set(roleKey(x.run, x.dispatchId), x);
|
|
245
|
+
}
|
|
246
|
+
return { roles, milestones, start: d ? startRow(d, props.run) : null };
|
|
247
|
+
}, [d, props.run]);
|
|
248
|
+
// until the cursor moves, it stands on the start row
|
|
249
|
+
const selected = chosen ?? byKey.start;
|
|
250
|
+
const selectedNow = () => chosenNow() ?? byKey.start;
|
|
251
|
+
const roleAt = (key: string | null) => (key === null ? null : (byKey.roles.get(key) ?? null));
|
|
252
|
+
const milestoneAt = (key: string | null) => (key === null ? null : (byKey.milestones.get(key) ?? null));
|
|
220
253
|
useCommandLayer("row.runs", {
|
|
221
254
|
"runs.open": () => {
|
|
222
255
|
const k = selectedNow();
|
|
@@ -275,7 +308,7 @@ function SessionView(props: {
|
|
|
275
308
|
: { text: `${glyph("waiting", ui.plain)} ${m.name}`, tone: "muted" },
|
|
276
309
|
m.landed
|
|
277
310
|
? { text: m.what ? ` ${m.what}` : "", tone: "muted" }
|
|
278
|
-
: { text:
|
|
311
|
+
: { text: ` not landed${m.what ? ` · ${m.what}` : ""}`, tone: "muted" },
|
|
279
312
|
]}
|
|
280
313
|
/>
|
|
281
314
|
),
|
|
@@ -349,10 +382,17 @@ function RoleView(props: {
|
|
|
349
382
|
const app = useApp();
|
|
350
383
|
const ui = useUi();
|
|
351
384
|
const [selected, setSelected] = useSelection();
|
|
385
|
+
// a role with its record is finished for good: nothing on its screen changes, so it is read no more
|
|
386
|
+
// (r still reads it again)
|
|
387
|
+
const [done, setDone] = useState(false);
|
|
352
388
|
const polled = usePoll(() => app.effects.role(props.run, props.dispatchId), RUN_EVERY_MS, {
|
|
353
|
-
paused: app.state.paused,
|
|
389
|
+
paused: app.state.paused || done,
|
|
354
390
|
key: `${props.run}/${props.dispatchId}`,
|
|
355
391
|
});
|
|
392
|
+
const recorded = polled.value?.record != null;
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
if (recorded) setDone(true);
|
|
395
|
+
}, [recorded]);
|
|
356
396
|
useRefresher(props.refresher, polled.refresh);
|
|
357
397
|
useBack(true, "view", () => app.dispatch({ type: "up" }));
|
|
358
398
|
const d = polled.value;
|
|
@@ -532,13 +572,16 @@ export function RunsView(props: { width: number; height: number }) {
|
|
|
532
572
|
app.toast({ variant: "info", message: app.getState().paused ? "Updates paused" : "Updates resumed" });
|
|
533
573
|
},
|
|
534
574
|
});
|
|
535
|
-
const { milestone, role, session } = app.state;
|
|
575
|
+
const { milestone, role, session, back } = app.state;
|
|
536
576
|
// esc lands on the row it came from: the last session opened, and the last milestone or role opened in it
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
577
|
+
const backRow =
|
|
578
|
+
back && session && back.session === session.key
|
|
579
|
+
? back.milestone
|
|
580
|
+
? milestoneKey(back.milestone.run, back.milestone.name)
|
|
581
|
+
: back.role
|
|
582
|
+
? roleKey(back.role.run, back.role.dispatchId)
|
|
583
|
+
: null
|
|
584
|
+
: null;
|
|
542
585
|
if (milestone)
|
|
543
586
|
return (
|
|
544
587
|
<MilestoneView
|
|
@@ -563,11 +606,14 @@ export function RunsView(props: { width: number; height: number }) {
|
|
|
563
606
|
return (
|
|
564
607
|
<SessionView
|
|
565
608
|
sessionKey={session.key}
|
|
609
|
+
run={session.run}
|
|
566
610
|
width={props.width}
|
|
567
611
|
height={props.height}
|
|
568
|
-
initial={
|
|
612
|
+
initial={backRow}
|
|
569
613
|
refresher={refresher}
|
|
570
614
|
/>
|
|
571
615
|
);
|
|
572
|
-
return
|
|
616
|
+
return (
|
|
617
|
+
<SessionList width={props.width} height={props.height} initial={back ? keyOf(back.session) : null} />
|
|
618
|
+
);
|
|
573
619
|
}
|
|
@@ -48,7 +48,7 @@ export interface SavePreview {
|
|
|
48
48
|
*/
|
|
49
49
|
function previewSave(
|
|
50
50
|
d: Draft,
|
|
51
|
-
fx: Pick<Effects, "catalog" | "validate" | "agents" | "readProfile">,
|
|
51
|
+
fx: Pick<Effects, "catalog" | "validate" | "agents" | "readProfile" | "profiles">,
|
|
52
52
|
now: ProfileDoc = fx.readProfile(d.name),
|
|
53
53
|
): SavePreview {
|
|
54
54
|
const before = resolveProfile(now, d.name, d.host);
|
|
@@ -62,7 +62,11 @@ function previewSave(
|
|
|
62
62
|
changes: diffProfiles(before, after),
|
|
63
63
|
treatLikes: Object.entries(d.treatLikes),
|
|
64
64
|
validation,
|
|
65
|
-
|
|
65
|
+
// as the ProfileService rules: a profile with no stored file has no errors a save could repair (1.2 minor)
|
|
66
|
+
repair:
|
|
67
|
+
validation.errors.length > 0 &&
|
|
68
|
+
fx.profiles().names.includes(d.name) &&
|
|
69
|
+
repairs(fx.validate(before, staged), validation),
|
|
66
70
|
agentsAdded: [...b].filter((x) => !a.has(x)),
|
|
67
71
|
agentsRemoved: [...a].filter((x) => !b.has(x)),
|
|
68
72
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Check } from "../../../services/doctor-checks.ts";
|
|
2
|
+
import { waitingLine } from "../../../services/orchestrator-wait.ts";
|
|
2
3
|
import { useApp, useNow } from "../providers/app.tsx";
|
|
3
4
|
import { useData } from "../providers/data.tsx";
|
|
4
5
|
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
@@ -19,6 +20,8 @@ function stateParts(state: Check["state"], word: string, plain: boolean): Part[]
|
|
|
19
20
|
/** A run in one line: its state word first, then title, repo and progress (research C4). */
|
|
20
21
|
export function runParts(r: RunRow, now: number, plain: boolean): Part[] {
|
|
21
22
|
const bits = [plural(r.roleRuns, "role run"), `${r.landed} landed`];
|
|
23
|
+
const waiting = waitingLine(r.waiting, now);
|
|
24
|
+
if (waiting) bits.push(waiting);
|
|
22
25
|
if (r.budget !== null) bits.push(`${Math.round(r.budget * 100)}% budget`);
|
|
23
26
|
return [
|
|
24
27
|
r.live
|
|
@@ -189,10 +192,11 @@ export function StatusView(props: { width: number; height: number }) {
|
|
|
189
192
|
if (name !== null) {
|
|
190
193
|
showProfile(app, name);
|
|
191
194
|
} else if (selected?.startsWith("run:")) {
|
|
192
|
-
// spec §4: a run opens in its session's screen, which shows it with the session's other runs
|
|
195
|
+
// spec §4: a run opens in its session's screen, which shows it with the session's other runs; the
|
|
196
|
+
// cursor starts on that run
|
|
193
197
|
const row = runs.find((r) => `run:${r.id}` === selected);
|
|
194
198
|
app.dispatch({ type: "tab", tab: "runs" });
|
|
195
|
-
if (row) app.dispatch({ type: "session", key: row.session });
|
|
199
|
+
if (row) app.dispatch({ type: "session", key: row.session, run: row.id });
|
|
196
200
|
}
|
|
197
201
|
},
|
|
198
202
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import { inspectWorkspace, workspaceStatus } from "../services/workspace-service.ts";
|
|
4
|
+
import { JSON_ARG, printJson } from "./cli-kit.ts";
|
|
5
|
+
import { defaultDeps } from "./deps.ts";
|
|
6
|
+
|
|
7
|
+
const inspect = defineCommand({
|
|
8
|
+
meta: { name: "inspect", description: "Read the explicit repositories in a workspace manifest" },
|
|
9
|
+
args: {
|
|
10
|
+
root: { type: "positional", description: "workspace root (default: current directory)" },
|
|
11
|
+
...JSON_ARG,
|
|
12
|
+
},
|
|
13
|
+
async run({ args }) {
|
|
14
|
+
const view = await inspectWorkspace(resolve(args.root ?? process.cwd()));
|
|
15
|
+
if (args.json) return printJson(view);
|
|
16
|
+
console.log(view.root);
|
|
17
|
+
for (const [name, path] of Object.entries(view.repos)) console.log(`${name}: ${path}`);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const status = defineCommand({
|
|
22
|
+
meta: { name: "status", description: "Read a workspace run's children, dependencies and budget" },
|
|
23
|
+
args: {
|
|
24
|
+
workspace: { type: "positional", required: true, description: "workspace run id" },
|
|
25
|
+
...JSON_ARG,
|
|
26
|
+
},
|
|
27
|
+
async run({ args }) {
|
|
28
|
+
const view = await workspaceStatus(defaultDeps(), args.workspace);
|
|
29
|
+
if (args.json) return printJson(view);
|
|
30
|
+
console.log(`${view.workspace.id}: ${view.workspace.title}`);
|
|
31
|
+
for (const step of view.steps) {
|
|
32
|
+
const blocked = step.blockedBy.length ? ` (waiting for ${step.blockedBy.join(", ")})` : "";
|
|
33
|
+
console.log(`${step.id}: ${step.repo} ${step.state}${step.run ? ` ${step.run}` : ""}${blocked}`);
|
|
34
|
+
}
|
|
35
|
+
console.log(
|
|
36
|
+
`spend: ${view.spend.tokens} tokens, ${view.spend.minutes.toFixed(2)} min, $${view.spend.usd.toFixed(2)}`,
|
|
37
|
+
);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const workspaceCommand = defineCommand({
|
|
42
|
+
meta: { name: "workspace", description: "Inspect workspace members and coordinated runs" },
|
|
43
|
+
subCommands: { inspect, status },
|
|
44
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { tryLock } from "./filelock.ts";
|
|
3
|
+
import { log } from "./log.ts";
|
|
4
|
+
import { locksDir } from "./paths.ts";
|
|
5
|
+
import { ensurePrivateDir } from "./store.ts";
|
|
6
|
+
|
|
7
|
+
/** The machine-wide lock the MCP server that runs the boot sync and reconcile holds for its life. */
|
|
8
|
+
export const bootLockTarget = (): string => join(locksDir(), "mcp-boot");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Plan 22 (the three MCP servers of one Codex session): boot sync and reconcile are single-flight across
|
|
12
|
+
* processes. The first server takes this lock (its pid and start time in it; a dead holder's is reclaimed) and
|
|
13
|
+
* keeps it until it closes; a second server finds it held and skips both. Returns the release, or null while
|
|
14
|
+
* another live server holds it. A lock that cannot be taken for another reason never costs a reconcile: the
|
|
15
|
+
* server runs the boot work, with nothing to release.
|
|
16
|
+
*/
|
|
17
|
+
export function takeBootLock(): (() => void) | null {
|
|
18
|
+
try {
|
|
19
|
+
ensurePrivateDir(locksDir());
|
|
20
|
+
return tryLock(bootLockTarget());
|
|
21
|
+
} catch (e) {
|
|
22
|
+
log("warn", "boot", { lock: bootLockTarget(), error: e instanceof Error ? e.message : String(e) });
|
|
23
|
+
return () => {};
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/infra/delivery.ts
CHANGED
|
@@ -24,6 +24,9 @@ const AttemptSchema = z
|
|
|
24
24
|
.refine((a) => a.status !== "accepted" || Boolean(a.msgId?.trim()), "accepted attempt needs a receipt");
|
|
25
25
|
const DeliverySchema = z.looseObject({ schema: z.literal(1), attempts: z.array(AttemptSchema) });
|
|
26
26
|
export type DeliveryAttempt = z.infer<typeof AttemptSchema>;
|
|
27
|
+
/** How a delivery to a role's thread is recorded (spec 1.5 plan 21); `status` warns about it. */
|
|
28
|
+
export const ROLE_THREAD_REFUSAL = "refused: the target is a role's thread";
|
|
29
|
+
|
|
27
30
|
export type DeliveryState = "pending" | "enqueue-accepted" | "ambiguous" | "collected";
|
|
28
31
|
|
|
29
32
|
export function readDelivery(dir: string): DeliveryAttempt[] {
|
|
@@ -45,6 +45,8 @@ export function dispatchPaths(dir: string) {
|
|
|
45
45
|
stall: join(dir, "stall.json"),
|
|
46
46
|
/** the message that announced the stall, once sent */
|
|
47
47
|
stallNotified: join(dir, "stall-notified.json"),
|
|
48
|
+
/** the role's own thread, written by the supervisor once the CLI names it (spec 1.5 plan 21) */
|
|
49
|
+
thread: join(dir, "thread.json"),
|
|
48
50
|
};
|
|
49
51
|
}
|
|
50
52
|
|
package/src/infra/env.ts
CHANGED
|
@@ -17,6 +17,9 @@ const SECRET_ENV = new Set([
|
|
|
17
17
|
"CLAUDE_CODE_HOST_SESSION_ID",
|
|
18
18
|
]);
|
|
19
19
|
|
|
20
|
+
/** Whether `name` is one of catherd's own secrets, which no process catherd starts ever gets. */
|
|
21
|
+
export const isCatherdSecret = (name: string): boolean => SECRET_ENV.has(name);
|
|
22
|
+
|
|
20
23
|
/** `base` without catherd's own secrets and without unset keys; for every process catherd starts. */
|
|
21
24
|
export function scrubSecrets(base: Record<string, string | undefined>): Record<string, string> {
|
|
22
25
|
const env: Record<string, string> = {};
|
|
@@ -62,11 +65,18 @@ const CHECK_ENV = new Set([
|
|
|
62
65
|
"CATHERD_LOCK_SLOTS",
|
|
63
66
|
]);
|
|
64
67
|
const CHECK_ENV_PATTERN = /^(LC_[A-Z_]+|XDG_[A-Z_]+_(HOME|DIR|DIRS))$/;
|
|
68
|
+
/**
|
|
69
|
+
* Plan 23: where the toolchains a check runs find their daemon, proxy and caches (DOCKER_HOST for OrbStack or
|
|
70
|
+
* rootless Docker, a proxy, Go's and pnpm's caches); without them a check that would run fails as if broken.
|
|
71
|
+
*/
|
|
72
|
+
const TOOL_ENV_PATTERN =
|
|
73
|
+
/^(DOCKER_(HOST|CONTEXT|CONFIG|CERT_PATH|TLS_VERIFY)|TESTCONTAINERS_[A-Z_]+|(HTTPS?|NO|ALL)_PROXY|(https?|no|all)_proxy|GO(PATH|CACHE|MODCACHE|FLAGS|PROXY|PRIVATE|NOSUMDB|TOOLCHAIN)|PNPM_HOME|npm_config_store_dir|BUN_INSTALL(_CACHE_DIR)?|NVM_DIR|JAVA_HOME|CARGO_HOME|RUSTUP_HOME)$/;
|
|
65
74
|
|
|
66
75
|
/** A strict allowlist of `base` plus PWD, for lane-authored shell text such as preflight checks. */
|
|
67
76
|
export function checkEnv(base: Record<string, string | undefined>, cwd: string): Record<string, string> {
|
|
68
77
|
const env: Record<string, string> = {};
|
|
69
78
|
for (const [k, v] of Object.entries(base))
|
|
70
|
-
if (v !== undefined && (CHECK_ENV.has(k) || CHECK_ENV_PATTERN.test(k)))
|
|
79
|
+
if (v !== undefined && (CHECK_ENV.has(k) || CHECK_ENV_PATTERN.test(k) || TOOL_ENV_PATTERN.test(k)))
|
|
80
|
+
env[k] = v;
|
|
71
81
|
return { ...env, PWD: cwd };
|
|
72
82
|
}
|
package/src/infra/git.ts
CHANGED
|
@@ -54,6 +54,12 @@ export async function gitToplevel(dir: string): Promise<string | null> {
|
|
|
54
54
|
return r.kind === "ok" ? r.out.trim() || null : null;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** The repo's `origin` remote URL, or null when it has none (or git cannot say). */
|
|
58
|
+
export async function gitOrigin(repo: string): Promise<string | null> {
|
|
59
|
+
const r = await git(repo, ["config", "--get", "remote.origin.url"]);
|
|
60
|
+
return (r.kind === "ok" && r.out.trim()) || null;
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
/** The short HEAD, or null when git cannot say (no commit, not a repo, failure, timeout). */
|
|
58
64
|
export async function gitHead(repo: string, timeoutMs?: number): Promise<string | null> {
|
|
59
65
|
const r = await git(repo, ["rev-parse", "--short", "HEAD"], timeoutMs);
|
package/src/infra/heavy-lock.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
1
3
|
import { availableParallelism } from "node:os";
|
|
2
4
|
import { join } from "node:path";
|
|
3
|
-
import { tryLock } from "./filelock.ts";
|
|
5
|
+
import { tryLock, withFileLock } from "./filelock.ts";
|
|
4
6
|
import { locksDir } from "./paths.ts";
|
|
5
|
-
import {
|
|
7
|
+
import { isAlive, selfIdentity } from "./proc.ts";
|
|
8
|
+
import { ensurePrivateDir, writeJsonAtomic } from "./store.ts";
|
|
6
9
|
|
|
7
10
|
/** A profile's `lock.heavy` as a slot count: a number, or half the cores. */
|
|
8
11
|
export function heavySlots(setting: number | "cpus/2" | undefined): number {
|
|
@@ -17,20 +20,106 @@ export function heavySlots(setting: number | "cpus/2" | undefined): number {
|
|
|
17
20
|
export async function withHeavySlot<T>(
|
|
18
21
|
slots: number,
|
|
19
22
|
fn: (slot: number) => T | Promise<T>,
|
|
20
|
-
o: { pollMs?: number } = {},
|
|
23
|
+
o: { pollMs?: number; onWait?: () => void } = {},
|
|
21
24
|
): Promise<T> {
|
|
25
|
+
const r = await withHeavySlotWithin(slots, Number.POSITIVE_INFINITY, fn, o);
|
|
26
|
+
if (r.busy) throw new Error("unreachable: an unbounded wait never gives up");
|
|
27
|
+
return r.value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* withHeavySlot with a wait budget (plan 23): `{ busy: true }` when no slot came free within `waitMs`, so a
|
|
32
|
+
* caller with a deadline of its own (preflight) reports the lock as busy instead of timing out behind it.
|
|
33
|
+
*/
|
|
34
|
+
export async function withHeavySlotWithin<T>(
|
|
35
|
+
slots: number,
|
|
36
|
+
waitMs: number,
|
|
37
|
+
fn: (slot: number) => T | Promise<T>,
|
|
38
|
+
o: { pollMs?: number; onWait?: () => void } = {},
|
|
39
|
+
): Promise<{ busy: false; value: T } | { busy: true }> {
|
|
22
40
|
const dir = locksDir();
|
|
23
41
|
ensurePrivateDir(dir);
|
|
42
|
+
const deadline = Date.now() + waitMs;
|
|
24
43
|
for (;;) {
|
|
25
44
|
for (let i = 0; i < slots; i++) {
|
|
26
45
|
const release = tryLock(join(dir, `slot-${i}`));
|
|
27
46
|
if (!release) continue;
|
|
28
47
|
try {
|
|
29
|
-
return await fn(i);
|
|
48
|
+
return { busy: false, value: await fn(i) };
|
|
30
49
|
} finally {
|
|
31
50
|
release();
|
|
32
51
|
}
|
|
33
52
|
}
|
|
53
|
+
const left = deadline - Date.now();
|
|
54
|
+
if (left <= 0) return { busy: true };
|
|
55
|
+
o.onWait?.();
|
|
56
|
+
await Bun.sleep(Math.min(o.pollMs ?? 500, left));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A process inside a role lock: who, so a dead one is dropped, and a token, so one process can hold twice. */
|
|
61
|
+
interface RoleHolder {
|
|
62
|
+
pid: number;
|
|
63
|
+
startTime: string | null;
|
|
64
|
+
token: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface RoleLockState {
|
|
68
|
+
/** whose the lock is: a run id (holders of one run share it), or a lone process's own key */
|
|
69
|
+
owner: string;
|
|
70
|
+
holders: RoleHolder[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readRoleState(file: string): RoleLockState | null {
|
|
74
|
+
if (!existsSync(file)) return null;
|
|
75
|
+
try {
|
|
76
|
+
const v = JSON.parse(readFileSync(file, "utf8")) as RoleLockState;
|
|
77
|
+
return typeof v?.owner === "string" && Array.isArray(v.holders) ? v : null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Spec 1.5 "Cross-run verifier contention": one owner at a time machine-wide holds the lock of `role`.
|
|
85
|
+
* Commands of the same owner (one run's verifier, side by side within its slots) share it; another owner's
|
|
86
|
+
* wait until the last of them ends, so two runs' verifiers never overlap. A dead holder is dropped.
|
|
87
|
+
*/
|
|
88
|
+
export async function withRoleLock<T>(
|
|
89
|
+
role: string,
|
|
90
|
+
owner: string,
|
|
91
|
+
fn: () => T | Promise<T>,
|
|
92
|
+
o: { pollMs?: number; onWait?: () => void } = {},
|
|
93
|
+
): Promise<T> {
|
|
94
|
+
const dir = locksDir();
|
|
95
|
+
ensurePrivateDir(dir);
|
|
96
|
+
const file = join(dir, `role-${role}.json`);
|
|
97
|
+
const me: RoleHolder = { ...selfIdentity(), token: randomUUID() };
|
|
98
|
+
const enter = () =>
|
|
99
|
+
withFileLock(file, () => {
|
|
100
|
+
const state = readRoleState(file);
|
|
101
|
+
const live = (state?.holders ?? []).filter((h) => isAlive(h.pid, h.startTime));
|
|
102
|
+
if (live.length && state?.owner !== owner) return false;
|
|
103
|
+
writeJsonAtomic(file, { owner, holders: [...live, me] } satisfies RoleLockState);
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
106
|
+
while (!(await enter())) {
|
|
107
|
+
o.onWait?.();
|
|
34
108
|
await Bun.sleep(o.pollMs ?? 500);
|
|
35
109
|
}
|
|
110
|
+
try {
|
|
111
|
+
return await fn();
|
|
112
|
+
} finally {
|
|
113
|
+
await withFileLock(file, () => {
|
|
114
|
+
const state = readRoleState(file);
|
|
115
|
+
if (state)
|
|
116
|
+
writeJsonAtomic(file, { ...state, holders: state.holders.filter((h) => h.token !== me.token) });
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Who holds the role lock now, or null. */
|
|
122
|
+
export function roleLockOwner(role: string): string | null {
|
|
123
|
+
const state = readRoleState(join(locksDir(), `role-${role}.json`));
|
|
124
|
+
return state && state.holders.some((h) => isAlive(h.pid, h.startTime)) ? state.owner : null;
|
|
36
125
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Plan 23: a host probe that fails once may have caught the network mid-change (a VPN just dropped, a route
|
|
2
|
+
// still settling). catherd's own host probes run twice, this far apart, before they call the host blocked.
|
|
3
|
+
|
|
4
|
+
/** The pause before the second try; tests shorten it. */
|
|
5
|
+
export const hostProbe = { retryMs: 5_000 };
|
|
6
|
+
|
|
7
|
+
/** `probe`, and once more after hostProbe.retryMs when `ok` says the first answer failed. */
|
|
8
|
+
export async function probeTwice<T>(
|
|
9
|
+
probe: () => Promise<T>,
|
|
10
|
+
ok: (r: T) => boolean,
|
|
11
|
+
): Promise<{ result: T; attempts: 1 | 2 }> {
|
|
12
|
+
const first = await probe();
|
|
13
|
+
if (ok(first)) return { result: first, attempts: 1 };
|
|
14
|
+
await Bun.sleep(hostProbe.retryMs);
|
|
15
|
+
return { result: await probe(), attempts: 2 };
|
|
16
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { locksDir } from "./paths.ts";
|
|
4
|
+
import { isAlive, processStartTime } from "./proc.ts";
|
|
5
|
+
import { ensurePrivateDir, writeJsonAtomic } from "./store.ts";
|
|
6
|
+
|
|
7
|
+
// Plan 23: a role's long gate runs behind `catherd lock`. While such a lock child is alive and writing, the
|
|
8
|
+
// role's wall timeout counts from its last output, not from the role's start. The lock writes here, in the
|
|
9
|
+
// locks dir every role may write; the supervisor reads it.
|
|
10
|
+
|
|
11
|
+
/** The env var the supervisor's spec carries, so a `catherd lock` inside the role knows its dispatch. */
|
|
12
|
+
export const DISPATCH_ID_ENV = "CATHERD_DISPATCH_ID";
|
|
13
|
+
|
|
14
|
+
/** A lock child reports at most this often, so a chatty command does not write a file per line. */
|
|
15
|
+
export const ACTIVITY_EVERY_MS = 1_000;
|
|
16
|
+
|
|
17
|
+
const SAFE_ID = /^[A-Za-z0-9][\w.-]{0,127}$/;
|
|
18
|
+
|
|
19
|
+
/** `<locks>/activity/<dispatch id>`: one file per live `catherd lock` of that dispatch. */
|
|
20
|
+
export const activityDir = (dispatchId: string): string => join(locksDir(), "activity", dispatchId);
|
|
21
|
+
|
|
22
|
+
interface Activity {
|
|
23
|
+
schema: 1;
|
|
24
|
+
pid: number;
|
|
25
|
+
startTime: string | null;
|
|
26
|
+
/** the last time the lock's command wrote output (its start, before the first line) */
|
|
27
|
+
at: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A reporter for this `catherd lock` process, or null outside a dispatch (no id, or one that is not a plain
|
|
32
|
+
* id: it names a path). `tick` records output, at most once per ACTIVITY_EVERY_MS; `done` removes the file.
|
|
33
|
+
*/
|
|
34
|
+
export function activityReporter(
|
|
35
|
+
dispatchId: string | undefined,
|
|
36
|
+
now: () => number = Date.now,
|
|
37
|
+
everyMs = ACTIVITY_EVERY_MS,
|
|
38
|
+
): { tick: () => void; done: () => void } | null {
|
|
39
|
+
if (!dispatchId || !SAFE_ID.test(dispatchId) || dispatchId.includes("..")) return null;
|
|
40
|
+
const dir = activityDir(dispatchId);
|
|
41
|
+
const file = join(dir, `${process.pid}.json`);
|
|
42
|
+
const startTime = processStartTime(process.pid);
|
|
43
|
+
let last = 0;
|
|
44
|
+
const write = (t: number): void => {
|
|
45
|
+
try {
|
|
46
|
+
ensurePrivateDir(dir);
|
|
47
|
+
writeJsonAtomic(file, {
|
|
48
|
+
schema: 1,
|
|
49
|
+
pid: process.pid,
|
|
50
|
+
startTime,
|
|
51
|
+
at: new Date(t).toISOString(),
|
|
52
|
+
} satisfies Activity);
|
|
53
|
+
last = t;
|
|
54
|
+
} catch {
|
|
55
|
+
// advisory: a lock that cannot report still runs its command
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
write(now());
|
|
59
|
+
return {
|
|
60
|
+
tick: () => {
|
|
61
|
+
const t = now();
|
|
62
|
+
if (t - last >= everyMs) write(t);
|
|
63
|
+
},
|
|
64
|
+
done: () => rmSync(file, { force: true }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* When a live `catherd lock` of the dispatch last wrote output, in ms; null when none is alive. A file whose
|
|
70
|
+
* process is gone is left alone (its lock may still be finishing its own cleanup) and never counts.
|
|
71
|
+
*/
|
|
72
|
+
export function lastLockOutput(dispatchId: string): number | null {
|
|
73
|
+
const dir = activityDir(dispatchId);
|
|
74
|
+
if (!existsSync(dir)) return null;
|
|
75
|
+
let latest: number | null = null;
|
|
76
|
+
for (const name of readdirSync(dir)) {
|
|
77
|
+
if (!name.endsWith(".json")) continue;
|
|
78
|
+
try {
|
|
79
|
+
const a = JSON.parse(readFileSync(join(dir, name), "utf8")) as Activity;
|
|
80
|
+
if (a.schema !== 1 || !isAlive(a.pid, a.startTime)) continue;
|
|
81
|
+
const at = Date.parse(a.at);
|
|
82
|
+
if (!Number.isNaN(at)) latest = Math.max(latest ?? at, at);
|
|
83
|
+
} catch {
|
|
84
|
+
// a file being written, or damaged: skipped this poll
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return latest;
|
|
88
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { scrubSecrets } from "./env.ts";
|
|
2
|
+
|
|
3
|
+
// Plan 23: what a login shell of the user's sees (DOCKER_HOST for OrbStack or rootless Docker, a proxy, a
|
|
4
|
+
// toolchain's PATH), which a server started by a desktop app or a plugin launcher may not inherit.
|
|
5
|
+
// `$SHELL -lc env` is run once per process and kept.
|
|
6
|
+
|
|
7
|
+
/** How long the login shell may take; one that hangs (a prompt in a profile) counts as an empty env. */
|
|
8
|
+
export const loginShell = { timeoutMs: 5_000 };
|
|
9
|
+
|
|
10
|
+
let captured: Record<string, string> | null = null;
|
|
11
|
+
|
|
12
|
+
/** Parses `env -0` output, or plain `env` lines from a shell whose env has no -0. */
|
|
13
|
+
export function parseEnv(out: string): Record<string, string> {
|
|
14
|
+
const env: Record<string, string> = {};
|
|
15
|
+
const rows = out.includes("\0") ? out.split("\0") : out.split("\n");
|
|
16
|
+
for (const row of rows) {
|
|
17
|
+
const eq = row.indexOf("=");
|
|
18
|
+
if (eq > 0) env[row.slice(0, eq)] = row.slice(eq + 1);
|
|
19
|
+
}
|
|
20
|
+
return env;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function capture(): Record<string, string> {
|
|
24
|
+
const shell = process.env.SHELL || "/bin/sh";
|
|
25
|
+
try {
|
|
26
|
+
const r = Bun.spawnSync([shell, "-lc", "env -0 2>/dev/null || env"], {
|
|
27
|
+
env: scrubSecrets(process.env),
|
|
28
|
+
stdin: "ignore",
|
|
29
|
+
stdout: "pipe",
|
|
30
|
+
stderr: "ignore",
|
|
31
|
+
timeout: loginShell.timeoutMs,
|
|
32
|
+
});
|
|
33
|
+
return r.exitCode === 0 ? scrubSecrets(parseEnv(r.stdout.toString())) : {};
|
|
34
|
+
} catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The user's login environment, without catherd's secrets; empty when the shell cannot say. Captured once. */
|
|
40
|
+
export function loginEnv(): Record<string, string> {
|
|
41
|
+
captured ??= capture();
|
|
42
|
+
return captured;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Forgets the captured env (tests, which change SHELL). */
|
|
46
|
+
export function resetLoginEnv(): void {
|
|
47
|
+
captured = null;
|
|
48
|
+
}
|