cursor-route 0.1.1 → 0.1.5

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 (45) hide show
  1. package/README.md +79 -12
  2. package/SECURITY.md +8 -5
  3. package/SUPPORT.md +24 -0
  4. package/bin/cursor-route +6 -8
  5. package/bin/cursor-route.js +11 -8
  6. package/dist/adapters/claude-ds.js +189 -0
  7. package/dist/adapters/grok.js +57 -0
  8. package/dist/adapters/index.js +17 -0
  9. package/dist/adapters/openrouter.js +78 -0
  10. package/dist/adapters/types.js +1 -0
  11. package/dist/cli.js +434 -0
  12. package/dist/config.js +54 -0
  13. package/dist/health.js +100 -0
  14. package/dist/jobs.js +410 -0
  15. package/dist/mark-complete.js +38 -0
  16. package/dist/openrouter-run.js +95 -0
  17. package/dist/runtime.js +27 -0
  18. package/dist/secrets.js +39 -0
  19. package/dist/tmux.js +117 -0
  20. package/dist/util.js +21 -0
  21. package/docs/DEMO_GIF.md +9 -2
  22. package/docs/demo-notes.md +9 -1
  23. package/docs/fixtures/claude-ds-smoke.log +1 -1
  24. package/llms.txt +29 -0
  25. package/package.json +13 -6
  26. package/skills/route-orch/SKILL.md +8 -6
  27. package/src/adapters/claude-ds.ts +68 -22
  28. package/src/adapters/grok.ts +10 -5
  29. package/src/adapters/index.ts +2 -0
  30. package/src/adapters/openrouter.test.ts +57 -0
  31. package/src/adapters/openrouter.ts +80 -0
  32. package/src/adapters/types.ts +2 -0
  33. package/src/cli.test.ts +30 -9
  34. package/src/cli.ts +117 -24
  35. package/src/config.ts +37 -6
  36. package/src/health.ts +8 -6
  37. package/src/integration.test.ts +174 -0
  38. package/src/jobs.ts +85 -11
  39. package/src/mark-complete.ts +2 -6
  40. package/src/openrouter-run.ts +102 -0
  41. package/src/runtime.ts +9 -4
  42. package/src/secrets.ts +21 -4
  43. package/src/tmux.ts +20 -8
  44. package/src/util.ts +3 -1
  45. package/docs/audit-2026-08-10-sol-grok-kimi.md +0 -64
package/src/cli.test.ts CHANGED
@@ -3,10 +3,13 @@ import { resolveWorker } from "./jobs.ts";
3
3
  import { config } from "./config.ts";
4
4
  import { shellQuote, newJobId } from "./util.ts";
5
5
  import { runHealth } from "./health.ts";
6
- import { looksLikeSecretMaterial } from "./secrets.ts";
7
- import { isDeepSeekRouted } from "./adapters/claude-ds.ts";
6
+ import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
7
+ import { isDeepSeekRouted, isDeepSeekBaseUrl } from "./adapters/claude-ds.ts";
8
8
 
