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.
Files changed (207) hide show
  1. package/README.md +141 -33
  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 +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -0,0 +1,159 @@
1
+ import { closeSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
2
+ import { CatherdError } from "../domain/errors.ts";
3
+ import { isAlive, isValidPid, selfIdentity } from "./proc.ts";
4
+ import { PRIVATE_FILE } from "./store.ts";
5
+
6
+ interface Holder {
7
+ pid: number;
8
+ startTime: string | null;
9
+ }
10
+
11
+ /** A lock or reclaim marker this old whose owner cannot be identified was left by a crash. */
12
+ const STALE_MS = 5_000;
13
+
14
+ const me = (): Holder => selfIdentity();
15
+
16
+ /** The holder recorded in `lock`, or null when it is missing, empty, unparsable or malformed. */
17
+ function readHolder(lock: string): Holder | null {
18
+ let v: unknown;
19
+ try {
20
+ v = JSON.parse(readFileSync(lock, "utf8"));
21
+ } catch {
22
+ return null;
23
+ }
24
+ const h = v as Partial<Holder> | null;
25
+ if (typeof h !== "object" || h === null || !isValidPid(h.pid)) return null;
26
+ if (h.startTime !== null && typeof h.startTime !== "string") return null;
27
+ return { pid: h.pid, startTime: h.startTime };
28
+ }
29
+
30
+ function olderThan(file: string, ms: number): boolean {
31
+ try {
32
+ return statSync(file).mtimeMs < Date.now() - ms;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** Creates `file` exclusively with `content`; false when it already exists. */
39
+ function tryCreate(file: string, content: string): boolean {
40
+ try {
41
+ const fd = openSync(file, "wx", PRIVATE_FILE);
42
+ try {
43
+ writeSync(fd, content);
44
+ } finally {
45
+ closeSync(fd);
46
+ }
47
+ return true;
48
+ } catch (e) {
49
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
50
+ return false;
51
+ }
52
+ }
53
+
54
+ const tryTake = (lock: string, self: Holder): boolean => tryCreate(lock, JSON.stringify(self));
55
+
56
+ /** Whether `lock` is abandoned: its holder is dead, or it names no valid holder and is older than STALE_MS. */
57
+ function abandoned(lock: string, h: Holder | null): boolean {
58
+ return h ? !isAlive(h.pid, h.startTime) : olderThan(lock, STALE_MS);
59
+ }
60
+
61
+ const sameHolder = (a: Holder | null, b: Holder | null): boolean =>
62
+ a === b || (a !== null && b !== null && a.pid === b.pid && a.startTime === b.startTime);
63
+
64
+ /**
65
+ * Removes an abandoned lock. Reclaims are serialised by `${lock}.reclaim`, and the reclaimer
66
+ * re-reads the lock while holding it and removes it only if it still names the same abandoned
67
+ * holder, so a waiter can never delete a lock that a live process took in the meantime.
68
+ */
69
+ function reclaimIfDead(lock: string): void {
70
+ const seen = readHolder(lock);
71
+ if (!abandoned(lock, seen)) return;
72
+ const marker = `${lock}.reclaim`;
73
+ if (!tryCreate(marker, String(process.pid))) {
74
+ // A reclaim takes milliseconds; a marker this old was left by a reclaimer that crashed.
75
+ if (olderThan(marker, STALE_MS)) rmSync(marker, { force: true });
76
+ return;
77
+ }
78
+ try {
79
+ const now = readHolder(lock);
80
+ if (sameHolder(now, seen) && abandoned(lock, now)) rmSync(lock, { force: true });
81
+ } finally {
82
+ rmSync(marker, { force: true });
83
+ }
84
+ }
85
+
86
+ const timedOut = (target: string, lock: string) =>
87
+ new CatherdError("E_IO_LOCK", `timed out waiting for the lock on ${target}`, {
88
+ fix: `if no catherd process is running, delete ${lock}`,
89
+ });
90
+
91
+ function release(lock: string, self: Holder): void {
92
+ const h = readHolder(lock);
93
+ if (h?.pid === self.pid && h.startTime === self.startTime) rmSync(lock, { force: true });
94
+ }
95
+
96
+ export async function withFileLock<T>(
97
+ target: string,
98
+ fn: () => T | Promise<T>,
99
+ o: { timeoutMs?: number; pollMs?: number } = {},
100
+ ): Promise<T> {
101
+ const lock = `${target}.lock`;
102
+ const self = me();
103
+ const deadline = Date.now() + (o.timeoutMs ?? 10_000);
104
+ while (!tryTake(lock, self)) {
105
+ reclaimIfDead(lock);
106
+ if (Date.now() > deadline) throw timedOut(target, lock);
107
+ await Bun.sleep(o.pollMs ?? 25);
108
+ }
109
+ try {
110
+ return await fn();
111
+ } finally {
112
+ release(lock, self);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * `withFileLock` for a synchronous critical section, waiting with a blocking sleep. For writers whose
118
+ * callers are synchronous (`withProfilesLock` in the profile service); the lock is held for milliseconds.
119
+ * Not reentrant: a section must not take the same lock again.
120
+ */
121
+ export function withFileLockSync<T>(
122
+ target: string,
123
+ fn: () => T,
124
+ o: { timeoutMs?: number; pollMs?: number } = {},
125
+ ): T {
126
+ const lock = `${target}.lock`;
127
+ const self = me();
128
+ const deadline = Date.now() + (o.timeoutMs ?? 10_000);
129
+ while (!tryTake(lock, self)) {
130
+ reclaimIfDead(lock);
131
+ if (Date.now() > deadline) throw timedOut(target, lock);
132
+ Bun.sleepSync(o.pollMs ?? 25);
133
+ }
134
+ try {
135
+ return fn();
136
+ } finally {
137
+ release(lock, self);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * One attempt at the lock on `target`, reclaiming it first from a dead holder. Returns its release,
143
+ * or null while a live holder keeps it. For locks held for a long time, like the heavy-command slots.
144
+ */
145
+ export function tryLock(target: string): (() => void) | null {
146
+ const lock = `${target}.lock`;
147
+ const self = me();
148
+ if (!tryTake(lock, self)) {
149
+ reclaimIfDead(lock);
150
+ if (!tryTake(lock, self)) return null;
151
+ }
152
+ return () => release(lock, self);
153
+ }
154
+
155
+ /** Whether a live process holds the lock on `target` (an unreadable holder counts as none). */
156
+ export function lockHeld(target: string): boolean {
157
+ const h = readHolder(`${target}.lock`);
158
+ return h !== null && isAlive(h.pid, h.startTime);
159
+ }
@@ -0,0 +1,91 @@
1
+ import { statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parsePorcelainZ, type Snapshot } from "../domain/changes.ts";
4
+ import { CatherdError } from "../domain/errors.ts";
5
+ import { scrubSecrets } from "./env.ts";
6
+
7
+ /** How one git command ended: its stdout, a failure (exit null when git could not start), or a timeout. */
8
+ export type GitResult =
9
+ | { kind: "ok"; out: string }
10
+ | { kind: "failed"; exit: number | null }
11
+ | { kind: "timed-out" };
12
+
13
+ /** How long one git command may run by default; finalize's claim window counts it (tests shorten it). */
14
+ export const gitLimits = { timeoutMs: 15_000 };
15
+
16
+ /**
17
+ * `git -C repo <args>` with a timeout. GIT_OPTIONAL_LOCKS=0 keeps `status` from taking index.lock,
18
+ * which would fail a worker's own git command running at the same moment. On a timeout git is killed
19
+ * and its stdout is not awaited, since a grandchild may still hold the pipe open.
20
+ */
21
+ export async function git(repo: string, args: string[], timeoutMs = gitLimits.timeoutMs): Promise<GitResult> {
22
+ let p: Bun.Subprocess<"ignore", "pipe", "ignore">;
23
+ try {
24
+ p = Bun.spawn(["git", "-C", repo, ...args], {
25
+ stdin: "ignore",
26
+ stdout: "pipe",
27
+ stderr: "ignore",
28
+ env: { ...scrubSecrets(process.env), GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" },
29
+ });
30
+ } catch {
31
+ return { kind: "failed", exit: null };
32
+ }
33
+ let timedOut = false;
34
+ const timer = setTimeout(() => {
35
+ timedOut = true;
36
+ p.kill("SIGKILL");
37
+ }, timeoutMs);
38
+ const out = new Response(p.stdout).text().catch(() => "");
39
+ const code = await p.exited;
40
+ clearTimeout(timer);
41
+ if (timedOut) return { kind: "timed-out" };
42
+ return code === 0 ? { kind: "ok", out: await out } : { kind: "failed", exit: code };
43
+ }
44
+
45
+ const gitBroken = (repo: string, args: string[], r: GitResult) =>
46
+ new CatherdError(
47
+ "E_IO_UNEXPECTED",
48
+ `git ${args[0]} ${r.kind === "timed-out" ? "timed out" : "failed"} in ${repo}`,
49
+ { fix: `check that git works in ${repo}` },
50
+ );
51
+
52
+ export async function gitToplevel(dir: string): Promise<string | null> {
53
+ const r = await git(dir, ["rev-parse", "--show-toplevel"]);
54
+ return r.kind === "ok" ? r.out.trim() || null : null;
55
+ }
56
+
57
+ /** The short HEAD, or null when git cannot say (no commit, not a repo, failure, timeout). */
58
+ export async function gitHead(repo: string, timeoutMs?: number): Promise<string | null> {
59
+ const r = await git(repo, ["rev-parse", "--short", "HEAD"], timeoutMs);
60
+ return (r.kind === "ok" && r.out.trim()) || null;
61
+ }
62
+
63
+ /** False for a missing commit; throws E_IO_UNEXPECTED when git times out, since that proves nothing. */
64
+ export async function commitExists(repo: string, commit: string, timeoutMs?: number): Promise<boolean> {
65
+ const args = ["cat-file", "-e", `${commit}^{commit}`];
66
+ const r = await git(repo, args, timeoutMs);
67
+ if (r.kind === "timed-out") throw gitBroken(repo, args, r);
68
+ return r.kind === "ok";
69
+ }
70
+
71
+ function fingerprint(repo: string, xy: string, path: string): string {
72
+ try {
73
+ const s = statSync(join(repo, path));
74
+ return `${xy} ${s.size} ${s.mtimeMs}`;
75
+ } catch {
76
+ return `${xy} gone`;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The repo's dirty files, each with a fingerprint. Throws E_IO_UNEXPECTED when git fails or times out
82
+ * (including outside a repository), so a broken git never reads as a clean tree.
83
+ */
84
+ export async function statusSnapshot(repo: string, timeoutMs?: number): Promise<Snapshot> {
85
+ const args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
86
+ const r = await git(repo, args, timeoutMs);
87
+ if (r.kind !== "ok") throw gitBroken(repo, args, r);
88
+ const snap: Snapshot = {};
89
+ for (const { xy, path } of parsePorcelainZ(r.out)) snap[path] = fingerprint(repo, xy, path);
90
+ return snap;
91
+ }
@@ -0,0 +1,36 @@
1
+ import { availableParallelism } from "node:os";
2
+ import { join } from "node:path";
3
+ import { tryLock } from "./filelock.ts";
4
+ import { locksDir } from "./paths.ts";
5
+ import { ensurePrivateDir } from "./store.ts";
6
+
7
+ /** A profile's `lock.heavy` as a slot count: a number, or half the cores. */
8
+ export function heavySlots(setting: number | "cpus/2" | undefined): number {
9
+ if (typeof setting === "number" && Number.isFinite(setting)) return Math.max(1, Math.floor(setting));
10
+ return Math.max(1, Math.floor(availableParallelism() / 2));
11
+ }
12
+
13
+ /**
14
+ * Runs `fn` holding one of `slots` machine-wide heavy-command slots, waiting for a free one. A slot is
15
+ * a file lock, so a slot whose holder died, or died before writing its name, is reclaimed.
16
+ */
17
+ export async function withHeavySlot<T>(
18
+ slots: number,
19
+ fn: (slot: number) => T | Promise<T>,
20
+ o: { pollMs?: number } = {},
21
+ ): Promise<T> {
22
+ const dir = locksDir();
23
+ ensurePrivateDir(dir);
24
+ for (;;) {
25
+ for (let i = 0; i < slots; i++) {
26
+ const release = tryLock(join(dir, `slot-${i}`));
27
+ if (!release) continue;
28
+ try {
29
+ return await fn(i);
30
+ } finally {
31
+ release();
32
+ }
33
+ }
34
+ await Bun.sleep(o.pollMs ?? 500);
35
+ }
36
+ }
@@ -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");