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.
@@ -3,14 +3,18 @@
3
3
  Simulated capture output for README / tweet assets — replace with a real GIF once tmux is available.
4
4
 
5
5
  ```
6
+ $ cursor-route --version
7
+ 0.1.4
8
+
6
9
  $ cursor-route health
7
- cursor-route v0.1.0
10
+ cursor-route v0.1.4
8
11
  health: OK
9
12
  ✓ tmux
10
13
  ✓ runtime bun ok
11
14
  ✓ script(1)
12
15
  ✓ worker:grok
13
16
  ✓ worker:claude-ds
17
+ ✓ jobs_dir ~/.local/share/cursor-route/jobs
14
18
 
15
19
  $ cursor-route start --lane mid "Add a failing test then make it pass"
16
20
  started a1b2c3d4 (claude-ds)
@@ -23,3 +27,6 @@ $ cursor-route jobs --json
23
27
 
24
28
  Verified locally (2026-08-10): headless `claude-ds` smoke returned `CURSOR_ROUTE_SMOKE_OK`.
25
29
  Grok smoke hit 402 (Build usage balance exhausted) — auth/PATH wiring works; top up Grok Build for live demos.
30
+
31
+ Current commands: `health`, `start`, `jobs`, `status`, `capture`, `send`, `attach`, `kill`, `sessions`, `clean`.
32
+ Headless demos (no tmux) use `--no-tmux` and `capture`/`status` instead of `attach`/`send`.
@@ -1,5 +1,5 @@
1
1
  claude-ds - DeepSeek Claude Code (model=deepseek-v4-flash)
2
2
  Install: ~/.deepseek-claude
3
- PromptFile: ~/.cursor-route/jobs/e7567d1f.prompt
3
+ PromptFile: ~/.local/share/cursor-route/jobs/e7567d1f.prompt
4
4
  WorkDir: ~/Projects/cursor-route
5
5
  CURSOR_ROUTE_SMOKE_OK
package/llms.txt ADDED
@@ -0,0 +1,29 @@
1
+ # cursor-route
2
+
3
+ > Cursor stays the planner. DeepSeek (mid) and Grok CLI (hard) run parallel coding workers in tmux.
4
+
5
+ MIT CLI + Cursor skill. npm: https://www.npmjs.com/package/cursor-route
6
+ GitHub: https://github.com/cemini23/cursor-route
7
+
8
+ ## FAQ
9
+
10
+ ### What is cursor-route?
11
+ cursor-route is a public MIT CLI and Cursor skill that runs parallel coding workers in tmux while Cursor remains the planner. DeepSeek handles the mid lane, and Grok CLI handles the hard lane.
12
+
13
+ ### How is this different from Codex orchestrator?
14
+ It uses the familiar strategist and worker-pane shape, but it is not a Codex clone. cursor-route uses Cursor as the planner and DeepSeek plus Grok CLI as workers. Codex is not required.
15
+
16
+ ### Does mid lane use Anthropic Claude?
17
+ No. The mid worker is DeepSeek. Claude Code is the harness, configured with `ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic` and a DeepSeek key in `ANTHROPIC_AUTH_TOKEN`.
18
+
19
+ ### How do I install?
20
+ Run `npm i -g cursor-route`, install tmux if needed, then run `cursor-route health`. The package is available at https://www.npmjs.com/package/cursor-route, and the source is at https://github.com/cemini23/cursor-route.
21
+
22
+ ### Is it free?
23
+ The cursor-route code is open source under MIT. It does not make the worker services free. Your costs depend on Cursor, DeepSeek API usage, and the Grok access or balance available to you.
24
+
25
+ ## Install
26
+
27
+ - npm: `npm i -g cursor-route`
28
+ - Health: `cursor-route health`
29
+ - Skill: `/route-orch` (Cursor)
package/package.json CHANGED
@@ -1,31 +1,37 @@
1
1
  {
2
2
  "name": "cursor-route",
3
- "version": "0.1.1",
4
- "description": "Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) are the parallel army lane-aware /route orchestration in tmux.",
3
+ "version": "0.1.4",
4
+ "description": "Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) are the parallel army \u2014 lane-aware /route orchestration in tmux.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {
8
- "cursor-route": "./bin/cursor-route.js"
8
+ "cursor-route": "./bin/cursor-route"
9
9
  },
10
10
  "engines": {
11
11
  "node": ">=20"
12
12
  },
13
13
  "files": [
14
14
  "bin",
15
+ "dist",
15
16
  "src",
16
17
  "skills",
17
- "docs",
18
+ "docs/DEMO_GIF.md",
19
+ "docs/demo-notes.md",
20
+ "docs/fixtures",
18
21
  "LICENSE",
19
22
  "SECURITY.md",
23
+ "SUPPORT.md",
20
24
  "CONTRIBUTING.md",
21
- "README.md"
25
+ "README.md",
26
+ "llms.txt"
22
27
  ],
23
28
  "scripts": {
24
29
  "start": "bun run src/cli.ts",
25
30
  "health": "bun run src/cli.ts health",
26
31
  "test": "bun test",
27
32
  "typecheck": "tsc --noEmit",
28
- "prepublishOnly": "bun test && bun run typecheck"
33
+ "build": "tsc -p tsconfig.json",
34
+ "prepublishOnly": "bun test && bun run typecheck && bun run build"
29
35
  },
30
36
  "keywords": [
31
37
  "cursor",
@@ -20,7 +20,7 @@ import { shellQuote } from "../util.ts";
20
20
  function which(cmd: string): string | null {
21
21
  try {
22
22
  return (
23
- execSync(`command -v ${cmd}`, {
23
+ execSync(`command -v ${shellQuote(cmd)}`, {
24
24
  encoding: "utf8",
25
25
  stdio: ["ignore", "pipe", "ignore"],
26
26
  }).trim() || null
@@ -30,37 +30,58 @@ function which(cmd: string): string | null {
30
30
  }
31
31
  }
32
32
 
33
+ /** True when URL hostname is deepseek.com (or a subdomain). */
34
+ export function isDeepSeekBaseUrl(url: string): boolean {
35
+ try {
36
+ const u = new URL(url);
37
+ const host = u.hostname.toLowerCase();
38
+ return host === "deepseek.com" || host.endsWith(".deepseek.com");
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
33
44
  function deepseekBaseFromSettings(): string | null {
34
- const candidates = [
35
- join(homedir(), ".claude", "settings.json"),
36
- join(process.cwd(), ".claude", "settings.json"),
37
- ];
38
- for (const p of candidates) {
39
- if (!existsSync(p)) continue;
40
- try {
41
- const j = JSON.parse(readFileSync(p, "utf8")) as {
42
- env?: Record<string, string>;
43
- };
44
- const url = j.env?.ANTHROPIC_BASE_URL;
45
- if (url) return url;
46
- } catch {
47
- /* ignore */
48
- }
45
+ // Home settings only — do not trust cwd/.claude/settings.json (spoof / exfil risk)
46
+ const p = join(homedir(), ".claude", "settings.json");
47
+ if (!existsSync(p)) return null;
48
+ try {
49
+ const j = JSON.parse(readFileSync(p, "utf8")) as {
50
+ env?: Record<string, string>;
51
+ };
52
+ const url = j.env?.ANTHROPIC_BASE_URL;
53
+ if (url && isDeepSeekBaseUrl(url)) return url;
54
+ } catch {
55
+ /* ignore */
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /** Resolved DeepSeek base URL for the mid-lane harness, or null. */
61
+ export function resolvedDeepSeekBaseUrl(): string | null {
62
+ for (const candidate of [
63
+ process.env.ANTHROPIC_BASE_URL,
64
+ process.env.CURSOR_ROUTE_ANTHROPIC_BASE_URL,
65
+ deepseekBaseFromSettings(),
66
+ ]) {
67
+ if (candidate && isDeepSeekBaseUrl(candidate)) return candidate;
49
68
  }
50
69
  return null;
51
70
  }
52
71
 
53
72
  /** True when Claude Code harness is routed to DeepSeek (cheap path). */
54
73
  export function isDeepSeekRouted(): boolean {
55
- const url =
56
- process.env.ANTHROPIC_BASE_URL ||
57
- process.env.CURSOR_ROUTE_ANTHROPIC_BASE_URL ||
58
- deepseekBaseFromSettings() ||
59
- "";
60
- return /deepseek\.com/i.test(url);
74
+ return Boolean(resolvedDeepSeekBaseUrl());
61
75
  }
62
76
 
63
77
  function resolveClaudeDs(): { binary: string; mode: string } | null {
78
+ // Env override lets tests pin a fake claude-ds (and power users pick a specific binary).
79
+ if (process.env.CURSOR_ROUTE_CLAUDE_DS_BIN) {
80
+ return {
81
+ binary: process.env.CURSOR_ROUTE_CLAUDE_DS_BIN,
82
+ mode: "claude-ds (CURSOR_ROUTE_CLAUDE_DS_BIN override)",
83
+ };
84
+ }
64
85
  for (const c of [
65
86
  { cmd: "claude-ds", mode: "claude-ds (DeepSeek shim)" },
66
87
  { cmd: "deepseek-claude", mode: "deepseek-claude" },
@@ -93,6 +114,25 @@ function resolveClaudeDs(): { binary: string; mode: string } | null {
93
114
  return null;
94
115
  }
95
116
 
117
+ /**
118
+ * Env that must reach stock `claude` for DeepSeek routing.
119
+ * Passed via process/tmux env — never interpolated into the printed command.
120
+ */
121
+ function deepSeekWorkerEnv(): Record<string, string> | undefined {
122
+ const base = resolvedDeepSeekBaseUrl();
123
+ if (!base) return undefined;
124
+ const env: Record<string, string> = { ANTHROPIC_BASE_URL: base };
125
+ const token =
126
+ process.env.ANTHROPIC_AUTH_TOKEN ||
127
+ process.env.ANTHROPIC_API_KEY ||
128
+ process.env.DEEPSEEK_API_KEY ||
129
+ "";
130
+ if (token) env.ANTHROPIC_AUTH_TOKEN = token;
131
+ const model = process.env.ANTHROPIC_MODEL;
132
+ if (model) env.ANTHROPIC_MODEL = model;
133
+ return env;
134
+ }
135
+
96
136
  export const claudeDsAdapter: Adapter = {
97
137
  kind: "claude-ds",
98
138
  label: "DeepSeek (via Claude Code harness)",
@@ -124,6 +164,10 @@ export const claudeDsAdapter: Adapter = {
124
164
 
125
165
  const ask = process.env.CURSOR_ROUTE_ASK === "1" || process.env.CLAUDE_DS_ASK === "1";
126
166
  const skip = alwaysApprove && !ask;
167
+ // Stock `claude` needs DeepSeek env injected into the worker process
168
+ // (tmux panes may not inherit client env from a long-lived server).
169
+ const env =
170
+ resolved.mode.startsWith("claude → DeepSeek") ? deepSeekWorkerEnv() : undefined;
127
171
 
128
172
  if (resolved.mode.startsWith("claude-ds")) {
129
173
  const parts = [
@@ -136,6 +180,7 @@ export const claudeDsAdapter: Adapter = {
136
180
  worker: "claude-ds",
137
181
  command: `cd ${shellQuote(cwd)} && ${parts.join(" ")}`,
138
182
  alwaysApprove: skip,
183
+ env,
139
184
  };
140
185
  }
141
186
 
@@ -149,6 +194,7 @@ export const claudeDsAdapter: Adapter = {
149
194
  worker: "claude-ds",
150
195
  command: `cd ${shellQuote(cwd)} && ${parts.join(" ")}`,
151
196
  alwaysApprove: skip,
197
+ env,
152
198
  };
153
199
  },
154
200
  };
@@ -3,11 +3,15 @@ import type { Adapter, WorkerHealth } from "./types.ts";
3
3
  import { shellQuote } from "../util.ts";
4
4
 
5
5
  function findGrok(): string | null {
6
+ // Env override lets tests pin a fake grok (and power users pick a specific binary).
7
+ if (process.env.CURSOR_ROUTE_GROK_BIN) return process.env.CURSOR_ROUTE_GROK_BIN;
6
8
  try {
7
- return execSync("command -v grok", {
8
- encoding: "utf8",
9
- stdio: ["ignore", "pipe", "ignore"],
10
- }).trim() || null;
9
+ return (
10
+ execSync("command -v grok", {
11
+ encoding: "utf8",
12
+ stdio: ["ignore", "pipe", "ignore"],
13
+ }).trim() || null
14
+ );
11
15
  } catch {
12
16
  return null;
13
17
  }
@@ -34,8 +38,9 @@ export const grokAdapter: Adapter = {
34
38
  };
35
39
  },
36
40
  buildLaunch({ promptFile, cwd, alwaysApprove }) {
41
+ const binary = findGrok() || "grok";
37
42
  const parts = [
38
- "grok",
43
+ shellQuote(binary),
39
44
  "-p",
40
45
  `"$(cat ${shellQuote(promptFile)})"`,
41
46
  "--cwd",
@@ -12,6 +12,8 @@ export interface LaunchPlan {
12
12
  /** Full shell command to run inside tmux (prompt already inlined via cat). */
13
13
  command: string;
14
14
  alwaysApprove: boolean;
15
+ /** Extra env for the worker process (never print secret values). */
16
+ env?: Record<string, string>;
15
17
  }
16
18
 
17
19
  export interface Adapter {
package/src/cli.test.ts CHANGED
@@ -3,8 +3,8 @@ import { resolveWorker } from "./jobs.ts";
3
3
  import { config } from "./config.ts";
4
4
  import { shellQuote, newJobId } from "./util.ts";
5
5
  import { runHealth } from "./health.ts";
6
- import { looksLikeSecretMaterial } from "./secrets.ts";
7
- import { isDeepSeekRouted } from "./adapters/claude-ds.ts";
6
+ import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
7
+ import { isDeepSeekRouted, isDeepSeekBaseUrl } from "./adapters/claude-ds.ts";
8
8
 
9
9
  describe("resolveWorker", () => {
10
10
  test("lane mid → claude-ds", () => {
@@ -40,8 +40,24 @@ describe("secrets", () => {
40
40
  test("blocks sk- material", () => {
41
41
  expect(looksLikeSecretMaterial("token sk-abcdefghijklmnopqrstuvwxyz1234")).toBe(true);
42
42
  });
43
- test("blocks ghp_ material", () => {
43
+ test("blocks sk-proj and sk-ant", () => {
44
+ expect(
45
+ looksLikeSecretMaterial("sk-proj-abcdefghijklmnopqrstuvwxyz123456"),
46
+ ).toBe(true);
47
+ expect(
48
+ looksLikeSecretMaterial("sk-ant-api03-abcdefghijklmnopqrstuvwxyz"),
49
+ ).toBe(true);
50
+ });
51
+ test("blocks ghp_ and github_pat", () => {
44
52
  expect(looksLikeSecretMaterial("ghp_abcdefghijklmnopqrstuvwx")).toBe(true);
53
+ expect(
54
+ looksLikeSecretMaterial("github_pat_11AAAAAAAAabcdefghijklmnopqrstuvwxyz"),
55
+ ).toBe(true);
56
+ });
57
+ test("redactSecrets strips material", () => {
58
+ const out = redactSecrets("see sk-abcdefghijklmnopqrstuvwxyz1234 end");
59
+ expect(out).toContain("[REDACTED]");
60
+ expect(out).not.toContain("sk-abcd");
45
61
  });
46
62
  });
47
63
 
@@ -51,6 +67,8 @@ describe("deepseek routing", () => {
51
67
  process.env.ANTHROPIC_BASE_URL = "https://api.deepseek.com/anthropic";
52
68
  try {
53
69
  expect(isDeepSeekRouted()).toBe(true);
70
+ expect(isDeepSeekBaseUrl("https://api.deepseek.com/anthropic")).toBe(true);
71
+ expect(isDeepSeekBaseUrl("https://evil-deepseek.com.attacker.tld")).toBe(false);
54
72
  } finally {
55
73
  if (prev === undefined) delete process.env.ANTHROPIC_BASE_URL;
56
74
  else process.env.ANTHROPIC_BASE_URL = prev;
@@ -67,16 +85,13 @@ describe("health", () => {
67
85
  expect(r.checks.some((c) => c.name === "cursor_cli")).toBe(true);
68
86
  });
69
87
 
70
- test("relaxed env can pass without tmux", () => {
88
+ test("relaxed env can pass without workers", () => {
71
89
  const prev = process.env.CURSOR_ROUTE_RELAXED;
72
90
  process.env.CURSOR_ROUTE_RELAXED = "1";
73
91
  try {
74
92
  const r = runHealth();
75
- const tmux = r.checks.find((c) => c.name === "tmux");
76
- if (tmux && !tmux.ok) {
77
- expect(r.ok).toBe(true);
78
- expect(r.checks.some((c) => c.name === "relaxed")).toBe(true);
79
- }
93
+ expect(r.ok).toBe(true);
94
+ expect(r.checks.some((c) => c.name === "relaxed")).toBe(true);
80
95
  } finally {
81
96
  if (prev === undefined) delete process.env.CURSOR_ROUTE_RELAXED;
82
97
  else process.env.CURSOR_ROUTE_RELAXED = prev;
package/src/cli.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * cursor-route CLI — Cursor brain, Grok + DeepSeek workers in tmux.
4
4
  */
5
- import { readFileSync, existsSync } from "node:fs";
5
+ import { readFileSync, existsSync, realpathSync, statSync } from "node:fs";
6
6
  import { resolve, basename } from "node:path";
7
7
  import { config, WORKERS, LANES, type WorkerKind, type Lane } from "./config.ts";
8
8
  import { runHealth, printHealth } from "./health.ts";
@@ -22,7 +22,7 @@ import {
22
22
  listManagedSessions,
23
23
  sessionExists,
24
24
  } from "./tmux.ts";
25
- import { looksLikeSecretMaterial } from "./secrets.ts";
25
+ import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
26
26
 
27
27
  function usage(exitCode = 0): never {
28
28
  console.log(`cursor-route v${config.version}
