cursor-route 0.1.6 → 0.1.8

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,40 @@
1
+ # Changelog
2
+
3
+ ## 0.1.8 — 2026-08-14
4
+
5
+ - Experimental `--worker deepseek`: official DeepSeek Harness (`dsh`, `@deepseek-ai/dsh`) runs headless — `dsh --profile headless` with a per-job Cordis patch pinning the model. Not a mid default: `laneWorkers.mid` stays `claude-ds`.
6
+ - `--model flash|pro` (and `deepseek-v4-pro[1m]`) now applies to both `claude-ds` and `deepseek`; patch sets `agent-default-model` → `provider: deepseek-official` (never rewrites `~/.dsh/settings.yaml`, so parallel jobs don't race).
7
+ - Always-approve → `DSH_PERMISSION_MODE=danger-full-access`; `--ask` → `workspace-write`. `DEEPSEEK_API_KEY` travels via env only — never in the launch command or patch.
8
+ - Health: `worker:deepseek` ✓ only when `dsh` is on PATH (override `CURSOR_ROUTE_DSH_BIN`) **and** `DEEPSEEK_API_KEY` is set.
9
+ - Dry-run for `--worker deepseek` works without `dsh` installed (falls back to plain `dsh` in the printed command); real starts stay gated by health.
10
+ - `clean` also removes leftover `.dsh-patch.yml` files; patch path never overwrites a non-`.prompt` prompt file.
11
+
12
+ ## 0.1.7 — 2026-08-12
13
+
14
+ - Fix: `CURSOR_ROUTE_DS_MODEL` / `ANTHROPIC_MODEL` now apply on the `start` product path (live default)
15
+ - Fix: Anthropic escape hatch (`CURSOR_ROUTE_ALLOW_ANTHROPIC=1`) no longer passes DeepSeek `--model` ids
16
+ - Fix: preserve `deepseek-v4-pro[1m]` (no silent SKU strip)
17
+ - `--model` validated only for `claude-ds`; ignored for grok/openrouter (including invalid values)
18
+ - `jobs` / `status` text output show `model` when set
19
+ - Tests cover env default via `startJob`, deepseek slot, Anthropic hatch, health OR-gate
20
+
21
+ ## 0.1.6 — 2026-08-12
22
+
23
+ - Mid lane defaults to DeepSeek **Flash** (`deepseek-v4-flash`)
24
+ - `cursor-route start --model flash|pro` (aliases / full ids accepted)
25
+ - `claude-ds` adapter passes `-Model`; stock `claude` → DeepSeek gets `--model`
26
+ - Reserved `deepseek` worker slot (unreleased official harness; mid stays on `claude-ds`)
27
+ - Docs: Grok **auth** (`grok login`) ≠ usage/quota out → use `--model pro`
28
+ - `route-orch` skill Flash/Pro table; living brief in `docs/briefs/WORKING.md`
29
+
30
+ ## 0.1.5
31
+
32
+ - OpenRouter free easy lane (`--lane easy` / `--worker openrouter`)
33
+
34
+ ## 0.1.4
35
+
36
+ - Compiled `dist/` happy path, integration tests, `CURSOR_ROUTE_MAX_JOBS`
37
+
38
+ ## Earlier
39
+
40
+ See git tags `v0.1.1`–`v0.1.3` for security/hardening and first npm ship notes.
package/CONTRIBUTING.md CHANGED
@@ -5,22 +5,31 @@
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"
14
+ ./bin/cursor-route start --dry-run --worker deepseek --model flash "ping" # needs dsh + DEEPSEEK_API_KEY for real starts
11
15
  ```
12
16
 
17
+ Keep `skills/route-orch/SKILL.md` and `.cursor/skills/route-orch/SKILL.md` identical when editing the skill.
18
+
13
19
  ## Rules
14
20
 
15
21
  - No secrets in prompts, fixtures, or commits
16
22
  - Do not vendor private `agent-toolkit` paths
17
23
  - Prefer adapters over rewriting the job core
18
24
  - Always-approve defaults stay documented + opt-out (`--ask` / `CURSOR_ROUTE_ASK=1`)
25
+ - Mid default is Flash; document Pro only as harder mid / Grok **usage** stand-in (not missing `grok login`)
19
26
 
20
27
  ## Release checklist
21
28
 
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)
29
+ 1. Update `package.json` version + [CHANGELOG.md](./CHANGELOG.md) + [docs/briefs/WORKING.md](./docs/briefs/WORKING.md)
30
+ 2. `bun test` && `bun run typecheck` && `bun run build`
31
+ 3. `cursor-route health` (tmux + ≥1 worker)
32
+ 4. One real `start` smoke (grok or claude-ds); dry-run both `--model flash` and `--model pro`
33
+ 5. `npm pack --dry-run` no secrets, no `node_modules`
34
+ 6. Commit, tag `v0.x.y`, push
35
+ 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 |
@@ -99,6 +102,36 @@ Working notes for this repo (edit in place): [docs/briefs/WORKING.md](./docs/bri
99
102
  | `sessions` | Managed tmux sessions |
100
103
  | `clean [--days N]` | Drop old job files |
101
104
 
105
+ ## Demo
106
+
107
+ A dry-run capture (no live workers, no secrets) is committed as a reproducible fixture:
108
+
109
+ ```bash
110
+ # Needs Bun or Node 20+ and a runnable ./bin/cursor-route (clone: bun install)
111
+ docs/fixtures/generate-hero-demo.sh # regenerate docs/fixtures/hero-demo.log
112
+ ```
113
+
114
+ ```text
115
+ $ cursor-route --version
116
+ 0.1.8
117
+
118
+ $ CURSOR_ROUTE_RELAXED=1 cursor-route health
119
+ cursor-route v0.1.8
120
+ health: OK
121
+
122
+ $ cursor-route start --lane mid --model flash --dry-run "Add a unit test for shellQuote"
123
+ dry-run job a1b2c3d4
124
+ worker: claude-ds
125
+ model: flash
126
+ command: cd '~/Projects/cursor-route' && '~/.local/bin/claude-ds' -PromptFile '~/.local/share/cursor-route/jobs/a1b2c3d4.prompt' -Model 'deepseek-v4-flash' --dangerously-skip-permissions
127
+
128
+ $ cursor-route jobs --json
129
+ []
130
+ ```
131
+
132
+ Full capture: [docs/fixtures/hero-demo.log](./docs/fixtures/hero-demo.log).
133
+ A real hero GIF is still pending — recording steps live in [docs/DEMO_GIF.md](./docs/DEMO_GIF.md).
134
+
102
135
  ## Lanes
103
136
 
104
137
  | Lane | Worker | Intent |
@@ -115,14 +148,19 @@ Always-approve is **on** by default. Opt out: `--ask` or `CURSOR_ROUTE_ASK=1`.
115
148
  |------|----------|------|
116
149
  | `--model flash` (default) | `deepseek-v4-flash` | Cheap mid execute |
117
150
  | `--model pro` | `deepseek-v4-pro` | Harder mid / Grok **usage** stand-in |
151
+ | `--model deepseek-v4-pro[1m]` | `deepseek-v4-pro[1m]` | Large-context Pro (SKU preserved) |
118
152
 
119
153
  ```bash
120
154
  cursor-route start --lane mid "…" # Flash
121
155
  cursor-route start --lane mid --model pro "…" # Pro
156
+ # Or set default without a flag:
157
+ export CURSOR_ROUTE_DS_MODEL=pro # also honors ANTHROPIC_MODEL; --model overrides
122
158
  ```
123
159
 
124
160
  **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
161
 
162
+ `CURSOR_ROUTE_ALLOW_ANTHROPIC=1` is an expensive escape hatch: it does **not** pass DeepSeek `--model` ids (Anthropic would reject them).
163
+
126
164
  ## DeepSeek setup (the cheap mid-lane — this is the point)
127
165
 
128
166
  `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.
@@ -147,7 +185,18 @@ Persist the same vars under `~/.claude/settings.json` → `"env": { … }` if yo
147
185
 
148
186
  **Also accepted:** `claude-ds` or `deepseek-claude` on PATH (Cemini shims). The adapter passes `-Model deepseek-v4-flash|deepseek-v4-pro`.
149
187
 
150
- **Reserved:** `--worker deepseek` is a slot for an official DeepSeek coding harness not wired yet. Mid stays on `claude-ds`.
188
+ **Experimental:** `--worker deepseek` runs the official DeepSeek Harness (`dsh`, npm `@deepseek-ai/dsh`) as an opt-in worker **mid stays on `claude-ds`**; don't default lanes here.
189
+
190
+ ```bash
191
+ npm i -g @deepseek-ai/dsh
192
+ export DEEPSEEK_API_KEY=YOUR_DEEPSEEK_API_KEY # from platform.deepseek.com
193
+
194
+ cursor-route health # worker:deepseek should be ✓
195
+ cursor-route start --worker deepseek "…" # Flash (default)
196
+ cursor-route start --worker deepseek --model pro "…" # Pro when you need it
197
+ ```
198
+
199
+ The adapter launches `dsh --profile headless` with a **per-job Cordis patch** (`jobs/<id>.dsh-patch.yml`, mode 0600) that pins `--model flash|pro` (`deepseek-v4-pro[1m]` preserved) — it never rewrites `~/.dsh/settings.yaml`, so parallel jobs don't race. Always-approve maps to `DSH_PERMISSION_MODE=danger-full-access`; `--ask` drops to `workspace-write`. Your `DEEPSEEK_API_KEY` travels via env only — never in the launch command or patch. Override the binary with `CURSOR_ROUTE_DSH_BIN`.
151
200
 
152
201
  **Not the default:** bare `claude` still talking to Anthropic. Health refuses that so a misconfigured install cannot silently burn frontier $ rates. Escape hatch only: `CURSOR_ROUTE_ALLOW_ANTHROPIC=1`.
153
202
 
@@ -202,6 +251,7 @@ The cursor-route code is open source under MIT. It does not make the worker serv
202
251
 
203
252
  ## Related
204
253
 
254
+ - Changelog: [CHANGELOG.md](./CHANGELOG.md)
205
255
  - Newsletter: [Outlier Weekly](https://outlierweekly.substack.com)
206
256
  - YouTube: [@Cemini23](https://www.youtube.com/@Cemini23)
207
257
  - Agent meta-wiki: [cemini-claude-code-CCC](https://github.com/cemini23/cemini-claude-code-CCC)
@@ -240,8 +290,8 @@ MIT © Cemini — see [LICENSE](LICENSE).
240
290
 
241
291
  ## Roadmap (explicitly later)
242
292
 
243
- - Homebrew tap
244
- - Native DeepSeek harness adapter
245
- - Codebase map injection (`--map`)
246
- - Cursor CLI `agent` as alternate supervisor
293
+ - Homebrew tap
294
+ - Stabilize the DeepSeek harness adapter (experimental `--worker deepseek` since 0.1.8)
295
+ - Codebase map injection (`--map`)
296
+ - Cursor CLI `agent` as alternate supervisor
247
297
  - Web UI / CAO-style MCP supervisor
@@ -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");
@@ -1,19 +1,138 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, writeFileSync, unlinkSync } from "node:fs";
3
+ import { shellQuote } from "../util.js";
4
+ import { DS_MODEL_IDS, resolveDsModel, } from "../config.js";
1
5
  /**
2
- * Reserved slot for the official DeepSeek coding harness when it ships.
3
- * Mid lane stays on claude-ds until then do not route jobs here.
6
+ * Experimental official DeepSeek Harness (`dsh`, npm @deepseek-ai/dsh) as a
7
+ * coding worker `dsh --profile headless` with a per-job Cordis patch that
8
+ * pins the model. Mid lane stays on claude-ds; this is an opt-in worker only
9
+ * (`--worker deepseek`), not a mid replacement.
10
+ *
11
+ * We never write ~/.dsh/settings.yaml (parallel jobs would race) and never
12
+ * put DEEPSEEK_API_KEY in the command or patch — the key travels via plan.env.
4
13
  */
14
+ function findDsh() {
15
+ // Env override lets tests pin a fake dsh — but it must exist, so a stale
16
+ // override cannot pass health with a dangling path.
17
+ const override = process.env.CURSOR_ROUTE_DSH_BIN;
18
+ if (override)
19
+ return existsSync(override) ? override : null;
20
+ try {
21
+ return (execSync("command -v dsh", {
22
+ encoding: "utf8",
23
+ stdio: ["ignore", "pipe", "ignore"],
24
+ // Bun may ignore mutated process.env.PATH unless env is passed explicitly
25
+ env: { ...process.env },
26
+ }).trim() || null);
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** Same resolution as claude-ds: passed model/modelId wins, else env, else Flash. */
33
+ function pickModel(requested, modelId) {
34
+ if (modelId) {
35
+ const alias = requested ?? resolveDsModel(modelId).alias;
36
+ return { alias, id: modelId };
37
+ }
38
+ if (requested) {
39
+ return { alias: requested, id: DS_MODEL_IDS[requested] };
40
+ }
41
+ // Env default (startJob normally resolves this; kept for direct buildLaunch callers)
42
+ return resolveDsModel(process.env.CURSOR_ROUTE_DS_MODEL || process.env.ANTHROPIC_MODEL);
43
+ }
44
+ /** Whitelist model ids before interpolating into YAML (no newlines / injection). */
45
+ function assertPatchModelId(modelId) {
46
+ if (!/^[a-z0-9][a-z0-9.\-[\]]*$/i.test(modelId)) {
47
+ throw new Error(`Invalid DeepSeek model id for dsh patch: ${modelId}`);
48
+ }
49
+ return modelId;
50
+ }
51
+ /** Per-job Cordis patch path. Never reuse the prompt path (would overwrite it). */
52
+ export function patchPathForPrompt(promptFile) {
53
+ return promptFile.endsWith(".prompt")
54
+ ? promptFile.replace(/\.prompt$/, ".dsh-patch.yml")
55
+ : `${promptFile}.dsh-patch.yml`;
56
+ }
57
+ /** Per-job Cordis patch (whole-row replace). `name` is required or dsh silently skips. */
58
+ function patchYaml(modelId) {
59
+ const id = assertPatchModelId(modelId);
60
+ return [
61
+ "- id: agent-default-model",
62
+ " name: '@deepseek-ai/dsh-agent-default-model'",
63
+ " config:",
64
+ " provider: deepseek-official",
65
+ ` model: '${id}'`,
66
+ ].join("\n") + "\n";
67
+ }
5
68
  export const deepseekAdapter = {
6
69
  kind: "deepseek",
7
- label: "Official DeepSeek harness (unreleased)",
70
+ label: "Official DeepSeek Harness (dsh)",
8
71
  health() {
72
+ const binary = findDsh();
73
+ if (!binary) {
74
+ return {
75
+ worker: "deepseek",
76
+ ok: false,
77
+ binary: null,
78
+ detail: "dsh (@deepseek-ai/dsh) not found — install: npm i -g @deepseek-ai/dsh. Mid default remains claude-ds.",
79
+ };
80
+ }
81
+ if (!process.env.DEEPSEEK_API_KEY) {
82
+ return {
83
+ worker: "deepseek",
84
+ ok: false,
85
+ binary,
86
+ detail: "DEEPSEEK_API_KEY not set — export your DeepSeek API key to use dsh (@deepseek-ai/dsh). Mid default remains claude-ds.",
87
+ };
88
+ }
9
89
  return {
10
90
  worker: "deepseek",
11
- ok: false,
12
- binary: null,
13
- detail: "unreleased mid lane uses claude-ds (DeepSeek behind Claude Code). See README.",
91
+ ok: true,
92
+ binary,
93
+ detail: "ok (dsh @deepseek-ai/dsh headless; mid default remains claude-ds)",
14
94
  };
15
95
  },
16
- buildLaunch() {
17
- throw new Error("Official DeepSeek harness is not available yet use --lane mid / --worker claude-ds");
96
+ buildLaunch({ promptFile, cwd, alwaysApprove, model, modelId, dryRun }) {
97
+ // Missing dsh is tolerated here so `--dry-run` can still print the command;
98
+ // real starts are gated by the health preflight (binary + DEEPSEEK_API_KEY).
99
+ const binary = findDsh() || "dsh";
100
+ const choice = pickModel(model, modelId);
101
+ // Per-job patch next to the prompt file (never touch ~/.dsh/settings.yaml).
102
+ const patchFile = patchPathForPrompt(promptFile);
103
+ writeFileSync(patchFile, patchYaml(choice.id), { mode: 0o600 });
104
+ if (dryRun) {
105
+ // Dry-run keeps no durable artifacts (jobs.ts removes the prompt likewise).
106
+ try {
107
+ unlinkSync(patchFile);
108
+ }
109
+ catch {
110
+ /* ignore */
111
+ }
112
+ }
113
+ // Launcher flags before the task; prompt inlined via cat (never the key).
114
+ const parts = [
115
+ shellQuote(binary),
116
+ "--profile",
117
+ "headless",
118
+ "--patch",
119
+ shellQuote(patchFile),
120
+ `"$(cat ${shellQuote(promptFile)})"`,
121
+ ];
122
+ const ask = process.env.CURSOR_ROUTE_ASK === "1";
123
+ const skip = alwaysApprove && !ask;
124
+ const env = {
125
+ DSH_PERMISSION_MODE: skip ? "danger-full-access" : "workspace-write",
126
+ };
127
+ // Key travels via env only — never interpolated into command or patch.
128
+ if (process.env.DEEPSEEK_API_KEY) {
129
+ env.DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY;
130
+ }
131
+ return {
132
+ worker: "deepseek",
133
+ command: `cd ${shellQuote(cwd)} && ${parts.join(" ")}`,
134
+ alwaysApprove: skip,
135
+ env,
136
+ };
18
137
  },
19
138
  };
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";
@@ -29,9 +29,9 @@ Usage:
29
29
  cursor-route clean [--days N]
30
30
 
31
31
  Start options:
32
- --worker <grok|claude-ds|openrouter> Worker adapter (default: grok; deepseek = reserved slot)
32
+ --worker <grok|claude-ds|openrouter|deepseek> Worker adapter (default: grok; deepseek = experimental official dsh)
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 for claude-ds + deepseek
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,10 +43,12 @@ 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
+ CURSOR_ROUTE_DSH_BIN Override the dsh binary path (tests / power users)
51
+ DEEPSEEK_API_KEY DeepSeek API key (required for --worker deepseek)
50
52
  OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
51
53
  CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
52
54
  OPENROUTER_BASE_URL OpenRouter API base (default: https://openrouter.ai/api/v1)
@@ -141,12 +143,12 @@ function asLane(v) {
141
143
  return v;
142
144
  throw new Error(`Invalid --lane ${v}; expected ${LANES.join("|")}`);
143
145
  }
144
- function asDsModel(v) {
146
+ function asDsModelChoice(v) {
145
147
  if (v === undefined || v === true)
146
148
  return undefined;
147
149
  if (typeof v !== "string")
148
150
  throw new Error(`Invalid --model; expected flash|pro`);
149
- return resolveDsModelAlias(v);
151
+ return resolveDsModel(v);
150
152
  }
151
153
  function refuseSecrets(text, context) {
152
154
  if (looksLikeSecretMaterial(text)) {
@@ -208,14 +210,34 @@ async function main() {
208
210
  requireStringFlag(f, "model");
209
211
  const promptFile = requireStringFlag(f, "prompt-file");
210
212
  const dirFlag = requireStringFlag(f, "dir");
211
- let model;
213
+ let worker;
214
+ let lane;
212
215
  try {
213
- model = asDsModel(f.model);
216
+ worker = asWorker(f.worker);
217
+ lane = asLane(f.lane);
214
218
  }
215
219
  catch (e) {
216
220
  console.error(e.message);
217
221
  process.exit(2);
218
222
  }
223
+ const resolvedWorker = worker ?? (lane ? config.laneWorkers[lane] : config.defaultWorker);
224
+ let model;
225
+ let modelId;
226
+ // --model is DeepSeek-only (claude-ds mid lane + experimental deepseek worker);
227
+ // ignore (do not validate) for other workers
228
+ if (f.model !== undefined && (resolvedWorker === "claude-ds" || resolvedWorker === "deepseek")) {
229
+ try {
230
+ const choice = asDsModelChoice(f.model);
231
+ if (choice) {
232
+ model = choice.alias;
233
+ modelId = choice.id;
234
+ }
235
+ }
236
+ catch (e) {
237
+ console.error(e.message);
238
+ process.exit(2);
239
+ }
240
+ }
219
241
  let prompt = "";
220
242
  if (promptFile) {
221
243
  const p = resolve(promptFile);
@@ -250,9 +272,10 @@ async function main() {
250
272
  }
251
273
  const result = startJob({
252
274
  prompt,
253
- worker: asWorker(f.worker),
254
- lane: asLane(f.lane),
275
+ worker,
276
+ lane,
255
277
  model,
278
+ modelId,
256
279
  cwd,
257
280
  alwaysApprove: !f.ask,
258
281
  dryRun: Boolean(f.dryRun),
@@ -304,7 +327,8 @@ async function main() {
304
327
  else {
305
328
  for (const j of jobs) {
306
329
  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, " ")}`);
330
+ const modelCol = j.model ? j.model.padEnd(6) : "".padEnd(6);
331
+ console.log(`${j.id} ${j.status.padEnd(10)} ${j.worker.padEnd(10)} ${modelCol} ${age} ${j.prompt.slice(0, 48).replace(/\n/g, " ")}`);
308
332
  }
309
333
  }
310
334
  return;
@@ -336,7 +360,7 @@ async function main() {
336
360
  if (json)
337
361
  console.log(JSON.stringify(view, null, 2));
338
362
  else {
339
- console.log(`${job.id} ${job.status} worker=${job.worker} sessionAlive=${alive}`);
363
+ console.log(`${job.id} ${job.status} worker=${job.worker}${job.model ? ` model=${job.model}` : ""} sessionAlive=${alive}`);
340
364
  if (job.error)
341
365
  console.log(`error: ${job.error}`);
342
366
  }