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/jobs.ts
CHANGED
|
@@ -98,10 +98,21 @@ export function writeJob(job: Job): void {
|
|
|
98
98
|
function pidAlive(pid: number): boolean {
|
|
99
99
|
try {
|
|
100
100
|
process.kill(pid, 0);
|
|
101
|
-
return true;
|
|
102
101
|
} catch {
|
|
103
102
|
return false;
|
|
104
103
|
}
|
|
104
|
+
// kill(pid, 0) stays true for zombies, and terminatePid's sync waits block the
|
|
105
|
+
// event loop so children can sit unreaped as zombies — a zombie is not a worker.
|
|
106
|
+
try {
|
|
107
|
+
const r = spawnSync("ps", ["-o", "state=", "-p", String(pid)], {
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
110
|
+
});
|
|
111
|
+
const state = (r.stdout || "").trim();
|
|
112
|
+
return state !== "" && !state.startsWith("Z");
|
|
113
|
+
} catch {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
function sleepMs(ms: number): void {
|
|
@@ -169,6 +180,25 @@ export function refreshStatus(job: Job): Job {
|
|
|
169
180
|
return job;
|
|
170
181
|
}
|
|
171
182
|
|
|
183
|
+
export function countActiveJobs(): number {
|
|
184
|
+
ensureJobsDir();
|
|
185
|
+
let n = 0;
|
|
186
|
+
for (const f of readdirSync(config.jobsDir)) {
|
|
187
|
+
if (!f.endsWith(".json")) continue;
|
|
188
|
+
const id = f.replace(/\.json$/, "");
|
|
189
|
+
if (!JOB_ID_RE.test(id)) continue;
|
|
190
|
+
try {
|
|
191
|
+
const job = JSON.parse(readFileSync(join(config.jobsDir, f), "utf8")) as Job;
|
|
192
|
+
if (job?.schema === "cursor-route.job.v1" && (job.status === "running" || job.status === "pending")) {
|
|
193
|
+
n++;
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
/* skip */
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return n;
|
|
200
|
+
}
|
|
201
|
+
|
|
172
202
|
export function listJobs(limit = config.jobsListLimit): Job[] {
|
|
173
203
|
ensureJobsDir();
|
|
174
204
|
const files = readdirSync(config.jobsDir).filter(
|
|
@@ -211,10 +241,21 @@ export function startJob(opts: StartOptions): {
|
|
|
211
241
|
} | { ok: false; error: string } {
|
|
212
242
|
const worker = resolveWorker(opts);
|
|
213
243
|
const cwd = opts.cwd || process.cwd();
|
|
244
|
+
// CURSOR_ROUTE_ASK applies to all workers; CLAUDE_DS_ASK is mid-lane only
|
|
214
245
|
const alwaysApprove =
|
|
215
246
|
opts.alwaysApprove !== false &&
|
|
216
247
|
process.env.CURSOR_ROUTE_ASK !== "1" &&
|
|
217
|
-
process.env.CLAUDE_DS_ASK
|
|
248
|
+
!(worker === "claude-ds" && process.env.CLAUDE_DS_ASK === "1");
|
|
249
|
+
|
|
250
|
+
if (!opts.dryRun) {
|
|
251
|
+
const active = countActiveJobs();
|
|
252
|
+
if (active >= config.maxConcurrentJobs) {
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
error: `Too many active jobs (${active} >= ${config.maxConcurrentJobs}) — wait for jobs to finish or raise CURSOR_ROUTE_MAX_JOBS`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
218
259
|
|
|
219
260
|
// Preflight: requested worker must be healthy
|
|
220
261
|
const adapter = getAdapter(worker);
|
|
@@ -235,6 +276,11 @@ export function startJob(opts: StartOptions): {
|
|
|
235
276
|
alwaysApprove,
|
|
236
277
|
});
|
|
237
278
|
} catch (e) {
|
|
279
|
+
try {
|
|
280
|
+
unlinkSync(paths.prompt);
|
|
281
|
+
} catch {
|
|
282
|
+
/* ignore */
|
|
283
|
+
}
|
|
238
284
|
return { ok: false, error: (e as Error).message };
|
|
239
285
|
}
|
|
240
286
|
|
|
@@ -258,7 +304,15 @@ export function startJob(opts: StartOptions): {
|
|
|
258
304
|
} catch {
|
|
259
305
|
/* ignore */
|
|
260
306
|
}
|
|
261
|
-
|
|
307
|
+
const envNote = plan.env
|
|
308
|
+
? ` (+env: ${Object.keys(plan.env).join(",")})`
|
|
309
|
+
: "";
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
job,
|
|
313
|
+
dryRun: true,
|
|
314
|
+
command: plan.command + envNote,
|
|
315
|
+
};
|
|
262
316
|
}
|
|
263
317
|
|
|
264
318
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -293,6 +347,7 @@ export function startJob(opts: StartOptions): {
|
|
|
293
347
|
cwd,
|
|
294
348
|
detached: true,
|
|
295
349
|
stdio: ["ignore", logFd, logFd],
|
|
350
|
+
env: plan.env ? { ...process.env, ...plan.env } : process.env,
|
|
296
351
|
});
|
|
297
352
|
try {
|
|
298
353
|
closeSync(logFd);
|
|
@@ -308,8 +363,13 @@ export function startJob(opts: StartOptions): {
|
|
|
308
363
|
return { ok: false, error: job.error };
|
|
309
364
|
}
|
|
310
365
|
|
|
311
|
-
|
|
312
|
-
|
|
366
|
+
// Merge-on-write: only set pid if still running (avoid clobbering mark-complete)
|
|
367
|
+
const latest = readJob(id) || job;
|
|
368
|
+
if (latest.status === "running" || latest.status === "pending") {
|
|
369
|
+
latest.pid = child.pid;
|
|
370
|
+
writeJob(latest);
|
|
371
|
+
Object.assign(job, latest);
|
|
372
|
+
}
|
|
313
373
|
child.unref();
|
|
314
374
|
return { ok: true, job };
|
|
315
375
|
}
|
|
@@ -321,6 +381,7 @@ export function startJob(opts: StartOptions): {
|
|
|
321
381
|
logFile: paths.log,
|
|
322
382
|
jobFile: paths.json,
|
|
323
383
|
markCompleteScript: markComplete,
|
|
384
|
+
env: plan.env,
|
|
324
385
|
});
|
|
325
386
|
|
|
326
387
|
if (!created.ok) {
|
|
@@ -331,14 +392,22 @@ export function startJob(opts: StartOptions): {
|
|
|
331
392
|
return { ok: false, error: created.error };
|
|
332
393
|
}
|
|
333
394
|
|
|
334
|
-
|
|
335
|
-
|
|
395
|
+
// Re-read before final write — mark-complete may have already finished
|
|
396
|
+
const after = readJob(id) || job;
|
|
397
|
+
if (after.status === "running" || after.status === "pending") {
|
|
398
|
+
writeJob(after);
|
|
399
|
+
}
|
|
400
|
+
return { ok: true, job: after };
|
|
336
401
|
}
|
|
337
402
|
|
|
338
403
|
export function killJob(id: string): { ok: true; job: Job } | { ok: false; error: string } {
|
|
339
404
|
const job = readJob(id);
|
|
340
405
|
if (!job) return { ok: false, error: `Job not found: ${id}` };
|
|
341
406
|
|
|
407
|
+
if (job.status === "completed" || job.status === "failed" || job.status === "killed") {
|
|
408
|
+
return { ok: false, error: `Job already terminal (${job.status}) — refuse kill rewrite` };
|
|
409
|
+
}
|
|
410
|
+
|
|
342
411
|
if (job.tmuxSession.startsWith("headless-")) {
|
|
343
412
|
if (job.pid) {
|
|
344
413
|
const ok = terminatePid(job.pid);
|
|
@@ -358,10 +427,15 @@ export function killJob(id: string): { ok: true; job: Job } | { ok: false; error
|
|
|
358
427
|
}
|
|
359
428
|
}
|
|
360
429
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
430
|
+
// Merge-on-write: do not clobber a completion that landed mid-kill
|
|
431
|
+
const latest = readJob(id) || job;
|
|
432
|
+
if (latest.status === "completed" || latest.status === "failed") {
|
|
433
|
+
return { ok: false, error: `Job finished during kill (${latest.status})` };
|
|
434
|
+
}
|
|
435
|
+
latest.status = "killed";
|
|
436
|
+
latest.completedAt = new Date().toISOString();
|
|
437
|
+
writeJob(latest);
|
|
438
|
+
return { ok: true, job: latest };
|
|
365
439
|
}
|
|
366
440
|
|
|
367
441
|
export function cleanJobs(olderThanDays = 7): number {
|
package/src/mark-complete.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Invoked from tmux / headless completion hooks — keep dep-free.
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { redactSecrets } from "./secrets.ts";
|
|
7
8
|
|
|
8
9
|
const jobPath = process.argv[2];
|
|
9
10
|
const exitCode = Number(process.argv[3] ?? "1");
|
|
@@ -24,12 +25,7 @@ try {
|
|
|
24
25
|
try {
|
|
25
26
|
const log = readFileSync(logPath, "utf8");
|
|
26
27
|
job.logBytes = Buffer.byteLength(log);
|
|
27
|
-
|
|
28
|
-
const redacted = log
|
|
29
|
-
.replace(/\bsk-[a-zA-Z0-9]{20,}\b/g, "[REDACTED]")
|
|
30
|
-
.replace(/\bghp_[A-Za-z0-9]{20,}\b/g, "[REDACTED]")
|
|
31
|
-
.replace(/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/gi, "[REDACTED]");
|
|
32
|
-
job.logTail = redacted.slice(-2000);
|
|
28
|
+
job.logTail = redactSecrets(log).slice(-2000);
|
|
33
29
|
} catch {
|
|
34
30
|
/* ignore */
|
|
35
31
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* One-shot OpenRouter easy-lane runner — reads a prompt file and POSTs it to
|
|
4
|
+
* OpenRouter's chat/completions, printing the assistant reply to stdout.
|
|
5
|
+
* No runtime npm deps (Node 20+ global fetch). Invoked by the openrouter
|
|
6
|
+
* adapter (dist via node, else src via bun). Never echoes OPENROUTER_API_KEY.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { openRouterModel, openRouterBaseUrl } from "./config.ts";
|
|
10
|
+
import { looksLikeSecretMaterial } from "./secrets.ts";
|
|
11
|
+
|
|
12
|
+
const SYSTEM_PROMPT =
|
|
13
|
+
"You are a drafting/rewrite helper for cursor-route. Never invent credentials, API keys, or secrets; if asked for secret material, refuse. Provide educational, general-purpose help.";
|
|
14
|
+
|
|
15
|
+
function fail(msg: string, code: number): never {
|
|
16
|
+
console.error(`cursor-route/openrouter-run: ${msg}`);
|
|
17
|
+
process.exit(code);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseArgs(argv: string[]): { promptFile?: string } {
|
|
21
|
+
const flags: { promptFile?: string } = {};
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const a = argv[i];
|
|
24
|
+
if (a === "-h" || a === "--help") {
|
|
25
|
+
console.log(
|
|
26
|
+
"usage: cursor-route/openrouter-run --prompt-file <path>\n" +
|
|
27
|
+
"env: OPENROUTER_API_KEY (required), CURSOR_ROUTE_OPENROUTER_MODEL, OPENROUTER_BASE_URL",
|
|
28
|
+
);
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
if (a === "--prompt-file") {
|
|
32
|
+
const v = argv[i + 1];
|
|
33
|
+
if (!v || v.startsWith("--")) fail("--prompt-file requires a value", 2);
|
|
34
|
+
flags.promptFile = v;
|
|
35
|
+
i++;
|
|
36
|
+
} else if (a.startsWith("--prompt-file=")) {
|
|
37
|
+
flags.promptFile = a.slice("--prompt-file=".length);
|
|
38
|
+
} else {
|
|
39
|
+
fail(`unknown argument: ${a}`, 2);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return flags;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function main(): Promise<void> {
|
|
46
|
+
const { promptFile } = parseArgs(process.argv.slice(2));
|
|
47
|
+
if (!promptFile) fail("--prompt-file <path> is required", 2);
|
|
48
|
+
|
|
49
|
+
let prompt: string;
|
|
50
|
+
try {
|
|
51
|
+
prompt = readFileSync(promptFile, "utf8");
|
|
52
|
+
} catch (e) {
|
|
53
|
+
fail(`cannot read prompt file: ${(e as Error).message}`, 2);
|
|
54
|
+
}
|
|
55
|
+
if (!prompt.trim()) fail("prompt file is empty", 2);
|
|
56
|
+
if (looksLikeSecretMaterial(prompt)) {
|
|
57
|
+
fail("refusing prompt: looks like secret key material — easy lane is for non-secret drafts", 3);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
61
|
+
if (!apiKey) fail("OPENROUTER_API_KEY is not set", 2);
|
|
62
|
+
|
|
63
|
+
const base = openRouterBaseUrl().replace(/\/+$/, "");
|
|
64
|
+
const url = `${base}/chat/completions`;
|
|
65
|
+
|
|
66
|
+
let res: Response;
|
|
67
|
+
try {
|
|
68
|
+
res = await fetch(url, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `Bearer ${apiKey}`,
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
// OpenRouter etiquette: identify the app so the provider can see usage source.
|
|
74
|
+
"HTTP-Referer": "https://github.com/cemini23/cursor-route",
|
|
75
|
+
"X-Title": "cursor-route",
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
model: openRouterModel(),
|
|
79
|
+
messages: [
|
|
80
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
81
|
+
{ role: "user", content: prompt },
|
|
82
|
+
],
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
} catch (e) {
|
|
86
|
+
fail(`network error calling ${url}: ${(e as Error).message}`, 1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
const body = await res.text().catch(() => "");
|
|
91
|
+
fail(`OpenRouter API ${res.status}: ${body.slice(0, 500)}`, 1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const json = (await res.json()) as {
|
|
95
|
+
choices?: Array<{ message?: { content?: string } }>;
|
|
96
|
+
};
|
|
97
|
+
const content = json.choices?.[0]?.message?.content ?? "";
|
|
98
|
+
if (!content) fail("OpenRouter returned no assistant content", 1);
|
|
99
|
+
process.stdout.write(content.endsWith("\n") ? content : content + "\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
main().catch((e) => fail(e instanceof Error ? e.message : String(e), 1));
|
package/src/runtime.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import { commandExists, shellQuote } from "./util.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* Resolve how to re-invoke
|
|
7
|
-
* Prefer
|
|
7
|
+
* Resolve how to re-invoke the completion hook (mark-complete) from shell hooks.
|
|
8
|
+
* Prefer compiled dist via node (no loader); else Bun on src. No npx/tsx on the
|
|
9
|
+
* happy path — surface a clear failure if neither is available.
|
|
8
10
|
*/
|
|
9
11
|
export function markCompleteInvoker(scriptPath: string): string {
|
|
12
|
+
const compiled = scriptPath.replace(/\.ts$/, ".js");
|
|
13
|
+
if (existsSync(compiled)) {
|
|
14
|
+
return `node ${shellQuote(compiled)}`;
|
|
15
|
+
}
|
|
10
16
|
if (commandExists("bun")) {
|
|
11
17
|
return `bun ${shellQuote(scriptPath)}`;
|
|
12
18
|
}
|
|
13
|
-
|
|
14
|
-
return `npx --yes tsx@4.19.4 ${shellQuote(scriptPath)}`;
|
|
19
|
+
return `sh -c 'echo "cursor-route: completion hook needs a compiled dist or Bun (run bun run build)" >&2; exit 1'`;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
/** XDG-ish default away from git-clone install dir (~/.cursor-route). */
|
package/src/secrets.ts
CHANGED
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Secret material detectors — match key *contents*, not the phrase "API key".
|
|
3
|
-
* Applied to `start` and `send
|
|
3
|
+
* Applied to `start` and `send`, and reused for log-tail redaction.
|
|
4
4
|
*/
|
|
5
|
-
const
|
|
5
|
+
export const SECRET_PATTERNS: RegExp[] = [
|
|
6
|
+
// OpenAI / DeepSeek legacy + project / Anthropic hyphenated keys
|
|
6
7
|
/\bsk-[a-zA-Z0-9]{20,}\b/,
|
|
8
|
+
/\bsk-(?:proj|ant|oai)-[A-Za-z0-9_-]{16,}\b/,
|
|
9
|
+
/\bsk-[a-zA-Z0-9-]{20,}\b/,
|
|
7
10
|
/\bghp_[A-Za-z0-9]{20,}\b/,
|
|
8
11
|
/\bgho_[A-Za-z0-9]{20,}\b/,
|
|
12
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
|
|
9
13
|
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/i,
|
|
14
|
+
/\bxai-[A-Za-z0-9_-]{20,}\b/,
|
|
10
15
|
/\bAIza[0-9A-Za-z_-]{20,}\b/,
|
|
11
|
-
/-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/,
|
|
16
|
+
/-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/,
|
|
12
17
|
/\bAKIA[0-9A-Z]{16}\b/,
|
|
18
|
+
/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]+/i,
|
|
13
19
|
];
|
|
14
20
|
|
|
15
21
|
export function looksLikeSecretMaterial(text: string): boolean {
|
|
16
|
-
return
|
|
22
|
+
return SECRET_PATTERNS.some((re) => re.test(text));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Redact secret-looking spans for job metadata / log tails. */
|
|
26
|
+
export function redactSecrets(text: string): string {
|
|
27
|
+
let out = text;
|
|
28
|
+
for (const re of SECRET_PATTERNS) {
|
|
29
|
+
// Clone with global flag so we replace all matches
|
|
30
|
+
const flags = re.flags.includes("g") ? re.flags : `${re.flags}g`;
|
|
31
|
+
out = out.replace(new RegExp(re.source, flags), "[REDACTED]");
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
17
34
|
}
|
|
18
35
|
|
|
19
36
|
export const JOB_ID_RE = /^[a-f0-9]{8}$/;
|
package/src/tmux.ts
CHANGED
|
@@ -49,10 +49,14 @@ export function capturePane(name: string, lines = 50): string {
|
|
|
49
49
|
|
|
50
50
|
export function sendKeys(name: string, message: string): boolean {
|
|
51
51
|
if (!sessionExists(name)) return false;
|
|
52
|
+
// Reject embedded newlines — they would submit early even with -l
|
|
53
|
+
if (/[\r\n]/.test(message)) return false;
|
|
52
54
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// -l = literal keys (so "C-c" types text, does not SIGINT the worker)
|
|
56
|
+
execSync(
|
|
57
|
+
`tmux send-keys -l -t ${shellQuote(name)} -- ${shellQuote(message)}`,
|
|
58
|
+
{ stdio: "ignore" },
|
|
59
|
+
);
|
|
56
60
|
spawnSync("sleep", ["0.25"]);
|
|
57
61
|
execSync(`tmux send-keys -t ${shellQuote(name)} Enter`, { stdio: "ignore" });
|
|
58
62
|
return true;
|
|
@@ -86,6 +90,7 @@ export function createWorkerSession(options: {
|
|
|
86
90
|
logFile: string;
|
|
87
91
|
jobFile: string;
|
|
88
92
|
markCompleteScript: string;
|
|
93
|
+
env?: Record<string, string>;
|
|
89
94
|
}): { ok: true; session: string } | { ok: false; error: string } {
|
|
90
95
|
const name = sessionName(options.jobId);
|
|
91
96
|
const isLinux = process.platform === "linux";
|
|
@@ -107,11 +112,18 @@ export function createWorkerSession(options: {
|
|
|
107
112
|
? `script -q -e -c ${shellQuote(`/bin/sh -c ${shellQuote(options.workerCmd)}`)} ${shellQuote(options.logFile)}; ${completion}`
|
|
108
113
|
: `script -q ${shellQuote(options.logFile)} /bin/sh -c ${shellQuote(options.workerCmd)}; ${completion}`;
|
|
109
114
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
const args = ["new-session", "-d", "-s", name, "-c", options.cwd];
|
|
116
|
+
if (options.env) {
|
|
117
|
+
for (const [k, v] of Object.entries(options.env)) {
|
|
118
|
+
args.push("-e", `${k}=${v}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
args.push(wrapped);
|
|
122
|
+
|
|
123
|
+
const r = spawnSync("tmux", args, {
|
|
124
|
+
encoding: "utf8",
|
|
125
|
+
cwd: options.cwd,
|
|
126
|
+
});
|
|
115
127
|
|
|
116
128
|
if (r.status !== 0) {
|
|
117
129
|
return {
|
package/src/util.ts
CHANGED
|
@@ -13,7 +13,9 @@ export function newJobId(): string {
|
|
|
13
13
|
|
|
14
14
|
export function commandExists(cmd: string): boolean {
|
|
15
15
|
try {
|
|
16
|
-
const r = spawnSync("sh", ["-c", `command -v ${cmd}`], {
|
|
16
|
+
const r = spawnSync("sh", ["-c", `command -v ${shellQuote(cmd)}`], {
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
});
|
|
17
19
|
return r.status === 0 && Boolean(r.stdout?.trim());
|
|
18
20
|
} catch {
|
|
19
21
|
return false;
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# Cursor audit — cursor-route v0.1.0→0.1.1
|
|
2
|
-
|
|
3
|
-
**Mode:** `code-debug` · **Roles → models:** code-implementation→`gpt-5.6-sol-medium`, adversarial→`cursor-grok-4.5-high-fast`, third-lens→`kimi-k3-max`
|
|
4
|
-
|
|
5
|
-
Auditors: [Sol](fad461d6-9be9-421e-b42e-081cd7888a56) · [Grok](79e70632-1de7-456e-871b-cdb66b7b72f1) · [Kimi](9a0b224f-eacf-4a2f-969c-6854db1d6876)
|
|
6
|
-
|
|
7
|
-
## Verdict rollup (pre-fix)
|
|
8
|
-
|
|
9
|
-
| Model | Verdict |
|
|
10
|
-
|-------|---------|
|
|
11
|
-
| GPTSOL | FAIL |
|
|
12
|
-
| Cursor Grok | FAIL |
|
|
13
|
-
| Kimi 3 | WARN |
|
|
14
|
-
|
|
15
|
-
**Overall (pre-fix):** REWORK — then SHIP-WITH-FIXES after 0.1.1.
|
|
16
|
-
|
|
17
|
-
## Consensus (≥2 auditors)
|
|
18
|
-
|
|
19
|
-
1. Headless `kill` did not kill (no PID) — phantom killed
|
|
20
|
-
2. `refreshStatus` inferred `completed` from missing tmux — lies on `--no-tmux`
|
|
21
|
-
3. Completion hooks hard-coded `bun` vs Node fallback
|
|
22
|
-
4. macOS `script` left `cd && …` unquoted
|
|
23
|
-
5. Stock `claude` silent fallback for “claude-ds”
|
|
24
|
-
6. Secret deny false-positive on prose “API key”; `send` bypass
|
|
25
|
-
7. Missing DeepSeek setup docs / mid-lane dead end for public users
|
|
26
|
-
8. Skill `/route` collision with federation `/route`
|
|
27
|
-
|
|
28
|
-
## Unique
|
|
29
|
-
|
|
30
|
-
- [Sol] Job-id path traversal; dry-run side effects; race before `running` write
|
|
31
|
-
- [Grok] Always-approve + skill contract burns private SIP/verify; Grok sandbox missing
|
|
32
|
-
- [Kimi] No hero.gif; typecheck red; jobsDir collided with clone path; no CI
|
|
33
|
-
|
|
34
|
-
## Conflicts
|
|
35
|
-
|
|
36
|
-
| Topic | Sol | Grok | Kimi | Resolution |
|
|
37
|
-
|-------|-----|------|------|------------|
|
|
38
|
-
| Overall ship | FAIL | FAIL | WARN | Fix criticals then soft-launch; GIF still deferred |
|
|
39
|
-
| Skill name `/route` | — | critical rename | — | Renamed triggers to `/route-orch` |
|
|
40
|
-
|
|
41
|
-
## Fixes shipped in `20b1580` (v0.1.1)
|
|
42
|
-
|
|
43
|
-
- PID-tracked headless spawn + polled terminate on `kill`
|
|
44
|
-
- No phantom `completed` from session absence; exit via mark-complete / failed unknown
|
|
45
|
-
- `markCompleteInvoker` (bun or pinned `tsx@4.19.4`)
|
|
46
|
-
- macOS/Linux `script` always via `/bin/sh -c`
|
|
47
|
-
- Stock Claude only with `CURSOR_ROUTE_ALLOW_STOCK_CLAUDE=1`
|
|
48
|
-
- Material-only secret deny on `start` + `send`
|
|
49
|
-
- XDG jobs dir `~/.local/share/cursor-route/jobs`
|
|
50
|
-
- README DeepSeek setup; skill deconflict; CI workflow; `--version`; typecheck green
|
|
51
|
-
|
|
52
|
-
## Verify after fix
|
|
53
|
-
|
|
54
|
-
- 11/11 tests · `tsc --noEmit` clean
|
|
55
|
-
- Headless claude-ds → `CURSOR_ROUTE_SMOKE_OK2` · `exitCode: 0`
|
|
56
|
-
- Headless `kill` → `status: killed`
|
|
57
|
-
- Prose “API key” dry-run allowed; `sk-…` refused exit 3
|
|
58
|
-
|
|
59
|
-
## Still open (not tweet-blockers for soft launch)
|
|
60
|
-
|
|
61
|
-
- Real tmux attach/send GIF (`docs/DEMO_GIF.md`) — needs `brew install tmux` (sudo)
|
|
62
|
-
- npm publish
|
|
63
|
-
- Grok Build balance for live grok demos
|
|
64
|
-
- Optional: grok `--sandbox`, `wait` command, remove skill file duplicate via symlink
|