cursor-route 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,297 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, writeFileSync, chmodSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { opencodeAdapter } from "./opencode.ts";
6
+ import { startJob } from "../jobs.ts";
7
+ import {
8
+ openCodeModel,
9
+ assertOpenCodeModel,
10
+ OPENCODE_DEFAULT_MODEL,
11
+ } from "../config.ts";
12
+
13
+ describe("openCodeModel", () => {
14
+ test("defaults to live Zen pick (Ox Alpha fallback when offline)", () => {
15
+ const prev = {
16
+ model: process.env.CURSOR_ROUTE_OPENCODE_MODEL,
17
+ offline: process.env.CURSOR_ROUTE_ZEN_OFFLINE,
18
+ cache: process.env.CURSOR_ROUTE_ZEN_CACHE_PATH,
19
+ json: process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON,
20
+ refresh: process.env.CURSOR_ROUTE_ZEN_REFRESH,
21
+ };
22
+ const cache = join(tmpdir(), `cr-oc-model-${process.pid}.json`);
23
+ delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
24
+ delete process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON;
25
+ process.env.CURSOR_ROUTE_ZEN_OFFLINE = "1";
26
+ process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = cache;
27
+ process.env.CURSOR_ROUTE_ZEN_REFRESH = "1";
28
+ try {
29
+ expect(openCodeModel()).toBe("opencode/x-preview-f-free");
30
+ expect(openCodeModel("")).toBe(OPENCODE_DEFAULT_MODEL);
31
+ expect(openCodeModel("free")).toBe("opencode/x-preview-f-free");
32
+ } finally {
33
+ if (prev.model === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
34
+ else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev.model;
35
+ if (prev.offline === undefined) delete process.env.CURSOR_ROUTE_ZEN_OFFLINE;
36
+ else process.env.CURSOR_ROUTE_ZEN_OFFLINE = prev.offline;
37
+ if (prev.cache === undefined) delete process.env.CURSOR_ROUTE_ZEN_CACHE_PATH;
38
+ else process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = prev.cache;
39
+ if (prev.json === undefined) delete process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON;
40
+ else process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON = prev.json;
41
+ if (prev.refresh === undefined) delete process.env.CURSOR_ROUTE_ZEN_REFRESH;
42
+ else process.env.CURSOR_ROUTE_ZEN_REFRESH = prev.refresh;
43
+ rmSync(cache, { force: true });
44
+ }
45
+ });
46
+
47
+ test("env override wins for empty/free", () => {
48
+ const prev = process.env.CURSOR_ROUTE_OPENCODE_MODEL;
49
+ process.env.CURSOR_ROUTE_OPENCODE_MODEL = "opencode/hy3-free";
50
+ try {
51
+ expect(openCodeModel()).toBe("opencode/hy3-free");
52
+ expect(openCodeModel("free")).toBe("opencode/hy3-free");
53
+ } finally {
54
+ if (prev === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
55
+ else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev;
56
+ }
57
+ });
58
+
59
+ test("explicit provider/model wins over env", () => {
60
+ const prev = process.env.CURSOR_ROUTE_OPENCODE_MODEL;
61
+ process.env.CURSOR_ROUTE_OPENCODE_MODEL = "opencode/hy3-free";
62
+ try {
63
+ expect(openCodeModel("opencode/mimo-v2.5-free")).toBe("opencode/mimo-v2.5-free");
64
+ } finally {
65
+ if (prev === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
66
+ else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev;
67
+ }
68
+ });
69
+
70
+ test("accepts OpenRouter-style extra slash and :free", () => {
71
+ expect(assertOpenCodeModel("openrouter/qwen/qwen3-coder:free")).toBe(
72
+ "openrouter/qwen/qwen3-coder:free",
73
+ );
74
+ });
75
+
76
+ test("rejects injection / flash|pro aliases", () => {
77
+ expect(() => openCodeModel("flash")).toThrow(/provider\/model/);
78
+ expect(() => openCodeModel("x\n--evil")).toThrow(/provider\/model/);
79
+ expect(() => openCodeModel("opencode/big pickle")).toThrow(/provider\/model/);
80
+ });
81
+ });
82
+
83
+ describe("opencode adapter", () => {
84
+ const makeFake = (suffix: string) => {
85
+ const dir = join(tmpdir(), `cr-oc-${process.pid}-${suffix}`);
86
+ mkdirSync(dir, { recursive: true });
87
+ const bin = join(dir, "opencode");
88
+ writeFileSync(bin, "#!/bin/sh\necho fake-opencode\n");
89
+ chmodSync(bin, 0o755);
90
+ return { dir, bin };
91
+ };
92
+
93
+ const withEnv = (patch: Record<string, string | undefined>, fn: () => void) => {
94
+ const prev = new Map<string, string | undefined>();
95
+ for (const k of Object.keys(patch)) {
96
+ prev.set(k, process.env[k]);
97
+ const v = patch[k];
98
+ if (v === undefined) delete process.env[k];
99
+ else process.env[k] = v;
100
+ }
101
+ try {
102
+ fn();
103
+ } finally {
104
+ for (const [k, v] of prev) {
105
+ if (v === undefined) delete process.env[k];
106
+ else process.env[k] = v;
107
+ }
108
+ }
109
+ };
110
+
111
+ test("health: no binary → not ok, install hint names npm package", () => {
112
+ withEnv(
113
+ { CURSOR_ROUTE_OPENCODE_BIN: join(tmpdir(), `missing-oc-${process.pid}`) },
114
+ () => {
115
+ const h = opencodeAdapter.health();
116
+ expect(h.ok).toBe(false);
117
+ expect(h.detail).toMatch(/npm i -g opencode-ai/);
118
+ },
119
+ );
120
+ });
121
+
122
+ test("health: binary → ok (auth at first start)", () => {
123
+ const { dir, bin } = makeFake("ok");
124
+ withEnv(
125
+ {
126
+ CURSOR_ROUTE_OPENCODE_BIN: bin,
127
+ CURSOR_ROUTE_ZEN_CACHE_PATH: join(dir, "missing-cache.json"),
128
+ },
129
+ () => {
130
+ const h = opencodeAdapter.health();
131
+ expect(h.ok).toBe(true);
132
+ expect(h.binary).toBe(bin);
133
+ expect(h.detail).toContain("live Zen pick");
134
+ expect(h.detail).toContain("opencode/x-preview-f-free");
135
+ },
136
+ );
137
+ rmSync(dir, { recursive: true, force: true });
138
+ });
139
+
140
+ test("buildLaunch: default free model + --auto; prompt via cat", () => {
141
+ const { dir, bin } = makeFake("launch");
142
+ const promptFile = join(dir, "job.prompt");
143
+ writeFileSync(promptFile, "ping");
144
+ withEnv(
145
+ {
146
+ CURSOR_ROUTE_OPENCODE_BIN: bin,
147
+ CURSOR_ROUTE_OPENCODE_MODEL: undefined,
148
+ CURSOR_ROUTE_ASK: undefined,
149
+ CURSOR_ROUTE_ZEN_OFFLINE: "1",
150
+ CURSOR_ROUTE_ZEN_CACHE_PATH: join(dir, "zen-cache.json"),
151
+ CURSOR_ROUTE_ZEN_REFRESH: "1",
152
+ CURSOR_ROUTE_ZEN_CATALOG_JSON: undefined,
153
+ },
154
+ () => {
155
+ const plan = opencodeAdapter.buildLaunch({
156
+ promptFile,
157
+ cwd: dir,
158
+ alwaysApprove: true,
159
+ });
160
+ expect(plan.command).toContain("run");
161
+ expect(plan.command).toContain("--auto");
162
+ expect(plan.command).toContain("opencode/x-preview-f-free");
163
+ expect(plan.command).toContain("$(cat");
164
+ expect(plan.command).toContain("--dir");
165
+ expect(plan.alwaysApprove).toBe(true);
166
+ expect(plan.command).not.toContain("npx");
167
+ },
168
+ );
169
+ rmSync(dir, { recursive: true, force: true });
170
+ });
171
+
172
+ test("buildLaunch: alwaysApprove false (--ask) omits --auto", () => {
173
+ const { dir, bin } = makeFake("ask");
174
+ const promptFile = join(dir, "job.prompt");
175
+ writeFileSync(promptFile, "ping");
176
+ withEnv({ CURSOR_ROUTE_OPENCODE_BIN: bin }, () => {
177
+ const plan = opencodeAdapter.buildLaunch({
178
+ promptFile,
179
+ cwd: dir,
180
+ alwaysApprove: false,
181
+ modelId: "opencode/hy3-free",
182
+ });
183
+ expect(plan.command).toContain("opencode/hy3-free");
184
+ expect(plan.command).not.toContain("--auto");
185
+ expect(plan.alwaysApprove).toBe(false);
186
+ });
187
+ rmSync(dir, { recursive: true, force: true });
188
+ });
189
+
190
+ test("buildLaunch: CURSOR_ROUTE_ASK=1 opts out even when alwaysApprove true", () => {
191
+ const { dir, bin } = makeFake("ask-env");
192
+ const promptFile = join(dir, "job.prompt");
193
+ writeFileSync(promptFile, "ping");
194
+ withEnv(
195
+ {
196
+ CURSOR_ROUTE_OPENCODE_BIN: bin,
197
+ CURSOR_ROUTE_ASK: "1",
198
+ CURSOR_ROUTE_ZEN_OFFLINE: "1",
199
+ CURSOR_ROUTE_ZEN_CACHE_PATH: join(dir, "zen-cache.json"),
200
+ CURSOR_ROUTE_ZEN_REFRESH: "1",
201
+ },
202
+ () => {
203
+ const plan = opencodeAdapter.buildLaunch({
204
+ promptFile,
205
+ cwd: dir,
206
+ alwaysApprove: true,
207
+ });
208
+ expect(plan.command).not.toContain("--auto");
209
+ expect(plan.alwaysApprove).toBe(false);
210
+ },
211
+ );
212
+ rmSync(dir, { recursive: true, force: true });
213
+ });
214
+
215
+ test("buildLaunch: missing binary falls back to plain `opencode` for dry-run", () => {
216
+ const { dir } = makeFake("nobin");
217
+ const promptFile = join(dir, "job.prompt");
218
+ writeFileSync(promptFile, "ping");
219
+ const empty = join(dir, "empty");
220
+ mkdirSync(empty, { recursive: true });
221
+ withEnv(
222
+ {
223
+ CURSOR_ROUTE_OPENCODE_BIN: undefined,
224
+ PATH: empty,
225
+ CURSOR_ROUTE_OPENCODE_MODEL: undefined,
226
+ CURSOR_ROUTE_ZEN_OFFLINE: "1",
227
+ CURSOR_ROUTE_ZEN_CACHE_PATH: join(dir, "zen-cache.json"),
228
+ CURSOR_ROUTE_ZEN_REFRESH: "1",
229
+ },
230
+ () => {
231
+ const plan = opencodeAdapter.buildLaunch({
232
+ promptFile,
233
+ cwd: dir,
234
+ alwaysApprove: true,
235
+ dryRun: true,
236
+ });
237
+ expect(plan.command).toContain("'opencode' run");
238
+ expect(plan.command).toContain("--auto");
239
+ },
240
+ );
241
+ rmSync(dir, { recursive: true, force: true });
242
+ });
243
+
244
+ test("--worker opencode dry-run succeeds with fake binary", () => {
245
+ const dir = join(tmpdir(), `cr-oc-start-${process.pid}`);
246
+ mkdirSync(dir, { recursive: true });
247
+ const bin = join(dir, "opencode");
248
+ writeFileSync(bin, "#!/bin/sh\necho fake-opencode\n");
249
+ chmodSync(bin, 0o755);
250
+ const prev = {
251
+ bin: process.env.CURSOR_ROUTE_OPENCODE_BIN,
252
+ model: process.env.CURSOR_ROUTE_OPENCODE_MODEL,
253
+ jobs: process.env.CURSOR_ROUTE_JOBS_DIR,
254
+ offline: process.env.CURSOR_ROUTE_ZEN_OFFLINE,
255
+ cache: process.env.CURSOR_ROUTE_ZEN_CACHE_PATH,
256
+ refresh: process.env.CURSOR_ROUTE_ZEN_REFRESH,
257
+ json: process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON,
258
+ };
259
+ process.env.CURSOR_ROUTE_OPENCODE_BIN = bin;
260
+ delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
261
+ delete process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON;
262
+ process.env.CURSOR_ROUTE_JOBS_DIR = join(dir, "jobs");
263
+ process.env.CURSOR_ROUTE_ZEN_OFFLINE = "1";
264
+ process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = join(dir, "zen-cache.json");
265
+ process.env.CURSOR_ROUTE_ZEN_REFRESH = "1";
266
+ try {
267
+ const result = startJob({
268
+ prompt: "ping",
269
+ worker: "opencode",
270
+ dryRun: true,
271
+ });
272
+ expect(result.ok).toBe(true);
273
+ if (!result.ok) return;
274
+ expect(result.job.worker).toBe("opencode");
275
+ expect(result.job.model).toBe("opencode/x-preview-f-free");
276
+ expect(result.command).toContain("run");
277
+ expect(result.command).toContain("--auto");
278
+ expect(result.command).toContain("$(cat");
279
+ } finally {
280
+ if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_BIN;
281
+ else process.env.CURSOR_ROUTE_OPENCODE_BIN = prev.bin;
282
+ if (prev.model === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
283
+ else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev.model;
284
+ if (prev.jobs === undefined) delete process.env.CURSOR_ROUTE_JOBS_DIR;
285
+ else process.env.CURSOR_ROUTE_JOBS_DIR = prev.jobs;
286
+ if (prev.offline === undefined) delete process.env.CURSOR_ROUTE_ZEN_OFFLINE;
287
+ else process.env.CURSOR_ROUTE_ZEN_OFFLINE = prev.offline;
288
+ if (prev.cache === undefined) delete process.env.CURSOR_ROUTE_ZEN_CACHE_PATH;
289
+ else process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = prev.cache;
290
+ if (prev.refresh === undefined) delete process.env.CURSOR_ROUTE_ZEN_REFRESH;
291
+ else process.env.CURSOR_ROUTE_ZEN_REFRESH = prev.refresh;
292
+ if (prev.json === undefined) delete process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON;
293
+ else process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON = prev.json;
294
+ rmSync(dir, { recursive: true, force: true });
295
+ }
296
+ });
297
+ });
@@ -0,0 +1,85 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import type { Adapter, WorkerHealth } from "./types.ts";
4
+ import { shellQuote } from "../util.ts";
5
+ import { openCodeModel, OPENCODE_DEFAULT_MODEL, cachedZenFreePick } from "../config.ts";
6
+
7
+ /**
8
+ * Opt-in OpenCode worker (`opencode run`) — a coding agent on live OpenCode
9
+ * Zen free models (`--model free` ranks the catalog; Ox Alpha wins while
10
+ * listed). Not a lane default: mid stays claude-ds; easy stays OpenRouter
11
+ * chat (no tools).
12
+ *
13
+ * We never rewrite ~/.config/opencode/opencode.json (parallel jobs would
14
+ * race). Always-approve maps to `opencode run --auto` (still honors explicit
15
+ * deny rules). Prompt is inlined via cat — never interpolated.
16
+ */
17
+
18
+ function findOpencode(): string | null {
19
+ // Env override lets tests pin a fake opencode — but it must exist, so a
20
+ // stale override cannot pass health with a dangling path.
21
+ const override = process.env.CURSOR_ROUTE_OPENCODE_BIN;
22
+ if (override) return existsSync(override) ? override : null;
23
+ try {
24
+ return (
25
+ execSync("command -v opencode", {
26
+ encoding: "utf8",
27
+ stdio: ["ignore", "pipe", "ignore"],
28
+ env: { ...process.env },
29
+ }).trim() || null
30
+ );
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export const opencodeAdapter: Adapter = {
37
+ kind: "opencode",
38
+ label: "OpenCode (free Zen / other models)",
39
+ health(): WorkerHealth {
40
+ const binary = findOpencode();
41
+ if (!binary) {
42
+ return {
43
+ worker: "opencode",
44
+ ok: false,
45
+ binary: null,
46
+ detail:
47
+ "opencode not found — install: npm i -g opencode-ai (or brew install opencode). Then: opencode auth login. Mid default remains claude-ds.",
48
+ };
49
+ }
50
+ // Health stays offline: show a fresh cache hit, else the Ox Alpha fallback.
51
+ const pick = cachedZenFreePick() || OPENCODE_DEFAULT_MODEL;
52
+ return {
53
+ worker: "opencode",
54
+ ok: true,
55
+ binary,
56
+ detail: `ok (opencode run; --model free = live Zen pick, now ${pick}; auth at first start — opencode auth login if jobs fail; mid default remains claude-ds)`,
57
+ };
58
+ },
59
+ buildLaunch({ promptFile, cwd, alwaysApprove, modelId }) {
60
+ // Missing binary is tolerated here so `--dry-run` can still print the
61
+ // command; real starts are gated by the health preflight.
62
+ const binary = findOpencode() || "opencode";
63
+ const id = openCodeModel(modelId);
64
+
65
+ const ask = process.env.CURSOR_ROUTE_ASK === "1";
66
+ const skip = alwaysApprove && !ask;
67
+
68
+ const parts = [
69
+ shellQuote(binary),
70
+ "run",
71
+ "--dir",
72
+ shellQuote(cwd),
73
+ "--model",
74
+ shellQuote(id),
75
+ ];
76
+ if (skip) parts.push("--auto");
77
+ parts.push(`"$(cat ${shellQuote(promptFile)})"`);
78
+
79
+ return {
80
+ worker: "opencode",
81
+ command: `cd ${shellQuote(cwd)} && ${parts.join(" ")}`,
82
+ alwaysApprove: skip,
83
+ };
84
+ },
85
+ };
@@ -26,7 +26,7 @@ export interface Adapter {
26
26
  alwaysApprove: boolean;
27
27
  /** Mid-lane DeepSeek flash|pro (claude-ds + deepseek; ignored by grok/openrouter / Anthropic escape hatch). */
28
28
  model?: DsModelAlias;
29
- /** Concrete DeepSeek model id for -Model/--model (preserves pro[1m]). */
29
+ /** Concrete DeepSeek id (preserves pro[1m]) or OpenCode provider/model. */
30
30
  modelId?: string;
31
31
  /** True on --dry-run: adapters may drop artifacts they just wrote (e.g. dsh patch). */
32
32
  dryRun?: boolean;
package/src/cli.test.ts CHANGED
@@ -41,6 +41,9 @@ describe("resolveWorker", () => {
41
41
  expect(resolveWorker({ prompt: "x", lane: "mid", worker: "openrouter" })).toBe(
42
42
  "openrouter",
43
43
  );
44
+ expect(resolveWorker({ prompt: "x", lane: "mid", worker: "opencode" })).toBe(
45
+ "opencode",
46
+ );
44
47
  });
45
48
  test("default worker", () => {
46
49
  expect(resolveWorker({ prompt: "x" })).toBe(config.defaultWorker);
@@ -615,7 +618,7 @@ describe("health", () => {
615
618
  test("returns structured report", () => {
616
619
  const r = runHealth();
617
620
  expect(r.product).toBe("cursor-route");
618
- expect(r.version).toBe("0.1.9");
621
+ expect(r.version).toBe("0.1.11");
619
622
  expect(r.checks.length).toBeGreaterThan(3);
620
623
  expect(r.checks.some((c) => c.name === "tmux")).toBe(true);
621
624
  expect(r.checks.some((c) => c.name === "cursor_cli")).toBe(true);
@@ -623,10 +626,32 @@ describe("health", () => {
623
626
  expect(mid).toBeDefined();
624
627
  expect(r.lanes.mid.worker).toBe("claude-ds");
625
628
  expect(r.lanes.mid.deepseek).toBe(mid!.ok);
629
+ expect(r.checks.some((c) => c.name === "worker:opencode")).toBe(true);
630
+ expect(r.checks.some((c) => c.name === "worker:deepseek")).toBe(true);
631
+ });
632
+
633
+ test("config version is 0.1.11", () => {
634
+ expect(config.version).toBe("0.1.11");
626
635
  });
627
636
 
628
- test("config version is 0.1.9", () => {
629
- expect(config.version).toBe("0.1.9");
637
+ test("OR-gate: ok can be true while worker:opencode is false", () => {
638
+ const prev = {
639
+ relaxed: process.env.CURSOR_ROUTE_RELAXED,
640
+ bin: process.env.CURSOR_ROUTE_OPENCODE_BIN,
641
+ };
642
+ process.env.CURSOR_ROUTE_RELAXED = "1";
643
+ process.env.CURSOR_ROUTE_OPENCODE_BIN = join(tmpdir(), `missing-oc-${process.pid}`);
644
+ try {
645
+ const r = runHealth();
646
+ const oc = r.checks.find((c) => c.name === "worker:opencode");
647
+ expect(oc?.ok).toBe(false);
648
+ expect(r.ok).toBe(true);
649
+ } finally {
650
+ if (prev.relaxed === undefined) delete process.env.CURSOR_ROUTE_RELAXED;
651
+ else process.env.CURSOR_ROUTE_RELAXED = prev.relaxed;
652
+ if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_BIN;
653
+ else process.env.CURSOR_ROUTE_OPENCODE_BIN = prev.bin;
654
+ }
630
655
  });
631
656
 
632
657
  test("OR-gate: ok can be true while worker:deepseek is false", () => {
package/src/cli.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * cursor-route CLI — Cursor brain, Grok + DeepSeek + OpenRouter (easy) workers in tmux.
3
+ * cursor-route CLI — Cursor brain, Grok + DeepSeek + OpenRouter (easy) + OpenCode (opt-in) workers in tmux.
4
4
  */
5
5
  import { readFileSync, existsSync, realpathSync, statSync } from "node:fs";
6
6
  import { resolve, basename } from "node:path";
@@ -9,6 +9,7 @@ import {
9
9
  WORKERS,
10
10
  LANES,
11
11
  resolveDsModel,
12
+ openCodeModel,
12
13
  type WorkerKind,
13
14
  type Lane,
14
15
  type DsModelAlias,
@@ -36,7 +37,7 @@ import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
36
37
  function usage(exitCode = 0): never {
37
38
  console.log(`cursor-route v${config.version}
38
39
 
39
- Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter (easy) are the parallel army.
40
+ Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter (easy) + OpenCode (opt-in free) are the parallel army.
40
41
 
41
42
  Usage:
42
43
  cursor-route --version
@@ -53,9 +54,9 @@ Usage:
53
54
  cursor-route clean [--days N]
54
55
 
55
56
  Start options:
56
- --worker <grok|claude-ds|openrouter|deepseek> Worker adapter (default: grok; deepseek = experimental official dsh)
57
+ --worker <grok|claude-ds|openrouter|deepseek|opencode> Worker adapter (default: grok; deepseek/opencode = opt-in)
57
58
  --lane <easy|mid|hard> Lane → worker (easy=openrouter, mid=claude-ds, hard=grok)
58
- --model <flash|pro> Mid DeepSeek only (default: flash / CURSOR_ROUTE_DS_MODEL). Parsed for claude-ds + deepseek
59
+ --model <flash|pro|free|provider/model> claude-ds/deepseek: flash|pro. opencode: free (live Zen catalog pick) or provider/model
59
60
  --dir <path> Working directory (default: cwd)
60
61
  --ask Disable always-approve for this job
61
62
  --dry-run Print launch command; do not start
@@ -72,10 +73,12 @@ Env:
72
73
  CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
73
74
  CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
74
75
  CURSOR_ROUTE_DSH_BIN Override the dsh binary path (tests / power users)
76
+ CURSOR_ROUTE_OPENCODE_BIN Override the opencode binary path (tests / power users)
75
77
  DEEPSEEK_API_KEY DeepSeek API key (required for --worker deepseek)
76
78
  OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
77
79
  CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
78
80
  OPENROUTER_BASE_URL OpenRouter API base (default: https://openrouter.ai/api/v1)
81
+ CURSOR_ROUTE_OPENCODE_MODEL OpenCode model pin (unset or free = live Zen free pick); --model overrides
79
82
  `);
80
83
  process.exit(exitCode);
81
84
  }
@@ -171,6 +174,14 @@ function asDsModelChoice(v: unknown): { alias: DsModelAlias; id: string } | unde
171
174
  return resolveDsModel(v);
172
175
  }
173
176
 
177
+ function asOpenCodeModel(v: unknown): string | undefined {
178
+ if (v === undefined || v === true) return undefined;
179
+ if (typeof v !== "string") {
180
+ throw new Error(`Invalid --model; expected provider/model (e.g. opencode/x-preview-f-free) or free`);
181
+ }
182
+ return openCodeModel(v);
183
+ }
184
+
174
185
  function refuseSecrets(text: string, context: string): void {
175
186
  if (looksLikeSecretMaterial(text)) {
176
187
  console.error(
@@ -253,8 +264,8 @@ async function main() {
253
264
 
254
265
  let model: DsModelAlias | undefined;
255
266
  let modelId: string | undefined;
256
- // --model is DeepSeek-only (claude-ds mid lane + experimental deepseek worker);
257
- // ignore (do not validate) for other workers
267
+ // --model: DeepSeek flash|pro for claude-ds/deepseek; provider/model (or free) for opencode;
268
+ // ignore (do not validate) for grok/openrouter
258
269
  if (f.model !== undefined && (resolvedWorker === "claude-ds" || resolvedWorker === "deepseek")) {
259
270
  try {
260
271
  const choice = asDsModelChoice(f.model);
@@ -266,6 +277,13 @@ async function main() {
266
277
  console.error((e as Error).message);
267
278
  process.exit(2);
268
279
  }
280
+ } else if (f.model !== undefined && resolvedWorker === "opencode") {
281
+ try {
282
+ modelId = asOpenCodeModel(f.model);
283
+ } catch (e) {
284
+ console.error((e as Error).message);
285
+ process.exit(2);
286
+ }
269
287
  }
270
288
 
271
289
  let prompt = "";
@@ -359,7 +377,7 @@ async function main() {
359
377
  } else {
360
378
  for (const j of jobs) {
361
379
  const age = j.startedAt || j.createdAt;
362
- const modelCol = j.model ? j.model.padEnd(6) : "".padEnd(6);
380
+ const modelCol = j.model ? j.model.padEnd(22) : "".padEnd(22);
363
381
  console.log(
364
382
  `${j.id} ${j.status.padEnd(10)} ${j.worker.padEnd(10)} ${modelCol} ${age} ${j.prompt.slice(0, 48).replace(/\n/g, " ")}`,
365
383
  );
package/src/config.ts CHANGED
@@ -3,10 +3,11 @@ import { join } from "node:path";
3
3
  import { defaultJobsDir } from "./runtime.ts";
4
4
 
5
5
  /**
6
- * Workers with a live adapter. `deepseek` is the experimental official
7
- * DeepSeek Harness (dsh) an opt-in worker, not a mid default.
6
+ * Workers with a live adapter. `deepseek` (official dsh) and `opencode`
7
+ * (free Zen / other models) are opt-in workers, not lane defaults.
8
+ * Mid stays on claude-ds; easy stays on openrouter (chat-only).
8
9
  */
9
- export type WorkerKind = "grok" | "claude-ds" | "openrouter" | "deepseek";
10
+ export type WorkerKind = "grok" | "claude-ds" | "openrouter" | "deepseek" | "opencode";
10
11
  export type Lane = "easy" | "mid" | "hard";
11
12
 
12
13
  /** Public CLI aliases for mid-lane DeepSeek models. */
@@ -18,7 +19,7 @@ export interface DsModelChoice {
18
19
  id: string;
19
20
  }
20
21
 
21
- export const WORKERS: WorkerKind[] = ["grok", "claude-ds", "openrouter", "deepseek"];
22
+ export const WORKERS: WorkerKind[] = ["grok", "claude-ds", "openrouter", "deepseek", "opencode"];
22
23
  export const LANES: Lane[] = ["easy", "mid", "hard"];
23
24
  export const DS_MODELS: DsModelAlias[] = ["flash", "pro"];
24
25
 
@@ -73,6 +74,13 @@ export function openRouterBaseUrl(): string {
73
74
  return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
74
75
  }
75
76
 
77
+ export {
78
+ OPENCODE_DEFAULT_MODEL,
79
+ assertOpenCodeModel,
80
+ openCodeModel,
81
+ cachedZenFreePick,
82
+ } from "./zen-free.ts";
83
+
76
84
  function maxConcurrentJobsFromEnv(): number {
77
85
  const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
78
86
  if (raw) {
@@ -89,7 +97,7 @@ function maxConcurrentJobsFromEnv(): number {
89
97
  */
90
98
  export const config = {
91
99
  product: "cursor-route",
92
- version: "0.1.9",
100
+ version: "0.1.11",
93
101
  get jobsDir(): string {
94
102
  return defaultJobsDir();
95
103
  },
@@ -97,7 +105,7 @@ export const config = {
97
105
  defaultWorker: "grok" as WorkerKind,
98
106
  /**
99
107
  * Lane → default worker (Cemini /route public core).
100
- * `deepseek` is experimental only — mid stays on claude-ds.
108
+ * `deepseek` and `opencode` are opt-in only — mid stays on claude-ds.
101
109
  */
102
110
  laneWorkers: {
103
111
  easy: "openrouter" as WorkerKind,
package/src/health.ts CHANGED
@@ -133,7 +133,7 @@ export function printHealth(report: HealthReport, asJson: boolean): void {
133
133
  if (!report.ok) {
134
134
  console.log("");
135
135
  console.log("Fix the ✗ items, then re-run: cursor-route health");
136
- console.log("Tip: start with one worker (grok OR claude-ds) before parallel demos.");
136
+ console.log("Tip: start with one worker (grok OR claude-ds OR opencode) before parallel demos.");
137
137
  } else if (report.checks.some((c) => c.name === "lane:mid" && !c.ok)) {
138
138
  console.log("");
139
139
  console.log(