cursor-route 0.1.6 → 0.1.7

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/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ ## 0.1.7 — 2026-08-12
4
+
5
+ - Fix: `CURSOR_ROUTE_DS_MODEL` / `ANTHROPIC_MODEL` now apply on the `start` product path (live default)
6
+ - Fix: Anthropic escape hatch (`CURSOR_ROUTE_ALLOW_ANTHROPIC=1`) no longer passes DeepSeek `--model` ids
7
+ - Fix: preserve `deepseek-v4-pro[1m]` (no silent SKU strip)
8
+ - `--model` validated only for `claude-ds`; ignored for grok/openrouter (including invalid values)
9
+ - `jobs` / `status` text output show `model` when set
10
+ - Tests cover env default via `startJob`, deepseek slot, Anthropic hatch, health OR-gate
11
+
12
+ ## 0.1.6 — 2026-08-12
13
+
14
+ - Mid lane defaults to DeepSeek **Flash** (`deepseek-v4-flash`)
15
+ - `cursor-route start --model flash|pro` (aliases / full ids accepted)
16
+ - `claude-ds` adapter passes `-Model`; stock `claude` → DeepSeek gets `--model`
17
+ - Reserved `deepseek` worker slot (unreleased official harness; mid stays on `claude-ds`)
18
+ - Docs: Grok **auth** (`grok login`) ≠ usage/quota out → use `--model pro`
19
+ - `route-orch` skill Flash/Pro table; living brief in `docs/briefs/WORKING.md`
20
+
21
+ ## 0.1.5
22
+
23
+ - OpenRouter free easy lane (`--lane easy` / `--worker openrouter`)
24
+
25
+ ## 0.1.4
26
+
27
+ - Compiled `dist/` happy path, integration tests, `CURSOR_ROUTE_MAX_JOBS`
28
+
29
+ ## Earlier
30
+
31
+ See git tags `v0.1.1`–`v0.1.3` for security/hardening and first npm ship notes.
package/CONTRIBUTING.md CHANGED
@@ -5,22 +5,30 @@
5
5
  ```bash
6
6
  bun install
7
7
  bun test
8
+ bun run typecheck
8
9
  ./bin/cursor-route health
9
10
  CURSOR_ROUTE_RELAXED=1 ./bin/cursor-route health # without tmux
10
11
  ./bin/cursor-route start --dry-run --worker grok "ping"
12
+ ./bin/cursor-route start --dry-run --lane mid --model flash "ping"
13
+ ./bin/cursor-route start --dry-run --lane mid --model pro "ping"
11
14
  ```
12
15
 
16
+ Keep `skills/route-orch/SKILL.md` and `.cursor/skills/route-orch/SKILL.md` identical when editing the skill.
17
+
13
18
  ## Rules
14
19
 
15
20
  - No secrets in prompts, fixtures, or commits
16
21
  - Do not vendor private `agent-toolkit` paths
17
22
  - Prefer adapters over rewriting the job core
18
23
  - Always-approve defaults stay documented + opt-out (`--ask` / `CURSOR_ROUTE_ASK=1`)
24
+ - Mid default is Flash; document Pro only as harder mid / Grok **usage** stand-in (not missing `grok login`)
19
25
 
20
26
  ## Release checklist
21
27
 
22
- 1. `bun test`
23
- 2. `cursor-route health` (tmux + ≥1 worker)
24
- 3. One real `start` smoke (grok or claude-ds)
25
- 4. `npm pack --dry-run` no secrets, no `node_modules`
26
- 5. Tag `v0.x.y` then `npm publish --access public` (maintainers)
28
+ 1. Update `package.json` version + [CHANGELOG.md](./CHANGELOG.md) + [docs/briefs/WORKING.md](./docs/briefs/WORKING.md)
29
+ 2. `bun test` && `bun run typecheck` && `bun run build`
30
+ 3. `cursor-route health` (tmux + ≥1 worker)
31
+ 4. One real `start` smoke (grok or claude-ds); dry-run both `--model flash` and `--model pro`
32
+ 5. `npm pack --dry-run` no secrets, no `node_modules`
33
+ 6. Commit, tag `v0.x.y`, push
34
+ 7. `npm publish --access public` with an isolated `--userconfig` Automation token (Bypass 2FA); **revoke** any chat-pasted token after
package/README.md CHANGED
@@ -27,7 +27,7 @@ If you already live in Cursor, X Premium (Grok CLI), and DeepSeek — stop payin
27
27
  |------|----------------|------------------|
28
28
  | Cursor | Premium plan | Plan / synthesize / verify (orchestrator) |
29
29
  | Grok CLI | X Premium | `--lane hard` implement |
30
- | DeepSeek via claude-ds | DeepSeek API / plan | `--lane mid` implement |
30
+ | DeepSeek via claude-ds | DeepSeek API / plan | `--lane mid` implement (**Flash** default; `--model pro` when needed) |
31
31
  | OpenRouter free models | OpenRouter API (free tier) | `--lane easy` wording/drafts — non-secret prompts only (see Security) |
32
32
  | Codex / extra Claude | Optional | Not required for v0 |
33
33
 
@@ -73,23 +73,26 @@ cd cursor-route && bun install
73
73
 
74
74
  ### Cursor skill
75
75
 
76
- Copy the skill into your project or user skills:
76
+ After `npm i -g cursor-route`, copy the packaged skill into a project (or user skills):
77
77
 
78
78
  ```bash
79
+ PKG="$(npm root -g)/cursor-route"
79
80
  mkdir -p .cursor/skills
80
- cp -R ~/.cursor-route-src/skills/route-orch .cursor/skills/
81
+ cp -R "$PKG/skills/route-orch" .cursor/skills/
81
82
  ```
82
83
 
84
+ From a git clone: `cp -R skills/route-orch .cursor/skills/`.
85
+
83
86
  Then say **`/route-orch`** or **spawn workers** in Cursor — the skill delegates to `cursor-route` (does not replace a private in-house `/route` skill).
84
87
 
85
- Working notes for this repo (edit in place): [docs/briefs/WORKING.md](./docs/briefs/WORKING.md).
88
+ Working notes for this repo (edit in place): [docs/briefs/WORKING.md](./docs/briefs/WORKING.md). See [CHANGELOG.md](./CHANGELOG.md) for release notes.
86
89
 
87
90
  ## Commands
88
91
 
89
92
  | Command | Description |
90
93
  |---------|-------------|
91
94
  | `health` | Gate #1 — tmux, runtime, workers |
92
- | `start <prompt>` | Spawn worker job (`--worker` / `--lane` / `--dir` / `--ask` / `--dry-run` / `--no-tmux`) |
95
+ | `start <prompt>` | Spawn worker job (`--worker` / `--lane` / `--model` / `--dir` / `--ask` / `--dry-run` / `--no-tmux`) |
93
96
  | `jobs [--json]` | List jobs |
94
97
  | `status <id>` | Job + sessionAlive |
95
98
  | `capture <id> [n]` | Last n pane/log lines |
@@ -115,14 +118,19 @@ Always-approve is **on** by default. Opt out: `--ask` or `CURSOR_ROUTE_ASK=1`.
115
118
  |------|----------|------|
116
119
  | `--model flash` (default) | `deepseek-v4-flash` | Cheap mid execute |
117
120
  | `--model pro` | `deepseek-v4-pro` | Harder mid / Grok **usage** stand-in |
121
+ | `--model deepseek-v4-pro[1m]` | `deepseek-v4-pro[1m]` | Large-context Pro (SKU preserved) |
118
122
 
119
123
  ```bash
120
124
  cursor-route start --lane mid "…" # Flash
121
125
  cursor-route start --lane mid --model pro "…" # Pro
126
+ # Or set default without a flag:
127
+ export CURSOR_ROUTE_DS_MODEL=pro # also honors ANTHROPIC_MODEL; --model overrides
122
128
  ```
123
129
 
124
130
  **Grok auth ≠ usage-out:** if `cursor-route health` shows `worker:grok` ✗, run `grok login` (or set `XAI_API_KEY`). That is auth. Quota / subscription usage exhausted is different — use `--lane mid --model pro` as the stand-in, not a missing login.
125
131
 
132
+ `CURSOR_ROUTE_ALLOW_ANTHROPIC=1` is an expensive escape hatch: it does **not** pass DeepSeek `--model` ids (Anthropic would reject them).
133
+
126
134
  ## DeepSeek setup (the cheap mid-lane — this is the point)
