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.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Fake-worker integration tests (headless, no tmux needed).
3
+ *
4
+ * Env (set before importing config/jobs so jobsDir + limit take effect):
5
+ * CURSOR_ROUTE_JOBS_DIR → unique tmpdir
6
+ * CURSOR_ROUTE_MAX_JOBS → 2 (so the concurrency refusal is cheap to test)
7
+ * CURSOR_ROUTE_GROK_BIN → absolute path to the fake grok shim (read in-process
8
+ * by the adapter; PATH shadowing alone is unreliable
9
+ * under bun, which snapshots env for child processes)
10
+ */
11
+ import { describe, expect, test, afterAll } from "bun:test";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, existsSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import type { Job } from "./jobs.ts";
16
+
17
+ const tmp = mkdtempSync(join(tmpdir(), "cursor-route-int-"));
18
+ const jobsDir = join(tmp, "jobs");
19
+ const shimDir = join(tmp, "bin");
20
+ const shimLog = join(tmp, "shim.log");
21
+
22
+ process.env.CURSOR_ROUTE_JOBS_DIR = jobsDir;
23
+ process.env.CURSOR_ROUTE_MAX_JOBS = "2";
24
+
25
+ mkdirSync(jobsDir, { recursive: true });
26
+ mkdirSync(shimDir, { recursive: true });
27
+
28
+ function writeShim(name: string): void {
29
+ const body = `#!/usr/bin/env sh
30
+ {
31
+ echo "argv=$*"
32
+ echo "pwd=$PWD"
33
+ } >> '${shimLog}'
34
+ sleep "\${FAKE_WORKER_SLEEP:-1}"
35
+ exit "\${FAKE_WORKER_EXIT:-0}"
36
+ `;
37
+ const p = join(shimDir, name);
38
+ writeFileSync(p, body);
39
+ chmodSync(p, 0o755);
40
+ }
41
+ writeShim("grok");
42
+ writeShim("claude-ds");
43
+ process.env.CURSOR_ROUTE_GROK_BIN = join(shimDir, "grok");
44
+ process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = join(shimDir, "claude-ds");
45
+
46
+ const { startJob, readJob, killJob, refreshStatus, countActiveJobs } = await import("./jobs.ts");
47
+
48
+ function sleep(ms: number): Promise<void> {
49
+ return new Promise((resolve) => setTimeout(resolve, ms));
50
+ }
51
+
52
+ async function waitForTerminal(jobId: string, timeoutMs = 15000): Promise<Job> {
53
+ const deadline = Date.now() + timeoutMs;
54
+ while (Date.now() < deadline) {
55
+ const job = readJob(jobId);
56
+ if (!job) {
57
+ await sleep(100);
58
+ continue;
59
+ }
60
+ const fresh = refreshStatus(job);
61
+ if (fresh.status === "completed" || fresh.status === "failed" || fresh.status === "killed") {
62
+ return fresh;
63
+ }
64
+ await sleep(200);
65
+ }
66
+ throw new Error(`timed out waiting for job ${jobId} to reach terminal state`);
67
+ }
68
+
69
+ afterAll(() => {
70
+ try {
71
+ rmSync(tmp, { recursive: true, force: true });
72
+ } catch {
73
+ /* ignore */
74
+ }
75
+ });
76
+
77
+ describe("fake-worker integration (headless, no tmux)", () => {
78
+ test(
79
+ "start → completed with exitCode 0",
80
+ async () => {
81
+ delete process.env.FAKE_WORKER_EXIT;
82
+ delete process.env.FAKE_WORKER_SLEEP;
83
+ const r = startJob({ prompt: "say ok", worker: "grok", noTmux: true });
84
+ expect(r.ok).toBe(true);
85
+ if (!r.ok) return;
86
+ const job = await waitForTerminal(r.job.id);
87
+ expect(job.status).toBe("completed");
88
+ expect(job.exitCode).toBe(0);
89
+ },
90
+ 20000,
91
+ );
92
+
93
+ test(
94
+ "FAKE_WORKER_EXIT=7 → failed with exitCode 7",
95
+ async () => {
96
+ process.env.FAKE_WORKER_EXIT = "7";
97
+ delete process.env.FAKE_WORKER_SLEEP;
98
+ try {
99
+ const r = startJob({ prompt: "say fail", worker: "grok", noTmux: true });
100
+ expect(r.ok).toBe(true);
101
+ if (!r.ok) return;
102
+ const job = await waitForTerminal(r.job.id);
103
+ expect(job.status).toBe("failed");
104
+ expect(job.exitCode).toBe(7);
105
+ } finally {
106
+ delete process.env.FAKE_WORKER_EXIT;
107
+ }
108
+ },
109
+ 20000,
110
+ );
111
+
112
+ test(
113
+ "kill running job → killed; second kill refused as terminal",
114
+ async () => {
115
+ process.env.FAKE_WORKER_SLEEP = "30";
116
+ delete process.env.FAKE_WORKER_EXIT;
117
+ try {
118
+ const r = startJob({ prompt: "long job", worker: "grok", noTmux: true });
119
+ expect(r.ok).toBe(true);
120
+ if (!r.ok) return;
121
+ // Give the detached spawn a beat so the pid is recorded and the shim is alive.
122
+ await sleep(300);
123
+ const k = killJob(r.job.id);
124
+ expect(k.ok).toBe(true);
125
+ if (k.ok) expect(k.job.status).toBe("killed");
126
+
127
+ const again = killJob(r.job.id);
128
+ expect(again.ok).toBe(false);
129
+ if (!again.ok) expect(again.error).toContain("terminal");
130
+ } finally {
131
+ delete process.env.FAKE_WORKER_SLEEP;
132
+ }
133
+ },
134
+ 20000,
135
+ );
136
+
137
+ test(
138
+ "concurrent limit: refuses start once active jobs reach CURSOR_ROUTE_MAX_JOBS",
139
+ async () => {
140
+ process.env.FAKE_WORKER_SLEEP = "30";
141
+ delete process.env.FAKE_WORKER_EXIT;
142
+ try {
143
+ const a = startJob({ prompt: "job a", worker: "grok", noTmux: true });
144
+ const b = startJob({ prompt: "job b", worker: "grok", noTmux: true });
145
+ expect(a.ok).toBe(true);
146
+ expect(b.ok).toBe(true);
147
+ if (!a.ok || !b.ok) return;
148
+ expect(countActiveJobs()).toBe(2);
149
+
150
+ const c = startJob({ prompt: "job c", worker: "grok", noTmux: true });
151
+ expect(c.ok).toBe(false);
152
+ if (!c.ok) expect(c.error).toContain("Too many active jobs");
153
+ } finally {
154
+ delete process.env.FAKE_WORKER_SLEEP;
155
+ // Cleanup any running jobs so the tmpdir can be removed.
156
+ if (existsSync(jobsDir)) {
157
+ for (const f of readdirSync(jobsDir)) {
158
+ if (!f.endsWith(".json")) continue;
159
+ const job = readJob(f.replace(/\.json$/, ""));
160
+ if (job && (job.status === "running" || job.status === "pending")) killJob(job.id);
161
+ }
162
+ }
163
+ }
164
+ },
165
+ 20000,
166
+ );
167
+ });
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 !== "1";
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
- return { ok: true, job, dryRun: true, command: plan.command };
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
- job.pid = child.pid;
312
- writeJob(job);
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
- writeJob(job);
335
- return { ok: true, job };
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
- job.status = "killed";
362
- job.completedAt = new Date().toISOString();
363
- writeJob(job);
364
- return { ok: true, job };
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 {
@@ -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
- // 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);
28
+ job.logTail = redactSecrets(log).slice(-2000);
33
29
  } catch {
34
30
  /* ignore */
35
31
  }
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 TypeScript helpers (mark-complete) from shell hooks.
7
- * Prefer bun; else pinned npx tsx never bare network-unpinned without note.
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
- // Pin tsx major for supply-chain predictability on Node-only hosts
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 PATTERNS: RegExp[] = [
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 PATTERNS.some((re) => re.test(text));
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
- execSync(`tmux send-keys -t ${shellQuote(name)} -- ${shellQuote(message)}`, {
54
- stdio: "ignore",
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 r = spawnSync(
111
- "tmux",
112
- ["new-session", "-d", "-s", name, "-c", options.cwd, wrapped],
113
- { encoding: "utf8", cwd: options.cwd },
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}`], { encoding: "utf8" });
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