catherd-cli 0.2.1 → 1.1.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 +141 -33
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +282 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +333 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +60 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +49 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +160 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +197 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Adapted from anomalyco/opencode packages/tui/src/component/spinner.tsx and startup-loading.tsx
|
|
2
|
+
// (MIT, © 2025 opencode); see THIRD_PARTY_NOTICES.md.
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useApp } from "../providers/app.tsx";
|
|
5
|
+
import { useTone, useUi } from "../providers/theme.tsx";
|
|
6
|
+
import { ascii } from "../text.ts";
|
|
7
|
+
import { SPINNER } from "../theme.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Spec §9.3 motion: true once `active` has lasted SPINNER.showAfterMs, and then for at least
|
|
11
|
+
* SPINNER.minShowMs, so a quick check never flickers.
|
|
12
|
+
*/
|
|
13
|
+
export function useDelayedPresence(active: boolean): boolean {
|
|
14
|
+
const { clock } = useApp();
|
|
15
|
+
const [shownAt, setShownAt] = useState<number | null>(null);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (active && shownAt === null) {
|
|
18
|
+
const h = clock.setTimeout(() => setShownAt(clock.now()), SPINNER.showAfterMs);
|
|
19
|
+
return () => clock.clearTimeout(h);
|
|
20
|
+
}
|
|
21
|
+
if (!active && shownAt !== null) {
|
|
22
|
+
const left = SPINNER.minShowMs - (clock.now() - shownAt);
|
|
23
|
+
if (left <= 0) {
|
|
24
|
+
setShownAt(null);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const h = clock.setTimeout(() => setShownAt(null), left);
|
|
28
|
+
return () => clock.clearTimeout(h);
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}, [active, shownAt, clock]);
|
|
32
|
+
return shownAt !== null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A braille spinner and what it waits on; a still `⋯` under reduced motion (spec §9.3). */
|
|
36
|
+
export function Spinner(props: { label: string }) {
|
|
37
|
+
const { clock } = useApp();
|
|
38
|
+
const ui = useUi();
|
|
39
|
+
const tone = useTone();
|
|
40
|
+
const [frame, setFrame] = useState(0);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (ui.reducedMotion) return;
|
|
43
|
+
const h = clock.setInterval(() => setFrame((f) => f + 1), SPINNER.ms);
|
|
44
|
+
return () => clock.clearInterval(h);
|
|
45
|
+
}, [clock, ui.reducedMotion]);
|
|
46
|
+
const frames = ui.plain ? SPINNER.plainFrames : SPINNER.frames;
|
|
47
|
+
const g = ui.reducedMotion ? SPINNER.still[ui.plain ? 1 : 0] : frames[frame % frames.length];
|
|
48
|
+
return <text fg={tone("muted")}>{`${g} ${ui.plain ? ascii(props.label) : props.label}`}</text>;
|
|
49
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Adapted from anomalyco/opencode packages/tui/src/ui/toast.tsx (MIT, © 2025 opencode);
|
|
2
|
+
// see THIRD_PARTY_NOTICES.md.
|
|
3
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
4
|
+
import { useToasts } from "../providers/toast.tsx";
|
|
5
|
+
import { useUi } from "../providers/theme.tsx";
|
|
6
|
+
import { truncateEnd, wrap } from "../text.ts";
|
|
7
|
+
import { glyph, type Token } from "../theme.ts";
|
|
8
|
+
import { Line } from "./line.tsx";
|
|
9
|
+
|
|
10
|
+
const TONE: Record<string, Token> = { info: "info", success: "success", warning: "warning", error: "error" };
|
|
11
|
+
|
|
12
|
+
/** Lines of a toast's message kept before the rest is cut; a fix command always shows in full. */
|
|
13
|
+
const MESSAGE_LINES = 3;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Spec §9.3: the current toast at the top right, with a `┃` bar in its colour and the queue's length.
|
|
17
|
+
* The message wraps to three lines; a fix command wraps in full on muted lines under it.
|
|
18
|
+
*/
|
|
19
|
+
export function ToastHost() {
|
|
20
|
+
const { current, queued } = useToasts();
|
|
21
|
+
const ui = useUi();
|
|
22
|
+
const dims = useTerminalDimensions();
|
|
23
|
+
if (!current) return null;
|
|
24
|
+
const bar = glyph("bar", ui.plain);
|
|
25
|
+
const max = Math.max(1, Math.min(60, dims.width - 6) - 4);
|
|
26
|
+
const said = wrap(`${current.message}${queued ? ` +${queued} more` : ""}`, max);
|
|
27
|
+
const message = said.slice(0, MESSAGE_LINES);
|
|
28
|
+
if (said.length > MESSAGE_LINES)
|
|
29
|
+
message[MESSAGE_LINES - 1] = truncateEnd(
|
|
30
|
+
`${message[MESSAGE_LINES - 1]} ${said[MESSAGE_LINES]}`,
|
|
31
|
+
max,
|
|
32
|
+
ui.plain,
|
|
33
|
+
);
|
|
34
|
+
const fix = current.fix ? wrap(current.fix, max) : [];
|
|
35
|
+
const text = Math.max(...[...message, ...fix].map((l) => Bun.stringWidth(l)));
|
|
36
|
+
const w = text + 4;
|
|
37
|
+
const line = (t: string, muted: boolean, i: number) => (
|
|
38
|
+
<Line
|
|
39
|
+
key={i}
|
|
40
|
+
width={w}
|
|
41
|
+
opaque
|
|
42
|
+
parts={[
|
|
43
|
+
{ text: `${bar} `, tone: TONE[current.variant] },
|
|
44
|
+
{ text: t.padEnd(text + t.length - Bun.stringWidth(t)), tone: muted ? "muted" : undefined },
|
|
45
|
+
{ text: ` ${bar}`, tone: TONE[current.variant] },
|
|
46
|
+
]}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
return (
|
|
50
|
+
<box
|
|
51
|
+
position="absolute"
|
|
52
|
+
top={1}
|
|
53
|
+
left={Math.max(0, dims.width - w - 2)}
|
|
54
|
+
zIndex={4000}
|
|
55
|
+
width={w}
|
|
56
|
+
height={message.length + fix.length}
|
|
57
|
+
flexDirection="column"
|
|
58
|
+
>
|
|
59
|
+
{[...message.map((t, i) => line(t, false, i)), ...fix.map((t, i) => line(t, true, message.length + i))]}
|
|
60
|
+
</box>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { claudeHome } from "./paths.ts";
|
|
4
|
+
import { isAlive } from "./proc.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Claude Code session catherd's MCP server runs in (spec §3.3; docs/research/2026-09-28-cross-session-messaging.md):
|
|
8
|
+
* what its environment says, and what the session's registry file (`<config>/sessions/<pid>.json`) says now.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The four variables a Claude Code session hands its MCP servers; each may be absent. */
|
|
12
|
+
export const SESSION_ENV_KEYS = {
|
|
13
|
+
sessionId: "CLAUDE_CODE_SESSION_ID",
|
|
14
|
+
hostSessionId: "CLAUDE_CODE_HOST_SESSION_ID",
|
|
15
|
+
socketPath: "CLAUDE_CODE_MESSAGING_SOCKET",
|
|
16
|
+
token: "CLAUDE_CODE_MESSAGING_TOKEN",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export interface SessionEnv {
|
|
20
|
+
/** the id at spawn: it goes stale after /clear, so the registry's is preferred */
|
|
21
|
+
sessionId: string | null;
|
|
22
|
+
/** set only when a host (Desktop) launched the session */
|
|
23
|
+
hostSessionId: string | null;
|
|
24
|
+
socketPath: string | null;
|
|
25
|
+
token: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The session's environment, or null outside Claude Code (neither a session id nor a socket). */
|
|
29
|
+
export function readSessionEnv(env: Record<string, string | undefined> = process.env): SessionEnv | null {
|
|
30
|
+
const get = (k: string) => env[k] || null;
|
|
31
|
+
const s: SessionEnv = {
|
|
32
|
+
sessionId: get(SESSION_ENV_KEYS.sessionId),
|
|
33
|
+
hostSessionId: get(SESSION_ENV_KEYS.hostSessionId),
|
|
34
|
+
socketPath: get(SESSION_ENV_KEYS.socketPath),
|
|
35
|
+
token: get(SESSION_ENV_KEYS.token),
|
|
36
|
+
};
|
|
37
|
+
return s.sessionId || s.socketPath ? s : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One live session's registry entry, as far as catherd reads it. */
|
|
41
|
+
export interface SessionFile {
|
|
42
|
+
pid: number;
|
|
43
|
+
sessionId: string;
|
|
44
|
+
name: string | null;
|
|
45
|
+
hostSessionId: string | null;
|
|
46
|
+
messagingSocketPath: string | null;
|
|
47
|
+
/** busy | idle | waiting, when the session says */
|
|
48
|
+
status: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
|
|
52
|
+
|
|
53
|
+
/** Every readable registry file (`<config>/sessions/<pid>.json`); a torn or foreign one is skipped. */
|
|
54
|
+
export function readSessionFiles(dir: string = join(claudeHome(), "sessions")): SessionFile[] {
|
|
55
|
+
if (!existsSync(dir)) return [];
|
|
56
|
+
const out: SessionFile[] = [];
|
|
57
|
+
for (const f of readdirSync(dir)) {
|
|
58
|
+
if (!/^\d+\.json$/.test(f)) continue;
|
|
59
|
+
try {
|
|
60
|
+
const v = JSON.parse(readFileSync(join(dir, f), "utf8")) as Record<string, unknown>;
|
|
61
|
+
const sessionId = str(v.sessionId);
|
|
62
|
+
if (typeof v.pid !== "number" || !sessionId) continue;
|
|
63
|
+
out.push({
|
|
64
|
+
pid: v.pid,
|
|
65
|
+
sessionId,
|
|
66
|
+
name: str(v.name),
|
|
67
|
+
hostSessionId: str(v.hostSessionId),
|
|
68
|
+
messagingSocketPath: str(v.messagingSocketPath),
|
|
69
|
+
status: str(v.status),
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
// written as we read it, or not Claude Code's
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* This server's session file: the one naming its socket, else its parent's pid (the MCP server is the session's
|
|
80
|
+
* child), else the one with its env's session id. Null when none is found.
|
|
81
|
+
*/
|
|
82
|
+
export function sessionFileFor(
|
|
83
|
+
s: SessionEnv,
|
|
84
|
+
files = readSessionFiles(),
|
|
85
|
+
ppid = process.ppid,
|
|
86
|
+
): SessionFile | null {
|
|
87
|
+
return (
|
|
88
|
+
(s.socketPath ? files.find((f) => f.messagingSocketPath === s.socketPath) : undefined) ??
|
|
89
|
+
files.find((f) => f.pid === ppid) ??
|
|
90
|
+
(s.sessionId ? files.find((f) => f.sessionId === s.sessionId) : undefined) ??
|
|
91
|
+
null
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The live session that has `sessionId` now, if one does: its file names it and its process is alive. */
|
|
96
|
+
export function liveSessionFile(sessionId: string, files = readSessionFiles()): SessionFile | null {
|
|
97
|
+
return files.find((f) => f.sessionId === sessionId && isAlive(f.pid, null)) ?? null;
|
|
98
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
linkSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
writeSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { EXIT_REASONS, type ExitInfo } from "../domain/record.ts";
|
|
17
|
+
import { lockHeld, withFileLock } from "./filelock.ts";
|
|
18
|
+
import { isAlive, selfIdentity } from "./proc.ts";
|
|
19
|
+
import { PRIVATE_FILE, readVersioned } from "./store.ts";
|
|
20
|
+
|
|
21
|
+
export function dispatchPaths(dir: string) {
|
|
22
|
+
return {
|
|
23
|
+
brief: join(dir, "brief.md"),
|
|
24
|
+
spec: join(dir, "spec.json"),
|
|
25
|
+
proc: join(dir, "proc.json"),
|
|
26
|
+
events: join(dir, "events.jsonl"),
|
|
27
|
+
stderr: join(dir, "stderr"),
|
|
28
|
+
reply: join(dir, "reply.md"),
|
|
29
|
+
exit: join(dir, "exit.json"),
|
|
30
|
+
claim: join(dir, "claim"),
|
|
31
|
+
cancel: join(dir, "cancel"),
|
|
32
|
+
/** held by the one supervisor of this dispatch for its lifetime (filelock, dead holders reclaimed) */
|
|
33
|
+
supervisorLock: join(dir, "supervisor.lock"),
|
|
34
|
+
/** exists from admission until the orchestrator reads the record (`result`, `cancel`): "not yet read" */
|
|
35
|
+
collect: join(dir, "collect"),
|
|
36
|
+
/** while a reader takes the record: names that reader's process, so a crash leaves it reclaimable */
|
|
37
|
+
lease: join(dir, "collect.lease"),
|
|
38
|
+
supervisorLog: join(dir, "supervisor.log"),
|
|
39
|
+
/** what failover did for this limited dispatch, written once under `failover.lock` (plan 10) */
|
|
40
|
+
failover: join(dir, "failover.json"),
|
|
41
|
+
/** the message that announced this dispatch's record to its session, once sent (plan 10) */
|
|
42
|
+
notified: join(dir, "notified.json"),
|
|
43
|
+
/** written once by the supervisor when the worker went quiet for half its idle timeout, not busy (spec §3.6) */
|
|
44
|
+
stall: join(dir, "stall.json"),
|
|
45
|
+
/** the message that announced the stall, once sent */
|
|
46
|
+
stallNotified: join(dir, "stall-notified.json"),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Exclusive create: the first caller finalizes; every later caller reads the record it wrote. The claim
|
|
52
|
+
* names its claimant (pid and start time), so a later caller can tell a dead claimant from a slow one.
|
|
53
|
+
*/
|
|
54
|
+
export function tryClaim(dir: string): boolean {
|
|
55
|
+
let fd: number;
|
|
56
|
+
try {
|
|
57
|
+
fd = openSync(dispatchPaths(dir).claim, "wx", PRIVATE_FILE);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
writeSync(fd, JSON.stringify(selfIdentity()));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
// a claim that names no one would hold every other finalizer off until it is stale: drop it
|
|
66
|
+
closeSync(fd);
|
|
67
|
+
try {
|
|
68
|
+
unlinkSync(dispatchPaths(dir).claim);
|
|
69
|
+
} catch {
|
|
70
|
+
// already gone
|
|
71
|
+
}
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
closeSync(fd);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Who holds the claim, or null when it names no one (an older build's empty claim, or one being written). */
|
|
79
|
+
export function readClaimant(dir: string): { pid: number; startTime: string | null } | null {
|
|
80
|
+
try {
|
|
81
|
+
const c = JSON.parse(readFileSync(dispatchPaths(dir).claim, "utf8")) as {
|
|
82
|
+
pid?: unknown;
|
|
83
|
+
startTime?: unknown;
|
|
84
|
+
};
|
|
85
|
+
if (typeof c.pid !== "number") return null;
|
|
86
|
+
return { pid: c.pid, startTime: typeof c.startTime === "string" ? c.startTime : null };
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Marks a dispatch's record as not yet read; written before its launch. */
|
|
93
|
+
export function markForCollect(dir: string): void {
|
|
94
|
+
writeFileSync(dispatchPaths(dir).collect, "", { mode: PRIVATE_FILE });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const errno = (e: unknown): string | undefined => (e as NodeJS.ErrnoException).code;
|
|
98
|
+
|
|
99
|
+
/** Test seams: `beforeTakeover` runs after a collector judged a lease dead, before it takes the lock. */
|
|
100
|
+
export const collectSeams = { beforeTakeover: async (_dir: string): Promise<void> => {} };
|
|
101
|
+
/** The hard link a lease is made with; tests replace it to stand for a filesystem without hard links. */
|
|
102
|
+
export const leaseFs = { link: linkSync };
|
|
103
|
+
|
|
104
|
+
/** An ownerless lease this old was left by a crash mid-write (only the no-hard-link fallback can leave one). */
|
|
105
|
+
const OWNERLESS_STALE_MS = 5_000;
|
|
106
|
+
/** A lease temp file this old, or whose pid is dead, was left by a crash. */
|
|
107
|
+
const TEMP_STALE_MS = 60_000;
|
|
108
|
+
|
|
109
|
+
const self = selfIdentity;
|
|
110
|
+
|
|
111
|
+
/** Who holds a collection lease, or null when none can be read. */
|
|
112
|
+
function leaseOwner(file: string): { pid: number; startTime: string | null } | null {
|
|
113
|
+
try {
|
|
114
|
+
const c = JSON.parse(readFileSync(file, "utf8")) as { pid?: unknown; startTime?: unknown };
|
|
115
|
+
if (typeof c.pid !== "number") return null;
|
|
116
|
+
return { pid: c.pid, startTime: typeof c.startTime === "string" ? c.startTime : null };
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function olderThan(file: string, ms: number): boolean {
|
|
123
|
+
try {
|
|
124
|
+
return statSync(file).mtimeMs < Date.now() - ms;
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A lease no live process holds: it exists, and its owner is dead, or it names none and is too old to be
|
|
132
|
+
* one still being written.
|
|
133
|
+
*/
|
|
134
|
+
function leaseDead(file: string): boolean {
|
|
135
|
+
if (!existsSync(file)) return false;
|
|
136
|
+
const who = leaseOwner(file);
|
|
137
|
+
return who ? !isAlive(who.pid, who.startTime) : olderThan(file, OWNERLESS_STALE_MS);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether this dispatch's record is still unread: it has the mark, or a lease whose reader died before the
|
|
142
|
+
* record went out.
|
|
143
|
+
*/
|
|
144
|
+
export function awaitsCollect(dir: string): boolean {
|
|
145
|
+
const p = dispatchPaths(dir);
|
|
146
|
+
return existsSync(p.collect) || leaseDead(p.lease);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Creates a lease naming this process, exclusively: true when it is now this process's. */
|
|
150
|
+
function createLease(dir: string): boolean {
|
|
151
|
+
const p = dispatchPaths(dir);
|
|
152
|
+
const content = JSON.stringify(self());
|
|
153
|
+
const tmp = `${p.lease}.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
154
|
+
writeFileSync(tmp, content, { mode: PRIVATE_FILE });
|
|
155
|
+
try {
|
|
156
|
+
// linked in whole: no reader ever sees a lease without its owner
|
|
157
|
+
leaseFs.link(tmp, p.lease);
|
|
158
|
+
return true;
|
|
159
|
+
} catch (e) {
|
|
160
|
+
if (errno(e) === "EEXIST") return false;
|
|
161
|
+
if (!["EPERM", "ENOTSUP", "EXDEV", "EOPNOTSUPP"].includes(errno(e) ?? "")) throw e;
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(tmp, { force: true });
|
|
164
|
+
}
|
|
165
|
+
// no hard links here: an exclusive create, its owner written at once (an ownerless lease is young)
|
|
166
|
+
let fd: number;
|
|
167
|
+
try {
|
|
168
|
+
fd = openSync(p.lease, "wx", PRIVATE_FILE);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
if (errno(e) === "EEXIST") return false;
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
writeSync(fd, content);
|
|
175
|
+
} finally {
|
|
176
|
+
closeSync(fd);
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Turns a dead collector's lease back into the mark, serialized by a lock on the lease, and only after
|
|
183
|
+
* reading it again under the lock: the mark is written before the lease goes, so the record is never
|
|
184
|
+
* without both, and a lease that is not dead (gone, or a live collector's) is left alone. A moved lease
|
|
185
|
+
* is never linked back.
|
|
186
|
+
*/
|
|
187
|
+
async function reviveDeadLease(dir: string): Promise<void> {
|
|
188
|
+
const p = dispatchPaths(dir);
|
|
189
|
+
await withFileLock(p.lease, () => {
|
|
190
|
+
if (!leaseDead(p.lease)) return;
|
|
191
|
+
markForCollect(dir);
|
|
192
|
+
rmSync(p.lease, { force: true });
|
|
193
|
+
sweepLeaseTemps(dir);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Collects the dispatch as a lease naming this process: true for the one caller that got it, so each
|
|
199
|
+
* record is read (marked read) once. The common path takes no lock: the lease is created exclusively, and
|
|
200
|
+
* the record is this caller's only if the mark still exists then; the mark goes only once the lease
|
|
201
|
+
* exists. A dead collector's lease is first turned back into the mark (`reviveDeadLease`), then
|
|
202
|
+
* collected the same way; a live collector's is never taken. The caller ends the lease with `endCollect`
|
|
203
|
+
* once the record has gone out, or turns it back into the mark with `putBackCollect`.
|
|
204
|
+
*/
|
|
205
|
+
export async function tryCollect(dir: string): Promise<boolean> {
|
|
206
|
+
const p = dispatchPaths(dir);
|
|
207
|
+
if (!existsSync(p.collect)) {
|
|
208
|
+
if (!leaseDead(p.lease)) return false;
|
|
209
|
+
await collectSeams.beforeTakeover(dir);
|
|
210
|
+
await reviveDeadLease(dir);
|
|
211
|
+
}
|
|
212
|
+
if (!createLease(dir)) {
|
|
213
|
+
// a lease is there: collect it only if it is dead, and only by way of the mark
|
|
214
|
+
if (!leaseDead(p.lease)) return false;
|
|
215
|
+
await collectSeams.beforeTakeover(dir);
|
|
216
|
+
await reviveDeadLease(dir);
|
|
217
|
+
if (!createLease(dir)) return false;
|
|
218
|
+
}
|
|
219
|
+
// no lease was there when this one was made: the record is ours only if the mark still is
|
|
220
|
+
if (!existsSync(p.collect)) {
|
|
221
|
+
endCollect(dir);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
rmSync(p.collect, { force: true });
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Ends this process's lease (never another's): the record has gone out. Sweeps leftover temp files. */
|
|
229
|
+
export function endCollect(dir: string): void {
|
|
230
|
+
const p = dispatchPaths(dir);
|
|
231
|
+
const who = leaseOwner(p.lease);
|
|
232
|
+
// the pid alone: while this process lives no other can hold its pid, so a lease naming it is its own
|
|
233
|
+
if (who?.pid === process.pid) rmSync(p.lease, { force: true });
|
|
234
|
+
sweepLeaseTemps(dir);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Turns this process's lease back into the mark: the record did not go out. */
|
|
238
|
+
export function putBackCollect(dir: string): void {
|
|
239
|
+
markForCollect(dir);
|
|
240
|
+
endCollect(dir);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const LEASE_TEMP = /^collect\.lease\.(?:dead\.)?(\d+)\.[^.]+$/;
|
|
244
|
+
|
|
245
|
+
/** Removes lease temp files a crash left behind: a dead pid's, or any older than a minute. */
|
|
246
|
+
function sweepLeaseTemps(dir: string): void {
|
|
247
|
+
let names: string[];
|
|
248
|
+
try {
|
|
249
|
+
names = readdirSync(dir);
|
|
250
|
+
} catch {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
for (const n of names) {
|
|
254
|
+
const m = LEASE_TEMP.exec(n);
|
|
255
|
+
if (!m) continue;
|
|
256
|
+
const f = join(dir, n);
|
|
257
|
+
if (!isAlive(Number(m[1]), null) || olderThan(f, TEMP_STALE_MS)) rmSync(f, { force: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const ExitFileSchema = z.looseObject({
|
|
262
|
+
schema: z.literal(1),
|
|
263
|
+
code: z.number().nullable(),
|
|
264
|
+
signal: z.string().nullable(),
|
|
265
|
+
reason: z.enum(EXIT_REASONS),
|
|
266
|
+
endedAt: z.string(),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
export function readExit(dir: string): ExitInfo | null {
|
|
270
|
+
try {
|
|
271
|
+
const e = readVersioned(dispatchPaths(dir).exit, ExitFileSchema, 1);
|
|
272
|
+
return { code: e.code, signal: e.signal, reason: e.reason, endedAt: e.endedAt };
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function requestCancel(dir: string): void {
|
|
279
|
+
writeFileSync(dispatchPaths(dir).cancel, new Date().toISOString(), { mode: PRIVATE_FILE });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The target of the dispatch's supervisor lock (`supervisor.lock`, as `dispatchPaths` names it). */
|
|
283
|
+
export const supervisorLockTarget = (dir: string): string => join(dir, "supervisor");
|
|
284
|
+
|
|
285
|
+
/** Whether a live supervisor holds the dispatch: launch evidence even before launch.json or proc.json. */
|
|
286
|
+
export const supervisorAlive = (dir: string): boolean => lockHeld(supervisorLockTarget(dir));
|
package/src/infra/env.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* catherd's own secrets; the user's backend credentials (OPENAI_API_KEY, …) stay, workers need them. The Claude Code
|
|
3
|
+
* session's messaging socket and token are the MCP server's alone (spec §3.2: it is the only sender), and so is the
|
|
4
|
+
* session's identity (a child that has it takes itself for that session: the doctor's MCP server would own its
|
|
5
|
+
* runs, a headless claude-code worker would inherit it): no process catherd starts gets them.
|
|
6
|
+
*/
|
|
7
|
+
const SECRET_ENV = new Set([
|
|
8
|
+
"TYPESAFE_API_KEY",
|
|
9
|
+
"CLAUDE_CODE_MESSAGING_SOCKET",
|
|
10
|
+
"CLAUDE_CODE_MESSAGING_TOKEN",
|
|
11
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
12
|
+
"CLAUDE_CODE_HOST_SESSION_ID",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/** `base` without catherd's own secrets and without unset keys; for every process catherd starts. */
|
|
16
|
+
export function scrubSecrets(base: Record<string, string | undefined>): Record<string, string> {
|
|
17
|
+
const env: Record<string, string> = {};
|
|
18
|
+
for (const [k, v] of Object.entries(base)) if (v !== undefined && !SECRET_ENV.has(k)) env[k] = v;
|
|
19
|
+
return env;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Spec 1.1 §12: the plugin's launcher runs `bunx` with TMPDIR in catherd's cache, so macOS never half-cleans
|
|
24
|
+
* the package, and passes the user's own TMPDIR in CATHERD_USER_TMPDIR (empty when unset). The server and
|
|
25
|
+
* every worker it starts get the user's TMPDIR back.
|
|
26
|
+
*/
|
|
27
|
+
export function restoreTmpdir(env: Record<string, string | undefined>): void {
|
|
28
|
+
const mine = env.CATHERD_USER_TMPDIR;
|
|
29
|
+
if (mine === undefined) return;
|
|
30
|
+
if (mine) env.TMPDIR = mine;
|
|
31
|
+
else delete env.TMPDIR;
|
|
32
|
+
delete env.CATHERD_USER_TMPDIR;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function workerEnv(
|
|
36
|
+
base: Record<string, string | undefined>,
|
|
37
|
+
overrides: Record<string, string>,
|
|
38
|
+
cwd: string,
|
|
39
|
+
): Record<string, string> {
|
|
40
|
+
return { ...scrubSecrets(base), ...overrides, PWD: cwd };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What a preflight check may see; everything else, credentials included, stays out (spec §4.7). */
|
|
44
|
+
const CHECK_ENV = new Set([
|
|
45
|
+
"PATH",
|
|
46
|
+
"HOME",
|
|
47
|
+
"USER",
|
|
48
|
+
"LOGNAME",
|
|
49
|
+
"SHELL",
|
|
50
|
+
"LANG",
|
|
51
|
+
"TZ",
|
|
52
|
+
"TERM",
|
|
53
|
+
"TMPDIR",
|
|
54
|
+
"CI",
|
|
55
|
+
"NODE_ENV",
|
|
56
|
+
"CATHERD_HOME",
|
|
57
|
+
"CATHERD_LOCK_SLOTS",
|
|
58
|
+
]);
|
|
59
|
+
const CHECK_ENV_PATTERN = /^(LC_[A-Z_]+|XDG_[A-Z_]+_(HOME|DIR|DIRS))$/;
|
|
60
|
+
|
|
61
|
+
/** A strict allowlist of `base` plus PWD, for lane-authored shell text such as preflight checks. */
|
|
62
|
+
export function checkEnv(base: Record<string, string | undefined>, cwd: string): Record<string, string> {
|
|
63
|
+
const env: Record<string, string> = {};
|
|
64
|
+
for (const [k, v] of Object.entries(base))
|
|
65
|
+
if (v !== undefined && (CHECK_ENV.has(k) || CHECK_ENV_PATTERN.test(k))) env[k] = v;
|
|
66
|
+
return { ...env, PWD: cwd };
|
|
67
|
+
}
|