127
135
 
128
136
  `cursor-route` mid lane runs **DeepSeek**, not Anthropic. Claude Code is only the terminal harness; bills go to DeepSeek when `ANTHROPIC_BASE_URL` points at them.
@@ -202,6 +210,7 @@ The cursor-route code is open source under MIT. It does not make the worker serv
202
210
 
203
211
  ## Related
204
212
 
213
+ - Changelog: [CHANGELOG.md](./CHANGELOG.md)
205
214
  - Newsletter: [Outlier Weekly](https://outlierweekly.substack.com)
206
215
  - YouTube: [@Cemini23](https://www.youtube.com/@Cemini23)
207
216
  - Agent meta-wiki: [cemini-claude-code-CCC](https://github.com/cemini23/cemini-claude-code-CCC)
@@ -2,7 +2,7 @@ import { execSync } from "node:child_process";
2
2
  import { readFileSync, existsSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { DS_MODEL_IDS, config, resolveDsModelAlias, } from "../config.js";
5
+ import { DS_MODEL_IDS, config, resolveDsModel, } from "../config.js";
6
6
  import { shellQuote } from "../util.js";
7
7
  /**
8
8
  * Mid-lane = DeepSeek (cheap). The Claude Code binary is only a harness —
@@ -20,6 +20,8 @@ function which(cmd) {
20
20
  return (execSync(`command -v ${shellQuote(cmd)}`, {
21
21
  encoding: "utf8",
22
22
  stdio: ["ignore", "pipe", "ignore"],
23
+ // Bun may ignore mutated process.env.PATH unless env is passed explicitly
24
+ env: { ...process.env },
23
25
  }).trim() || null);
24
26
  }
25
27
  catch {
@@ -55,15 +57,16 @@ function deepseekBaseFromSettings() {
55
57
  }
56
58
  /** Resolved DeepSeek base URL for the mid-lane harness, or null. */
57
59
  export function resolvedDeepSeekBaseUrl() {
60
+ // Explicit env wins: a non-DeepSeek ANTHROPIC_BASE_URL must not fall through to settings.
58
61
  for (const candidate of [
59
62
  process.env.ANTHROPIC_BASE_URL,
60
63
  process.env.CURSOR_ROUTE_ANTHROPIC_BASE_URL,
61
- deepseekBaseFromSettings(),
62
64
  ]) {
63
- if (candidate && isDeepSeekBaseUrl(candidate))
64
- return candidate;
65
+ if (candidate) {
66
+ return isDeepSeekBaseUrl(candidate) ? candidate : null;
67
+ }
65
68
  }
66
- return null;
69
+ return deepseekBaseFromSettings();
67
70
  }
68
71
  /** True when Claude Code harness is routed to DeepSeek (cheap path). */
69
72
  export function isDeepSeekRouted() {
@@ -105,20 +108,16 @@ function resolveClaudeDs() {
105
108
  }
106
109
  return null;
107
110
  }
108
- function pickModelAlias(requested) {
109
- if (requested)
110
- return requested;
111
- // Env override for power users who already export ANTHROPIC_MODEL
112
- const fromEnv = process.env.CURSOR_ROUTE_DS_MODEL || process.env.ANTHROPIC_MODEL;
113
- if (fromEnv) {
114
- try {
115
- return resolveDsModelAlias(fromEnv);
116
- }
117
- catch {
118
- /* fall through to default */
119
- }
111
+ function pickModel(requested, modelId) {
112
+ if (modelId) {
113
+ const alias = requested ?? resolveDsModel(modelId).alias;
114
+ return { alias, id: modelId };
120
115
  }
121
- return config.defaultDsModel;
116
+ if (requested) {
117
+ return { alias: requested, id: DS_MODEL_IDS[requested] };
118
+ }
119
+ // Env default (startJob normally resolves this; kept for direct buildLaunch callers)
120
+ return resolveDsModel(process.env.CURSOR_ROUTE_DS_MODEL || process.env.ANTHROPIC_MODEL);
122
121
  }
123
122
  /**
124
123
  * Env that must reach stock `claude` for DeepSeek routing.
@@ -163,27 +162,41 @@ export const claudeDsAdapter = {
163
162
  detail: `ok (${resolved.mode}; default model ${DS_MODEL_IDS[config.defaultDsModel]})`,
164
163
  };
165
164
  },
166
- buildLaunch({ promptFile, cwd, alwaysApprove, model }) {
165
+ buildLaunch({ promptFile, cwd, alwaysApprove, model, modelId }) {
167
166
  const resolved = resolveClaudeDs();
168
167
  if (!resolved) {
169
168
  throw new Error("DeepSeek worker not available — run: cursor-route health");
170
169
  }
171
- const alias = pickModelAlias(model);
172
- const modelId = DS_MODEL_IDS[alias];
173
170
  const ask = process.env.CURSOR_ROUTE_ASK === "1" || process.env.CLAUDE_DS_ASK === "1";
174
171
  const skip = alwaysApprove && !ask;
175
- // Stock `claude` needs DeepSeek env injected into the worker process
176
- // (tmux panes may not inherit client env from a long-lived server).
177
- const env = resolved.mode.startsWith("claude DeepSeek")
178
- ? deepSeekWorkerEnv(modelId)
179
- : undefined;
180
- if (resolved.mode.startsWith("claude-ds") || resolved.mode.startsWith("deepseek-claude")) {
172
+ const isAnthropicEscape = resolved.mode.startsWith("claude Anthropic");
173
+ const isDeepSeekStock = resolved.mode.startsWith("claude DeepSeek");
174
+ const isShim = resolved.mode.startsWith("claude-ds") || resolved.mode.startsWith("deepseek-claude");
175
+ // Anthropic escape hatch: do not pass DeepSeek model ids (unknown to Anthropic).
176
+ // --model flash|pro is DeepSeek-only; stock Claude uses its own defaults / ANTHROPIC_MODEL.
177
+ if (isAnthropicEscape) {
178
+ const parts = [
179
+ shellQuote(resolved.binary),
180
+ "-p",
181
+ `"$(cat ${shellQuote(promptFile)})"`,
182
+ ];
183
+ if (skip)
184
+ parts.push("--dangerously-skip-permissions");
185
+ return {
186
+ worker: "claude-ds",
187
+ command: `cd ${shellQuote(cwd)} && ${parts.join(" ")}`,
188
+ alwaysApprove: skip,
189
+ };
190
+ }
191
+ const choice = pickModel(model, modelId);
192
+ const env = isDeepSeekStock ? deepSeekWorkerEnv(choice.id) : undefined;
193
+ if (isShim) {
181
194
  const parts = [
182
195
  shellQuote(resolved.binary),
183
196
  "-PromptFile",
184
197
  shellQuote(promptFile),
185
198
  "-Model",
186
- shellQuote(modelId),
199
+ shellQuote(choice.id),
187
200
  ];
188
201
  if (skip)
189
202
  parts.push("--dangerously-skip-permissions");
@@ -194,12 +207,13 @@ export const claudeDsAdapter = {
194
207
  env,
195
208
  };
196
209
  }
210
+ // Stock claude → DeepSeek
197
211
  const parts = [
198
212
  shellQuote(resolved.binary),
199
213
  "-p",
200
214
  `"$(cat ${shellQuote(promptFile)})"`,
201
215
  "--model",
202
- shellQuote(modelId),
216
+ shellQuote(choice.id),
203
217
  ];
204
218
  if (skip)
205
219
  parts.push("--dangerously-skip-permissions");
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { readFileSync, existsSync, realpathSync, statSync } from "node:fs";
6
6
  import { resolve, basename } from "node:path";
7
- import { config, WORKERS, LANES, resolveDsModelAlias, } from "./config.js";
7
+ import { config, WORKERS, LANES, resolveDsModel, } from "./config.js";
8
8
  import { runHealth, printHealth } from "./health.js";
9
9
  import { startJob, listJobs, readJob, killJob, cleanJobs, jobPaths, refreshStatus, } from "./jobs.js";
10
10
  import { capturePane, sendKeys, attachHint, listManagedSessions, sessionExists, } from "./tmux.js";
@@ -31,7 +31,7 @@ Usage:
31
31
  Start options:
32
32
  --worker <grok|claude-ds|openrouter> Worker adapter (default: grok; deepseek = reserved slot)
33
33
  --lane <easy|mid|hard> Lane → worker (easy=openrouter, mid=claude-ds, hard=grok)
34
- --model <flash|pro> Mid DeepSeek model (default: flash). Ignored by grok/openrouter
34
+ --model <flash|pro> Mid DeepSeek only (default: flash / CURSOR_ROUTE_DS_MODEL). Parsed only for claude-ds
35
35
  --dir <path> Working directory (default: cwd)
36
36
  --ask Disable always-approve for this job
37
37
  --dry-run Print launch command; do not start
@@ -43,8 +43,8 @@ Env:
43
43
  CURSOR_ROUTE_JOBS_DIR Override jobs dir (default: ~/.local/share/cursor-route/jobs)
44
44
  CURSOR_ROUTE_MAX_JOBS Max active jobs (default: 50)
45
45
  CURSOR_ROUTE_RELAXED=1 health OK without tmux/workers (CI / infra smoke)
46
- CURSOR_ROUTE_ALLOW_ANTHROPIC=1 Allow mid-lane on Anthropic Claude (expensive; not default)
47
- CURSOR_ROUTE_DS_MODEL Default mid model flash|pro (overridden by --model)
46
+ CURSOR_ROUTE_ALLOW_ANTHROPIC=1 Allow mid-lane on Anthropic Claude (expensive; not default; --model ignored)
47
+ CURSOR_ROUTE_DS_MODEL Default mid model flash|pro (or deepseek-v4-pro[1m]); overridden by --model
48
48
  CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
49
49
  CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
50
50
  OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
@@ -141,12 +141,12 @@ function asLane(v) {
141
141
  return v;
142
142
  throw new Error(`Invalid --lane ${v}; expected ${LANES.join("|")}`);
143
143
  }
144
- function asDsModel(v) {
144
+ function asDsModelChoice(v) {
145
145
  if (v === undefined || v === true)
146
146
  return undefined;
147
147
  if (typeof v !== "string")
148
148
  throw new Error(`Invalid --model; expected flash|pro`);
149
- return resolveDsModelAlias(v);
149
+ return resolveDsModel(v);
150
150
  }
151
151
  function refuseSecrets(text, context) {
152
152
  if (looksLikeSecretMaterial(text)) {
@@ -208,14 +208,33 @@ async function main() {
208
208
  requireStringFlag(f, "model");
209
209
  const promptFile = requireStringFlag(f, "prompt-file");
210
210
  const dirFlag = requireStringFlag(f, "dir");
211
- let model;
211
+ let worker;
212
+ let lane;
212
213
  try {
213
- model = asDsModel(f.model);
214
+ worker = asWorker(f.worker);
215
+ lane = asLane(f.lane);
214
216
  }
215
217
  catch (e) {
216
218
  console.error(e.message);
217
219
  process.exit(2);
218
220
  }
221
+ const resolvedWorker = worker ?? (lane ? config.laneWorkers[lane] : config.defaultWorker);
222
+ let model;
223
+ let modelId;
224
+ // --model is DeepSeek/claude-ds only; ignore (do not validate) for other workers
225
+ if (f.model !== undefined && resolvedWorker === "claude-ds") {
226
+ try {
227
+ const choice = asDsModelChoice(f.model);
228
+ if (choice) {
229
+ model = choice.alias;
230
+ modelId = choice.id;
231
+ }
232
+ }
233
+ catch (e) {
234
+ console.error(e.message);
235
+ process.exit(2);
236
+ }
237
+ }
219
238
  let prompt = "";
220
239
  if (promptFile) {
221
240
  const p = resolve(promptFile);
@@ -250,9 +269,10 @@ async function main() {
250
269
  }
251
270
  const result = startJob({
252
271
  prompt,
253
- worker: asWorker(f.worker),
254
- lane: asLane(f.lane),
272
+ worker,
273
+ lane,
255
274
  model,
275
+ modelId,
256
276
  cwd,
257
277
  alwaysApprove: !f.ask,
258
278
  dryRun: Boolean(f.dryRun),
@@ -304,7 +324,8 @@ async function main() {
304
324
  else {
305
325
  for (const j of jobs) {
306
326
  const age = j.startedAt || j.createdAt;
307
- console.log(`${j.id} ${j.status.padEnd(10)} ${j.worker.padEnd(10)} ${age} ${j.prompt.slice(0, 48).replace(/\n/g, " ")}`);
327
+ const modelCol = j.model ? j.model.padEnd(6) : "".padEnd(6);
328
+ console.log(`${j.id} ${j.status.padEnd(10)} ${j.worker.padEnd(10)} ${modelCol} ${age} ${j.prompt.slice(0, 48).replace(/\n/g, " ")}`);
308
329
  }
309
330
  }
310
331
  return;
@@ -336,7 +357,7 @@ async function main() {
336
357
  if (json)
337
358
  console.log(JSON.stringify(view, null, 2));
338
359
  else {
339
- console.log(`${job.id} ${job.status} worker=${job.worker} sessionAlive=${alive}`);
360
+ console.log(`${job.id} ${job.status} worker=${job.worker}${job.model ? ` model=${job.model}` : ""} sessionAlive=${alive}`);
340
361
  if (job.error)
341
362
  console.log(`error: ${job.error}`);
342
363
  }
package/dist/config.js CHANGED
@@ -8,17 +8,39 @@ export const DS_MODEL_IDS = {
8
8
  flash: "deepseek-v4-flash",
9
9
  pro: "deepseek-v4-pro",
10
10
  };
11
- /** Resolve --model flash|pro (or full deepseek-v4-* id) to a CLI alias. Default: flash. */
12
- export function resolveDsModelAlias(raw) {
13
- if (!raw || !raw.trim())
14
- return "flash";
11
+ /**
12
+ * Resolve --model / env to alias + concrete model id.
13
+ * Preserves `deepseek-v4-pro[1m]` (does not silently strip the SKU).
14
+ * Empty → Flash.
15
+ */
16
+ export function resolveDsModel(raw) {
17
+ if (!raw || !raw.trim()) {
18
+ return { alias: "flash", id: DS_MODEL_IDS.flash };
19
+ }
15
20
  const v = raw.trim().toLowerCase();
16
- if (v === "flash" || v === "deepseek-v4-flash")
17
- return "flash";
18
- if (v === "pro" || v === "deepseek-v4-pro" || v === "deepseek-v4-pro[1m]")
19
- return "pro";
21
+ if (v === "flash" || v === "deepseek-v4-flash") {
22
+ return { alias: "flash", id: DS_MODEL_IDS.flash };
23
+ }
24
+ if (v === "pro" || v === "deepseek-v4-pro") {
25
+ return { alias: "pro", id: DS_MODEL_IDS.pro };
26
+ }
27
+ if (v === "deepseek-v4-pro[1m]") {
28
+ return { alias: "pro", id: "deepseek-v4-pro[1m]" };
29
+ }
20
30
  throw new Error(`Invalid --model ${raw}; expected flash|pro`);
21
31
  }
32
+ /** Alias-only helper (tests / callers that do not need the concrete id). */
33
+ export function resolveDsModelAlias(raw) {
34
+ return resolveDsModel(raw).alias;
35
+ }
36
+ /**
37
+ * Mid default from env: CURSOR_ROUTE_DS_MODEL, else ANTHROPIC_MODEL, else flash.
38
+ * Throws if the env value is set but invalid (fail loud on start).
39
+ */
40
+ export function defaultDsModelFromEnv() {
41
+ const raw = process.env.CURSOR_ROUTE_DS_MODEL || process.env.ANTHROPIC_MODEL;
42
+ return resolveDsModel(raw);
43
+ }
22
44
  /** OpenRouter model for the easy lane (env CURSOR_ROUTE_OPENROUTER_MODEL). */
23
45
  export function openRouterModel() {
24
46
  return process.env.CURSOR_ROUTE_OPENROUTER_MODEL || "openrouter/free";
@@ -43,7 +65,7 @@ function maxConcurrentJobsFromEnv() {
43
65
  */
44
66
  export const config = {
45
67
  product: "cursor-route",
46
- version: "0.1.6",
68
+ version: "0.1.7",
47
69
  get jobsDir() {
48
70
  return defaultJobsDir();
49
71
  },
@@ -55,8 +77,19 @@ export const config = {
55
77
  mid: "claude-ds",
56
78
  hard: "grok",
57
79
  },
58
- /** Default mid DeepSeek model (Flash = cheap execute). Override: --model pro */
59
- defaultDsModel: "flash",
80
+ /**
81
+ * Default mid DeepSeek model (Flash = cheap execute).
82
+ * Live: CURSOR_ROUTE_DS_MODEL / ANTHROPIC_MODEL; invalid env → flash (health-safe).
83
+ * Override on start: --model. startJob uses defaultDsModelFromEnv() and fails on invalid env.
84
+ */
85
+ get defaultDsModel() {
86
+ try {
87
+ return defaultDsModelFromEnv().alias;
88
+ }
89
+ catch {
90
+ return "flash";
91
+ }
92
+ },
60
93
  jobsListLimit: 20,
61
94
  /** Max simultaneously active (running|pending) jobs. Override: CURSOR_ROUTE_MAX_JOBS. */
62
95
  get maxConcurrentJobs() {
package/dist/jobs.js CHANGED
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, unlink
2
2
  import { join, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname } from "node:path";
5
- import { config, sessionName, } from "./config.js";
5
+ import { config, sessionName, defaultDsModelFromEnv, DS_MODEL_IDS, } from "./config.js";
6
6
  import { getAdapter } from "./adapters/index.js";
7
7
  import { spawn } from "node:child_process";
8
8
  import { spawnSync } from "node:child_process";
@@ -217,7 +217,30 @@ export function startJob(opts) {
217
217
  const id = newJobId();
218
218
  const paths = jobPaths(id);
219
219
  writeSecure(paths.prompt, opts.prompt);
220
- const model = worker === "claude-ds" ? opts.model ?? config.defaultDsModel : undefined;
220
+ let model;
221
+ let modelId;
222
+ if (worker === "claude-ds") {
223
+ if (opts.model) {
224
+ model = opts.model;
225
+ modelId = opts.modelId ?? DS_MODEL_IDS[opts.model];
226
+ }
227
+ else {
228
+ try {
229
+ const choice = defaultDsModelFromEnv();
230
+ model = choice.alias;
231
+ modelId = opts.modelId ?? choice.id;
232
+ }
233
+ catch (e) {
234
+ try {
235
+ unlinkSync(paths.prompt);
236
+ }
237
+ catch {
238
+ /* ignore */
239
+ }
240
+ return { ok: false, error: e.message };
241
+ }
242
+ }
243
+ }
221
244
  let plan;
222
245
  try {
223
246
  plan = adapter.buildLaunch({
@@ -225,6 +248,7 @@ export function startJob(opts) {
225
248
  cwd,
226
249
  alwaysApprove,
227
250
  model,
251
+ modelId,
228
252
  });
229
253
  }
230
254
  catch (e) {
package/docs/DEMO_GIF.md CHANGED
@@ -13,7 +13,8 @@ cursor-route health
13
13
 
14
14
  # Terminal A — three parallel jobs
15
15
  cursor-route start --lane hard --dir "$PWD" "Add README section Demo"
16
- cursor-route start --lane mid --dir "$PWD" "Add a unit test for shellQuote"
16
+ cursor-route start --lane mid --dir "$PWD" "Add a unit test for shellQuote" # Flash default
17
+ # Optional: cursor-route start --lane mid --model pro --dir "$PWD" "…"
17
18
  cursor-route start --worker grok --dir "$PWD" "List open TODOs in src/"
18
19
 
19
20
  # Terminal B — watch
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: cursor-route workspace — working brief (edit in place)
3
3
  repo: ~/Projects/cursor-route
4
- npm: cursor-route@0.1.6
4
+ npm: cursor-route@0.1.7 (LIVE on npm latest)
5
5
  created: 2026-08-12
6
6
  updated: 2026-08-12
7
7
  ---
@@ -19,19 +19,22 @@ Cursor Agent plans. Workers run in tmux via `cursor-route`:
19
19
  | Lane | Worker | Intent |
20
20
  |------|--------|--------|
21
21
  | `easy` | OpenRouter free | Wording / drafts — non-secret prompts only |
22
- | `mid` | claude-ds (DeepSeek behind Claude Code) | Default implement (**Flash**) |
22
+ | `mid` | claude-ds (DeepSeek behind Claude Code) | Default implement (**Flash**; `--model pro` when needed) |
23
23
  | `hard` | Grok CLI | Hard implement |
24
24
 
25
25
  Always-approve on (`--ask` / `CURSOR_ROUTE_ASK=1` to opt out). Jobs live in `~/.local/share/cursor-route/jobs`, not in this clone.
26
26
 
27
+ Install: `npm i -g cursor-route` → **0.1.7**. Release notes: [CHANGELOG.md](../../CHANGELOG.md).
28
+
27
29
  ## Open (edit / check off)
28
30
 
29
- - [x] **Flash vs Pro on the CLI** — public default **Flash**; `--model pro` → `deepseek-v4-pro`
31
+ - [x] **Flash vs Pro on the CLI** — public default **Flash**; `--model pro` → `deepseek-v4-pro` (LIVE 0.1.6+)
30
32
  - [x] pass `claude-ds -Model deepseek-v4-flash|deepseek-v4-pro` from the adapter
31
33
  - [x] add `--model flash|pro` on `start`
32
34
  - [x] document Grok **auth** ≠ usage-out (`grok login`) vs quota → Pro stand-in
33
35
  - [x] **Skill `route-orch`** — Flash/Pro table in `skills/` + `.cursor/skills/`
34
36
  - [x] **Official DeepSeek Harness** — `deepseek` adapter slot present; mid stays on claude-ds
37
+ - [x] **0.1.7 debug fixes** — env default wired through `startJob`; Anthropic hatch omits DS ids; preserve `[1m]`
35
38
  - [ ] **Hero GIF** — still outstanding (`docs/DEMO_GIF.md`)
36
39
  - [x] **Do not** paste private `ROUTE_KIT`, SIP, prod paths, or hang-watchdog env into this public repo
37
40
 
@@ -45,6 +48,7 @@ Always-approve on (`--ask` / `CURSOR_ROUTE_ASK=1` to opt out). Jobs live in `~/.
45
48
  | `src/adapters/grok.ts` | Hard worker |
46
49
  | `src/adapters/openrouter.ts` | Easy worker |
47
50
  | `skills/route-orch/SKILL.md` | Cursor skill — spawn CLI, do not implement in-session |
51
+ | `CHANGELOG.md` | Release notes |
48
52
  | `SECURITY.md` | Secret refuse gate |
49
53
 
50
54
  ## Edit log
@@ -53,3 +57,5 @@ Always-approve on (`--ask` / `CURSOR_ROUTE_ASK=1` to opt out). Jobs live in `~/.
53
57
  |------|--------|
54
58
  | 2026-08-12 | Brief created in this repo. Flash/Pro CLI + skill still open. |
55
59
  | 2026-08-12 | Shipped Flash default + `--model pro`, deepseek slot, skill table → 0.1.6. |
60
+ | 2026-08-12 | npm `cursor-route@0.1.6` LIVE; CHANGELOG + README skill-install path fixed. |
61
+ | 2026-08-12 | Grok debug → 0.1.7: env DS default, Anthropic hatch, `[1m]` preserve. |
package/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Cursor stays the planner. DeepSeek (mid), Grok CLI (hard), and OpenRouter free models (easy) run parallel coding workers in tmux.
4
4
 
5
- MIT CLI + Cursor skill. npm: https://www.npmjs.com/package/cursor-route
5
+ MIT CLI + Cursor skill. npm: https://www.npmjs.com/package/cursor-route (latest **0.1.7**)
6
6
  GitHub: https://github.com/cemini23/cursor-route
7
7
 
8
8
  ## FAQ
@@ -16,8 +16,14 @@ It uses the familiar strategist and worker-pane shape, but it is not a Codex clo
16
16
  ### Does mid lane use Anthropic Claude?
17
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`. Default model is Flash (`--model flash`); use `--model pro` for harder mid work or when Grok usage is exhausted (not the same as a missing `grok login`).
18
18
 
19
+ ### Flash vs Pro?
20
+ | Flag | Model | When |
21
+ |------|-------|------|
22
+ | `--model flash` (default) | `deepseek-v4-flash` | Cheap mid execute |
23
+ | `--model pro` | `deepseek-v4-pro` | Harder mid / Grok usage stand-in |
24
+
19
25
  ### 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.
26
+ Run `npm i -g cursor-route`, install tmux if needed, then run `cursor-route health`. Copy the Cursor skill from `$(npm root -g)/cursor-route/skills/route-orch`. Source: https://github.com/cemini23/cursor-route
21
27
 
22
28
  ### Is it free?
23
29
  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. The easy lane can be free on OpenRouter's free-model route (`openrouter/free`).
@@ -26,4 +32,5 @@ The cursor-route code is open source under MIT. It does not make the worker serv
26
32
 
27
33
  - npm: `npm i -g cursor-route`
28
34
  - Health: `cursor-route health`
35
+ - Mid: `cursor-route start --lane mid "…"` (Flash) · `--model pro` when needed
29
36
  - Skill: `/route-orch` (Cursor)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-route",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter easy lane are the parallel army \u2014 lane-aware /route orchestration in tmux.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@
23
23
  "SECURITY.md",
24
24
  "SUPPORT.md",
25
25
  "CONTRIBUTING.md",
26
+ "CHANGELOG.md",
26
27
  "README.md",
27
28
  "llms.txt"
28
29
  ],
@@ -7,7 +7,7 @@ import {
7
7
  DS_MODEL_IDS,
8
8
  type DsModelAlias,
9
9
  config,
10
- resolveDsModelAlias,
10
+ resolveDsModel,
11
11
  } from "../config.ts";
12
12
  import { shellQuote } from "../util.ts";
13
13
 
@@ -29,6 +29,8 @@ function which(cmd: string): string | null {
29
29
  execSync(`command -v ${shellQuote(cmd)}`, {
30
30
  encoding: "utf8",
31
31
  stdio: ["ignore", "pipe", "ignore"],
32
+ // Bun may ignore mutated process.env.PATH unless env is passed explicitly
33
+ env: { ...process.env },
32
34
  }).trim() || null
33
35
  );
34
36
  } catch {
@@ -65,14 +67,16 @@ function deepseekBaseFromSettings(): string | null {
65
67
 
66
68
  /** Resolved DeepSeek base URL for the mid-lane harness, or null. */
67
69
  export function resolvedDeepSeekBaseUrl(): string | null {
70
+ // Explicit env wins: a non-DeepSeek ANTHROPIC_BASE_URL must not fall through to settings.
68
71
  for (const candidate of [
69
72
  process.env.ANTHROPIC_BASE_URL,
70
73
  process.env.CURSOR_ROUTE_ANTHROPIC_BASE_URL,
71
- deepseekBaseFromSettings(),
72
74
  ]) {
73
- if (candidate && isDeepSeekBaseUrl(candidate)) return candidate;
75
+ if (candidate) {
76
+ return isDeepSeekBaseUrl(candidate) ? candidate : null;
77
+ }
74
78
  }
75
- return null;
79
+ return deepseekBaseFromSettings();
76
80
  }
77
81
 
78
82
  /** True when Claude Code harness is routed to DeepSeek (cheap path). */
@@ -120,18 +124,16 @@ function resolveClaudeDs(): { binary: string; mode: string } | null {
120
124
  return null;
121
125
  }
122
126
 
123
- function pickModelAlias(requested?: DsModelAlias): DsModelAlias {
124
- if (requested) return requested;
125
- // Env override for power users who already export ANTHROPIC_MODEL
126
- const fromEnv = process.env.CURSOR_ROUTE_DS_MODEL || process.env.ANTHROPIC_MODEL;
127
- if (fromEnv) {
128
- try {
129
- return resolveDsModelAlias(fromEnv);
130
- } catch {
131
- /* fall through to default */
132
- }
127
+ function pickModel(requested?: DsModelAlias, modelId?: string): { alias: DsModelAlias; id: string } {
128
+ if (modelId) {
129
+ const alias = requested ?? resolveDsModel(modelId).alias;
130
+ return { alias, id: modelId };
131
+ }
132
+ if (requested) {
133
+ return { alias: requested, id: DS_MODEL_IDS[requested] };
133
134
  }
134
- return config.defaultDsModel;
135
+ // Env default (startJob normally resolves this; kept for direct buildLaunch callers)
136
+ return resolveDsModel(process.env.CURSOR_ROUTE_DS_MODEL || process.env.ANTHROPIC_MODEL);
135
137
  }
136
138
 
137
139
  /**
@@ -177,30 +179,45 @@ export const claudeDsAdapter: Adapter = {
177
179
  detail: `ok (${resolved.mode}; default model ${DS_MODEL_IDS[config.defaultDsModel]})`,
178
180
  };
179
181
  },
180
- buildLaunch({ promptFile, cwd, alwaysApprove, model }) {
182
+ buildLaunch({ promptFile, cwd, alwaysApprove, model, modelId }) {
181
183
  const resolved = resolveClaudeDs();
182
184
  if (!resolved) {
183
185
  throw new Error("DeepSeek worker not available — run: cursor-route health");
184
186
  }
185
187
 
186
- const alias = pickModelAlias(model);
187
- const modelId = DS_MODEL_IDS[alias];
188
-
189
188
  const ask = process.env.CURSOR_ROUTE_ASK === "1" || process.env.CLAUDE_DS_ASK === "1";
190
189
  const skip = alwaysApprove && !ask;
191
- // Stock `claude` needs DeepSeek env injected into the worker process
192
- // (tmux panes may not inherit client env from a long-lived server).
193
- const env = resolved.mode.startsWith("claude → DeepSeek")
194
- ? deepSeekWorkerEnv(modelId)
195
- : undefined;
190
+ const isAnthropicEscape = resolved.mode.startsWith("claude Anthropic");
191
+ const isDeepSeekStock = resolved.mode.startsWith("claude DeepSeek");
192
+ const isShim =
193
+ resolved.mode.startsWith("claude-ds") || resolved.mode.startsWith("deepseek-claude");
194
+
195
+ // Anthropic escape hatch: do not pass DeepSeek model ids (unknown to Anthropic).
196
+ // --model flash|pro is DeepSeek-only; stock Claude uses its own defaults / ANTHROPIC_MODEL.
197
+ if (isAnthropicEscape) {
198
+ const parts = [
199
+ shellQuote(resolved.binary),
200
+ "-p",
201
+ `"$(cat ${shellQuote(promptFile)})"`,
202
+ ];
203
+ if (skip) parts.push("--dangerously-skip-permissions");
204
+ return {
205
+ worker: "claude-ds",
206
+ command: `cd ${shellQuote(cwd)} && ${parts.join(" ")}`,
207
+ alwaysApprove: skip,
208
+ };
209
+ }
210
+
211
+ const choice = pickModel(model, modelId);
212
+ const env = isDeepSeekStock ? deepSeekWorkerEnv(choice.id) : undefined;
196
213
 
197
- if (resolved.mode.startsWith("claude-ds") || resolved.mode.startsWith("deepseek-claude")) {
214
+ if (isShim) {
198
215
  const parts = [
199
216
  shellQuote(resolved.binary),
200
217
  "-PromptFile",
201
218
  shellQuote(promptFile),
202
219
  "-Model",
203
- shellQuote(modelId),
220
+ shellQuote(choice.id),
204
221
  ];
205
222
  if (skip) parts.push("--dangerously-skip-permissions");
206
223
  return {
@@ -211,12 +228,13 @@ export const claudeDsAdapter: Adapter = {
211
228
  };
212
229
  }
213
230
 
231
+ // Stock claude → DeepSeek
214
232
  const parts = [
215
233
  shellQuote(resolved.binary),
216
234
  "-p",
217
235
  `"$(cat ${shellQuote(promptFile)})"`,
218
236
  "--model",
219
- shellQuote(modelId),
237
+ shellQuote(choice.id),
220
238
  ];
221
239
  if (skip) parts.push("--dangerously-skip-permissions");
222
240
  return {
@@ -24,7 +24,9 @@ export interface Adapter {
24
24
  promptFile: string;
25
25
  cwd: string;
26
26
  alwaysApprove: boolean;
27
- /** Mid-lane DeepSeek flash|pro (ignored by other workers). */
27
+ /** Mid-lane DeepSeek flash|pro (ignored by other workers / Anthropic escape hatch). */
28
28
  model?: DsModelAlias;
29
+ /** Concrete DeepSeek model id for -Model/--model (preserves pro[1m]). */
30
+ modelId?: string;
29
31
  }): LaunchPlan;
30
32
  }
package/src/cli.test.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { resolveWorker } from "./jobs.ts";
3
- import { config, resolveDsModelAlias } from "./config.ts";
2
+ import { mkdirSync, writeFileSync, chmodSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { resolveWorker, startJob } from "./jobs.ts";
6
+ import { config, resolveDsModel, resolveDsModelAlias } from "./config.ts";
4
7
  import { shellQuote, newJobId } from "./util.ts";
5
8
  import { runHealth } from "./health.ts";
6
9
  import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
7
10
  import { isDeepSeekRouted, isDeepSeekBaseUrl, claudeDsAdapter } from "./adapters/claude-ds.ts";
8
11
  import { deepseekAdapter } from "./adapters/deepseek.ts";
12
+ import { grokAdapter } from "./adapters/grok.ts";
9
13
 
10
14
  describe("resolveWorker", () => {
11
15
  test("lane easy → openrouter", () => {
@@ -30,27 +34,32 @@ describe("resolveWorker", () => {
30
34
  });
31
35
  });
32
36
 
33
- describe("resolveDsModelAlias", () => {
37
+ describe("resolveDsModel", () => {
34
38
  test("defaults to flash", () => {
35
- expect(resolveDsModelAlias()).toBe("flash");
39
+ expect(resolveDsModel()).toEqual({ alias: "flash", id: "deepseek-v4-flash" });
36
40
  expect(resolveDsModelAlias("")).toBe("flash");
37
41
  });
38
42
  test("accepts aliases and full ids", () => {
39
- expect(resolveDsModelAlias("flash")).toBe("flash");
40
- expect(resolveDsModelAlias("pro")).toBe("pro");
41
- expect(resolveDsModelAlias("deepseek-v4-flash")).toBe("flash");
42
- expect(resolveDsModelAlias("deepseek-v4-pro")).toBe("pro");
43
- expect(resolveDsModelAlias("deepseek-v4-pro[1m]")).toBe("pro");
43
+ expect(resolveDsModel("flash").id).toBe("deepseek-v4-flash");
44
+ expect(resolveDsModel("pro").id).toBe("deepseek-v4-pro");
45
+ expect(resolveDsModel("deepseek-v4-pro[1m]")).toEqual({
46
+ alias: "pro",
47
+ id: "deepseek-v4-pro[1m]",
48
+ });
44
49
  });
45
50
  test("rejects unknown", () => {
46
- expect(() => resolveDsModelAlias("opus")).toThrow(/flash\|pro/);
51
+ expect(() => resolveDsModel("opus")).toThrow(/flash\|pro/);
47
52
  });
48
53
  });
49
54
 
50
55
  describe("claude-ds -Model", () => {
51
56
  test("shim path passes -Model deepseek-v4-flash by default", () => {
52
57
  const prev = process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
58
+ const prevDs = process.env.CURSOR_ROUTE_DS_MODEL;
59
+ const prevAm = process.env.ANTHROPIC_MODEL;
53
60
  process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = "/tmp/fake-claude-ds";
61
+ delete process.env.CURSOR_ROUTE_DS_MODEL;
62
+ delete process.env.ANTHROPIC_MODEL;
54
63
  try {
55
64
  const plan = claudeDsAdapter.buildLaunch({
56
65
  promptFile: "/tmp/p.prompt",
@@ -62,6 +71,10 @@ describe("claude-ds -Model", () => {
62
71
  } finally {
63
72
  if (prev === undefined) delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
64
73
  else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev;
74
+ if (prevDs === undefined) delete process.env.CURSOR_ROUTE_DS_MODEL;
75
+ else process.env.CURSOR_ROUTE_DS_MODEL = prevDs;
76
+ if (prevAm === undefined) delete process.env.ANTHROPIC_MODEL;
77
+ else process.env.ANTHROPIC_MODEL = prevAm;
65
78
  }
66
79
  });
67
80
  test("pro alias maps to deepseek-v4-pro", () => {
@@ -81,6 +94,130 @@ describe("claude-ds -Model", () => {
81
94
  else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev;
82
95
  }
83
96
  });
97
+ test("preserves deepseek-v4-pro[1m] via modelId", () => {
98
+ const prev = process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
99
+ process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = "/tmp/fake-claude-ds";
100
+ try {
101
+ const plan = claudeDsAdapter.buildLaunch({
102
+ promptFile: "/tmp/p.prompt",
103
+ cwd: "/tmp",
104
+ alwaysApprove: true,
105
+ model: "pro",
106
+ modelId: "deepseek-v4-pro[1m]",
107
+ });
108
+ expect(plan.command).toContain("deepseek-v4-pro[1m]");
109
+ } finally {
110
+ if (prev === undefined) delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
111
+ else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev;
112
+ }
113
+ });
114
+ test("Anthropic escape hatch omits DeepSeek --model", () => {
115
+ const shimDir = join(tmpdir(), `cr-anth-escape-${process.pid}`);
116
+ mkdirSync(shimDir, { recursive: true });
117
+ const claude = join(shimDir, "claude");
118
+ writeFileSync(claude, "#!/bin/sh\necho ok\n");
119
+ chmodSync(claude, 0o755);
120
+
121
+ const prev = {
122
+ bin: process.env.CURSOR_ROUTE_CLAUDE_DS_BIN,
123
+ allow: process.env.CURSOR_ROUTE_ALLOW_ANTHROPIC,
124
+ base: process.env.ANTHROPIC_BASE_URL,
125
+ path: process.env.PATH,
126
+ };
127
+ delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
128
+ // Explicit non-DeepSeek base so ~/.claude/settings.json cannot force DeepSeek routing
129
+ process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com";
130
+ process.env.CURSOR_ROUTE_ALLOW_ANTHROPIC = "1";
131
+ process.env.PATH = `${shimDir}:/usr/bin:/bin`;
132
+ try {
133
+ const plan = claudeDsAdapter.buildLaunch({
134
+ promptFile: "/tmp/p.prompt",
135
+ cwd: "/tmp",
136
+ alwaysApprove: true,
137
+ model: "pro",
138
+ });
139
+ expect(plan.command).toContain(claude);
140
+ expect(plan.command).not.toContain("deepseek-v4");
141
+ expect(plan.command).not.toContain("--model");
142
+ } finally {
143
+ if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
144
+ else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev.bin;
145
+ if (prev.allow === undefined) delete process.env.CURSOR_ROUTE_ALLOW_ANTHROPIC;
146
+ else process.env.CURSOR_ROUTE_ALLOW_ANTHROPIC = prev.allow;
147
+ if (prev.base === undefined) delete process.env.ANTHROPIC_BASE_URL;
148
+ else process.env.ANTHROPIC_BASE_URL = prev.base;
149
+ process.env.PATH = prev.path || "";
150
+ rmSync(shimDir, { recursive: true, force: true });
151
+ }
152
+ });
153
+ });
154
+
155
+ describe("startJob product path", () => {
156
+ test("CURSOR_ROUTE_DS_MODEL=pro applies when --model unset", () => {
157
+ const prev = {
158
+ bin: process.env.CURSOR_ROUTE_CLAUDE_DS_BIN,
159
+ ds: process.env.CURSOR_ROUTE_DS_MODEL,
160
+ am: process.env.ANTHROPIC_MODEL,
161
+ jobs: process.env.CURSOR_ROUTE_JOBS_DIR,
162
+ };
163
+ const jobsDir = join(tmpdir(), `cr-jobs-ds-${process.pid}`);
164
+ mkdirSync(jobsDir, { recursive: true });
165
+ process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = "/tmp/fake-claude-ds";
166
+ process.env.CURSOR_ROUTE_DS_MODEL = "pro";
167
+ delete process.env.ANTHROPIC_MODEL;
168
+ process.env.CURSOR_ROUTE_JOBS_DIR = jobsDir;
169
+ try {
170
+ const result = startJob({
171
+ prompt: "ping",
172
+ lane: "mid",
173
+ dryRun: true,
174
+ });
175
+ expect(result.ok).toBe(true);
176
+ if (!result.ok) return;
177
+ expect(result.job.model).toBe("pro");
178
+ expect(result.command).toContain("deepseek-v4-pro");
179
+ expect(result.command).not.toContain("deepseek-v4-flash");
180
+ } finally {
181
+ if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
182
+ else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev.bin;
183
+ if (prev.ds === undefined) delete process.env.CURSOR_ROUTE_DS_MODEL;
184
+ else process.env.CURSOR_ROUTE_DS_MODEL = prev.ds;
185
+ if (prev.am === undefined) delete process.env.ANTHROPIC_MODEL;
186
+ else process.env.ANTHROPIC_MODEL = prev.am;
187
+ if (prev.jobs === undefined) delete process.env.CURSOR_ROUTE_JOBS_DIR;
188
+ else process.env.CURSOR_ROUTE_JOBS_DIR = prev.jobs;
189
+ rmSync(jobsDir, { recursive: true, force: true });
190
+ }
191
+ });
192
+
193
+ test("--worker deepseek dry-run fails", () => {
194
+ const result = startJob({
195
+ prompt: "ping",
196
+ worker: "deepseek",
197
+ dryRun: true,
198
+ });
199
+ expect(result.ok).toBe(false);
200
+ if (result.ok) return;
201
+ expect(result.error).toMatch(/not available|claude-ds/i);
202
+ });
203
+
204
+ test("grok command never includes deepseek-v4", () => {
205
+ const prev = process.env.CURSOR_ROUTE_GROK_BIN;
206
+ process.env.CURSOR_ROUTE_GROK_BIN = "/tmp/fake-grok";
207
+ try {
208
+ const plan = grokAdapter.buildLaunch({
209
+ promptFile: "/tmp/p.prompt",
210
+ cwd: "/tmp",
211
+ alwaysApprove: true,
212
+ model: "pro",
213
+ });
214
+ expect(plan.command).not.toContain("deepseek-v4");
215
+ expect(plan.command).not.toMatch(/(^|[\s'-])Model\b/);
216
+ } finally {
217
+ if (prev === undefined) delete process.env.CURSOR_ROUTE_GROK_BIN;
218
+ else process.env.CURSOR_ROUTE_GROK_BIN = prev;
219
+ }
220
+ });
84
221
  });
85
222
 
86
223
  describe("deepseek adapter slot", () => {
@@ -153,11 +290,26 @@ describe("health", () => {
153
290
  test("returns structured report", () => {
154
291
  const r = runHealth();
155
292
  expect(r.product).toBe("cursor-route");
293
+ expect(r.version).toBe("0.1.7");
156
294
  expect(r.checks.length).toBeGreaterThan(3);
157
295
  expect(r.checks.some((c) => c.name === "tmux")).toBe(true);
158
296
  expect(r.checks.some((c) => c.name === "cursor_cli")).toBe(true);
159
297
  });
160
298
 
299
+ test("OR-gate: ok can be true while worker:deepseek is false", () => {
300
+ const prev = process.env.CURSOR_ROUTE_RELAXED;
301
+ process.env.CURSOR_ROUTE_RELAXED = "1";
302
+ try {
303
+ const r = runHealth();
304
+ const ds = r.checks.find((c) => c.name === "worker:deepseek");
305
+ expect(ds?.ok).toBe(false);
306
+ expect(r.ok).toBe(true);
307
+ } finally {
308
+ if (prev === undefined) delete process.env.CURSOR_ROUTE_RELAXED;
309
+ else process.env.CURSOR_ROUTE_RELAXED = prev;
310
+ }
311
+ });
312
+
161
313
  test("relaxed env can pass without workers", () => {
162
314
  const prev = process.env.CURSOR_ROUTE_RELAXED;
163
315
  process.env.CURSOR_ROUTE_RELAXED = "1";
package/src/cli.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  config,
9
9
  WORKERS,
10
10
  LANES,
11
- resolveDsModelAlias,
11
+ resolveDsModel,
12
12
  type WorkerKind,
13
13
  type Lane,
14
14
  type DsModelAlias,
@@ -54,7 +54,7 @@ Usage:
54
54
  Start options:
55
55
  --worker <grok|claude-ds|openrouter> Worker adapter (default: grok; deepseek = reserved slot)
56
56
  --lane <easy|mid|hard> Lane → worker (easy=openrouter, mid=claude-ds, hard=grok)
57
- --model <flash|pro> Mid DeepSeek model (default: flash). Ignored by grok/openrouter
57
+ --model <flash|pro> Mid DeepSeek only (default: flash / CURSOR_ROUTE_DS_MODEL). Parsed only for claude-ds
58
58
  --dir <path> Working directory (default: cwd)
59
59
  --ask Disable always-approve for this job
60
60
  --dry-run Print launch command; do not start
@@ -66,8 +66,8 @@ Env:
66
66
  CURSOR_ROUTE_JOBS_DIR Override jobs dir (default: ~/.local/share/cursor-route/jobs)
67
67
  CURSOR_ROUTE_MAX_JOBS Max active jobs (default: 50)
68
68
  CURSOR_ROUTE_RELAXED=1 health OK without tmux/workers (CI / infra smoke)
69
- CURSOR_ROUTE_ALLOW_ANTHROPIC=1 Allow mid-lane on Anthropic Claude (expensive; not default)
70
- CURSOR_ROUTE_DS_MODEL Default mid model flash|pro (overridden by --model)
69
+ CURSOR_ROUTE_ALLOW_ANTHROPIC=1 Allow mid-lane on Anthropic Claude (expensive; not default; --model ignored)
70
+ CURSOR_ROUTE_DS_MODEL Default mid model flash|pro (or deepseek-v4-pro[1m]); overridden by --model
71
71
  CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
72
72
  CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
73
73
  OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
@@ -162,10 +162,10 @@ function asLane(v: unknown): Lane | undefined {
162
162
  throw new Error(`Invalid --lane ${v}; expected ${LANES.join("|")}`);
163
163
  }
164
164
 
165
- function asDsModel(v: unknown): DsModelAlias | undefined {
165
+ function asDsModelChoice(v: unknown): { alias: DsModelAlias; id: string } | undefined {
166
166
  if (v === undefined || v === true) return undefined;
167
167
  if (typeof v !== "string") throw new Error(`Invalid --model; expected flash|pro`);
168
- return resolveDsModelAlias(v);
168
+ return resolveDsModel(v);
169
169
  }
170
170
 
171
171
  function refuseSecrets(text: string, context: string): void {
@@ -235,13 +235,34 @@ async function main() {
235
235
  requireStringFlag(f, "model");
236
236
  const promptFile = requireStringFlag(f, "prompt-file");
237
237
  const dirFlag = requireStringFlag(f, "dir");
238
- let model: DsModelAlias | undefined;
238
+
239
+ let worker: WorkerKind | undefined;
240
+ let lane: Lane | undefined;
239
241
  try {
240
- model = asDsModel(f.model);
242
+ worker = asWorker(f.worker);
243
+ lane = asLane(f.lane);
241
244
  } catch (e) {
242
245
  console.error((e as Error).message);
243
246
  process.exit(2);
244
247
  }
248
+ const resolvedWorker =
249
+ worker ?? (lane ? config.laneWorkers[lane] : config.defaultWorker);
250
+
251
+ let model: DsModelAlias | undefined;
252
+ let modelId: string | undefined;
253
+ // --model is DeepSeek/claude-ds only; ignore (do not validate) for other workers
254
+ if (f.model !== undefined && resolvedWorker === "claude-ds") {
255
+ try {
256
+ const choice = asDsModelChoice(f.model);
257
+ if (choice) {
258
+ model = choice.alias;
259
+ modelId = choice.id;
260
+ }
261
+ } catch (e) {
262
+ console.error((e as Error).message);
263
+ process.exit(2);
264
+ }
265
+ }
245
266
 
246
267
  let prompt = "";
247
268
  if (promptFile) {
@@ -277,9 +298,10 @@ async function main() {
277
298
 
278
299
  const result = startJob({
279
300
  prompt,
280
- worker: asWorker(f.worker),
281
- lane: asLane(f.lane),
301
+ worker,
302
+ lane,
282
303
  model,
304
+ modelId,
283
305
  cwd,
284
306
  alwaysApprove: !f.ask,
285
307
  dryRun: Boolean(f.dryRun),
@@ -333,8 +355,9 @@ async function main() {
333
355
  } else {
334
356
  for (const j of jobs) {
335
357
  const age = j.startedAt || j.createdAt;
358
+ const modelCol = j.model ? j.model.padEnd(6) : "".padEnd(6);
336
359
  console.log(
337
- `${j.id} ${j.status.padEnd(10)} ${j.worker.padEnd(10)} ${age} ${j.prompt.slice(0, 48).replace(/\n/g, " ")}`,
360
+ `${j.id} ${j.status.padEnd(10)} ${j.worker.padEnd(10)} ${modelCol} ${age} ${j.prompt.slice(0, 48).replace(/\n/g, " ")}`,
338
361
  );
339
362
  }
340
363
  }
@@ -366,7 +389,9 @@ async function main() {
366
389
  const view = { ...job, sessionAlive: alive };
367
390
  if (json) console.log(JSON.stringify(view, null, 2));
368
391
  else {
369
- console.log(`${job.id} ${job.status} worker=${job.worker} sessionAlive=${alive}`);
392
+ console.log(
393
+ `${job.id} ${job.status} worker=${job.worker}${job.model ? ` model=${job.model}` : ""} sessionAlive=${alive}`,
394
+ );
370
395
  if (job.error) console.log(`error: ${job.error}`);
371
396
  }
372
397
  return;
package/src/config.ts CHANGED
@@ -9,6 +9,12 @@ export type Lane = "easy" | "mid" | "hard";
9
9
  /** Public CLI aliases for mid-lane DeepSeek models. */
10
10
  export type DsModelAlias = "flash" | "pro";
11
11
 
12
+ export interface DsModelChoice {
13
+ alias: DsModelAlias;
14
+ /** Exact id passed to claude-ds `-Model` / stock `claude --model`. */
15
+ id: string;
16
+ }
17
+
12
18
  export const WORKERS: WorkerKind[] = ["grok", "claude-ds", "openrouter", "deepseek"];
13
19
  export const LANES: Lane[] = ["easy", "mid", "hard"];
14
20
  export const DS_MODELS: DsModelAlias[] = ["flash", "pro"];
@@ -18,15 +24,42 @@ export const DS_MODEL_IDS: Record<DsModelAlias, string> = {
18
24
  pro: "deepseek-v4-pro",
19
25
  };
20
26
 
21
- /** Resolve --model flash|pro (or full deepseek-v4-* id) to a CLI alias. Default: flash. */
22
- export function resolveDsModelAlias(raw?: string | null): DsModelAlias {
23
- if (!raw || !raw.trim()) return "flash";
27
+ /**
28
+ * Resolve --model / env to alias + concrete model id.
29
+ * Preserves `deepseek-v4-pro[1m]` (does not silently strip the SKU).
30
+ * Empty → Flash.
31
+ */
32
+ export function resolveDsModel(raw?: string | null): DsModelChoice {
33
+ if (!raw || !raw.trim()) {
34
+ return { alias: "flash", id: DS_MODEL_IDS.flash };
35
+ }
24
36
  const v = raw.trim().toLowerCase();
25
- if (v === "flash" || v === "deepseek-v4-flash") return "flash";
26
- if (v === "pro" || v === "deepseek-v4-pro" || v === "deepseek-v4-pro[1m]") return "pro";
37
+ if (v === "flash" || v === "deepseek-v4-flash") {
38
+ return { alias: "flash", id: DS_MODEL_IDS.flash };
39
+ }
40
+ if (v === "pro" || v === "deepseek-v4-pro") {
41
+ return { alias: "pro", id: DS_MODEL_IDS.pro };
42
+ }
43
+ if (v === "deepseek-v4-pro[1m]") {
44
+ return { alias: "pro", id: "deepseek-v4-pro[1m]" };
45
+ }
27
46
  throw new Error(`Invalid --model ${raw}; expected flash|pro`);
28
47
  }
29
48
 
49
+ /** Alias-only helper (tests / callers that do not need the concrete id). */
50
+ export function resolveDsModelAlias(raw?: string | null): DsModelAlias {
51
+ return resolveDsModel(raw).alias;
52
+ }
53
+
54
+ /**
55
+ * Mid default from env: CURSOR_ROUTE_DS_MODEL, else ANTHROPIC_MODEL, else flash.
56
+ * Throws if the env value is set but invalid (fail loud on start).
57
+ */
58
+ export function defaultDsModelFromEnv(): DsModelChoice {
59
+ const raw = process.env.CURSOR_ROUTE_DS_MODEL || process.env.ANTHROPIC_MODEL;
60
+ return resolveDsModel(raw);
61
+ }
62
+
30
63
  /** OpenRouter model for the easy lane (env CURSOR_ROUTE_OPENROUTER_MODEL). */
31
64
  export function openRouterModel(): string {
32
65
  return process.env.CURSOR_ROUTE_OPENROUTER_MODEL || "openrouter/free";
@@ -53,7 +86,7 @@ function maxConcurrentJobsFromEnv(): number {
53
86
  */
54
87
  export const config = {
55
88
  product: "cursor-route",
56
- version: "0.1.6",
89
+ version: "0.1.7",
57
90
  get jobsDir(): string {
58
91
  return defaultJobsDir();
59
92
  },
@@ -65,8 +98,18 @@ export const config = {
65
98
  mid: "claude-ds" as WorkerKind,
66
99
  hard: "grok" as WorkerKind,
67
100
  },
68
- /** Default mid DeepSeek model (Flash = cheap execute). Override: --model pro */
69
- defaultDsModel: "flash" as DsModelAlias,
101
+ /**
102
+ * Default mid DeepSeek model (Flash = cheap execute).
103
+ * Live: CURSOR_ROUTE_DS_MODEL / ANTHROPIC_MODEL; invalid env → flash (health-safe).
104
+ * Override on start: --model. startJob uses defaultDsModelFromEnv() and fails on invalid env.
105
+ */
106
+ get defaultDsModel(): DsModelAlias {
107
+ try {
108
+ return defaultDsModelFromEnv().alias;
109
+ } catch {
110
+ return "flash";
111
+ }
112
+ },
70
113
  jobsListLimit: 20,
71
114
  /** Max simultaneously active (running|pending) jobs. Override: CURSOR_ROUTE_MAX_JOBS. */
72
115
  get maxConcurrentJobs(): number {
package/src/jobs.ts CHANGED
@@ -15,6 +15,8 @@ import { dirname } from "node:path";
15
15
  import {
16
16
  config,
17
17
  sessionName,
18
+ defaultDsModelFromEnv,
19
+ DS_MODEL_IDS,
18
20
  type Lane,
19
21
  type WorkerKind,
20
22
  type DsModelAlias,
@@ -231,6 +233,8 @@ export interface StartOptions {
231
233
  lane?: Lane;
232
234
  /** Mid-lane DeepSeek: flash (default) | pro. Ignored by grok/openrouter. */
233
235
  model?: DsModelAlias;
236
+ /** Concrete DeepSeek id (preserves pro[1m]). Derived from --model / env when unset. */
237
+ modelId?: string;
234
238
  cwd?: string;
235
239
  alwaysApprove?: boolean;
236
240
  dryRun?: boolean;
@@ -278,8 +282,27 @@ export function startJob(opts: StartOptions): {
278
282
  const paths = jobPaths(id);
279
283
  writeSecure(paths.prompt, opts.prompt);
280
284
 
281
- const model =
282
- worker === "claude-ds" ? opts.model ?? config.defaultDsModel : undefined;
285
+ let model: DsModelAlias | undefined;
286
+ let modelId: string | undefined;
287
+ if (worker === "claude-ds") {
288
+ if (opts.model) {
289
+ model = opts.model;
290
+ modelId = opts.modelId ?? DS_MODEL_IDS[opts.model];
291
+ } else {
292
+ try {
293
+ const choice = defaultDsModelFromEnv();
294
+ model = choice.alias;
295
+ modelId = opts.modelId ?? choice.id;
296
+ } catch (e) {
297
+ try {
298
+ unlinkSync(paths.prompt);
299
+ } catch {
300
+ /* ignore */
301
+ }
302
+ return { ok: false, error: (e as Error).message };
303
+ }
304
+ }
305
+ }
283
306
 
284
307
  let plan;
285
308
  try {
@@ -288,6 +311,7 @@ export function startJob(opts: StartOptions): {
288
311
  cwd,
289
312
  alwaysApprove,
290
313
  model,
314
+ modelId,
291
315
  });
292
316
  } catch (e) {
293
317
  try {