9
9
  describe("resolveWorker", () => {
10
+ test("lane easy → openrouter", () => {
11
+ expect(resolveWorker({ prompt: "x", lane: "easy" })).toBe("openrouter");
12
+ });
10
13
  test("lane mid → claude-ds", () => {
11
14
  expect(resolveWorker({ prompt: "x", lane: "mid" })).toBe("claude-ds");
12
15
  });
@@ -17,6 +20,9 @@ describe("resolveWorker", () => {
17
20
  expect(resolveWorker({ prompt: "x", lane: "hard", worker: "claude-ds" })).toBe(
18
21
  "claude-ds",
19
22
  );
23
+ expect(resolveWorker({ prompt: "x", lane: "mid", worker: "openrouter" })).toBe(
24
+ "openrouter",
25
+ );
20
26
  });
21
27
  test("default worker", () => {
22
28
  expect(resolveWorker({ prompt: "x" })).toBe(config.defaultWorker);
@@ -40,8 +46,24 @@ describe("secrets", () => {
40
46
  test("blocks sk- material", () => {
41
47
  expect(looksLikeSecretMaterial("token sk-abcdefghijklmnopqrstuvwxyz1234")).toBe(true);
42
48
  });
43
- test("blocks ghp_ material", () => {
49
+ test("blocks sk-proj and sk-ant", () => {
50
+ expect(
51
+ looksLikeSecretMaterial("sk-proj-abcdefghijklmnopqrstuvwxyz123456"),
52
+ ).toBe(true);
53
+ expect(
54
+ looksLikeSecretMaterial("sk-ant-api03-abcdefghijklmnopqrstuvwxyz"),
55
+ ).toBe(true);
56
+ });
57
+ test("blocks ghp_ and github_pat", () => {
44
58
  expect(looksLikeSecretMaterial("ghp_abcdefghijklmnopqrstuvwx")).toBe(true);
59
+ expect(
60
+ looksLikeSecretMaterial("github_pat_11AAAAAAAAabcdefghijklmnopqrstuvwxyz"),
61
+ ).toBe(true);
62
+ });
63
+ test("redactSecrets strips material", () => {
64
+ const out = redactSecrets("see sk-abcdefghijklmnopqrstuvwxyz1234 end");
65
+ expect(out).toContain("[REDACTED]");
66
+ expect(out).not.toContain("sk-abcd");
45
67
  });
46
68
  });
47
69
 
@@ -51,6 +73,8 @@ describe("deepseek routing", () => {
51
73
  process.env.ANTHROPIC_BASE_URL = "https://api.deepseek.com/anthropic";
52
74
  try {
53
75
  expect(isDeepSeekRouted()).toBe(true);
76
+ expect(isDeepSeekBaseUrl("https://api.deepseek.com/anthropic")).toBe(true);
77
+ expect(isDeepSeekBaseUrl("https://evil-deepseek.com.attacker.tld")).toBe(false);
54
78
  } finally {
55
79
  if (prev === undefined) delete process.env.ANTHROPIC_BASE_URL;
56
80
  else process.env.ANTHROPIC_BASE_URL = prev;
@@ -67,16 +91,13 @@ describe("health", () => {
67
91
  expect(r.checks.some((c) => c.name === "cursor_cli")).toBe(true);
68
92
  });
69
93
 
70
- test("relaxed env can pass without tmux", () => {
94
+ test("relaxed env can pass without workers", () => {
71
95
  const prev = process.env.CURSOR_ROUTE_RELAXED;
72
96
  process.env.CURSOR_ROUTE_RELAXED = "1";
73
97
  try {
74
98
  const r = runHealth();
75
- const tmux = r.checks.find((c) => c.name === "tmux");
76
- if (tmux && !tmux.ok) {
77
- expect(r.ok).toBe(true);
78
- expect(r.checks.some((c) => c.name === "relaxed")).toBe(true);
79
- }
99
+ expect(r.ok).toBe(true);
100
+ expect(r.checks.some((c) => c.name === "relaxed")).toBe(true);
80
101
  } finally {
81
102
  if (prev === undefined) delete process.env.CURSOR_ROUTE_RELAXED;
82
103
  else process.env.CURSOR_ROUTE_RELAXED = prev;
package/src/cli.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * cursor-route CLI — Cursor brain, Grok + DeepSeek workers in tmux.
3
+ * cursor-route CLI — Cursor brain, Grok + DeepSeek + OpenRouter (easy) workers in tmux.
4
4
  */
5
- import { readFileSync, existsSync } from "node:fs";
5
+ import { readFileSync, existsSync, realpathSync, statSync } from "node:fs";
6
6
  import { resolve, basename } from "node:path";
7
7
  import { config, WORKERS, LANES, type WorkerKind, type Lane } from "./config.ts";
8
8
  import { runHealth, printHealth } from "./health.ts";
@@ -22,12 +22,12 @@ import {
22
22
  listManagedSessions,
23
23
  sessionExists,
24
24
  } from "./tmux.ts";
25
- import { looksLikeSecretMaterial } from "./secrets.ts";
25
+ import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
26
26
 
27
27
  function usage(exitCode = 0): never {
28
28
  console.log(`cursor-route v${config.version}
29
29
 
30
- Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) are the parallel army.
30
+ Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter (easy) are the parallel army.
31
31
 
32
32
  Usage:
33
33
  cursor-route --version
@@ -44,19 +44,25 @@ Usage:
44
44
  cursor-route clean [--days N]
45
45
 
46
46
  Start options:
47
- --worker <grok|claude-ds> Worker adapter (default: grok)
48
- --lane <mid|hard> Lane → worker (mid=claude-ds, hard=grok)
49
- --dir <path> Working directory (default: cwd)
50
- --ask Disable always-approve for this job
51
- --dry-run Print launch command; do not start
52
- --no-tmux Headless background process (no attach/send)
53
- --json JSON output where supported
47
+ --worker <grok|claude-ds|openrouter> Worker adapter (default: grok)
48
+ --lane <easy|mid|hard> Lane → worker (easy=openrouter, mid=claude-ds, hard=grok)
49
+ --dir <path> Working directory (default: cwd)
50
+ --ask Disable always-approve for this job
51
+ --dry-run Print launch command; do not start
52
+ --no-tmux Headless background process (no attach/send)
53
+ --json JSON output where supported
54
54
 
55
55
  Env:
56
56
  CURSOR_ROUTE_ASK=1 Opt out of always-approve
57
57
  CURSOR_ROUTE_JOBS_DIR Override jobs dir (default: ~/.local/share/cursor-route/jobs)
58
- CURSOR_ROUTE_RELAXED=1 health OK without tmux (headless CI)
58
+ CURSOR_ROUTE_MAX_JOBS Max active jobs (default: 50)
59
+ CURSOR_ROUTE_RELAXED=1 health OK without tmux/workers (CI / infra smoke)
59
60
  CURSOR_ROUTE_ALLOW_ANTHROPIC=1 Allow mid-lane on Anthropic Claude (expensive; not default)
61
+ CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
62
+ CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
63
+ OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
64
+ CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
65
+ OPENROUTER_BASE_URL OpenRouter API base (default: https://openrouter.ai/api/v1)
60
66
  `);
61
67
  process.exit(exitCode);
62
68
  }
@@ -66,6 +72,10 @@ function parseArgs(argv: string[]) {
66
72
  const positional: string[] = [];
67
73
  for (let i = 0; i < argv.length; i++) {
68
74
  const a = argv[i];
75
+ if (a === "--") {
76
+ positional.push(...argv.slice(i + 1));
77
+ break;
78
+ }
69
79
  if (
70
80
  a === "--json" ||
71
81
  a === "--ask" ||
@@ -106,6 +116,30 @@ function parseArgs(argv: string[]) {
106
116
  return { flags, positional };
107
117
  }
108
118
 
119
+ /** Require a string value for flags that must not be bare booleans. */
120
+ function requireStringFlag(
121
+ flags: Record<string, string | boolean>,
122
+ key: string,
123
+ ): string | undefined {
124
+ if (!(key in flags)) return undefined;
125
+ const v = flags[key];
126
+ if (typeof v !== "string" || !v.trim()) {
127
+ console.error(`--${key} requires a value`);
128
+ process.exit(2);
129
+ }
130
+ return v;
131
+ }
132
+
133
+ function requireNonNegNumber(raw: string | undefined, label: string, fallback: number): number {
134
+ if (raw === undefined) return fallback;
135
+ const n = Number(raw);
136
+ if (!Number.isFinite(n) || n < 0) {
137
+ console.error(`${label} must be a non-negative number`);
138
+ process.exit(2);
139
+ }
140
+ return n;
141
+ }
142
+
109
143
  function asWorker(v: unknown): WorkerKind | undefined {
110
144
  if (typeof v !== "string") return undefined;
111
145
  if ((WORKERS as string[]).includes(v)) return v as WorkerKind;
@@ -128,14 +162,31 @@ function refuseSecrets(text: string, context: string): void {
128
162
  }
129
163
 
130
164
  function refuseDangerousPromptFile(path: string): void {
131
- const base = basename(path);
132
- const resolved = resolve(path);
165
+ let resolved: string;
166
+ try {
167
+ resolved = realpathSync(path);
168
+ } catch {
169
+ resolved = resolve(path);
170
+ }
171
+ const base = basename(resolved);
172
+ const lower = resolved.toLowerCase();
133
173
  if (
134
174
  base.startsWith(".env") ||
135
- resolved.includes("/.ssh/") ||
175
+ lower.includes("/.ssh/") ||
176
+ lower.includes("/.aws/") ||
177
+ lower.includes("/.kube/") ||
178
+ base === ".npmrc" ||
179
+ base === ".git-credentials" ||
180
+ base === ".pgpass" ||
136
181
  base === "id_rsa" ||
137
182
  base === "id_ed25519" ||
138
- base.endsWith(".pem")
183
+ base === "id_ecdsa" ||
184
+ base === "id_dsa" ||
185
+ base === "credentials" ||
186
+ base.endsWith(".pem") ||
187
+ base.endsWith(".key") ||
188
+ base.endsWith(".p12") ||
189
+ base.endsWith(".pfx")
139
190
  ) {
140
191
  console.error(`Refusing --prompt-file path that looks credential-related: ${path}`);
141
192
  process.exit(3);
@@ -163,9 +214,14 @@ async function main() {
163
214
  }
164
215
 
165
216
  if (cmd === "start") {
217
+ requireStringFlag(f, "worker");
218
+ requireStringFlag(f, "lane");
219
+ const promptFile = requireStringFlag(f, "prompt-file");
220
+ const dirFlag = requireStringFlag(f, "dir");
221
+
166
222
  let prompt = "";
167
- if (f["prompt-file"]) {
168
- const p = resolve(String(f["prompt-file"]));
223
+ if (promptFile) {
224
+ const p = resolve(promptFile);
169
225
  refuseDangerousPromptFile(p);
170
226
  if (!existsSync(p)) {
171
227
  console.error(`prompt file not found: ${p}`);
@@ -181,11 +237,25 @@ async function main() {
181
237
  }
182
238
  refuseSecrets(prompt, "to start");
183
239
 
240
+ let cwd = process.cwd();
241
+ if (dirFlag) {
242
+ cwd = resolve(dirFlag);
243
+ try {
244
+ if (!statSync(cwd).isDirectory()) {
245
+ console.error(`--dir is not a directory: ${cwd}`);
246
+ process.exit(2);
247
+ }
248
+ } catch {
249
+ console.error(`--dir does not exist: ${cwd}`);
250
+ process.exit(2);
251
+ }
252
+ }
253
+
184
254
  const result = startJob({
185
255
  prompt,
186
256
  worker: asWorker(f.worker),
187
257
  lane: asLane(f.lane),
188
- cwd: f.dir ? resolve(String(f.dir)) : process.cwd(),
258
+ cwd,
189
259
  alwaysApprove: !f.ask,
190
260
  dryRun: Boolean(f.dryRun),
191
261
  noTmux: Boolean(f.noTmux),
@@ -197,11 +267,20 @@ async function main() {
197
267
  }
198
268
 
199
269
  if (json) {
200
- console.log(JSON.stringify({ ...result.job, command: result.command }, null, 2));
270
+ console.log(
271
+ JSON.stringify(
272
+ {
273
+ ...result.job,
274
+ command: result.command ? redactSecrets(result.command) : result.command,
275
+ },
276
+ null,
277
+ 2,
278
+ ),
279
+ );
201
280
  } else if (result.dryRun) {
202
281
  console.log(`dry-run job ${result.job.id}`);
203
282
  console.log(`worker: ${result.job.worker}`);
204
- console.log(`command: ${result.command}`);
283
+ console.log(`command: ${redactSecrets(result.command || "")}`);
205
284
  } else {
206
285
  console.log(`started ${result.job.id} (${result.job.worker})`);
207
286
  console.log(`session: ${result.job.tmuxSession}`);
@@ -217,7 +296,8 @@ async function main() {
217
296
  }
218
297
 
219
298
  if (cmd === "jobs") {
220
- const limit = f.limit ? Number(f.limit) : config.jobsListLimit;
299
+ const limitRaw = requireStringFlag(f, "limit");
300
+ const limit = requireNonNegNumber(limitRaw, "--limit", config.jobsListLimit);
221
301
  const jobs = listJobs(limit);
222
302
  if (json) {
223
303
  console.log(JSON.stringify(jobs, null, 2));
@@ -267,7 +347,10 @@ async function main() {
267
347
 
268
348
  if (cmd === "capture") {
269
349
  const id = pos[0];
270
- const lines = pos[1] ? Number(pos[1]) : 50;
350
+ const linesRaw = pos[1];
351
+ const lines = linesRaw
352
+ ? requireNonNegNumber(linesRaw, "capture lines", 50)
353
+ : 50;
271
354
  if (!id) {
272
355
  console.error("capture requires <jobId>");
273
356
  process.exit(2);
@@ -326,6 +409,15 @@ async function main() {
326
409
  console.error("attach requires <jobId>");
327
410
  process.exit(2);
328
411
  }
412
+ const job = readJob(id);
413
+ if (!job) {
414
+ console.error(`Job not found: ${id}`);
415
+ process.exit(1);
416
+ }
417
+ if (job.tmuxSession.startsWith("headless-")) {
418
+ console.error("headless job — use capture/status (no tmux attach)");
419
+ process.exit(1);
420
+ }
329
421
  console.log(attachHint(id));
330
422
  return;
331
423
  }
@@ -354,7 +446,8 @@ async function main() {
354
446
  }
355
447
 
356
448
  if (cmd === "clean") {
357
- const days = f.days ? Number(f.days) : 7;
449
+ const daysRaw = requireStringFlag(f, "days");
450
+ const days = requireNonNegNumber(daysRaw, "--days", 7);
358
451
  const n = cleanJobs(days);
359
452
  console.log(`cleaned ${n} job(s) older than ${days}d`);
360
453
  return;
package/src/config.ts CHANGED
@@ -2,24 +2,55 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { defaultJobsDir } from "./runtime.ts";
4
4
 
5
- export type WorkerKind = "grok" | "claude-ds";
6
- export type Lane = "mid" | "hard";
5
+ export type WorkerKind = "grok" | "claude-ds" | "openrouter";
6
+ export type Lane = "easy" | "mid" | "hard";
7
7
 
8
- export const WORKERS: WorkerKind[] = ["grok", "claude-ds"];
9
- export const LANES: Lane[] = ["mid", "hard"];
8
+ export const WORKERS: WorkerKind[] = ["grok", "claude-ds", "openrouter"];
9
+ export const LANES: Lane[] = ["easy", "mid", "hard"];
10
10
 
11
+ /** OpenRouter model for the easy lane (env CURSOR_ROUTE_OPENROUTER_MODEL). */
12
+ export function openRouterModel(): string {
13
+ return process.env.CURSOR_ROUTE_OPENROUTER_MODEL || "openrouter/free";
14
+ }
15
+
16
+ /** OpenRouter API base URL (env OPENROUTER_BASE_URL). */
17
+ export function openRouterBaseUrl(): string {
18
+ return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
19
+ }
20
+
21
+ function maxConcurrentJobsFromEnv(): number {
22
+ const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
23
+ if (raw) {
24
+ const n = Number(raw);
25
+ if (Number.isInteger(n) && n > 0) return n;
26
+ }
27
+ return 50;
28
+ }
29
+
30
+ /**
31
+ * Live getters for env-derived paths/limits so tests can set
32
+ * CURSOR_ROUTE_JOBS_DIR / CURSOR_ROUTE_MAX_JOBS before exercising jobs
33
+ * even if another module imported config earlier.
34
+ */
11
35
  export const config = {
12
36
  product: "cursor-route",
13
- version: "0.1.1",
14
- jobsDir: defaultJobsDir(),
37
+ version: "0.1.5",
38
+ get jobsDir(): string {
39
+ return defaultJobsDir();
40
+ },
15
41
  tmuxPrefix: "cursor-route",
16
42
  defaultWorker: "grok" as WorkerKind,
17
43
  /** Lane → default worker (Cemini /route public core). */
18
44
  laneWorkers: {
45
+ easy: "openrouter" as WorkerKind,
19
46
  mid: "claude-ds" as WorkerKind,
20
47
  hard: "grok" as WorkerKind,
21
48
  },
22
49
  jobsListLimit: 20,
50
+ /** Max simultaneously active (running|pending) jobs. Override: CURSOR_ROUTE_MAX_JOBS. */
51
+ get maxConcurrentJobs(): number {
52
+ return maxConcurrentJobsFromEnv();
53
+ },
23
54
  };
24
55
 
25
56
  export function sessionName(jobId: string): string {
package/src/health.ts CHANGED
@@ -32,7 +32,7 @@ export function runHealth(): HealthReport {
32
32
  checks.push({
33
33
  name: "runtime",
34
34
  ok: bunOk || nodeOk,
35
- detail: bunOk ? "bun ok" : nodeOk ? "node ok (tsx via npx for TS)" : "need bun or node 20+",
35
+ detail: bunOk ? "bun ok" : nodeOk ? "node ok (compiled dist)" : "need bun or node 20+",
36
36
  });
37
37
 
38
38
  const scriptOk = (() => {
@@ -71,18 +71,20 @@ export function runHealth(): HealthReport {
71
71
  : "optional — Cursor CLI agent not on PATH (skill-only supervisor is fine for v0)",
72
72
  });
73
73
 
74
- // At least one worker must be healthy; tmux is hard-required for attach/send product path.
75
- // Set CURSOR_ROUTE_RELAXED=1 to pass health without tmux (headless/--no-tmux CI).
74
+ // At least one worker must be healthy for a green health gate.
75
+ // CURSOR_ROUTE_RELAXED=1: pass without tmux and without workers (CI / infra smoke).
76
76
  const workerOk = checks.some((c) => c.name.startsWith("worker:") && c.ok);
77
77
  const relaxed = process.env.CURSOR_ROUTE_RELAXED === "1";
78
78
  const hardOk = (tmuxOk || relaxed) && (bunOk || nodeOk) && scriptOk;
79
- const ok = hardOk && workerOk;
79
+ const ok = hardOk && (workerOk || relaxed);
80
80
 
81
- if (relaxed && !tmuxOk) {
81
+ if (relaxed) {
82
82
  checks.push({
83
83
  name: "relaxed",
84
84
  ok: true,
85
- detail: "CURSOR_ROUTE_RELAXED=1 — tmux not required (headless only)",
85
+ detail: workerOk
86
+ ? "CURSOR_ROUTE_RELAXED=1 — tmux optional (headless OK)"
87
+ : "CURSOR_ROUTE_RELAXED=1 — tmux/workers optional (CI / infra smoke)",
86
88
  });
87
89
  }
88
90
 
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Fake-worker integration tests (headless, no tmux needed).
3
+ *
4
+ * Env (set before importing config/jobs so jobsDir + limit take effect):
5
+ * CURSOR_ROUTE_JOBS_DIR → unique tmpdir
6
+ * CURSOR_ROUTE_MAX_JOBS → 2 (so the concurrency refusal is cheap to test)
7
+ * CURSOR_ROUTE_GROK_BIN → absolute path to the fake grok shim (read in-process
8
+ * by the adapter; PATH shadowing alone is unreliable
9
+ * under bun, which snapshots env for child processes)
10
+ */
11
+ import { describe, expect, test, afterAll } from "bun:test";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, existsSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import type { Job } from "./jobs.ts";
16
+
17
+ const tmp = mkdtempSync(join(tmpdir(), "cursor-route-int-"));
18
+ const jobsDir = join(tmp, "jobs");
19
+ const shimDir = join(tmp, "bin");
20
+ const shimLog = join(tmp, "shim.log");
21
+
22
+ process.env.CURSOR_ROUTE_JOBS_DIR = jobsDir;
23
+ process.env.CURSOR_ROUTE_MAX_JOBS = "2";
24
+
25
+ mkdirSync(jobsDir, { recursive: true });
26
+ mkdirSync(shimDir, { recursive: true });
27
+
28
+ function writeShim(name: string): void {
29
+ const body = `#!/usr/bin/env sh
30
+ {
31
+ echo "argv=$*"
32
+ echo "pwd=$PWD"
33
+ } >> '${shimLog}'
34
+ sleep "\${FAKE_WORKER_SLEEP:-1}"
35
+ exit "\${FAKE_WORKER_EXIT:-0}"
36
+ `;
37
+ const p = join(shimDir, name);
38
+ writeFileSync(p, body);
39
+ chmodSync(p, 0o755);
40
+ }
41
+ writeShim("grok");
42
+ writeShim("claude-ds");
43
+ process.env.CURSOR_ROUTE_GROK_BIN = join(shimDir, "grok");
44
+ process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = join(shimDir, "claude-ds");
45
+
46
+ const { startJob, readJob, killJob, refreshStatus, countActiveJobs } = await import("./jobs.ts");
47
+
48
+ function sleep(ms: number): Promise<void> {
49
+ return new Promise((resolve) => setTimeout(resolve, ms));
50
+ }
51
+
52
+ async function waitForTerminal(jobId: string, timeoutMs = 15000): Promise<Job> {
53
+ const deadline = Date.now() + timeoutMs;
54
+ while (Date.now() < deadline) {
55
+ const job = readJob(jobId);
56
+ if (!job) {
57
+ await sleep(100);
58
+ continue;
59
+ }
60
+ const fresh = refreshStatus(job);
61
+ if (fresh.status === "completed" || fresh.status === "failed" || fresh.status === "killed") {
62
+ return fresh;
63
+ }
64
+ await sleep(200);
65
+ }
66
+ throw new Error(`timed out waiting for job ${jobId} to reach terminal state`);
67
+ }
68
+
69
+ afterAll(() => {
70
+ try {
71
+ rmSync(tmp, { recursive: true, force: true });
72
+ } catch {
73
+ /* ignore */
74
+ }
75
+ });
76
+
77
+ describe("fake-worker integration (headless, no tmux)", () => {
78
+ test(
79
+ "start → completed with exitCode 0",
80
+ async () => {
81
+ delete process.env.FAKE_WORKER_EXIT;
82
+ delete process.env.FAKE_WORKER_SLEEP;
83
+ const r = startJob({ prompt: "say ok", worker: "grok", noTmux: true });
84
+ expect(r.ok).toBe(true);
85
+ if (!r.ok) return;
86
+ const job = await waitForTerminal(r.job.id);
87
+ expect(job.status).toBe("completed");
88
+ expect(job.exitCode).toBe(0);
89
+ },
90
+ 20000,
91
+ );
92
+
93
+ test(
94
+ "FAKE_WORKER_EXIT=7 → failed with exitCode 7",
95
+ async () => {
96
+ process.env.FAKE_WORKER_EXIT = "7";
97
+ delete process.env.FAKE_WORKER_SLEEP;
98
+ try {
99
+ const r = startJob({ prompt: "say fail", worker: "grok", noTmux: true });
100
+ expect(r.ok).toBe(true);
101
+ if (!r.ok) return;
102
+ const job = await waitForTerminal(r.job.id);
103
+ expect(job.status).toBe("failed");
104
+ expect(job.exitCode).toBe(7);
105
+ } finally {
106
+ delete process.env.FAKE_WORKER_EXIT;
107
+ }
108
+ },
109
+ 20000,
110
+ );
111
+
112
+ test(
113
+ "kill running job → killed; second kill refused as terminal",
114
+ async () => {
115
+ process.env.FAKE_WORKER_SLEEP = "30";
116
+ delete process.env.FAKE_WORKER_EXIT;
117
+ try {
118
+ const r = startJob({ prompt: "long job", worker: "grok", noTmux: true });
119
+ expect(r.ok).toBe(true);
120
+ if (!r.ok) return;
121
+ // Give the detached spawn a beat so the pid is recorded and the shim is alive.
122
+ await sleep(300);
123
+ const k = killJob(r.job.id);
124
+ expect(k.ok).toBe(true);
125
+ if (k.ok) expect(k.job.status).toBe("killed");
126
+
127
+ const again = killJob(r.job.id);
128
+ expect(again.ok).toBe(false);
129
+ if (!again.ok) expect(again.error).toContain("terminal");
130
+ } finally {
131
+ delete process.env.FAKE_WORKER_SLEEP;
132
+ }
133
+ },
134
+ 20000,
135
+ );
136
+
137
+ test("openrouter worker without key refuses start (preflight)", () => {
138
+ delete process.env.OPENROUTER_API_KEY;
139
+ const r = startJob({ prompt: "say ok", worker: "openrouter", noTmux: true });
140
+ expect(r.ok).toBe(false);
141
+ if (!r.ok) expect(r.error).toContain("openrouter");
142
+ });
143
+
144
+ test(
145
+ "concurrent limit: refuses start once active jobs reach CURSOR_ROUTE_MAX_JOBS",
146
+ async () => {
147
+ process.env.FAKE_WORKER_SLEEP = "30";
148
+ delete process.env.FAKE_WORKER_EXIT;
149
+ try {
150
+ const a = startJob({ prompt: "job a", worker: "grok", noTmux: true });
151
+ const b = startJob({ prompt: "job b", worker: "grok", noTmux: true });
152
+ expect(a.ok).toBe(true);
153
+ expect(b.ok).toBe(true);
154
+ if (!a.ok || !b.ok) return;
155
+ expect(countActiveJobs()).toBe(2);
156
+
157
+ const c = startJob({ prompt: "job c", worker: "grok", noTmux: true });
158
+ expect(c.ok).toBe(false);
159
+ if (!c.ok) expect(c.error).toContain("Too many active jobs");
160
+ } finally {
161
+ delete process.env.FAKE_WORKER_SLEEP;
162
+ // Cleanup any running jobs so the tmpdir can be removed.
163
+ if (existsSync(jobsDir)) {
164
+ for (const f of readdirSync(jobsDir)) {
165
+ if (!f.endsWith(".json")) continue;
166
+ const job = readJob(f.replace(/\.json$/, ""));
167
+ if (job && (job.status === "running" || job.status === "pending")) killJob(job.id);
168
+ }
169
+ }
170
+ }
171
+ },
172
+ 20000,
173
+ );
174
+ });