catherd-cli 1.2.1 → 1.4.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 (104) hide show
  1. package/README.md +146 -36
  2. package/catalog/models.json +148 -5
  3. package/catalog/scores.json +24 -0
  4. package/catalog/sources.json +12 -2
  5. package/package.json +3 -2
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +11 -0
  8. package/plugin/.mcp-codex.json +10 -0
  9. package/plugin/bin/catherd-mcp +1 -1
  10. package/plugin/skills/catherd/SKILL.md +45 -39
  11. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  12. package/src/adapters/access.ts +61 -18
  13. package/src/adapters/all.ts +6 -0
  14. package/src/adapters/antigravity/events.ts +131 -0
  15. package/src/adapters/antigravity/home.ts +46 -0
  16. package/src/adapters/antigravity/index.ts +320 -0
  17. package/src/adapters/antigravity/models.ts +37 -0
  18. package/src/adapters/backend.ts +34 -1
  19. package/src/adapters/claude-code/index.ts +2 -0
  20. package/src/adapters/cli.ts +1 -64
  21. package/src/adapters/codex/index.ts +2 -0
  22. package/src/adapters/cursor/events.ts +120 -0
  23. package/src/adapters/cursor/home.ts +79 -0
  24. package/src/adapters/cursor/index.ts +331 -0
  25. package/src/adapters/cursor/models.ts +30 -0
  26. package/src/adapters/discovery.ts +21 -0
  27. package/src/adapters/grok/events.ts +112 -0
  28. package/src/adapters/grok/home.ts +132 -0
  29. package/src/adapters/grok/index.ts +339 -0
  30. package/src/adapters/grok/models.ts +48 -0
  31. package/src/adapters/opencode/index.ts +2 -0
  32. package/src/cli.ts +49 -19
  33. package/src/domain/catalog.ts +10 -1
  34. package/src/domain/cost.ts +1 -0
  35. package/src/domain/errors.ts +1 -0
  36. package/src/domain/failover.ts +5 -2
  37. package/src/domain/host.ts +17 -0
  38. package/src/domain/ids.ts +2 -2
  39. package/src/domain/notice.ts +5 -2
  40. package/src/domain/profile-rules.ts +17 -0
  41. package/src/domain/profile.ts +89 -5
  42. package/src/domain/record.ts +3 -0
  43. package/src/domain/util.ts +3 -0
  44. package/src/entry/catalog-command.ts +4 -2
  45. package/src/entry/deps.ts +6 -3
  46. package/src/entry/doctor-command.ts +11 -2
  47. package/src/entry/host-arg.ts +28 -0
  48. package/src/entry/init-command.ts +18 -7
  49. package/src/entry/knowledge-command.ts +50 -0
  50. package/src/entry/lock-command.ts +7 -2
  51. package/src/entry/mcp/handshake.ts +3 -1
  52. package/src/entry/mcp/run-tools.ts +5 -2
  53. package/src/entry/mcp/server.ts +129 -40
  54. package/src/entry/mcp/setup-tools.ts +7 -2
  55. package/src/entry/profile-command.ts +108 -12
  56. package/src/entry/runs-command.ts +81 -5
  57. package/src/entry/tui/effects.ts +30 -10
  58. package/src/entry/tui/fixtures.ts +34 -10
  59. package/src/entry/tui/profile-tree.ts +10 -1
  60. package/src/entry/tui/run.tsx +10 -4
  61. package/src/entry/tui/state.ts +19 -6
  62. package/src/entry/tui/stories.ts +11 -4
  63. package/src/entry/tui/views/profile-actions.ts +4 -2
  64. package/src/entry/tui/views/profiles.tsx +16 -3
  65. package/src/entry/tui/views/save-dialog.tsx +2 -2
  66. package/src/infra/cli.ts +65 -0
  67. package/src/infra/codex-queue.ts +165 -0
  68. package/src/infra/delivery.ts +83 -0
  69. package/src/infra/dispatch-dir.ts +1 -0
  70. package/src/infra/env.ts +4 -1
  71. package/src/infra/host-context.ts +58 -0
  72. package/src/infra/paths.ts +3 -1
  73. package/src/infra/peer-inbox.ts +4 -1
  74. package/src/infra/supervisor.ts +3 -2
  75. package/src/services/admission.ts +34 -7
  76. package/src/services/agent-links.ts +30 -5
  77. package/src/services/backends.ts +49 -4
  78. package/src/services/capture.ts +112 -54
  79. package/src/services/catalog-refresh.ts +2 -1
  80. package/src/services/dispatch-service.ts +17 -7
  81. package/src/services/dispatches.ts +8 -1
  82. package/src/services/doctor-access.ts +14 -7
  83. package/src/services/doctor-backends.ts +39 -7
  84. package/src/services/doctor-checks.ts +43 -3
  85. package/src/services/doctor-push.ts +69 -90
  86. package/src/services/doctor.ts +107 -32
  87. package/src/services/finalize.ts +2 -1
  88. package/src/services/lane-service.ts +4 -10
  89. package/src/services/notifier.ts +322 -70
  90. package/src/services/peek.ts +46 -9
  91. package/src/services/ports.ts +8 -2
  92. package/src/services/profile-service.ts +117 -31
  93. package/src/services/profile-store.ts +126 -5
  94. package/src/services/routing-service.ts +6 -1
  95. package/src/services/run-debug.ts +98 -4
  96. package/src/services/run-service.ts +39 -4
  97. package/src/services/run-store.ts +21 -5
  98. package/src/services/runs-page.ts +3 -2
  99. package/src/services/session-view.ts +18 -13
  100. package/src/services/sessions.ts +32 -16
  101. package/src/services/setup.ts +31 -10
  102. package/src/services/source-derive.ts +32 -7
  103. package/src/services/summary.ts +40 -5
  104. package/src/services/treat-likes.ts +22 -9