@@ -55,8 +55,11 @@ Start options:
55
55
  Env:
56
56
  CURSOR_ROUTE_ASK=1 Opt out of always-approve
57
57
  CURSOR_ROUTE_JOBS_DIR Override jobs dir (default: ~/.local/share/cursor-route/jobs)
58
- CURSOR_ROUTE_RELAXED=1 health OK without tmux (headless CI)
58
+ CURSOR_ROUTE_MAX_JOBS Max active jobs (default: 50)
59
+ CURSOR_ROUTE_RELAXED=1 health OK without tmux/workers (CI / infra smoke)
59
60
  CURSOR_ROUTE_ALLOW_ANTHROPIC=1 Allow mid-lane on Anthropic Claude (expensive; not default)
61
+ CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
62
+ CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
60
63
  `);
61
64
  process.exit(exitCode);
62
65
  }
@@ -66,6 +69,10 @@ function parseArgs(argv: string[]) {
66
69
  const positional: string[] = [];
67
70
  for (let i = 0; i < argv.length; i++) {
68
71
  const a = argv[i];
72
+ if (a === "--") {
73
+ positional.push(...argv.slice(i + 1));
74
+ break;
75
+ }
69
76
  if (
70
77
  a === "--json" ||
71
78
  a === "--ask" ||
@@ -106,6 +113,30 @@ function parseArgs(argv: string[]) {
106
113
  return { flags, positional };
107
114
  }
108
115
 
116
+ /** Require a string value for flags that must not be bare booleans. */
117
+ function requireStringFlag(
118
+ flags: Record<string, string | boolean>,
119
+ key: string,
120
+ ): string | undefined {
121
+ if (!(key in flags)) return undefined;
122
+ const v = flags[key];
123
+ if (typeof v !== "string" || !v.trim()) {
124
+ console.error(`--${key} requires a value`);
125
+ process.exit(2);
126
+ }
127
+ return v;
128
+ }
129
+
130
+ function requireNonNegNumber(raw: string | undefined, label: string, fallback: number): number {
131
+ if (raw === undefined) return fallback;
132
+ const n = Number(raw);
133
+ if (!Number.isFinite(n) || n < 0) {
134
+ console.error(`${label} must be a non-negative number`);
135
+ process.exit(2);
136
+ }
137
+ return n;
138
+ }
139
+
109
140
  function asWorker(v: unknown): WorkerKind | undefined {
110
141
  if (typeof v !== "string") return undefined;
111
142
  if ((WORKERS as string[]).includes(v)) return v as WorkerKind;
@@ -128,14 +159,31 @@ function refuseSecrets(text: string, context: string): void {
128
159
  }
129
160
 
130
161
  function refuseDangerousPromptFile(path: string): void {
131
- const base = basename(path);
132
- const resolved = resolve(path);
162
+ let resolved: string;
163
+ try {
164
+ resolved = realpathSync(path);
165
+ } catch {
166
+ resolved = resolve(path);
167
+ }
168
+ const base = basename(resolved);
169
+ const lower = resolved.toLowerCase();
133
170
  if (
134
171
  base.startsWith(".env") ||
135
- resolved.includes("/.ssh/") ||
172
+ lower.includes("/.ssh/") ||
173
+ lower.includes("/.aws/") ||
174
+ lower.includes("/.kube/") ||
175
+ base === ".npmrc" ||
176
+ base === ".git-credentials" ||
177
+ base === ".pgpass" ||
136
178
  base === "id_rsa" ||
137
179
  base === "id_ed25519" ||
138
- base.endsWith(".pem")
180
+ base === "id_ecdsa" ||
181
+ base === "id_dsa" ||
182
+ base === "credentials" ||
183
+ base.endsWith(".pem") ||
184
+ base.endsWith(".key") ||
185
+ base.endsWith(".p12") ||
186
+ base.endsWith(".pfx")
139
187
  ) {
140
188
  console.error(`Refusing --prompt-file path that looks credential-related: ${path}`);
141
189
  process.exit(3);
@@ -163,9 +211,14 @@ async function main() {
163
211
  }
164
212
 
165
213
  if (cmd === "start") {
214
+ requireStringFlag(f, "worker");
215
+ requireStringFlag(f, "lane");
216
+ const promptFile = requireStringFlag(f, "prompt-file");
217
+ const dirFlag = requireStringFlag(f, "dir");
218
+
166
219
  let prompt = "";
167
- if (f["prompt-file"]) {
168
- const p = resolve(String(f["prompt-file"]));
220
+ if (promptFile) {
221
+ const p = resolve(promptFile);
169
222
  refuseDangerousPromptFile(p);
170
223
  if (!existsSync(p)) {
171
224
  console.error(`prompt file not found: ${p}`);
@@ -181,11 +234,25 @@ async function main() {
181
234
  }
182
235
  refuseSecrets(prompt, "to start");
183
236
 
237
+ let cwd = process.cwd();
238
+ if (dirFlag) {
239
+ cwd = resolve(dirFlag);
240
+ try {
241
+ if (!statSync(cwd).isDirectory()) {
242
+ console.error(`--dir is not a directory: ${cwd}`);
243
+ process.exit(2);
244
+ }
245
+ } catch {
246
+ console.error(`--dir does not exist: ${cwd}`);
247
+ process.exit(2);
248
+ }
249
+ }
250
+
184
251
  const result = startJob({
185
252
  prompt,
186
253
  worker: asWorker(f.worker),
187
254
  lane: asLane(f.lane),
188
- cwd: f.dir ? resolve(String(f.dir)) : process.cwd(),
255
+ cwd,
189
256
  alwaysApprove: !f.ask,
190
257
  dryRun: Boolean(f.dryRun),
191
258
  noTmux: Boolean(f.noTmux),
@@ -197,11 +264,20 @@ async function main() {
197
264
  }
198
265
 
199
266
  if (json) {
200
- console.log(JSON.stringify({ ...result.job, command: result.command }, null, 2));
267
+ console.log(
268
+ JSON.stringify(
269
+ {
270
+ ...result.job,
271
+ command: result.command ? redactSecrets(result.command) : result.command,
272
+ },
273
+ null,
274
+ 2,
275
+ ),
276
+ );
201
277
  } else if (result.dryRun) {
202
278
  console.log(`dry-run job ${result.job.id}`);
203
279
  console.log(`worker: ${result.job.worker}`);
204
- console.log(`command: ${result.command}`);
280
+ console.log(`command: ${redactSecrets(result.command || "")}`);
205
281
  } else {
206
282
  console.log(`started ${result.job.id} (${result.job.worker})`);
207
283
  console.log(`session: ${result.job.tmuxSession}`);
@@ -217,7 +293,8 @@ async function main() {
217
293
  }
218
294
 
219
295
  if (cmd === "jobs") {
220
- const limit = f.limit ? Number(f.limit) : config.jobsListLimit;
296
+ const limitRaw = requireStringFlag(f, "limit");
297
+ const limit = requireNonNegNumber(limitRaw, "--limit", config.jobsListLimit);
221
298
  const jobs = listJobs(limit);
222
299
  if (json) {
223
300
  console.log(JSON.stringify(jobs, null, 2));
@@ -267,7 +344,10 @@ async function main() {
267
344
 
268
345
  if (cmd === "capture") {
269
346
  const id = pos[0];
270
- const lines = pos[1] ? Number(pos[1]) : 50;
347
+ const linesRaw = pos[1];
348
+ const lines = linesRaw
349
+ ? requireNonNegNumber(linesRaw, "capture lines", 50)
350
+ : 50;
271
351
  if (!id) {
272
352
  console.error("capture requires <jobId>");
273
353
  process.exit(2);
@@ -326,6 +406,15 @@ async function main() {
326
406
  console.error("attach requires <jobId>");
327
407
  process.exit(2);
328
408
  }
409
+ const job = readJob(id);
410
+ if (!job) {
411
+ console.error(`Job not found: ${id}`);
412
+ process.exit(1);
413
+ }
414
+ if (job.tmuxSession.startsWith("headless-")) {
415
+ console.error("headless job — use capture/status (no tmux attach)");
416
+ process.exit(1);
417
+ }
329
418
  console.log(attachHint(id));
330
419
  return;
331
420
  }
@@ -354,7 +443,8 @@ async function main() {
354
443
  }
355
444
 
356
445
  if (cmd === "clean") {
357
- const days = f.days ? Number(f.days) : 7;
446
+ const daysRaw = requireStringFlag(f, "days");
447
+ const days = requireNonNegNumber(daysRaw, "--days", 7);
358
448
  const n = cleanJobs(days);
359
449
  console.log(`cleaned ${n} job(s) older than ${days}d`);
360
450
  return;
package/src/config.ts CHANGED
@@ -8,10 +8,26 @@ export type Lane = "mid" | "hard";
8
8
  export const WORKERS: WorkerKind[] = ["grok", "claude-ds"];
9
9
  export const LANES: Lane[] = ["mid", "hard"];
10
10
 
11
+ function maxConcurrentJobsFromEnv(): number {
12
+ const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
13
+ if (raw) {
14
+ const n = Number(raw);
15
+ if (Number.isInteger(n) && n > 0) return n;
16
+ }
17
+ return 50;
18
+ }
19
+
20
+ /**
21
+ * Live getters for env-derived paths/limits so tests can set
22
+ * CURSOR_ROUTE_JOBS_DIR / CURSOR_ROUTE_MAX_JOBS before exercising jobs
23
+ * even if another module imported config earlier.
24
+ */
11
25
  export const config = {
12
26
  product: "cursor-route",
13
- version: "0.1.1",
14
- jobsDir: defaultJobsDir(),
27
+ version: "0.1.4",
28
+ get jobsDir(): string {
29
+ return defaultJobsDir();
30
+ },
15
31
  tmuxPrefix: "cursor-route",
16
32
  defaultWorker: "grok" as WorkerKind,
17
33
  /** Lane → default worker (Cemini /route public core). */
@@ -20,6 +36,10 @@ export const config = {
20
36
  hard: "grok" as WorkerKind,
21
37
  },
22
38
  jobsListLimit: 20,
39
+ /** Max simultaneously active (running|pending) jobs. Override: CURSOR_ROUTE_MAX_JOBS. */
40
+ get maxConcurrentJobs(): number {
41
+ return maxConcurrentJobsFromEnv();
42
+ },
23
43
  };
24
44
 
25
45
  export function sessionName(jobId: string): string {
package/src/health.ts CHANGED
@@ -32,7 +32,7 @@ export function runHealth(): HealthReport {
32
32
  checks.push({
33
33
  name: "runtime",
34
34
  ok: bunOk || nodeOk,
35
- detail: bunOk ? "bun ok" : nodeOk ? "node ok (tsx via npx for TS)" : "need bun or node 20+",
35
+ detail: bunOk ? "bun ok" : nodeOk ? "node ok (compiled dist)" : "need bun or node 20+",
36
36
  });
37
37
 
38
38
  const scriptOk = (() => {
@@ -71,18 +71,20 @@ export function runHealth(): HealthReport {
71
71
  : "optional — Cursor CLI agent not on PATH (skill-only supervisor is fine for v0)",
72
72
  });
73
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).
74
+ // At least one worker must be healthy for a green health gate.
75
+ // CURSOR_ROUTE_RELAXED=1: pass without tmux and without workers (CI / infra smoke).
76
76
  const workerOk = checks.some((c) => c.name.startsWith("worker:") && c.ok);
77
77
  const relaxed = process.env.CURSOR_ROUTE_RELAXED === "1";
78
78
  const hardOk = (tmuxOk || relaxed) && (bunOk || nodeOk) && scriptOk;
79
- const ok = hardOk && workerOk;
79
+ const ok = hardOk && (workerOk || relaxed);
80
80
 
81
- if (relaxed && !tmuxOk) {
81
+ if (relaxed) {
82
82
  checks.push({
83
83
  name: "relaxed",
84
84
  ok: true,
85
- detail: "CURSOR_ROUTE_RELAXED=1 — tmux not required (headless only)",
85
+ detail: workerOk
86
+ ? "CURSOR_ROUTE_RELAXED=1 — tmux optional (headless OK)"
87
+ : "CURSOR_ROUTE_RELAXED=1 — tmux/workers optional (CI / infra smoke)",
86
88
  });
87
89
  }
88
90