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.
- package/README.md +79 -12
- package/SECURITY.md +8 -5
- package/SUPPORT.md +24 -0
- package/bin/cursor-route +6 -8
- package/bin/cursor-route.js +11 -8
- package/dist/adapters/claude-ds.js +189 -0
- package/dist/adapters/grok.js +57 -0
- package/dist/adapters/index.js +17 -0
- package/dist/adapters/openrouter.js +78 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.js +434 -0
- package/dist/config.js +54 -0
- package/dist/health.js +100 -0
- package/dist/jobs.js +410 -0
- package/dist/mark-complete.js +38 -0
- package/dist/openrouter-run.js +95 -0
- package/dist/runtime.js +27 -0
- package/dist/secrets.js +39 -0
- package/dist/tmux.js +117 -0
- package/dist/util.js +21 -0
- package/docs/DEMO_GIF.md +9 -2
- package/docs/demo-notes.md +9 -1
- package/docs/fixtures/claude-ds-smoke.log +1 -1
- package/llms.txt +29 -0
- package/package.json +13 -6
- package/skills/route-orch/SKILL.md +8 -6
- package/src/adapters/claude-ds.ts +68 -22
- package/src/adapters/grok.ts +10 -5
- package/src/adapters/index.ts +2 -0
- package/src/adapters/openrouter.test.ts +57 -0
- package/src/adapters/openrouter.ts +80 -0
- package/src/adapters/types.ts +2 -0
- package/src/cli.test.ts +30 -9
- package/src/cli.ts +117 -24
- package/src/config.ts +37 -6
- package/src/health.ts +8 -6
- package/src/integration.test.ts +174 -0
- package/src/jobs.ts +85 -11
- package/src/mark-complete.ts +2 -6
- package/src/openrouter-run.ts +102 -0
- package/src/runtime.ts +9 -4
- package/src/secrets.ts +21 -4
- package/src/tmux.ts +20 -8
- package/src/util.ts +3 -1
- 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
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
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>
|
|
48
|
-
--lane <mid|hard>
|
|
49
|
-
--dir <path>
|
|
50
|
-
--ask
|
|
51
|
-
--dry-run
|
|
52
|
-
--no-tmux
|
|
53
|
-
--json
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
168
|
-
const p = resolve(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
14
|
-
jobsDir:
|
|
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 (
|
|
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
|
|
75
|
-
//
|
|
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
|
|
81
|
+
if (relaxed) {
|
|
82
82
|
checks.push({
|
|
83
83
|
name: "relaxed",
|
|
84
84
|
ok: true,
|
|
85
|
-
detail:
|
|
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
|
+
});
|