cursor-route 0.1.1

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/src/health.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { execSync } from "node:child_process";
2
+ import { allAdapters } from "./adapters/index.ts";
3
+ import { config } from "./config.ts";
4
+ import { isTmuxAvailable } from "./tmux.ts";
5
+ import { commandExists } from "./util.ts";
6
+
7
+ export interface HealthReport {
8
+ ok: boolean;
9
+ product: string;
10
+ version: string;
11
+ checks: Array<{
12
+ name: string;
13
+ ok: boolean;
14
+ detail: string;
15
+ }>;
16
+ }
17
+
18
+ export function runHealth(): HealthReport {
19
+ const checks: HealthReport["checks"] = [];
20
+
21
+ const tmuxOk = isTmuxAvailable();
22
+ checks.push({
23
+ name: "tmux",
24
+ ok: tmuxOk,
25
+ detail: tmuxOk
26
+ ? "ok"
27
+ : "missing — install: brew install tmux (macOS) or apt install tmux (Linux)",
28
+ });
29
+
30
+ const bunOk = commandExists("bun");
31
+ const nodeOk = commandExists("node");
32
+ checks.push({
33
+ name: "runtime",
34
+ ok: bunOk || nodeOk,
35
+ detail: bunOk ? "bun ok" : nodeOk ? "node ok (tsx via npx for TS)" : "need bun or node 20+",
36
+ });
37
+
38
+ const scriptOk = (() => {
39
+ try {
40
+ execSync("command -v script", { stdio: "ignore" });
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ })();
46
+ checks.push({
47
+ name: "script(1)",
48
+ ok: scriptOk,
49
+ detail: scriptOk ? "ok (tty log capture)" : "missing — needed for job logs on macOS/Linux",
50
+ });
51
+
52
+ for (const adapter of allAdapters()) {
53
+ const h = adapter.health();
54
+ checks.push({
55
+ name: `worker:${h.worker}`,
56
+ ok: h.ok,
57
+ detail: h.binary ? `${h.detail} @ ${h.binary}` : h.detail,
58
+ });
59
+ }
60
+
61
+ // Optional supervisor probe (v0 skill-only; Cursor CLI agent is informational)
62
+ const agentBin =
63
+ (commandExists("agent") && "agent") ||
64
+ (commandExists("cursor-agent") && "cursor-agent") ||
65
+ null;
66
+ checks.push({
67
+ name: "cursor_cli",
68
+ ok: true, // informational — does not fail health
69
+ detail: agentBin
70
+ ? `optional ok (${agentBin} on PATH) — v0 supervisor is Cursor skill, not CLI`
71
+ : "optional — Cursor CLI agent not on PATH (skill-only supervisor is fine for v0)",
72
+ });
73
+
74
+ // At least one worker must be healthy; tmux is hard-required for attach/send product path.
75
+ // Set CURSOR_ROUTE_RELAXED=1 to pass health without tmux (headless/--no-tmux CI).
76
+ const workerOk = checks.some((c) => c.name.startsWith("worker:") && c.ok);
77
+ const relaxed = process.env.CURSOR_ROUTE_RELAXED === "1";
78
+ const hardOk = (tmuxOk || relaxed) && (bunOk || nodeOk) && scriptOk;
79
+ const ok = hardOk && workerOk;
80
+
81
+ if (relaxed && !tmuxOk) {
82
+ checks.push({
83
+ name: "relaxed",
84
+ ok: true,
85
+ detail: "CURSOR_ROUTE_RELAXED=1 — tmux not required (headless only)",
86
+ });
87
+ }
88
+
89
+ checks.push({
90
+ name: "jobs_dir",
91
+ ok: true,
92
+ detail: config.jobsDir,
93
+ });
94
+
95
+ return {
96
+ ok,
97
+ product: config.product,
98
+ version: config.version,
99
+ checks,
100
+ };
101
+ }
102
+
103
+ export function printHealth(report: HealthReport, asJson: boolean): void {
104
+ if (asJson) {
105
+ console.log(JSON.stringify(report, null, 2));
106
+ return;
107
+ }
108
+ console.log(`${report.product} v${report.version}`);
109
+ console.log(report.ok ? "health: OK" : "health: NEEDS SETUP");
110
+ console.log("");
111
+ for (const c of report.checks) {
112
+ const mark = c.ok ? "✓" : "✗";
113
+ console.log(` ${mark} ${c.name.padEnd(16)} ${c.detail}`);
114
+ }
115
+ if (!report.ok) {
116
+ console.log("");
117
+ console.log("Fix the ✗ items, then re-run: cursor-route health");
118
+ console.log("Tip: start with one worker (grok OR claude-ds) before parallel demos.");
119
+ }
120
+ }
package/src/jobs.ts ADDED
@@ -0,0 +1,394 @@
1
+ import {
2
+ mkdirSync,
3
+ writeFileSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ existsSync,
7
+ unlinkSync,
8
+ openSync,
9
+ closeSync,
10
+ chmodSync,
11
+ } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { dirname } from "node:path";
15
+ import { config, sessionName, type Lane, type WorkerKind } from "./config.ts";
16
+ import { getAdapter } from "./adapters/index.ts";
17
+ import { spawn } from "node:child_process";
18
+ import { spawnSync } from "node:child_process";
19
+ import { createWorkerSession, sessionExists, killSession } from "./tmux.ts";
20
+ import { newJobId, shellQuote } from "./util.ts";
21
+ import { markCompleteInvoker } from "./runtime.ts";
22
+ import { assertJobId, JOB_ID_RE } from "./secrets.ts";
23
+
24
+ export type JobStatus = "pending" | "running" | "completed" | "failed" | "killed";
25
+
26
+ export interface Job {
27
+ id: string;
28
+ schema: "cursor-route.job.v1";
29
+ status: JobStatus;
30
+ worker: WorkerKind;
31
+ lane?: Lane;
32
+ prompt: string;
33
+ cwd: string;
34
+ alwaysApprove: boolean;
35
+ tmuxSession: string;
36
+ /** Headless process id (process group leader). */
37
+ pid?: number;
38
+ createdAt: string;
39
+ startedAt?: string;
40
+ completedAt?: string;
41
+ error?: string;
42
+ logBytes?: number;
43
+ logTail?: string;
44
+ exitCode?: number;
45
+ }
46
+
47
+ function ensureJobsDir(): void {
48
+ mkdirSync(config.jobsDir, { recursive: true, mode: 0o700 });
49
+ try {
50
+ chmodSync(config.jobsDir, 0o700);
51
+ } catch {
52
+ /* ignore */
53
+ }
54
+ }
55
+
56
+ function writeSecure(path: string, data: string): void {
57
+ writeFileSync(path, data, { mode: 0o600 });
58
+ }
59
+
60
+ function underJobsDir(id: string, ext: string): string {
61
+ assertJobId(id);
62
+ const base = resolve(config.jobsDir);
63
+ const full = resolve(join(base, `${id}${ext}`));
64
+ if (!full.startsWith(base + "/") && full !== base) {
65
+ throw new Error("Job path escapes jobsDir");
66
+ }
67
+ return full;
68
+ }
69
+
70
+ export function jobPaths(id: string) {
71
+ ensureJobsDir();
72
+ return {
73
+ json: underJobsDir(id, ".json"),
74
+ prompt: underJobsDir(id, ".prompt"),
75
+ log: underJobsDir(id, ".log"),
76
+ };
77
+ }
78
+
79
+ export function readJob(id: string): Job | null {
80
+ try {
81
+ assertJobId(id);
82
+ } catch {
83
+ return null;
84
+ }
85
+ const p = jobPaths(id).json;
86
+ if (!existsSync(p)) return null;
87
+ try {
88
+ return JSON.parse(readFileSync(p, "utf8")) as Job;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ export function writeJob(job: Job): void {
95
+ writeSecure(jobPaths(job.id).json, JSON.stringify(job, null, 2));
96
+ }
97
+
98
+ function pidAlive(pid: number): boolean {
99
+ try {
100
+ process.kill(pid, 0);
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ function sleepMs(ms: number): void {
108
+ spawnSync("sleep", [String(ms / 1000)]);
109
+ }
110
+
111
+ function terminatePid(pid: number): boolean {
112
+ const trySignal = (sig: NodeJS.Signals | number, group: boolean) => {
113
+ try {
114
+ process.kill(group ? -pid : pid, sig);
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ };
120
+ trySignal("SIGTERM", true);
121
+ trySignal("SIGTERM", false);
122
+ for (let i = 0; i < 10; i++) {
123
+ if (!pidAlive(pid)) return true;
124
+ sleepMs(100);
125
+ }
126
+ trySignal("SIGKILL", true);
127
+ trySignal("SIGKILL", false);
128
+ for (let i = 0; i < 10; i++) {
129
+ if (!pidAlive(pid)) return true;
130
+ sleepMs(100);
131
+ }
132
+ return !pidAlive(pid);
133
+ }
134
+
135
+ /** Refresh running jobs without inventing success from "session gone". */
136
+ export function refreshStatus(job: Job): Job {
137
+ if (job.status !== "running") return job;
138
+
139
+ // Prefer completion-hook result if already written.
140
+ const fresh = readJob(job.id);
141
+ if (fresh && fresh.status !== "running") return fresh;
142
+
143
+ const headless = job.tmuxSession.startsWith("headless-");
144
+ if (headless) {
145
+ if (job.pid && pidAlive(job.pid)) return job;
146
+ if (job.pid && !pidAlive(job.pid)) {
147
+ // Process exited but hook may not have run — mark unknown failure, not success.
148
+ const again = readJob(job.id);
149
+ if (again && again.status !== "running") return again;
150
+ job.status = "failed";
151
+ job.error = job.error || "Headless worker exited without completion marker";
152
+ job.completedAt = new Date().toISOString();
153
+ writeJob(job);
154
+ return job;
155
+ }
156
+ // No pid recorded (legacy) — do not flip to completed.
157
+ return job;
158
+ }
159
+
160
+ // tmux path: session gone → re-read; if still running, mark failed (unknown), not completed.
161
+ if (!sessionExists(job.tmuxSession)) {
162
+ const again = readJob(job.id);
163
+ if (again && again.status !== "running") return again;
164
+ job.status = "failed";
165
+ job.error = job.error || "tmux session ended without completion marker";
166
+ job.completedAt = new Date().toISOString();
167
+ writeJob(job);
168
+ }
169
+ return job;
170
+ }
171
+
172
+ export function listJobs(limit = config.jobsListLimit): Job[] {
173
+ ensureJobsDir();
174
+ const files = readdirSync(config.jobsDir).filter(
175
+ (f) => f.endsWith(".json") && JOB_ID_RE.test(f.replace(/\.json$/, "")),
176
+ );
177
+ const jobs: Job[] = [];
178
+ for (const f of files) {
179
+ try {
180
+ const job = JSON.parse(readFileSync(join(config.jobsDir, f), "utf8")) as Job;
181
+ if (job?.schema === "cursor-route.job.v1") jobs.push(refreshStatus(job));
182
+ } catch {
183
+ /* skip */
184
+ }
185
+ }
186
+ jobs.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
187
+ return jobs.slice(0, limit);
188
+ }
189
+
190
+ export interface StartOptions {
191
+ prompt: string;
192
+ worker?: WorkerKind;
193
+ lane?: Lane;
194
+ cwd?: string;
195
+ alwaysApprove?: boolean;
196
+ dryRun?: boolean;
197
+ noTmux?: boolean;
198
+ }
199
+
200
+ export function resolveWorker(opts: StartOptions): WorkerKind {
201
+ if (opts.worker) return opts.worker;
202
+ if (opts.lane) return config.laneWorkers[opts.lane];
203
+ return config.defaultWorker;
204
+ }
205
+
206
+ export function startJob(opts: StartOptions): {
207
+ ok: true;
208
+ job: Job;
209
+ dryRun?: boolean;
210
+ command?: string;
211
+ } | { ok: false; error: string } {
212
+ const worker = resolveWorker(opts);
213
+ const cwd = opts.cwd || process.cwd();
214
+ const alwaysApprove =
215
+ opts.alwaysApprove !== false &&
216
+ process.env.CURSOR_ROUTE_ASK !== "1" &&
217
+ process.env.CLAUDE_DS_ASK !== "1";
218
+
219
+ // Preflight: requested worker must be healthy
220
+ const adapter = getAdapter(worker);
221
+ const health = adapter.health();
222
+ if (!health.ok && !opts.dryRun) {
223
+ return { ok: false, error: `Worker ${worker} unavailable: ${health.detail}` };
224
+ }
225
+
226
+ const id = newJobId();
227
+ const paths = jobPaths(id);
228
+ writeSecure(paths.prompt, opts.prompt);
229
+
230
+ let plan;
231
+ try {
232
+ plan = adapter.buildLaunch({
233
+ promptFile: paths.prompt,
234
+ cwd,
235
+ alwaysApprove,
236
+ });
237
+ } catch (e) {
238
+ return { ok: false, error: (e as Error).message };
239
+ }
240
+
241
+ const job: Job = {
242
+ id,
243
+ schema: "cursor-route.job.v1",
244
+ status: "pending",
245
+ worker,
246
+ lane: opts.lane,
247
+ prompt: opts.prompt,
248
+ cwd,
249
+ alwaysApprove: plan.alwaysApprove,
250
+ tmuxSession: opts.noTmux ? `headless-${id}` : sessionName(id),
251
+ createdAt: new Date().toISOString(),
252
+ };
253
+
254
+ if (opts.dryRun) {
255
+ // No durable prompt retention for dry-run
256
+ try {
257
+ unlinkSync(paths.prompt);
258
+ } catch {
259
+ /* ignore */
260
+ }
261
+ return { ok: true, job, dryRun: true, command: plan.command };
262
+ }
263
+
264
+ const here = dirname(fileURLToPath(import.meta.url));
265
+ const markComplete = join(here, "mark-complete.ts");
266
+ const invoker = markCompleteInvoker(markComplete);
267
+
268
+ // Persist running BEFORE launch so fast workers cannot race completion overwrite.
269
+ job.status = "running";
270
+ job.startedAt = new Date().toISOString();
271
+ writeJob(job);
272
+
273
+ if (opts.noTmux) {
274
+ const wrapped = [
275
+ plan.command,
276
+ `exit_code=$?`,
277
+ `${invoker} ${shellQuote(paths.json)} "$exit_code" ${shellQuote(paths.log)}`,
278
+ `exit $exit_code`,
279
+ ].join("; ");
280
+
281
+ let logFd: number | undefined;
282
+ try {
283
+ logFd = openSync(paths.log, "a", 0o600);
284
+ } catch (e) {
285
+ job.status = "failed";
286
+ job.error = `Cannot open log: ${(e as Error).message}`;
287
+ job.completedAt = new Date().toISOString();
288
+ writeJob(job);
289
+ return { ok: false, error: job.error };
290
+ }
291
+
292
+ const child = spawn("sh", ["-c", wrapped], {
293
+ cwd,
294
+ detached: true,
295
+ stdio: ["ignore", logFd, logFd],
296
+ });
297
+ try {
298
+ closeSync(logFd);
299
+ } catch {
300
+ /* ignore */
301
+ }
302
+
303
+ if (child.pid == null) {
304
+ job.status = "failed";
305
+ job.error = "Failed to spawn headless worker";
306
+ job.completedAt = new Date().toISOString();
307
+ writeJob(job);
308
+ return { ok: false, error: job.error };
309
+ }
310
+
311
+ job.pid = child.pid;
312
+ writeJob(job);
313
+ child.unref();
314
+ return { ok: true, job };
315
+ }
316
+
317
+ const created = createWorkerSession({
318
+ jobId: id,
319
+ cwd,
320
+ workerCmd: plan.command,
321
+ logFile: paths.log,
322
+ jobFile: paths.json,
323
+ markCompleteScript: markComplete,
324
+ });
325
+
326
+ if (!created.ok) {
327
+ job.status = "failed";
328
+ job.error = created.error;
329
+ job.completedAt = new Date().toISOString();
330
+ writeJob(job);
331
+ return { ok: false, error: created.error };
332
+ }
333
+
334
+ writeJob(job);
335
+ return { ok: true, job };
336
+ }
337
+
338
+ export function killJob(id: string): { ok: true; job: Job } | { ok: false; error: string } {
339
+ const job = readJob(id);
340
+ if (!job) return { ok: false, error: `Job not found: ${id}` };
341
+
342
+ if (job.tmuxSession.startsWith("headless-")) {
343
+ if (job.pid) {
344
+ const ok = terminatePid(job.pid);
345
+ if (!ok) {
346
+ return { ok: false, error: `Failed to kill pid ${job.pid}` };
347
+ }
348
+ } else {
349
+ return {
350
+ ok: false,
351
+ error: "Headless job has no pid — cannot kill (legacy job). Kill the worker process manually.",
352
+ };
353
+ }
354
+ } else {
355
+ const killed = killSession(job.tmuxSession);
356
+ if (!killed && sessionExists(job.tmuxSession)) {
357
+ return { ok: false, error: `Failed to kill tmux session ${job.tmuxSession}` };
358
+ }
359
+ }
360
+
361
+ job.status = "killed";
362
+ job.completedAt = new Date().toISOString();
363
+ writeJob(job);
364
+ return { ok: true, job };
365
+ }
366
+
367
+ export function cleanJobs(olderThanDays = 7): number {
368
+ ensureJobsDir();
369
+ const cutoff = Date.now() - olderThanDays * 86400000;
370
+ let n = 0;
371
+ for (const f of readdirSync(config.jobsDir)) {
372
+ if (!f.endsWith(".json")) continue;
373
+ const id = f.replace(/\.json$/, "");
374
+ if (!JOB_ID_RE.test(id)) continue;
375
+ try {
376
+ const job = JSON.parse(readFileSync(join(config.jobsDir, f), "utf8")) as Job;
377
+ const t = Date.parse(job.completedAt || job.createdAt);
378
+ if (
379
+ Number.isFinite(t) &&
380
+ t < cutoff &&
381
+ (job.status === "completed" || job.status === "failed" || job.status === "killed")
382
+ ) {
383
+ for (const ext of [".json", ".prompt", ".log"] as const) {
384
+ const fp = underJobsDir(id, ext);
385
+ if (existsSync(fp)) unlinkSync(fp);
386
+ }
387
+ n++;
388
+ }
389
+ } catch {
390
+ /* skip */
391
+ }
392
+ }
393
+ return n;
394
+ }
@@ -0,0 +1,42 @@
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
+
8
+ const jobPath = process.argv[2];
9
+ const exitCode = Number(process.argv[3] ?? "1");
10
+ const logPath = process.argv[4];
11
+
12
+ if (!jobPath) process.exit(0);
13
+
14
+ try {
15
+ const job = JSON.parse(readFileSync(jobPath, "utf8")) as Record<string, unknown>;
16
+ if (job.status === "running" || job.status === "pending") {
17
+ job.status = exitCode === 0 ? "completed" : "failed";
18
+ job.exitCode = exitCode;
19
+ job.completedAt = new Date().toISOString();
20
+ if (exitCode !== 0 && !job.error) {
21
+ job.error = `Worker exited with code ${exitCode}`;
22
+ }
23
+ if (logPath) {
24
+ try {
25
+ const log = readFileSync(logPath, "utf8");
26
+ job.logBytes = Buffer.byteLength(log);
27
+ // Avoid retaining secret-looking tails in job metadata
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);
33
+ } catch {
34
+ /* ignore */
35
+ }
36
+ }
37
+ writeFileSync(jobPath, JSON.stringify(job, null, 2), { mode: 0o600 });
38
+ }
39
+ } catch {
40
+ /* never fail the shell hook hard */
41
+ }
42
+ process.exit(0);
package/src/runtime.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { commandExists, shellQuote } from "./util.ts";
4
+
5
+ /**
6
+ * Resolve how to re-invoke TypeScript helpers (mark-complete) from shell hooks.
7
+ * Prefer bun; else pinned npx tsx — never bare network-unpinned without note.
8
+ */
9
+ export function markCompleteInvoker(scriptPath: string): string {
10
+ if (commandExists("bun")) {
11
+ return `bun ${shellQuote(scriptPath)}`;
12
+ }
13
+ // Pin tsx major for supply-chain predictability on Node-only hosts
14
+ return `npx --yes tsx@4.19.4 ${shellQuote(scriptPath)}`;
15
+ }
16
+
17
+ /** XDG-ish default away from git-clone install dir (~/.cursor-route). */
18
+ export function defaultJobsDir(): string {
19
+ if (process.env.CURSOR_ROUTE_JOBS_DIR) return process.env.CURSOR_ROUTE_JOBS_DIR;
20
+ const home = process.env.HOME || homedir();
21
+ const xdg = process.env.XDG_DATA_HOME || join(home, ".local", "share");
22
+ return join(xdg, "cursor-route", "jobs");
23
+ }
package/src/secrets.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Secret material detectors — match key *contents*, not the phrase "API key".
3
+ * Applied to `start` and `send`.
4
+ */
5
+ const PATTERNS: RegExp[] = [
6
+ /\bsk-[a-zA-Z0-9]{20,}\b/,
7
+ /\bghp_[A-Za-z0-9]{20,}\b/,
8
+ /\bgho_[A-Za-z0-9]{20,}\b/,
9
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/i,
10
+ /\bAIza[0-9A-Za-z_-]{20,}\b/,
11
+ /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/,
12
+ /\bAKIA[0-9A-Z]{16}\b/,
13
+ ];
14
+
15
+ export function looksLikeSecretMaterial(text: string): boolean {
16
+ return PATTERNS.some((re) => re.test(text));
17
+ }
18
+
19
+ export const JOB_ID_RE = /^[a-f0-9]{8}$/;
20
+
21
+ export function assertJobId(id: string): string {
22
+ if (!JOB_ID_RE.test(id)) {
23
+ throw new Error(`Invalid job id: ${id} (expected 8 hex chars)`);
24
+ }
25
+ return id;
26
+ }