cursor-route 0.1.1 → 0.1.4
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 +53 -8
- package/SECURITY.md +3 -1
- 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 +15 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.js +431 -0
- package/dist/config.js +45 -0
- package/dist/health.js +100 -0
- package/dist/jobs.js +410 -0
- package/dist/mark-complete.js +38 -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 +8 -1
- package/docs/fixtures/claude-ds-smoke.log +1 -1
- package/llms.txt +29 -0
- package/package.json +12 -6
- package/src/adapters/claude-ds.ts +68 -22
- package/src/adapters/grok.ts +10 -5
- package/src/adapters/types.ts +2 -0
- package/src/cli.test.ts +24 -9
- package/src/cli.ts +105 -15
- package/src/config.ts +22 -2
- package/src/health.ts +8 -6
- package/src/integration.test.ts +167 -0
- package/src/jobs.ts +85 -11
- package/src/mark-complete.ts +2 -6
- 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);
|
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
|
+
}
|
package/dist/tmux.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { spawnSync, execSync } from "node:child_process";
|
|
2
|
+
import { config, sessionName } from "./config.js";
|
|
3
|
+
import { shellQuote } from "./util.js";
|
|
4
|
+
import { markCompleteInvoker } from "./runtime.js";
|
|
5
|
+
export function isTmuxAvailable() {
|
|
6
|
+
try {
|
|
7
|
+
execSync("command -v tmux", { stdio: "ignore" });
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function sessionExists(name) {
|
|
15
|
+
try {
|
|
16
|
+
execSync(`tmux has-session -t ${shellQuote(name)} 2>/dev/null`, {
|
|
17
|
+
stdio: "ignore",
|
|
18
|
+
});
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function listManagedSessions() {
|
|
26
|
+
try {
|
|
27
|
+
const out = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', {
|
|
28
|
+
encoding: "utf8",
|
|
29
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
30
|
+
}).trim();
|
|
31
|
+
if (!out)
|
|
32
|
+
return [];
|
|
33
|
+
return out.split("\n").filter((n) => n.startsWith(`${config.tmuxPrefix}-`));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function capturePane(name, lines = 50) {
|
|
40
|
+
try {
|
|
41
|
+
return execSync(`tmux capture-pane -t ${shellQuote(name)} -p -S -${Math.max(1, lines)}`, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function sendKeys(name, message) {
|
|
48
|
+
if (!sessionExists(name))
|
|
49
|
+
return false;
|
|
50
|
+
// Reject embedded newlines — they would submit early even with -l
|
|
51
|
+
if (/[\r\n]/.test(message))
|
|
52
|
+
return false;
|
|
53
|
+
try {
|
|
54
|
+
// -l = literal keys (so "C-c" types text, does not SIGINT the worker)
|
|
55
|
+
execSync(`tmux send-keys -l -t ${shellQuote(name)} -- ${shellQuote(message)}`, { stdio: "ignore" });
|
|
56
|
+
spawnSync("sleep", ["0.25"]);
|
|
57
|
+
execSync(`tmux send-keys -t ${shellQuote(name)} Enter`, { stdio: "ignore" });
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function killSession(name) {
|
|
65
|
+
try {
|
|
66
|
+
execSync(`tmux kill-session -t ${shellQuote(name)}`, { stdio: "ignore" });
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function attachHint(jobId) {
|
|
74
|
+
return `tmux attach -t ${sessionName(jobId)}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create a detached tmux session that runs `workerCmd` via `sh -c` under `script`,
|
|
78
|
+
* then marks the job complete and exits the session.
|
|
79
|
+
* Always quote the shell expression so macOS BSD `script` does not split `cd && …`.
|
|
80
|
+
*/
|
|
81
|
+
export function createWorkerSession(options) {
|
|
82
|
+
const name = sessionName(options.jobId);
|
|
83
|
+
const isLinux = process.platform === "linux";
|
|
84
|
+
const invoker = markCompleteInvoker(options.markCompleteScript);
|
|
85
|
+
const completion = [
|
|
86
|
+
`exit_code=$?`,
|
|
87
|
+
`${invoker} ${shellQuote(options.jobFile)} "$exit_code" ${shellQuote(options.logFile)}`,
|
|
88
|
+
`echo ""`,
|
|
89
|
+
`echo "[cursor-route: session complete — closing in 5s]"`,
|
|
90
|
+
`sleep 5`,
|
|
91
|
+
`tmux kill-session -t ${shellQuote(name)} 2>/dev/null || true`,
|
|
92
|
+
].join("; ");
|
|
93
|
+
// Always run workerCmd under sh -c so `cd … && …` stays one expression.
|
|
94
|
+
// Linux script: script -q -e -c '<cmd>' <logfile>
|
|
95
|
+
// macOS script: script -q <logfile> <cmd> <args...>
|
|
96
|
+
const wrapped = isLinux
|
|
97
|
+
? `script -q -e -c ${shellQuote(`/bin/sh -c ${shellQuote(options.workerCmd)}`)} ${shellQuote(options.logFile)}; ${completion}`
|
|
98
|
+
: `script -q ${shellQuote(options.logFile)} /bin/sh -c ${shellQuote(options.workerCmd)}; ${completion}`;
|
|
99
|
+
const args = ["new-session", "-d", "-s", name, "-c", options.cwd];
|
|
100
|
+
if (options.env) {
|
|
101
|
+
for (const [k, v] of Object.entries(options.env)) {
|
|
102
|
+
args.push("-e", `${k}=${v}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
args.push(wrapped);
|
|
106
|
+
const r = spawnSync("tmux", args, {
|
|
107
|
+
encoding: "utf8",
|
|
108
|
+
cwd: options.cwd,
|
|
109
|
+
});
|
|
110
|
+
if (r.status !== 0) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
error: (r.stderr || r.stdout || "tmux new-session failed").toString().trim(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, session: name };
|
|
117
|
+
}
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/** Shell-escape a string for single-quoted POSIX use. */
|
|
3
|
+
export function shellQuote(value) {
|
|
4
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
5
|
+
}
|
|
6
|
+
/** Short job id (8 hex chars). */
|
|
7
|
+
export function newJobId() {
|
|
8
|
+
const bytes = crypto.getRandomValues(new Uint8Array(4));
|
|
9
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
10
|
+
}
|
|
11
|
+
export function commandExists(cmd) {
|
|
12
|
+
try {
|
|
13
|
+
const r = spawnSync("sh", ["-c", `command -v ${shellQuote(cmd)}`], {
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
});
|
|
16
|
+
return r.status === 0 && Boolean(r.stdout?.trim());
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/docs/DEMO_GIF.md
CHANGED
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
tmux is required for the viral attach/send demo. On this laptop brew needs sudo — record after:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
# tmux may live at ~/.local/bin/tmux (already on PATH in this setup); otherwise:
|
|
7
|
+
# brew install tmux
|
|
8
|
+
command -v tmux
|
|
8
9
|
cursor-route health
|
|
9
10
|
|
|
11
|
+
# Jobs live under the XDG data dir, not a git clone:
|
|
12
|
+
# ~/.local/share/cursor-route/jobs (override: CURSOR_ROUTE_JOBS_DIR)
|
|
13
|
+
|
|
10
14
|
# Terminal A — three parallel jobs
|
|
11
15
|
cursor-route start --lane hard --dir "$PWD" "Add README section Demo"
|
|
12
16
|
cursor-route start --lane mid --dir "$PWD" "Add a unit test for shellQuote"
|
|
@@ -17,6 +21,9 @@ cursor-route jobs --json
|
|
|
17
21
|
# Attach one pane for the GIF: tmux attach -t cursor-route-<id>
|
|
18
22
|
```
|
|
19
23
|
|
|
24
|
+
If you previously exported `$HOME/.cursor-route/bin`, that dir is stale — remove it:
|
|
25
|
+
`rm -rf ~/.cursor-route/bin` (the launcher lives in the installed package, not there).
|
|
26
|
+
|
|
20
27
|
Record with [asciinema](https://asciinema.org/) or CleanShot → export GIF → `docs/fixtures/hero.gif`.
|
|
21
28
|
|
|
22
29
|
Until then, use `docs/fixtures/claude-ds-smoke.log` as the committed proof fixture.
|