@@ -1,67 +1,4 @@
1
- import { scrubSecrets } from "../infra/env.ts";
2
- import { log } from "../infra/log.ts";
3
- import { killGroup } from "../infra/proc.ts";
4
-
5
- export interface CliResult {
6
- ok: boolean;
7
- out: string;
8
- err: string;
9
- }
10
-
11
- /** Process groups of CLI calls still running: an exiting catherd (Ctrl-C is exit 130) kills them. */
12
- const live = new Set<number>();
13
- let hooked = false;
14
- function track(pid: number): void {
15
- live.add(pid);
16
- if (hooked) return;
17
- hooked = true;
18
- // a detached group gets no terminal SIGINT, and process.exit skips the timeout, so kill it on the way out
19
- process.on("exit", () => {
20
- for (const g of live) killGroup(g, "SIGKILL");
21
- });
22
- }
23
-
24
- /**
25
- * Runs `bin <args>` without catherd's secrets and with no stdin; null when `bin` is not on PATH. A call
26
- * past `timeoutMs` is killed with every process it started (it runs in its own process group) and is
27
- * reported as failed, so neither a wedged CLI nor a child holding its pipes stalls the caller.
28
- */
29
- export async function runCli(
30
- bin: string,
31
- args: string[],
32
- o: { timeoutMs: number; env?: Record<string, string>; cwd?: string },
33
- ): Promise<CliResult | null> {
34
- if (!Bun.which(bin, { PATH: process.env.PATH ?? "" })) return null;
35
- log("debug", "spawn", { argv: [bin, ...args], env: o.env ?? {} });
36
- const p = Bun.spawn([bin, ...args], {
37
- cwd: o.cwd,
38
- env: { ...scrubSecrets(process.env), ...o.env },
39
- stdin: "ignore",
40
- stdout: "pipe",
41
- stderr: "pipe",
42
- detached: true,
43
- });
44
- track(p.pid);
45
- let timer: ReturnType<typeof setTimeout> | undefined;
46
- const late = new Promise<null>((resolve) => {
47
- timer = setTimeout(() => resolve(null), o.timeoutMs);
48
- });
49
- try {
50
- const done = await Promise.race([
51
- Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]),
52
- late,
53
- ]);
54
- if (!done) {
55
- killGroup(p.pid, "SIGKILL");
56
- return { ok: false, out: "", err: `${bin} ${args.join(" ")} timed out after ${o.timeoutMs} ms` };
57
- }
58
- const [code, out, err] = done;
59
- return { ok: code === 0, out, err };
60
- } finally {
61
- clearTimeout(timer);
62
- live.delete(p.pid);
63
- }
64
- }
1
+ export { runCli, type CliResult } from "../infra/cli.ts";
65
2
 
66
3
  /** The JSON a CLI printed, or null for empty or unreadable output. */
