catherd-cli 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +114 -30
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -26
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -84
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
@@ -0,0 +1,162 @@
1
+ // Spec §5.5 transport: a thin client (the official SDK is 0.x and Node-targeted) with the SDK's retry
2
+ // semantics (research 2026-09-25-jev.md §4, §5.6).
3
+
4
+ export const JEV_BASE = "https://api.typesafe.ai/v1";
5
+
6
+ export interface JevTransport {
7
+ fetchImpl?: typeof fetch;
8
+ /** the clock and the wait between attempts; tests replace both */
9
+ now?: () => number;
10
+ sleep?: (ms: number) => Promise<void>;
11
+ random?: () => number;
12
+ /** per attempt (10 s) */
13
+ attemptMs?: number;
14
+ /** for the whole call, retries included (25 s) */
15
+ deadlineMs?: number;
16
+ retries?: number;
17
+ }
18
+
19
+ export type JevResponse =
20
+ | { ok: true; body: unknown; requestId: string | null; latencyMs: number; attempts: number }
21
+ | { ok: false; error: string; status: number | null; latencyMs: number; attempts: number };
22
+
23
+ const RETRY_AFTER_MAX_MS = 10_000;
24
+ const retryable = (status: number) => status === 408 || status === 429 || status >= 500;
25
+
26
+ /** A header's value, or null when it is absent or blank. */
27
+ const header = (h: Headers, name: string): string | null => {
28
+ const v = h.get(name)?.trim();
29
+ return v ? v : null;
30
+ };
31
+
32
+ /** `Retry-After` (seconds or an HTTP date) or `retry-after-ms`, capped at 10 s; null when absent. */
33
+ export function retryAfterMs(h: Headers, now: number): number | null {
34
+ const rawMs = header(h, "retry-after-ms");
35
+ const ms = Number(rawMs);
36
+ if (rawMs !== null && Number.isFinite(ms)) return Math.min(Math.max(ms, 0), RETRY_AFTER_MAX_MS);
37
+ const v = header(h, "retry-after");
38
+ if (v === null) return null;
39
+ const secs = Number(v);
40
+ const wait = Number.isFinite(secs) ? secs * 1000 : Date.parse(v) - now;
41
+ return Number.isFinite(wait) ? Math.min(Math.max(wait, 0), RETRY_AFTER_MAX_MS) : null;
42
+ }
43
+
44
+ /** 500 ms doubling per retry, ±25 % jitter. */
45
+ const backoffMs = (retry: number, random: () => number) => 500 * 2 ** (retry - 1) * (0.75 + random() * 0.5);
46
+
47
+ /**
48
+ * A failed status as an error, naming a validation error by Jev's own `error_type` and `message` only,
49
+ * never the raw body (which could echo the request's state).
50
+ */
51
+ function httpError(status: number, text: string): string {
52
+ let detail: unknown = null;
53
+ try {
54
+ detail = (JSON.parse(text) as { detail?: unknown })?.detail;
55
+ } catch {
56
+ // not JSON: nothing to name
57
+ }
58
+ if (detail && typeof detail === "object") {
59
+ const { error_type: type, message } = detail as { error_type?: unknown; message?: unknown };
60
+ if (typeof type === "string" && /validation/i.test(type))
61
+ return `http ${status}: ${type}${typeof message === "string" ? `: ${message.slice(0, 200)}` : ""}`;
62
+ }
63
+ return `http ${status}`;
64
+ }
65
+
66
+ /**
67
+ * `method path` against the Jev API with a bearer key: 10 s per attempt, up to two retries on 408, 429,
68
+ * 5xx, a network error or a timeout, honouring Retry-After, and never past the 25 s deadline. The attempt
69
+ * timer covers the whole exchange, body included, and aborts the request when it fires.
70
+ */
71
+ export async function jevRequest(
72
+ method: "GET" | "POST",
73
+ path: string,
74
+ key: string,
75
+ body: unknown,
76
+ o: JevTransport = {},
77
+ ): Promise<JevResponse> {
78
+ const now = o.now ?? Date.now;
79
+ const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms));
80
+ const random = o.random ?? Math.random;
81
+ const fetchImpl = o.fetchImpl ?? globalThis.fetch;
82
+ const start = now();
83
+ const deadline = start + (o.deadlineMs ?? 25_000);
84
+ const retries = o.retries ?? 2;
85
+ let last: { error: string; status: number | null } = { error: "network error", status: null };
86
+ let attempt = 0;
87
+ for (;;) {
88
+ attempt++;
89
+ const left = deadline - now();
90
+ const ctl = new AbortController();
91
+ let timer: ReturnType<typeof setTimeout> | undefined;
92
+ const timeout = new Promise<"timeout">((resolve) => {
93
+ timer = setTimeout(
94
+ () => {
95
+ // settle first, so the race reads a timeout rather than the abort it causes
96
+ resolve("timeout");
97
+ ctl.abort();
98
+ },
99
+ Math.min(o.attemptMs ?? 10_000, Math.max(left, 0)),
100
+ );
101
+ });
102
+ const exchange = async () => {
103
+ const res = await fetchImpl(`${JEV_BASE}${path}`, {
104
+ method,
105
+ headers: {
106
+ authorization: `Bearer ${key}`,
107
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
108
+ ...(attempt > 1 ? { "x-typesafe-retry-count": String(attempt - 1) } : {}),
109
+ },
110
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
111
+ signal: ctl.signal,
112
+ });
113
+ const text = res.ok ? await res.text() : await res.text().catch(() => "");
114
+ return { res, text };
115
+ };
116
+ let wait: number | null = null;
117
+ try {
118
+ const r = await Promise.race([exchange(), timeout]);
119
+ if (r === "timeout") {
120
+ last = { error: "timeout", status: null };
121
+ } else if (r.res.ok) {
122
+ let parsed: unknown = null;
123
+ try {
124
+ parsed = JSON.parse(r.text);
125
+ } catch {
126
+ return {
127
+ ok: false,
128
+ error: "unexpected response",
129
+ status: r.res.status,
130
+ latencyMs: now() - start,
131
+ attempts: attempt,
132
+ };
133
+ }
134
+ return {
135
+ ok: true,
136
+ body: parsed,
137
+ requestId: r.res.headers.get("x-typesafe-request-id"),
138
+ latencyMs: now() - start,
139
+ attempts: attempt,
140
+ };
141
+ } else {
142
+ last = { error: httpError(r.res.status, r.text), status: r.res.status };
143
+ if (!retryable(r.res.status))
144
+ return { ok: false, ...last, latencyMs: now() - start, attempts: attempt };
145
+ wait = retryAfterMs(r.res.headers, now());
146
+ }
147
+ } catch {
148
+ last = { error: "network error", status: null };
149
+ } finally {
150
+ clearTimeout(timer);
151
+ ctl.abort();
152
+ }
153
+ if (attempt > retries) break;
154
+ const pause = wait ?? backoffMs(attempt, random);
155
+ if (now() + pause >= deadline) {
156
+ last = { error: `deadline (${last.error})`, status: last.status };
157
+ break;
158
+ }
159
+ await sleep(pause);
160
+ }
161
+ return { ok: false, ...last, latencyMs: now() - start, attempts: attempt };
162
+ }
@@ -0,0 +1,25 @@
1
+ import { closeSync, openSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { scrubSecrets } from "./env.ts";
5
+ import { log as logRow } from "./log.ts";
6
+ import { PRIVATE_FILE } from "./store.ts";
7
+
8
+ /** A thin executable that supervises one spec; unlike src/cli.ts it never loads the TUI. */
9
+ export const SUPERVISE_ENTRY = fileURLToPath(new URL("../entry/supervise-bin.ts", import.meta.url));
10
+
11
+ /** Starts `supervise-bin.ts <spec>` in its own session and returns at once; it outlives this process. */
12
+ export function launchSupervisor(specPath: string): number {
13
+ const log = openSync(join(dirname(specPath), "supervisor.log"), "a", PRIVATE_FILE);
14
+ const p = Bun.spawn([process.execPath, SUPERVISE_ENTRY, specPath], {
15
+ stdin: "ignore",
16
+ stdout: log,
17
+ stderr: log,
18
+ detached: true,
19
+ env: scrubSecrets(process.env),
20
+ });
21
+ closeSync(log);
22
+ p.unref();
23
+ logRow("info", "launch", { spec: specPath, pid: p.pid });
24
+ return p.pid;
25
+ }
@@ -0,0 +1,84 @@
1
+ import { existsSync, readdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { MIN_SECRET, replaceSecrets, scrubKeyShapes, secretEnvValues } from "../domain/secrets.ts";
4
+ import { isPlain } from "../domain/util.ts";
5
+ import { logsDir } from "./paths.ts";
6
+ import { appendJsonl, ensureJsonlHeader } from "./store.ts";
7
+
8
+ // Spec §10.2: `<data>/logs/catherd-<date>.jsonl`, 7-day rotation, level from CATHERD_LOG or `--verbose`.
9
+
10
+ const LEVELS = ["off", "error", "warn", "info", "debug"] as const;
11
+ export type Level = Exclude<(typeof LEVELS)[number], "off">;
12
+ /** Days of logs kept, today included. */
13
+ const KEEP_DAYS = 7;
14
+
15
+ /** `CATHERD_LOG` (off, error, warn, info, debug); info when unset or unknown. */
16
+ function logLevel(): (typeof LEVELS)[number] {
17
+ const v = process.env.CATHERD_LOG?.toLowerCase();
18
+ return (LEVELS as readonly string[]).includes(v ?? "") ? (v as Level) : "info";
19
+ }
20
+
21
+ const rank = (l: string) => LEVELS.indexOf(l as (typeof LEVELS)[number]);
22
+
23
+ const extra = new Set<string>();
24
+ /** A secret that lives outside the env (the saved Jev key): scrubbed from every row from now on. */
25
+ export function addSecret(value: string | null | undefined): void {
26
+ if (value && value.length >= MIN_SECRET) extra.add(value);
27
+ }
28
+
29
+ /** Every literal secret a row must not carry: credential-named env values and each added secret. */
30
+ export const knownSecrets = (env: Record<string, string | undefined> = process.env): string[] => [
31
+ ...secretEnvValues(env),
32
+ ...extra,
33
+ ];
34
+
35
+ /**
36
+ * `v` with every known secret and every key-shaped string replaced by `[redacted]`, in any string at
37
+ * any depth, and every env map (a field named `env`) reduced to its keys.
38
+ */
39
+ export function redact<T>(v: T, secrets: string[] = knownSecrets()): T {
40
+ const walk = (x: unknown, key: string | null): unknown => {
41
+ if (typeof x === "string") return scrubKeyShapes(replaceSecrets(x, secrets, "[redacted]"), "[redacted]");
42
+ if (Array.isArray(x)) return x.map((y) => walk(y, null));
43
+ if (isPlain(x)) {
44
+ if (key === "env") return Object.keys(x).sort();
45
+ return Object.fromEntries(Object.entries(x).map(([k, y]) => [k, walk(y, k)]));
46
+ }
47
+ return x;
48
+ };
49
+ return walk(v, null) as T;
50
+ }
51
+
52
+ const day = (d: Date) => d.toISOString().slice(0, 10);
53
+ export const logFile = (now: Date = new Date()): string => join(logsDir(), `catherd-${day(now)}.jsonl`);
54
+
55
+ let rotatedOn: string | null = null;
56
+ /** Deletes log files from before the last KEEP_DAYS days; once per process per day. */
57
+ export function rotate(now: Date = new Date()): void {
58
+ if (rotatedOn === day(now) || !existsSync(logsDir())) return;
59
+ rotatedOn = day(now);
60
+ const oldest = day(new Date(now.getTime() - (KEEP_DAYS - 1) * 86_400_000));
61
+ for (const f of readdirSync(logsDir())) {
62
+ const m = /^catherd-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(f);
63
+ if (m && (m[1] as string) < oldest) rmSync(join(logsDir(), f), { force: true });
64
+ }
65
+ }
66
+
67
+ /** Tests only: rotate again on the next write. */
68
+ export const resetRotation = (): void => {
69
+ rotatedOn = null;
70
+ };
71
+
72
+ /** One redacted JSONL row. Never throws: logging must not fail the work it records. */
73
+ export function log(level: Level, event: string, fields: Record<string, unknown> = {}): void {
74
+ if (rank(level) > rank(logLevel())) return;
75
+ const now = new Date();
76
+ try {
77
+ rotate(now);
78
+ const file = logFile(now);
79
+ ensureJsonlHeader(file, "log");
80
+ appendJsonl(file, redact({ at: now.toISOString(), level, event, pid: process.pid, ...fields }));
81
+ } catch {
82
+ // a full disk or an unwritable data dir costs the log row, never the call
83
+ }
84
+ }
@@ -0,0 +1,38 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ function root(kind: "config" | "data"): string {
5
+ // set for an isolated worker whose XDG_CONFIG_HOME points elsewhere, so its `catherd lock` reads this config
6
+ if (kind === "config" && process.env.CATHERD_CONFIG_DIR) return process.env.CATHERD_CONFIG_DIR;
7
+ const home = process.env.CATHERD_HOME;
8
+ if (home) return join(home, kind);
9
+ if (kind === "config") return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "catherd");
10
+ return join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "catherd");
11
+ }
12
+
13
+ export const configDir = (): string => root("config");
14
+ export const dataDir = (): string => root("data");
15
+
16
+ /** Claude Code's own folder, as Claude Code finds it (`CLAUDE_CONFIG_DIR`, else `~/.claude`). */
17
+ export const claudeHome = (): string => process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
18
+
19
+ /** Where Claude Code reads user agents; catherd links its agent files here (spec §7.3). */
20
+ export const claudeAgentsDir = (): string =>
21
+ process.env.CATHERD_CLAUDE_AGENTS_DIR || join(claudeHome(), "agents");
22
+
23
+ /** A readable slug of the repo's git toplevel plus 8 hex chars of its hash: `/a-b/c` ≠ `/a/b-c`. */
24
+ export function repoKey(toplevel: string): string {
25
+ const slug =
26
+ toplevel
27
+ .replace(/^\/+/, "")
28
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
29
+ .slice(0, 60) || "root";
30
+ const hash = new Bun.CryptoHasher("sha256").update(toplevel).digest("hex").slice(0, 8);
31
+ return `${slug}-${hash}`;
32
+ }
33
+
34
+ export const repoDir = (toplevel: string): string => join(dataDir(), "repos", repoKey(toplevel));
35
+ export const runsDir = (toplevel: string): string => join(repoDir(toplevel), "runs");
36
+ export const logsDir = (): string => join(dataDir(), "logs");
37
+ export const discoveryDir = (): string => join(dataDir(), "discovery");
38
+ export const locksDir = (): string => join(dataDir(), "locks");
@@ -0,0 +1,110 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { scrubSecrets } from "./env.ts";
3
+
4
+ /** A string that differs between two processes that held the same pid: /proc starttime, else `ps lstart`. */
5
+ export function processStartTime(pid: number): string | null {
6
+ try {
7
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
8
+ // Field 22 (starttime); the command name in field 2 may hold spaces, so split after its ')'.
9
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
10
+ return fields[19] ?? null;
11
+ } catch {
12
+ return psStartTime(pid);
13
+ }
14
+ }
15
+
16
+ let ownStart: string | null = null;
17
+ /**
18
+ * This process's own identity for the leases and claims it writes. The start time is read once and kept:
19
+ * on macOS `ps` can fail for a moment, and a second read must never make a process a stranger to itself.
20
+ */
21
+ export function selfIdentity(): { pid: number; startTime: string | null } {
22
+ ownStart ??= processStartTime(process.pid);
23
+ return { pid: process.pid, startTime: ownStart };
24
+ }
25
+
26
+ /** `ps` by absolute path where it lives, so a caller with a narrow PATH (a test, a sandbox) still finds it. */
27
+ const PS = existsSync("/bin/ps") ? "/bin/ps" : "ps";
28
+
29
+ /** `ps lstart` for a pid, or null when ps is missing or fails (macOS has no /proc). */
30
+ export function psStartTime(
31
+ pid: number,
32
+ env: Record<string, string | undefined> = process.env,
33
+ ): string | null {
34
+ try {
35
+ // lstart is printed in the local time zone and locale: pin both so every process agrees.
36
+ const ps = Bun.spawnSync([PS, "-o", "lstart=", "-p", String(pid)], {
37
+ stdout: "pipe",
38
+ stderr: "ignore",
39
+ env: { ...scrubSecrets(env), LC_ALL: "C", TZ: "UTC" },
40
+ });
41
+ const out = ps.success ? ps.stdout.toString("utf8").trim() : "";
42
+ return out || null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Only an integer >= 1 names one real process: 0 and negatives address groups. Pid 1 is valid
50
+ * (catherd may be a container's entrypoint); only killGroup refuses it, since kill(-1) signals everything.
51
+ */
52
+ export const isValidPid = (pid: unknown): pid is number => Number.isInteger(pid) && (pid as number) >= 1;
53
+
54
+ /**
55
+ * Whether a live pid is still the process recorded with `recorded`. A start time that cannot be read now
56
+ * (`ps` fails for a moment on macOS) counts as the same process: a live lock holder or worker is never
57
+ * taken for dead, and the worst case is a lock that times out with E_IO_LOCK instead.
58
+ */
59
+ export const sameProcess = (recorded: string | null, current: string | null): boolean =>
60
+ recorded === null || current === null || current === recorded;
61
+
62
+ /**
63
+ * For a signal only: the pid is live and its start time reads back exactly as recorded. An unreadable or
64
+ * unrecorded start time is not a match, since the pid may belong to another process by now.
65
+ */
66
+ export const surelySame = (recorded: string | null, current: string | null): boolean =>
67
+ recorded !== null && current !== null && current === recorded;
68
+
69
+ export function isSurelyAlive(pid: number, startTime: string | null): boolean {
70
+ return isAlive(pid, startTime) && surelySame(startTime, processStartTime(pid));
71
+ }
72
+
73
+ /** Whether /proc shows the pid as a zombie: dead, only never reaped (a pid 1 that reaps no orphans). */
74
+ function isZombie(pid: number): boolean {
75
+ try {
76
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
77
+ return stat.slice(stat.lastIndexOf(")") + 2, stat.lastIndexOf(")") + 3) === "Z";
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ export function isAlive(pid: number, startTime: string | null): boolean {
84
+ if (!isValidPid(pid)) return false;
85
+ try {
86
+ process.kill(pid, 0);
87
+ } catch (e) {
88
+ if ((e as NodeJS.ErrnoException).code !== "EPERM") return false;
89
+ }
90
+ if (isZombie(pid)) return false;
91
+ return startTime === null || sameProcess(startTime, processStartTime(pid));
92
+ }
93
+
94
+ /**
95
+ * Signals the process group a detached child leads (pgid === pid), falling back to the pid alone. No
96
+ * group (ESRCH) means its leader is gone too, and that pid may belong to another process by now.
97
+ */
98
+ export function killGroup(pid: number, signal: NodeJS.Signals = "SIGTERM"): void {
99
+ if (!isValidPid(pid) || pid === 1) return;
100
+ try {
101
+ process.kill(-pid, signal);
102
+ } catch (e) {
103
+ if ((e as NodeJS.ErrnoException).code === "ESRCH") return;
104
+ try {
105
+ process.kill(pid, signal);
106
+ } catch {
107
+ // already gone
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,229 @@
1
+ import {
2
+ appendFileSync,
3
+ chmodSync,
4
+ closeSync,
5
+ existsSync,
6
+ fchmodSync,
7
+ fsyncSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ readSync,
12
+ renameSync,
13
+ statSync,
14
+ writeSync,
15
+ } from "node:fs";
16
+ import { dirname, sep } from "node:path";
17
+ import { z } from "zod";
18
+ import { CatherdError, UPGRADE } from "../domain/errors.ts";
19
+ import { configDir, dataDir } from "./paths.ts";
20
+
21
+ // Audit S2: catherd's dirs hold briefs, argv, stderr tails and logs, so they are 0700 and its files 0600.
22
+ export const PRIVATE_DIR = 0o700;
23
+ export const PRIVATE_FILE = 0o600;
24
+
25
+ const tightened = new Set<string>();
26
+ /** Once per process per dir: `dir` at 0700, if an older catherd left it open to others. */
27
+ function tighten(dir: string): void {
28
+ if (tightened.has(dir)) return;
29
+ tightened.add(dir);
30
+ try {
31
+ if (statSync(dir).mode & 0o077) chmodSync(dir, PRIVATE_DIR);
32
+ } catch {
33
+ // not ours to fix (another owner, a read-only mount): the files inside are still 0600
34
+ }
35
+ }
36
+
37
+ /**
38
+ * `dir` and any missing parent, created 0700. `mkdirSync` leaves an existing dir as it is, so under
39
+ * catherd's config or data dir, `dir` and every dir up to that root are tightened to 0700 (a 0.x install).
40
+ */
41
+ export function ensurePrivateDir(dir: string): void {
42
+ mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR });
43
+ for (const root of [configDir(), dataDir()]) {
44
+ if (dir !== root && !dir.startsWith(root + sep)) continue;
45
+ for (let d = dir; d.length >= root.length; d = dirname(d)) {
46
+ tighten(d);
47
+ if (d === root) break;
48
+ }
49
+ }
50
+ }
51
+
52
+ /** `file` at 0600 when it exists and others may read it: a file another program wrote into catherd's dirs. */
53
+ export function makePrivate(file: string): void {
54
+ try {
55
+ if (statSync(file).mode & 0o077) chmodSync(file, PRIVATE_FILE);
56
+ } catch {
57
+ // missing, or not ours: its dir is 0700 all the same
58
+ }
59
+ }
60
+
61
+ /** `mode` (0600 by default) is the file's exact permission bits (umask aside), set before any byte is written. */
62
+ export function writeTextAtomic(file: string, text: string, o: { mode?: number } = {}): void {
63
+ const mode = o.mode ?? PRIVATE_FILE;
64
+ ensurePrivateDir(dirname(file));
65
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
66
+ const fd = openSync(tmp, "w", mode);
67
+ try {
68
+ fchmodSync(fd, mode);
69
+ writeSync(fd, text);
70
+ fsyncSync(fd);
71
+ } finally {
72
+ closeSync(fd);
73
+ }
74
+ renameSync(tmp, file);
75
+ }
76
+
77
+ export const writeJsonAtomic = (file: string, value: unknown, o: { mode?: number } = {}): void =>
78
+ writeTextAtomic(file, `${JSON.stringify(value, null, 2)}\n`, o);
79
+
80
+ function newer(file: string, found: number, current: number): CatherdError {
81
+ return new CatherdError(
82
+ "E_CONFIG_NEWER_SCHEMA",
83
+ `${file} has schema ${found}, newer than this catherd (${current})`,
84
+ {
85
+ fix: `upgrade catherd: ${UPGRADE}`,
86
+ },
87
+ );
88
+ }
89
+
90
+ /**
91
+ * The parsed content of a JSON file catherd keeps. The error never carries the parser's message: it
92
+ * quotes the offending text, which for a key pasted into credentials.json is the key itself (audit B1).
93
+ */
94
+ export function readJsonFile(file: string, fix = `fix or delete ${file}`): unknown {
95
+ let text: string;
96
+ try {
97
+ text = readFileSync(file, "utf8");
98
+ } catch (e) {
99
+ const code = (e as NodeJS.ErrnoException).code ?? "an I/O error";
100
+ throw new CatherdError("E_CONFIG_INVALID", `${file} cannot be read (${code})`, { fix });
101
+ }
102
+ try {
103
+ return JSON.parse(text);
104
+ } catch {
105
+ throw new CatherdError("E_CONFIG_INVALID", `${file} is not valid JSON`, { fix });
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Reads a `"schema": n` JSON file. Schemas are loose objects, so unknown fields survive a rewrite.
111
+ * `fix` replaces the default fix of an unreadable or invalid file.
112
+ */
113
+ export function readVersioned<T>(
114
+ file: string,
115
+ schema: z.ZodType<T>,
116
+ current: number,
117
+ o: { fix?: string } = {},
118
+ ): T {
119
+ const fix = o.fix ?? `fix or delete ${file}`;
120
+ const raw = readJsonFile(file, fix);
121
+ const found = (raw as { schema?: unknown } | null)?.schema;
122
+ if (typeof found === "number" && found > current) throw newer(file, found, current);
123
+ const r = schema.safeParse(raw);
124
+ if (!r.success)
125
+ throw new CatherdError("E_CONFIG_INVALID", `${file} is invalid:\n${z.prettifyError(r.error)}`, { fix });
126
+ return r.data;
127
+ }
128
+
129
+ /** True when `file` exists, is non-empty and does not end in a newline (a crash-truncated tail). */
130
+ function endsMidLine(file: string): boolean {
131
+ let size: number;
132
+ try {
133
+ size = statSync(file).size;
134
+ } catch {
135
+ return false;
136
+ }
137
+ if (size === 0) return false;
138
+ const fd = openSync(file, "r");
139
+ try {
140
+ const last = Buffer.alloc(1);
141
+ readSync(fd, last, 0, 1, size - 1);
142
+ return last[0] !== 0x0a;
143
+ } finally {
144
+ closeSync(fd);
145
+ }
146
+ }
147
+
148
+ /**
149
+ * One `appendFileSync` per row, so a crash can only ever truncate the last line. A truncated
150
+ * tail left by an earlier crash is ended first, in the same write, so the new row stays whole.
151
+ */
152
+ export function appendJsonl(file: string, value: unknown): void {
153
+ ensurePrivateDir(dirname(file));
154
+ const prefix = endsMidLine(file) ? "\n" : "";
155
+ appendPrivate(file, `${prefix}${JSON.stringify(value)}\n`);
156
+ }
157
+
158
+ const madePrivate = new Set<string>();
159
+ /**
160
+ * Appends `text` to `file`, created 0600. `appendFileSync`'s mode only applies to a new file, so one
161
+ * an older catherd left open to others is tightened first, once per process.
162
+ */
163
+ export function appendPrivate(file: string, text: string): void {
164
+ ensurePrivateDir(dirname(file));
165
+ if (!madePrivate.has(file)) {
166
+ makePrivate(file);
167
+ madePrivate.add(file);
168
+ }
169
+ appendFileSync(file, text, { mode: PRIVATE_FILE });
170
+ }
171
+
172
+ export function ensureJsonlHeader(file: string, kind: string): void {
173
+ ensurePrivateDir(dirname(file));
174
+ try {
175
+ // O_APPEND: a row another process appends between the create and this write is kept, not overwritten
176
+ const fd = openSync(file, "ax", PRIVATE_FILE);
177
+ try {
178
+ writeSync(fd, `${JSON.stringify({ schema: 1, kind })}\n`);
179
+ } finally {
180
+ closeSync(fd);
181
+ }
182
+ } catch (e) {
183
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
184
+ }
185
+ }
186
+
187
+ const isHeader = (v: unknown): v is { schema: number; kind: string } =>
188
+ typeof v === "object" &&
189
+ v !== null &&
190
+ Object.keys(v).length === 2 &&
191
+ typeof (v as { schema?: unknown }).schema === "number" &&
192
+ typeof (v as { kind?: unknown }).kind === "string";
193
+
194
+ /** The non-blank lines of `file`; none when it cannot be read (missing, or not written yet). */
195
+ export function nonBlankLines(file: string): string[] {
196
+ let text: string;
197
+ try {
198
+ text = readFileSync(file, "utf8");
199
+ } catch {
200
+ return [];
201
+ }
202
+ return text.split("\n").filter((l) => l.trim());
203
+ }
204
+
205
+ export function readJsonl<T>(file: string, current = 1): { kind: string | null; rows: T[]; corrupt: number } {
206
+ if (!existsSync(file)) return { kind: null, rows: [], corrupt: 0 };
207
+ const rows: T[] = [];
208
+ let kind: string | null = null;
209
+ let corrupt = 0;
210
+ const lines = readFileSync(file, "utf8").split("\n");
211
+ lines.forEach((line) => {
212
+ if (!line.trim()) return;
213
+ let v: unknown;
214
+ try {
215
+ v = JSON.parse(line);
216
+ } catch {
217
+ corrupt++;
218
+ return;
219
+ }
220
+ // the header is first unless another writer's row won the race to a new file (ensureJsonlHeader)
221
+ if (kind === null && isHeader(v)) {
222
+ if (v.schema > current) throw newer(file, v.schema, current);
223
+ kind = v.kind;
224
+ return;
225
+ }
226
+ rows.push(v as T);
227
+ });
228
+ return { kind, rows, corrupt };
229
+ }