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/dist/jobs.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, unlinkSync, openSync, closeSync, chmodSync, } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { config, sessionName } from "./config.js";
|
|
6
|
+
import { getAdapter } from "./adapters/index.js";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { createWorkerSession, sessionExists, killSession } from "./tmux.js";
|
|
10
|
+
import { newJobId, shellQuote } from "./util.js";
|
|
11
|
+
import { markCompleteInvoker } from "./runtime.js";
|
|
12
|
+
import { assertJobId, JOB_ID_RE } from "./secrets.js";
|
|
13
|
+
function ensureJobsDir() {
|
|
14
|
+
mkdirSync(config.jobsDir, { recursive: true, mode: 0o700 });
|
|
15
|
+
try {
|
|
16
|
+
chmodSync(config.jobsDir, 0o700);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* ignore */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function writeSecure(path, data) {
|
|
23
|
+
writeFileSync(path, data, { mode: 0o600 });
|
|
24
|
+
}
|
|
25
|
+
function underJobsDir(id, ext) {
|
|
26
|
+
assertJobId(id);
|
|
27
|
+
const base = resolve(config.jobsDir);
|
|
28
|
+
const full = resolve(join(base, `${id}${ext}`));
|
|
29
|
+
if (!full.startsWith(base + "/") && full !== base) {
|
|
30
|
+
throw new Error("Job path escapes jobsDir");
|
|
31
|
+
}
|
|
32
|
+
return full;
|
|
33
|
+
}
|
|
34
|
+
export function jobPaths(id) {
|
|
35
|
+
ensureJobsDir();
|
|
36
|
+
return {
|
|
37
|
+
json: underJobsDir(id, ".json"),
|
|
38
|
+
prompt: underJobsDir(id, ".prompt"),
|
|
39
|
+
log: underJobsDir(id, ".log"),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function readJob(id) {
|
|
43
|
+
try {
|
|
44
|
+
assertJobId(id);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const p = jobPaths(id).json;
|
|
50
|
+
if (!existsSync(p))
|
|
51
|
+
return null;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function writeJob(job) {
|
|
60
|
+
writeSecure(jobPaths(job.id).json, JSON.stringify(job, null, 2));
|
|
61
|
+
}
|
|
62
|
+
function pidAlive(pid) {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(pid, 0);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
// kill(pid, 0) stays true for zombies, and terminatePid's sync waits block the
|
|
70
|
+
// event loop so children can sit unreaped as zombies — a zombie is not a worker.
|
|
71
|
+
try {
|
|
72
|
+
const r = spawnSync("ps", ["-o", "state=", "-p", String(pid)], {
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
75
|
+
});
|
|
76
|
+
const state = (r.stdout || "").trim();
|
|
77
|
+
return state !== "" && !state.startsWith("Z");
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function sleepMs(ms) {
|
|
84
|
+
spawnSync("sleep", [String(ms / 1000)]);
|
|
85
|
+
}
|
|
86
|
+
function terminatePid(pid) {
|
|
87
|
+
const trySignal = (sig, group) => {
|
|
88
|
+
try {
|
|
89
|
+
process.kill(group ? -pid : pid, sig);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
trySignal("SIGTERM", true);
|
|
97
|
+
trySignal("SIGTERM", false);
|
|
98
|
+
for (let i = 0; i < 10; i++) {
|
|
99
|
+
if (!pidAlive(pid))
|
|
100
|
+
return true;
|
|
101
|
+
sleepMs(100);
|
|
102
|
+
}
|
|
103
|
+
trySignal("SIGKILL", true);
|
|
104
|
+
trySignal("SIGKILL", false);
|
|
105
|
+
for (let i = 0; i < 10; i++) {
|
|
106
|
+
if (!pidAlive(pid))
|
|
107
|
+
return true;
|
|
108
|
+
sleepMs(100);
|
|
109
|
+
}
|
|
110
|
+
return !pidAlive(pid);
|
|
111
|
+
}
|
|
112
|
+
/** Refresh running jobs without inventing success from "session gone". */
|
|
113
|
+
export function refreshStatus(job) {
|
|
114
|
+
if (job.status !== "running")
|
|
115
|
+
return job;
|
|
116
|
+
// Prefer completion-hook result if already written.
|
|
117
|
+
const fresh = readJob(job.id);
|
|
118
|
+
if (fresh && fresh.status !== "running")
|
|
119
|
+
return fresh;
|
|
120
|
+
const headless = job.tmuxSession.startsWith("headless-");
|
|
121
|
+
if (headless) {
|
|
122
|
+
if (job.pid && pidAlive(job.pid))
|
|
123
|
+
return job;
|
|
124
|
+
if (job.pid && !pidAlive(job.pid)) {
|
|
125
|
+
// Process exited but hook may not have run — mark unknown failure, not success.
|
|
126
|
+
const again = readJob(job.id);
|
|
127
|
+
if (again && again.status !== "running")
|
|
128
|
+
return again;
|
|
129
|
+
job.status = "failed";
|
|
130
|
+
job.error = job.error || "Headless worker exited without completion marker";
|
|
131
|
+
job.completedAt = new Date().toISOString();
|
|
132
|
+
writeJob(job);
|
|
133
|
+
return job;
|
|
134
|
+
}
|
|
135
|
+
// No pid recorded (legacy) — do not flip to completed.
|
|
136
|
+
return job;
|
|
137
|
+
}
|
|
138
|
+
// tmux path: session gone → re-read; if still running, mark failed (unknown), not completed.
|
|
139
|
+
if (!sessionExists(job.tmuxSession)) {
|
|
140
|
+
const again = readJob(job.id);
|
|
141
|
+
if (again && again.status !== "running")
|
|
142
|
+
return again;
|
|
143
|
+
job.status = "failed";
|
|
144
|
+
job.error = job.error || "tmux session ended without completion marker";
|
|
145
|
+
job.completedAt = new Date().toISOString();
|
|
146
|
+
writeJob(job);
|
|
147
|
+
}
|
|
148
|
+
return job;
|
|
149
|
+
}
|
|
150
|
+
export function countActiveJobs() {
|
|
151
|
+
ensureJobsDir();
|
|
152
|
+
let n = 0;
|
|
153
|
+
for (const f of readdirSync(config.jobsDir)) {
|
|
154
|
+
if (!f.endsWith(".json"))
|
|
155
|
+
continue;
|
|
156
|
+
const id = f.replace(/\.json$/, "");
|
|
157
|
+
if (!JOB_ID_RE.test(id))
|
|
158
|
+
continue;
|
|
159
|
+
try {
|
|
160
|
+
const job = JSON.parse(readFileSync(join(config.jobsDir, f), "utf8"));
|
|
161
|
+
if (job?.schema === "cursor-route.job.v1" && (job.status === "running" || job.status === "pending")) {
|
|
162
|
+
n++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
/* skip */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return n;
|
|
170
|
+
}
|
|
171
|
+
export function listJobs(limit = config.jobsListLimit) {
|
|
172
|
+
ensureJobsDir();
|
|
173
|
+
const files = readdirSync(config.jobsDir).filter((f) => f.endsWith(".json") && JOB_ID_RE.test(f.replace(/\.json$/, "")));
|
|
174
|
+
const jobs = [];
|
|
175
|
+
for (const f of files) {
|
|
176
|
+
try {
|
|
177
|
+
const job = JSON.parse(readFileSync(join(config.jobsDir, f), "utf8"));
|
|
178
|
+
if (job?.schema === "cursor-route.job.v1")
|
|
179
|
+
jobs.push(refreshStatus(job));
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* skip */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
jobs.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
|
186
|
+
return jobs.slice(0, limit);
|
|
187
|
+
}
|
|
188
|
+
export function resolveWorker(opts) {
|
|
189
|
+
if (opts.worker)
|
|
190
|
+
return opts.worker;
|
|
191
|
+
if (opts.lane)
|
|
192
|
+
return config.laneWorkers[opts.lane];
|
|
193
|
+
return config.defaultWorker;
|
|
194
|
+
}
|
|
195
|
+
export function startJob(opts) {
|
|
196
|
+
const worker = resolveWorker(opts);
|
|
197
|
+
const cwd = opts.cwd || process.cwd();
|
|
198
|
+
// CURSOR_ROUTE_ASK applies to all workers; CLAUDE_DS_ASK is mid-lane only
|
|
199
|
+
const alwaysApprove = opts.alwaysApprove !== false &&
|
|
200
|
+
process.env.CURSOR_ROUTE_ASK !== "1" &&
|
|
201
|
+
!(worker === "claude-ds" && process.env.CLAUDE_DS_ASK === "1");
|
|
202
|
+
if (!opts.dryRun) {
|
|
203
|
+
const active = countActiveJobs();
|
|
204
|
+
if (active >= config.maxConcurrentJobs) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
error: `Too many active jobs (${active} >= ${config.maxConcurrentJobs}) — wait for jobs to finish or raise CURSOR_ROUTE_MAX_JOBS`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Preflight: requested worker must be healthy
|
|
212
|
+
const adapter = getAdapter(worker);
|
|
213
|
+
const health = adapter.health();
|
|
214
|
+
if (!health.ok && !opts.dryRun) {
|
|
215
|
+
return { ok: false, error: `Worker ${worker} unavailable: ${health.detail}` };
|
|
216
|
+
}
|
|
217
|
+
const id = newJobId();
|
|
218
|
+
const paths = jobPaths(id);
|
|
219
|
+
writeSecure(paths.prompt, opts.prompt);
|
|
220
|
+
let plan;
|
|
221
|
+
try {
|
|
222
|
+
plan = adapter.buildLaunch({
|
|
223
|
+
promptFile: paths.prompt,
|
|
224
|
+
cwd,
|
|
225
|
+
alwaysApprove,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
try {
|
|
230
|
+
unlinkSync(paths.prompt);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
/* ignore */
|
|
234
|
+
}
|
|
235
|
+
return { ok: false, error: e.message };
|
|
236
|
+
}
|
|
237
|
+
const job = {
|
|
238
|
+
id,
|
|
239
|
+
schema: "cursor-route.job.v1",
|
|
240
|
+
status: "pending",
|
|
241
|
+
worker,
|
|
242
|
+
lane: opts.lane,
|
|
243
|
+
prompt: opts.prompt,
|
|
244
|
+
cwd,
|
|
245
|
+
alwaysApprove: plan.alwaysApprove,
|
|
246
|
+
tmuxSession: opts.noTmux ? `headless-${id}` : sessionName(id),
|
|
247
|
+
createdAt: new Date().toISOString(),
|
|
248
|
+
};
|
|
249
|
+
if (opts.dryRun) {
|
|
250
|
+
// No durable prompt retention for dry-run
|
|
251
|
+
try {
|
|
252
|
+
unlinkSync(paths.prompt);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
/* ignore */
|
|
256
|
+
}
|
|
257
|
+
const envNote = plan.env
|
|
258
|
+
? ` (+env: ${Object.keys(plan.env).join(",")})`
|
|
259
|
+
: "";
|
|
260
|
+
return {
|
|
261
|
+
ok: true,
|
|
262
|
+
job,
|
|
263
|
+
dryRun: true,
|
|
264
|
+
command: plan.command + envNote,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
268
|
+
const markComplete = join(here, "mark-complete.ts");
|
|
269
|
+
const invoker = markCompleteInvoker(markComplete);
|
|
270
|
+
// Persist running BEFORE launch so fast workers cannot race completion overwrite.
|
|
271
|
+
job.status = "running";
|
|
272
|
+
job.startedAt = new Date().toISOString();
|
|
273
|
+
writeJob(job);
|
|
274
|
+
if (opts.noTmux) {
|
|
275
|
+
const wrapped = [
|
|
276
|
+
plan.command,
|
|
277
|
+
`exit_code=$?`,
|
|
278
|
+
`${invoker} ${shellQuote(paths.json)} "$exit_code" ${shellQuote(paths.log)}`,
|
|
279
|
+
`exit $exit_code`,
|
|
280
|
+
].join("; ");
|
|
281
|
+
let logFd;
|
|
282
|
+
try {
|
|
283
|
+
logFd = openSync(paths.log, "a", 0o600);
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
job.status = "failed";
|
|
287
|
+
job.error = `Cannot open log: ${e.message}`;
|
|
288
|
+
job.completedAt = new Date().toISOString();
|
|
289
|
+
writeJob(job);
|
|
290
|
+
return { ok: false, error: job.error };
|
|
291
|
+
}
|
|
292
|
+
const child = spawn("sh", ["-c", wrapped], {
|
|
293
|
+
cwd,
|
|
294
|
+
detached: true,
|
|
295
|
+
stdio: ["ignore", logFd, logFd],
|
|
296
|
+
env: plan.env ? { ...process.env, ...plan.env } : process.env,
|
|
297
|
+
});
|
|
298
|
+
try {
|
|
299
|
+
closeSync(logFd);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
/* ignore */
|
|
303
|
+
}
|
|
304
|
+
if (child.pid == null) {
|
|
305
|
+
job.status = "failed";
|
|
306
|
+
job.error = "Failed to spawn headless worker";
|
|
307
|
+
job.completedAt = new Date().toISOString();
|
|
308
|
+
writeJob(job);
|
|
309
|
+
return { ok: false, error: job.error };
|
|
310
|
+
}
|
|
311
|
+
// Merge-on-write: only set pid if still running (avoid clobbering mark-complete)
|
|
312
|
+
const latest = readJob(id) || job;
|
|
313
|
+
if (latest.status === "running" || latest.status === "pending") {
|
|
314
|
+
latest.pid = child.pid;
|
|
315
|
+
writeJob(latest);
|
|
316
|
+
Object.assign(job, latest);
|
|
317
|
+
}
|
|
318
|
+
child.unref();
|
|
319
|
+
return { ok: true, job };
|
|
320
|
+
}
|
|
321
|
+
const created = createWorkerSession({
|
|
322
|
+
jobId: id,
|
|
323
|
+
cwd,
|
|
324
|
+
workerCmd: plan.command,
|
|
325
|
+
logFile: paths.log,
|
|
326
|
+
jobFile: paths.json,
|
|
327
|
+
markCompleteScript: markComplete,
|
|
328
|
+
env: plan.env,
|
|
329
|
+
});
|
|
330
|
+
if (!created.ok) {
|
|
331
|
+
job.status = "failed";
|
|
332
|
+
job.error = created.error;
|
|
333
|
+
job.completedAt = new Date().toISOString();
|
|
334
|
+
writeJob(job);
|
|
335
|
+
return { ok: false, error: created.error };
|
|
336
|
+
}
|
|
337
|
+
// Re-read before final write — mark-complete may have already finished
|
|
338
|
+
const after = readJob(id) || job;
|
|
339
|
+
if (after.status === "running" || after.status === "pending") {
|
|
340
|
+
writeJob(after);
|
|
341
|
+
}
|
|
342
|
+
return { ok: true, job: after };
|
|
343
|
+
}
|
|
344
|
+
export function killJob(id) {
|
|
345
|
+
const job = readJob(id);
|
|
346
|
+
if (!job)
|
|
347
|
+
return { ok: false, error: `Job not found: ${id}` };
|
|
348
|
+
if (job.status === "completed" || job.status === "failed" || job.status === "killed") {
|
|
349
|
+
return { ok: false, error: `Job already terminal (${job.status}) — refuse kill rewrite` };
|
|
350
|
+
}
|
|
351
|
+
if (job.tmuxSession.startsWith("headless-")) {
|
|
352
|
+
if (job.pid) {
|
|
353
|
+
const ok = terminatePid(job.pid);
|
|
354
|
+
if (!ok) {
|
|
355
|
+
return { ok: false, error: `Failed to kill pid ${job.pid}` };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
error: "Headless job has no pid — cannot kill (legacy job). Kill the worker process manually.",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
const killed = killSession(job.tmuxSession);
|
|
367
|
+
if (!killed && sessionExists(job.tmuxSession)) {
|
|
368
|
+
return { ok: false, error: `Failed to kill tmux session ${job.tmuxSession}` };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Merge-on-write: do not clobber a completion that landed mid-kill
|
|
372
|
+
const latest = readJob(id) || job;
|
|
373
|
+
if (latest.status === "completed" || latest.status === "failed") {
|
|
374
|
+
return { ok: false, error: `Job finished during kill (${latest.status})` };
|
|
375
|
+
}
|
|
376
|
+
latest.status = "killed";
|
|
377
|
+
latest.completedAt = new Date().toISOString();
|
|
378
|
+
writeJob(latest);
|
|
379
|
+
return { ok: true, job: latest };
|
|
380
|
+
}
|
|
381
|
+
export function cleanJobs(olderThanDays = 7) {
|
|
382
|
+
ensureJobsDir();
|
|
383
|
+
const cutoff = Date.now() - olderThanDays * 86400000;
|
|
384
|
+
let n = 0;
|
|
385
|
+
for (const f of readdirSync(config.jobsDir)) {
|
|
386
|
+
if (!f.endsWith(".json"))
|
|
387
|
+
continue;
|
|
388
|
+
const id = f.replace(/\.json$/, "");
|
|
389
|
+
if (!JOB_ID_RE.test(id))
|
|
390
|
+
continue;
|
|
391
|
+
try {
|
|
392
|
+
const job = JSON.parse(readFileSync(join(config.jobsDir, f), "utf8"));
|
|
393
|
+
const t = Date.parse(job.completedAt || job.createdAt);
|
|
394
|
+
if (Number.isFinite(t) &&
|
|
395
|
+
t < cutoff &&
|
|
396
|
+
(job.status === "completed" || job.status === "failed" || job.status === "killed")) {
|
|
397
|
+
for (const ext of [".json", ".prompt", ".log"]) {
|
|
398
|
+
const fp = underJobsDir(id, ext);
|
|
399
|
+
if (existsSync(fp))
|
|
400
|
+
unlinkSync(fp);
|
|
401
|
+
}
|
|
402
|
+
n++;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
/* skip */
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return n;
|
|
410
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Marks a job completed/failed after the worker process exits.
|
|
4
|
+
* Invoked from tmux / headless completion hooks — keep dep-free.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { redactSecrets } from "./secrets.js";
|
|
8
|
+
const jobPath = process.argv[2];
|
|
9
|
+
const exitCode = Number(process.argv[3] ?? "1");
|
|
10
|
+
const logPath = process.argv[4];
|
|
11
|
+
if (!jobPath)
|
|
12
|
+
process.exit(0);
|
|
13
|
+
try {
|
|
14
|
+
const job = JSON.parse(readFileSync(jobPath, "utf8"));
|
|
15
|
+
if (job.status === "running" || job.status === "pending") {
|
|
16
|
+
job.status = exitCode === 0 ? "completed" : "failed";
|
|
17
|
+
job.exitCode = exitCode;
|
|
18
|
+
job.completedAt = new Date().toISOString();
|
|
19
|
+
if (exitCode !== 0 && !job.error) {
|
|
20
|
+
job.error = `Worker exited with code ${exitCode}`;
|
|
21
|
+
}
|
|
22
|
+
if (logPath) {
|
|
23
|
+
try {
|
|
24
|
+
const log = readFileSync(logPath, "utf8");
|
|
25
|
+
job.logBytes = Buffer.byteLength(log);
|
|
26
|
+
job.logTail = redactSecrets(log).slice(-2000);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* ignore */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
writeFileSync(jobPath, JSON.stringify(job, null, 2), { mode: 0o600 });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* never fail the shell hook hard */
|
|
37
|
+
}
|
|
38
|
+
process.exit(0);
|
|
@@ -0,0 +1,95 @@
|
|
|
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.js";
|
|
10
|
+
import { looksLikeSecretMaterial } from "./secrets.js";
|
|
11
|
+
const SYSTEM_PROMPT = "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.";
|
|
12
|
+
function fail(msg, code) {
|
|
13
|
+
console.error(`cursor-route/openrouter-run: ${msg}`);
|
|
14
|
+
process.exit(code);
|
|
15
|
+
}
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const flags = {};
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (a === "-h" || a === "--help") {
|
|
21
|
+
console.log("usage: cursor-route/openrouter-run --prompt-file <path>\n" +
|
|
22
|
+
"env: OPENROUTER_API_KEY (required), CURSOR_ROUTE_OPENROUTER_MODEL, OPENROUTER_BASE_URL");
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
if (a === "--prompt-file") {
|
|
26
|
+
const v = argv[i + 1];
|
|
27
|
+
if (!v || v.startsWith("--"))
|
|
28
|
+
fail("--prompt-file requires a value", 2);
|
|
29
|
+
flags.promptFile = v;
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
else if (a.startsWith("--prompt-file=")) {
|
|
33
|
+
flags.promptFile = a.slice("--prompt-file=".length);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
fail(`unknown argument: ${a}`, 2);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return flags;
|
|
40
|
+
}
|
|
41
|
+
async function main() {
|
|
42
|
+
const { promptFile } = parseArgs(process.argv.slice(2));
|
|
43
|
+
if (!promptFile)
|
|
44
|
+
fail("--prompt-file <path> is required", 2);
|
|
45
|
+
let prompt;
|
|
46
|
+
try {
|
|
47
|
+
prompt = readFileSync(promptFile, "utf8");
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
fail(`cannot read prompt file: ${e.message}`, 2);
|
|
51
|
+
}
|
|
52
|
+
if (!prompt.trim())
|
|
53
|
+
fail("prompt file is empty", 2);
|
|
54
|
+
if (looksLikeSecretMaterial(prompt)) {
|
|
55
|
+
fail("refusing prompt: looks like secret key material — easy lane is for non-secret drafts", 3);
|
|
56
|
+
}
|
|
57
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
58
|
+
if (!apiKey)
|
|
59
|
+
fail("OPENROUTER_API_KEY is not set", 2);
|
|
60
|
+
const base = openRouterBaseUrl().replace(/\/+$/, "");
|
|
61
|
+
const url = `${base}/chat/completions`;
|
|
62
|
+
let res;
|
|
63
|
+
try {
|
|
64
|
+
res = await fetch(url, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: `Bearer ${apiKey}`,
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
// OpenRouter etiquette: identify the app so the provider can see usage source.
|
|
70
|
+
"HTTP-Referer": "https://github.com/cemini23/cursor-route",
|
|
71
|
+
"X-Title": "cursor-route",
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
model: openRouterModel(),
|
|
75
|
+
messages: [
|
|
76
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
77
|
+
{ role: "user", content: prompt },
|
|
78
|
+
],
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
fail(`network error calling ${url}: ${e.message}`, 1);
|
|
84
|
+
}
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const body = await res.text().catch(() => "");
|
|
87
|
+
fail(`OpenRouter API ${res.status}: ${body.slice(0, 500)}`, 1);
|
|
88
|
+
}
|
|
89
|
+
const json = (await res.json());
|
|
90
|
+
const content = json.choices?.[0]?.message?.content ?? "";
|
|
91
|
+
if (!content)
|
|
92
|
+
fail("OpenRouter returned no assistant content", 1);
|
|
93
|
+
process.stdout.write(content.endsWith("\n") ? content : content + "\n");
|
|
94
|
+
}
|
|
95
|
+
main().catch((e) => fail(e instanceof Error ? e.message : String(e), 1));
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { commandExists, shellQuote } from "./util.js";
|
|
5
|
+
/**
|
|
6
|
+
* Resolve how to re-invoke the completion hook (mark-complete) from shell hooks.
|
|
7
|
+
* Prefer compiled dist via node (no loader); else Bun on src. No npx/tsx on the
|
|
8
|
+
* happy path — surface a clear failure if neither is available.
|
|
9
|
+
*/
|
|
10
|
+
export function markCompleteInvoker(scriptPath) {
|
|
11
|
+
const compiled = scriptPath.replace(/\.ts$/, ".js");
|
|
12
|
+
if (existsSync(compiled)) {
|
|
13
|
+
return `node ${shellQuote(compiled)}`;
|
|
14
|
+
}
|
|
15
|
+
if (commandExists("bun")) {
|
|
16
|
+
return `bun ${shellQuote(scriptPath)}`;
|
|
17
|
+
}
|
|
18
|
+
return `sh -c 'echo "cursor-route: completion hook needs a compiled dist or Bun (run bun run build)" >&2; exit 1'`;
|
|
19
|
+
}
|
|
20
|
+
/** XDG-ish default away from git-clone install dir (~/.cursor-route). */
|
|
21
|
+
export function defaultJobsDir() {
|
|
22
|
+
if (process.env.CURSOR_ROUTE_JOBS_DIR)
|
|
23
|
+
return process.env.CURSOR_ROUTE_JOBS_DIR;
|
|
24
|
+
const home = process.env.HOME || homedir();
|
|
25
|
+
const xdg = process.env.XDG_DATA_HOME || join(home, ".local", "share");
|
|
26
|
+
return join(xdg, "cursor-route", "jobs");
|
|
27
|
+
}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret material detectors — match key *contents*, not the phrase "API key".
|
|
3
|
+
* Applied to `start` and `send`, and reused for log-tail redaction.
|
|
4
|
+
*/
|
|
5
|
+
export const SECRET_PATTERNS = [
|
|
6
|
+
// OpenAI / DeepSeek legacy + project / Anthropic hyphenated keys
|
|
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/,
|
|
10
|
+
/\bghp_[A-Za-z0-9]{20,}\b/,
|
|
11
|
+
/\bgho_[A-Za-z0-9]{20,}\b/,
|
|
12
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
|
|
13
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/i,
|
|
14
|
+
/\bxai-[A-Za-z0-9_-]{20,}\b/,
|
|
15
|
+
/\bAIza[0-9A-Za-z_-]{20,}\b/,
|
|
16
|
+
/-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/,
|
|
17
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
18
|
+
/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]+/i,
|
|
19
|
+
];
|
|
20
|
+
export function looksLikeSecretMaterial(text) {
|
|
21
|
+
return SECRET_PATTERNS.some((re) => re.test(text));
|
|
22
|
+
}
|
|
23
|
+
/** Redact secret-looking spans for job metadata / log tails. */
|
|
24
|
+
export function redactSecrets(text) {
|
|
25
|
+
let out = text;
|
|
26
|
+
for (const re of SECRET_PATTERNS) {
|
|
27
|
+
// Clone with global flag so we replace all matches
|
|
28
|
+
const flags = re.flags.includes("g") ? re.flags : `${re.flags}g`;
|
|
29
|
+
out = out.replace(new RegExp(re.source, flags), "[REDACTED]");
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
export const JOB_ID_RE = /^[a-f0-9]{8}$/;
|
|
34
|
+
export function assertJobId(id) {
|
|
35
|
+
if (!JOB_ID_RE.test(id)) {
|
|
36
|
+
throw new Error(`Invalid job id: ${id} (expected 8 hex chars)`);
|
|
37
|
+
}
|
|
38
|
+
return id;
|
|
39
|
+
}
|