67
4
  export function jsonOf(out: string | undefined): unknown {
@@ -251,6 +251,7 @@ function codexActivity(e: Record<string, any>): string | undefined {
251
251
  export const codexAdapter: BackendAdapter = {
252
252
  id: "codex",
253
253
  minVersion: CODEX_MIN_VERSION,
254
+ install: "npm i -g @openai/codex",
254
255
  probe,
255
256
  listModels,
256
257
  plan,
@@ -278,5 +279,6 @@ export const codexAdapter: BackendAdapter = {
278
279
  errors: { limit: CODEX_LIMIT, tooOld: CODEX_TOO_OLD },
279
280
  resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
280
281
  graceAfterFinalMs: null,
282
+ reportsCost: false,
281
283
  accessShell,
282
284
  };
@@ -0,0 +1,120 @@
1
+ import { type Tokens, ZERO_TOKENS } from "../../domain/record.ts";
2
+
3
+ export interface CursorResult {
4
+ isError: boolean;
5
+ text: string;
6
+ tokens: Tokens;
7
+ }
8
+
9
+ export interface CursorFold {
10
+ thread: string | null;
11
+ /**
12
+ * The assistant text after the last tool call, as Cursor's text format prints it; `result.result` runs every
13
+ * segment together, which would bury the STATUS line (spec 1.3 §3.1, research §2.3). null: no assistant text.
14
+ */
15
+ reply: string | null;
16
+ result: CursorResult | null;
17
+ lastEvent: string | null;
18
+ }
19
+
20
+ /**
21
+ * A usage or rate limit (research §2.3: `ActionRequiredError` and the server's codes; the user-facing text
22
+ * comes from the server). A team policy looks like one but never passes by waiting (CURSOR_POLICY).
23
+ */
24
+ export const CURSOR_LIMIT = [
25
+ /usage limit/i,
26
+ /rate limit/i,
27
+ /too many requests/i,
28
+ /ActionRequiredError/,
29
+ /USAGE_LIMIT/,
30
+ /RATE_LIMIT/,
31
+ ];
32
+ export const CURSOR_POLICY = [/administrator has disabled/i];
33
+ /** What an older `cursor-agent` prints for a flag catherd passes (commander, exit 1), or the server's verdict. */
34
+ export const CURSOR_TOO_OLD = [/unknown option/i, /OUTDATED_CLIENT/];
35
+ /** research §2.2 [run]: no login and no CURSOR_API_KEY, on stderr, before any event */
36
+ const CURSOR_AUTH = [/Authentication required/i];
37
+
38
+ type Event = Record<string, any>;
39
+
40
+ export function parseCursorLine(line: string): Event | null {
41
+ if (!line.trim().startsWith("{")) return null;
42
+ try {
43
+ const e = JSON.parse(line) as unknown;
44
+ return e && typeof e === "object" && !Array.isArray(e) ? (e as Event) : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Spec §4.3's convention from `result.usage` (camelCase): `inputTokens` is uncached only (research §2.3,
52
+ * `max(total - cacheRead - cacheWrite, 0)`), so input adds cache reads and writes back.
53
+ */
54
+ export function cursorTokens(u: Event | undefined): Tokens {
55
+ if (!u) return { ...ZERO_TOKENS };
56
+ const n = (k: string) => (typeof u[k] === "number" ? (u[k] as number) : 0);
57
+ const cached = n("cacheReadTokens");
58
+ return { input: n("inputTokens") + cached + n("cacheWriteTokens"), cached, output: n("outputTokens") };
59
+ }
60
+
61
+ const textOf = (e: Event): string =>
62
+ ((e.message?.content ?? []) as Event[])
63
+ .filter((c) => c?.type === "text" && typeof c.text === "string")
64
+ .map((c) => c.text as string)
65
+ .join("");
66
+
67
+ /** A tool call's tool: `readToolCall`, `writeToolCall`, or a `function`'s name. */
68
+ function toolOf(e: Event): { name: string; args: Event } {
69
+ const [kind, call] = Object.entries((e.tool_call ?? {}) as Event)[0] ?? ["?", {}];
70
+ if (kind !== "function") return { name: kind, args: (call?.args ?? {}) as Event };
71
+ let args: Event = {};
72
+ try {
73
+ args = typeof call?.arguments === "string" ? JSON.parse(call.arguments) : (call?.arguments ?? {});
74
+ } catch {
75
+ // arguments that are not JSON: the name alone
76
+ }
77
+ return { name: String(call?.name ?? "function"), args };
78
+ }
79
+
80
+ export function eventName(e: Event): string {
81
+ if (e.type === "tool_call")
82
+ return `tool_call/${e.subtype ?? "?"}/${Object.keys(e.tool_call ?? {})[0] ?? "?"}`;
83
+ return e.subtype ? `${e.type}/${e.subtype}` : String(e.type ?? "?");
84
+ }
85
+
86
+ /** Spec §3.7: a command the worker runs, a file it reads or edits, or its message. */
87
+ export function cursorActivity(e: Event): string | undefined {
88
+ if (e.type === "assistant") return textOf(e) || undefined;
89
+ if (e.type !== "tool_call" || e.subtype !== "started") return undefined;
90
+ const { name, args } = toolOf(e);
91
+ if (typeof args.command === "string") return `$ ${args.command}`;
92
+ if (typeof args.path === "string") return `${name === "readToolCall" ? "read" : "edit"} ${args.path}`;
93
+ return name;
94
+ }
95
+
96
+ export const isLimit = (text: string): boolean =>
97
+ CURSOR_LIMIT.some((r) => r.test(text)) && !CURSOR_POLICY.some((r) => r.test(text));
98
+ export const isTooOld = (text: string): boolean => CURSOR_TOO_OLD.some((r) => r.test(text));
99
+ export const isAuthFailure = (text: string): boolean => CURSOR_AUTH.some((r) => r.test(text));
100
+
101
+ export function foldCursorEvents(lines: string[]): CursorFold {
102
+ const f: CursorFold = { thread: null, reply: null, result: null, lastEvent: null };
103
+ for (const line of lines) {
104
+ const e = parseCursorLine(line);
105
+ if (!e) continue;
106
+ f.lastEvent = eventName(e);
107
+ if (typeof e.session_id === "string" && e.session_id) f.thread ??= e.session_id;
108
+ if (e.type === "tool_call") f.reply = null;
109
+ else if (e.type === "assistant") {
110
+ const t = textOf(e);
111
+ if (t) f.reply = (f.reply ?? "") + t;
112
+ } else if (e.type === "result")
113
+ f.result = {
114
+ isError: e.is_error === true,
115
+ text: typeof e.result === "string" ? e.result : "",
116
+ tokens: cursorTokens(e.usage),
117
+ };
118
+ }
119
+ return f;
120
+ }
@@ -0,0 +1,79 @@
1
+ import { mkdirSync, readlinkSync, symlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Access } from "../../domain/record.ts";
4
+ import { dataDir } from "../../infra/paths.ts";
5
+ import { ensurePrivateDir, writeJsonAtomic } from "../../infra/store.ts";
6
+ import { movedHomeEnv, writableRoots } from "../access.ts";
7
+
8
+ /** Where isolated Cursor runs keep their homes; computing it creates nothing. */
9
+ export const isolatedCursorRoot = (): string => join(dataDir(), "cursor-home");
10
+
11
+ /**
12
+ * Spec 1.3 §4.3–§4.4: an isolated run's HOME. Cursor reads its sandbox policy from one `sandbox.json` per home,
13
+ * so each access (and a workspace-write role without network) gets its own home: lanes of different access
14
+ * run side by side without rewriting each other's policy.
15
+ */
16
+ export const isolatedCursorHome = (access: Access, network = true): string =>
17
+ join(isolatedCursorRoot(), access === "workspace-write" && !network ? "workspace-write-offline" : access);
18
+
19
+ /**
20
+ * The env of an isolated run: the moved HOME (the only thing that stops Cursor's hard-coded `~/.claude`,
21
+ * `~/.codex`, `~/.grok` and `~/.agents` reads, research §2.8), and Cursor's config and data dirs inside it, so a
22
+ * CURSOR_CONFIG_DIR or XDG_CONFIG_HOME of the user's cannot point it back. Its credentials stay in memory: on
23
+ * macOS Cursor otherwise saves the CURSOR_API_KEY login to the moved HOME's keychain, which does not exist, and
24
+ * the failed `security` call raises a "Keychain Not Found" dialog.
25
+ */
26
+ export function cursorHomeEnv(access: Access, network = true): Record<string, string> {
27
+ const home = isolatedCursorHome(access, network);
28
+ const dot = join(home, ".cursor");
29
+ return {
30
+ ...movedHomeEnv(home),
31
+ CURSOR_CONFIG_DIR: dot,
32
+ CURSOR_DATA_DIR: dot,
33
+ AGENT_CLI_CREDENTIAL_STORE: "memory",
34
+ };
35
+ }
36
+
37
+ /**
38
+ * The `sandbox.json` of an isolated home (research §2.6): read-only is the kernel's `workspace_readonly`;
39
+ * workspace-write adds catherd's writable roots and, unless the role has no network, allows it; `full` runs
40
+ * with the sandbox off (`--force --sandbox disabled`) and needs none.
41
+ */
42
+ export function cursorSandboxPolicy(access: Access, network = true): Record<string, unknown> | null {
43
+ if (access === "read-only") return { type: "workspace_readonly" };
44
+ if (access === "full") return null;
45
+ return {
46
+ type: "workspace_readwrite",
47
+ additionalReadwritePaths: writableRoots(),
48
+ ...(network ? { networkPolicy: { default: "allow" } } : {}),
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Makes the isolated home for `access` and writes its `sandbox.json`. Every home links one shared `chats`
54
+ * dir: Cursor looks a resumed chat up there, and an id it cannot find silently starts an empty chat
55
+ * (research §2.5), so a thread resumed under another access must find it.
56
+ */
57
+ export function prepareCursorHome(access: Access, network = true): string {
58
+ const home = isolatedCursorHome(access, network);
59
+ const dot = join(home, ".cursor");
60
+ ensurePrivateDir(dot);
61
+ const chats = join(isolatedCursorRoot(), "chats");
62
+ mkdirSync(chats, { recursive: true, mode: 0o700 });
63
+ ensureLink(chats, join(dot, "chats"));
64
+ const policy = cursorSandboxPolicy(access, network);
65
+ if (policy) writeJsonAtomic(join(dot, "sandbox.json"), policy);
66
+ return home;
67
+ }
68
+
69
+ /**
70
+ * Links `path` to `target` in one step: two catherd processes preparing the same home race here, and the one that
71
+ * loses finds the winner's link (EEXIST). That link is kept when it points at `target`; anything else throws.
72
+ */
73
+ export function ensureLink(target: string, path: string): void {
74
+ try {
75
+ symlinkSync(target, path);
76
+ } catch (e) {
77
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST" || readlinkSync(path) !== target) throw e;
78
+ }
79
+ }
@@ -0,0 +1,331 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { CatherdError } from "../../domain/errors.ts";
3
+ import type { Rung } from "../../domain/ids.ts";
4
+ import type { Access, RunStatus } from "../../domain/record.ts";
5
+ import { scratchShell } from "../access.ts";
6
+ import {
7
+ type AccessShell,
8
+ type BackendAdapter,
9
+ compareVersions,
10
+ type DiscoveredModel,
11
+ type EventDelta,
12
+ extractVersion,
13
+ type FinishedRun,
14
+ type Outcome,
15
+ type Probe,
16
+ type RunRequest,
17
+ type SpawnPlan,
18
+ } from "../backend.ts";
19
+ import { runCli } from "../cli.ts";
20
+ import { discovered } from "../discovery.ts";
21
+ import {
22
+ CURSOR_LIMIT,
23
+ CURSOR_TOO_OLD,
24
+ cursorActivity,
25
+ cursorTokens,
26
+ eventName,
27
+ foldCursorEvents,
28
+ isAuthFailure,
29
+ isLimit,
30
+ isTooOld,
31
+ parseCursorLine,
32
+ } from "./events.ts";
33
+ import { cursorHomeEnv, prepareCursorHome } from "./home.ts";
34
+ import { cursorSlug, parseCursorModels } from "./models.ts";
35
+
36
+ export { isolatedCursorHome, isolatedCursorRoot } from "./home.ts";
37
+
38
+ /** Spec 1.3 §4.1: the version whose help and bundle were read; 2026.06.04 lacks `--add-dir` and more. */
39
+ export const CURSOR_MIN_VERSION = "2026.09.28";
40
+ export const CURSOR_INSTALL = "curl https://cursor.com/install -fsS | bash";
41
+ const THREAD = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
42
+ /** Cursor's versions are a date and a hash (`2026.09.28-64d2043`); grok's `agent` prints `grok 1.0.44 (…)`. */
43
+ const CURSOR_VERSION = /^\s*\d{4}\.\d{1,2}\.\d{1,2}-[0-9a-f]+\b/;
44
+ const DAY_MS = 24 * 60 * 60_000;
45
+ const LOGIN_FIX = "cursor-agent login, or export CURSOR_API_KEY=<key>";
46
+
47
+ /** How long a `cursor-agent` query (version, models, a sandbox probe's `true`) may take before it counts as failed. */
48
+ export const cursorShell = { timeoutMs: 15_000 };
49
+
50
+ const which = (bin: string): string | null => Bun.which(bin, { PATH: process.env.PATH ?? "" });
51
+
52
+ /**
53
+ * Spec 1.3 §4.1: `cursor-agent` first. The installers of Cursor and grok both link `agent` (research §2.1,
54
+ * §3.1), so `agent` is taken only when `cursor-agent` is missing, and the probe checks what it is.
55
+ */
56
+ export function cursorBin(): "cursor-agent" | "agent" {
57
+ return which("cursor-agent") ? "cursor-agent" : "agent";
58
+ }
59
+
60
+ /**
61
+ * Spec 1.3 §4.3. Headless without `--force` rejects every approval, and `--sandbox enabled` runs sandboxable
62
+ * commands without one (research §2.6); `--force` turns the sandbox off, so only `full` passes it.
63
+ */
64
+ export const CURSOR_ACCESS: Record<Access, string[]> = {
65
+ "read-only": ["--mode", "ask", "--sandbox", "enabled"],
66
+ "workspace-write": ["--sandbox", "enabled"],
67
+ full: ["--force", "--sandbox", "disabled", "--approve-mcps"],
68
+ };
69
+
70
+ function plan(r: RunRequest): SpawnPlan {
71
+ if (r.thread !== null && !THREAD.test(r.thread))
72
+ throw new CatherdError("E_ADMIT_THREAD", `"${r.thread}" is not a Cursor chat id`, {
73
+ fix: "pass the thread from the earlier RunRecord",
74
+ });
75
+ return {
76
+ cmd: cursorBin(),
77
+ args: [
78
+ "-p",
79
+ "--output-format",
80
+ "stream-json",
81
+ "--trust",
82
+ "--workspace",
83
+ r.repo,
84
+ "--model",
85
+ cursorSlug(r.rung.model, r.rung.effort),
86
+ // hidden (research §2.1): no env var turns the self-update off, and it must not swap the binary mid-run
87
+ "--disable-auto-update",
88
+ ...CURSOR_ACCESS[r.access],
89
+ // a resume runs in the recorded repo: Cursor looks a chat up by its cwd (research §2.5)
90
+ ...(r.thread === null ? [] : ["--resume", r.thread]),
91
+ ],
92
+ env: {
93
+ NO_OPEN_BROWSER: "1",
94
+ ...(r.isolated ? cursorHomeEnv(r.access, r.network !== false) : {}),
95
+ },
96
+ cwd: r.repo,
97
+ // research §2.2: stdin is read to EOF when no positional prompt is given
98
+ stdinPath: r.briefPath,
99
+ };
100
+ }
101
+
102
+ async function listModels(): Promise<DiscoveredModel[]> {
103
+ const r = await runCli(cursorBin(), ["models"], { ...cursorShell, env: { NO_OPEN_BROWSER: "1" } });
104
+ return r?.ok ? parseCursorModels(r.out) : [];
105
+ }
106
+
107
+ /**
108
+ * Spec 1.3 §4.4, §4.6: an isolated run needs CURSOR_API_KEY (its HOME holds no login) and gets its home and
109
+ * `sandbox.json`; the rung's slug must be one Cursor lists: the model with that effort suffix, or the bare slug
110
+ * for `#default`.
111
+ */
112
+ async function prepare(req: {
113
+ rung: Rung;
114
+ access: Access;
115
+ isolated: boolean;
116
+ network?: boolean;
117
+ }): Promise<void> {
118
+ if (req.isolated) {
119
+ if (!process.env.CURSOR_API_KEY)
120
+ throw new CatherdError("E_BACKEND_NOT_LOGGED_IN", "an isolated cursor run needs CURSOR_API_KEY", {
121
+ fix: "export CURSOR_API_KEY=<key>, or catherd profile set harness.cursor.isolated false",
122
+ });
123
+ prepareCursorHome(req.access, req.network !== false);
124
+ }
125
+ const { model, effort } = req.rung;
126
+ const models = await discovered("cursor", listModels, { maxAgeMs: DAY_MS, need: model });
127
+ if (models.length === 0) return; // Cursor listed nothing: let the run itself say what is wrong
128
+ const m = models.find((x) => x.id === model);
129
+ if (!m)
130
+ throw new CatherdError("E_BACKEND_MODEL_UNKNOWN", `Cursor does not list ${model}`, {
131
+ fix: `run ${cursorBin()} models; a rung names the slug without its effort suffix`,
132
+ });
133
+ if (!m.efforts.includes(effort))
134
+ throw new CatherdError("E_BACKEND_MODEL_UNKNOWN", `Cursor lists no ${cursorSlug(model, effort)}`, {
135
+ fix: `use one of: ${m.efforts.map((e) => `cursor:${model}#${e}`).join(", ")}`,
136
+ });
137
+ }
138
+
139
+ function finalize(run: FinishedRun): Outcome {
140
+ const f = foldCursorEvents(run.eventLines);
141
+ const res = f.result;
142
+ const stopped = run.exit.reason;
143
+ const said = [run.stderr, res?.isError ? res.text : ""].join("\n");
144
+ const status: RunStatus =
145
+ stopped === "cancelled"
146
+ ? "cancelled"
147
+ : stopped === "idle-timeout" || stopped === "wall-timeout"
148
+ ? "timeout"
149
+ : res !== null && !res.isError
150
+ ? "ok"
151
+ : isTooOld(said)
152
+ ? "cli-too-old"
153
+ : isLimit(said)
154
+ ? "limit"
155
+ : "failed";
156
+ const lastErr = run.stderr.trim().split("\n").at(-1) ?? "";
157
+ const message =
158
+ res?.isError && res.text
159
+ ? res.text
160
+ : isAuthFailure(lastErr)
161
+ ? `${lastErr} (fix: ${LOGIN_FIX})`
162
+ : lastErr || `no result event (${stopped}, exit ${run.exit.code ?? run.exit.signal})`;
163
+ return {
164
+ status,
165
+ thread: f.thread ?? run.request.thread,
166
+ tokens: res?.tokens ?? cursorTokens(undefined),
167
+ costUsd: null,
168
+ images: [],
169
+ error: status === "ok" ? null : { code: status, message },
170
+ ...(status === "ok" ? { reply: f.reply ?? res?.text ?? "" } : {}),
171
+ };
172
+ }
173
+
174
+ function parse(line: string): EventDelta {
175
+ const e = parseCursorLine(line);
176
+ if (!e) return {};
177
+ const d: EventDelta = { lastEvent: eventName(e) };
178
+ const activity = cursorActivity(e);
179
+ if (activity) d.activity = activity;
180
+ if (typeof e.session_id === "string" && e.session_id) d.thread = e.session_id;
181
+ if (e.type === "retry" && e.subtype === "starting") d.retrying = true;
182
+ // a tool call runs between its started and completed events, often printing nothing: the run is busy
183
+ if (e.type === "tool_call" && typeof e.call_id === "string")
184
+ d.item = { id: e.call_id, open: e.subtype === "started" };
185
+ if (e.type === "result") {
186
+ d.final = true;
187
+ d.tokens = cursorTokens(e.usage);
188
+ if (e.is_error === true) {
189
+ d.failure = String(e.result ?? "error");
190
+ if (isLimit(d.failure)) d.limit = true;
191
+ }
192
+ }
193
+ return d;
194
+ }
195
+
196
+ const realOr = (p: string): string => {
197
+ try {
198
+ return realpathSync(p);
199
+ } catch {
200
+ return p;
201
+ }
202
+ };
203
+
204
+ /**
205
+ * Spec 1.3 §4.1, §4.7. Installed: a `--version` in Cursor's date-hash form (an `agent` that prints another is
206
+ * someone else's). Logged in: `models` answered; `status` reports token presence, not a working login
207
+ * (research §2.9), and `models` fails fast, with no browser, when logged out. An `agent` that is another
208
+ * program than `cursor-agent` is noted, never run.
209
+ */
210
+ async function probe(): Promise<Probe> {
211
+ const bin = cursorBin();
212
+ let v: Awaited<ReturnType<typeof runCli>>;
213
+ try {
214
+ v = await runCli(bin, ["--version"], cursorShell);
215
+ } catch (e) {
216
+ // an `agent` that is not Cursor's may not even run here (the owner's is a Linux grok, research §3.1)
217
+ if (bin === "agent") v = { ok: false, out: "", err: String(e) };
218
+ else throw e;
219
+ }
220
+ if (!v || !CURSOR_VERSION.test(v.out)) {
221
+ const says = (v?.out || v?.err || "").trim().split("\n")[0];
222
+ return {
223
+ installed: false,
224
+ version: null,
225
+ versionOk: false,
226
+ loggedIn: null,
227
+ problems: [
228
+ {
229
+ code: "E_BACKEND_MISSING",
230
+ message: v
231
+ ? `the ${bin} on PATH is not Cursor's CLI (it says: ${says})`
232
+ : "cursor-agent is not on PATH",
233
+ fix: CURSOR_INSTALL,
234
+ },
235
+ ],
236
+ };
237
+ }
238
+ const version = extractVersion(v.out);
239
+ const versionOk = version !== null && compareVersions(version, CURSOR_MIN_VERSION) >= 0;
240
+ const m = await runCli(bin, ["models"], { ...cursorShell, env: { NO_OPEN_BROWSER: "1" } });
241
+ const loggedIn = m?.ok ? true : isAuthFailure(`${m?.out ?? ""}\n${m?.err ?? ""}`) ? false : null;
242
+ const apiKey = !!process.env.CURSOR_API_KEY;
243
+ const problems: Probe["problems"] = [];
244
+ if (!versionOk)
245
+ problems.push({
246
+ code: "E_BACKEND_TOO_OLD",
247
+ message: `${bin} ${version ?? "?"} is older than ${CURSOR_MIN_VERSION}`,
248
+ fix: `${bin} update`,
249
+ });
250
+ if (loggedIn === false)
251
+ problems.push({
252
+ code: "E_BACKEND_NOT_LOGGED_IN",
253
+ message: `${bin} is not logged in (a server call says: Authentication required)`,
254
+ fix: LOGIN_FIX,
255
+ });
256
+ const agent = bin === "cursor-agent" ? which("agent") : null;
257
+ const other = agent && realOr(agent) !== realOr(which("cursor-agent") as string) ? realOr(agent) : null;
258
+ return {
259
+ installed: true,
260
+ version,
261
+ versionOk,
262
+ loggedIn,
263
+ ...(loggedIn ? { login: apiKey ? "API key" : "Cursor" } : {}),
264
+ // an API key bills at API rates; a Cursor login's billing is the profile's (spec 1.3 §9 Q4)
265
+ ...(loggedIn && apiKey ? { billing: "metered" as const } : {}),
266
+ problems,
267
+ ...(other
268
+ ? {
269
+ info: [
270
+ {
271
+ id: "name:agent",
272
+ label: "agent",
273
+ detail: `the agent on PATH (${other}) is another program than cursor-agent; catherd runs cursor-agent`,
274
+ },
275
+ ],
276
+ }
277
+ : {}),
278
+ };
279
+ }
280
+
281
+ /** How a native worker gets what Cursor's sandbox refused: its own sandbox.json, or catherd's when isolated. */
282
+ const SANDBOX_FIXES: AccessShell["fixes"] = {
283
+ lock: "a native Cursor worker writes only where ~/.cursor/sandbox.json or the repo's .cursor/sandbox.json lets it: add the directory to additionalReadwritePaths there, or isolate cursor (harness.cursor.isolated), whose sandbox.json catherd writes",
284
+ temp: "add the temp dir to additionalReadwritePaths in ~/.cursor/sandbox.json, or isolate cursor",
285
+ loopback:
286
+ "Cursor's sandbox blocks this bind: allow it in ~/.cursor/sandbox.json's networkPolicy, or isolate cursor",
287
+ https:
288
+ "Cursor's sandbox reaches only the domains networkPolicy allows: add the registry to networkPolicy.allow in ~/.cursor/sandbox.json, or isolate cursor",
289
+ docker:
290
+ "Cursor's sandbox cannot reach the Docker socket: run the Docker checks in the verifier (full access), or give the role full access",
291
+ };
292
+
293
+ /**
294
+ * Spec 1.3 §4.7: doctor's probes run through the hidden `cursor-agent sandbox run`, which runs a command in
295
+ * Cursor's sandbox with no model turn (research §2.6), under the user's own sandbox policy, as a native worker
296
+ * runs.
297
+ */
298
+ async function accessShell(): Promise<AccessShell | string> {
299
+ const bin = cursorBin();
300
+ // `sandbox run` joins its args with spaces and runs the result in a shell
301
+ const shell = scratchShell(`Cursor's sandbox (${bin} sandbox run)`, [bin, "sandbox", "run", "--"], {
302
+ oneLine: true,
303
+ });
304
+ if ((await shell.run("true", []))?.ok) return { ...shell, fixes: SANDBOX_FIXES };
305
+ shell.close();
306
+ return `no Cursor sandbox runner here: ${bin} sandbox run (hidden) did not run \`true\``;
307
+ }
308
+
309
+ export const cursorAdapter: BackendAdapter = {
310
+ id: "cursor",
311
+ minVersion: CURSOR_MIN_VERSION,
312
+ install: CURSOR_INSTALL,
313
+ probe,
314
+ listModels,
315
+ prepare,
316
+ plan,
317
+ parse,
318
+ finalize,
319
+ // read-only is enforced by the kernel only in an isolated home's sandbox.json; native it is `--mode ask`
320
+ enforcement: { "read-only": "advisory", "workspace-write": "enforced", full: "enforced" },
321
+ errors: { limit: CURSOR_LIMIT, tooOld: CURSOR_TOO_OLD },
322
+ // each run applies its own flags; "Resuming a conversation recomputes Run Everything" (research §2.5)
323
+ resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
324
+ accessShell,
325
+ isolationNote:
326
+ "native Cursor loads your Claude Code hooks, skills, plugins and permission rules, and your Cursor User Rules; isolation cannot remove the User Rules",
327
+ isolationKey: "CURSOR_API_KEY",
328
+ // Cursor waits for background shells after its last turn, with no limit (research §2.10)
329
+ graceAfterFinalMs: 30_000,
330
+ reportsCost: false,
331
+ };
@@ -0,0 +1,30 @@
1
+ import type { DiscoveredModel } from "../backend.ts";
2
+ import { foldEffortSlugs, stripAnsi } from "../discovery.ts";
3
+
4
+ /** Effort suffixes Cursor puts on a legacy slug (`gpt-6-sol-xhigh`); any other suffix names a model. */
5
+ export const CURSOR_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
6
+
7
+ const LINE = /^\s*([a-z0-9][a-z0-9._-]*)\s+-\s+\S/;
8
+
9
+ /** Spec 1.3 §4.6: an effort is its own slug; `default` is the bare one, with no effort suffix. */
10
+ export const cursorSlug = (model: string, effort: string): string =>
11
+ effort === "default" ? model : `${model}-${effort}`;
12
+
13
+ /**
14
+ * `cursor-agent models` prints `<slug> - <Display Name>` lines under a header, with ` (current, default)`
15
+ * markers and a tip (research §2.4). Slugs that differ only by an effort suffix fold into one model; its
16
+ * efforts list `default` when the bare slug is listed too, since only then does a `#default` rung have a slug
17
+ * to run. Bracket variant strings are never listed, so they are never built.
18
+ */
19
+ export function parseCursorModels(text: string): DiscoveredModel[] {
20
+ const slugs = stripAnsi(text)
21
+ .split("\n")
22
+ .map((l) => LINE.exec(l)?.[1])
23
+ .filter((s): s is string => s !== undefined);
24
+ return [...foldEffortSlugs(slugs, CURSOR_EFFORTS)].map(([id, efforts]) => ({
25
+ id,
26
+ efforts: ["default", ...CURSOR_EFFORTS].filter((e) => efforts.has(e)),
27
+ context: null,
28
+ imageIn: false,
29
+ }));
30
+ }
@@ -85,3 +85,24 @@ export async function discovered(
85
85
  if (models.length === 0) return cached?.models ?? [];
86
86
  return writeDiscovery(backend, models, now, o.repo).models;
87
87
  }
88
+
89
+ /**
90
+ * Spec 1.3 §4.6, §6.6: a listing's slugs by model, those that differ only by one of `suffixes` (`gpt-6-sol-xhigh`,
91
+ * `gemini-3.8-flash-high`) folded into one; each model's efforts hold the suffixes it was listed with, and
92
+ * `default` when its bare slug is listed too.
93
+ */
94
+ export function foldEffortSlugs(slugs: string[], suffixes: readonly string[]): Map<string, Set<string>> {
95
+ const byModel = new Map<string, Set<string>>();
96
+ for (const slug of slugs) {
97
+ const effort = suffixes.find((e) => slug.endsWith(`-${e}`));
98
+ const model = effort ? slug.slice(0, -(effort.length + 1)) : slug;
99
+ const efforts = byModel.get(model) ?? new Set<string>();
100
+ efforts.add(effort ?? "default");
101
+ byModel.set(model, efforts);
102
+ }
103
+ return byModel;
104
+ }
105
+
106
+ /** A listing may be coloured: ESC `[` … a letter. */
107
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
108
+ export const stripAnsi = (text: string): string => text.replace(ANSI, "");