cursor-route 0.1.9 → 0.1.10

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.
@@ -2,10 +2,10 @@
2
2
  name: route-orch
3
3
  description: >-
4
4
  Delegate coding work from Cursor to parallel Grok CLI / claude-ds (DeepSeek) /
5
- OpenRouter (easy lane) workers via cursor-route. Use when the user says
6
- /route-orch, spawn workers, parallel agents with cursor-route, or explicitly
7
- asks to outsource implementation to Grok/DeepSeek panes — not for private
8
- Cemini /route.
5
+ OpenRouter (easy lane) / OpenCode (opt-in free) workers via cursor-route. Use
6
+ when the user says /route-orch, spawn workers, parallel agents with
7
+ cursor-route, or explicitly asks to outsource implementation to
8
+ Grok/DeepSeek/OpenCode panes — not for private Cemini /route.
9
9
  ---
10
10
 
11
11
  # route-orch (cursor-route)
@@ -14,8 +14,8 @@ You are the **orchestrator**. Do **not** implement bulk code in this Cursor sess
14
14
 
15
15
  ## When to activate
16
16
 
17
- - User says `/route-orch`, `spawn workers`, or asks for parallel Grok/DeepSeek via **cursor-route**
18
- - Mid/hard implementation that should run on a subscription worker (Grok CLI / claude-ds)
17
+ - User says `/route-orch`, `spawn workers`, or asks for parallel Grok/DeepSeek/OpenCode via **cursor-route**
18
+ - Mid/hard implementation that should run on a subscription worker (Grok CLI / claude-ds) or opt-in OpenCode free models
19
19
  - Multi-file investigation that benefits from parallel panes
20
20
 
21
21
  **Do not steal federation `/route`.** Private Cemini `/route` (route-task → verify → Grok/claude-ds chain) is a different skill. This public skill only drives the `cursor-route` CLI.
@@ -59,6 +59,20 @@ cursor-route start --worker deepseek --model pro --dir "$PWD" "…" # --model
59
59
 
60
60
  Health ✓ needs `dsh` on PATH and `DEEPSEEK_API_KEY` set. The adapter pins `--model` via a per-job `--patch` (never touches `~/.dsh/settings.yaml`); always-approve → `DSH_PERMISSION_MODE=danger-full-access`, `--ask` → `workspace-write`. The key never enters the command or patch.
61
61
 
62
+ ## Experimental: --worker opencode (free Zen)
63
+
64
+ OpenCode as an opt-in **coding agent** on OpenCode Zen free models — **not a lane default** (mid stays `claude-ds`; easy stays OpenRouter chat). Use this to cut Grok / DeepSeek usage on implement work.
65
+
66
+ ```bash
67
+ npm i -g opencode-ai
68
+ opencode auth login
69
+ cursor-route start --worker opencode --dir "$PWD" "…"
70
+ cursor-route start --worker opencode --model free --dir "$PWD" "…"
71
+ cursor-route start --worker opencode --model opencode/hy3-free --dir "$PWD" "…"
72
+ ```
73
+
74
+ Health ✓ needs `opencode` on PATH (override `CURSOR_ROUTE_OPENCODE_BIN`). Default model is `opencode/big-pickle` (`CURSOR_ROUTE_OPENCODE_MODEL` / `--model free`). Always-approve → `opencode run --auto`; `--ask` omits `--auto`. Never rewrites `~/.config/opencode/opencode.json`. Free Zen models may log/train — keep secrets off this worker (same refuse gate as easy).
75
+
62
76
  ## Workflow
63
77
 
64
78
  1. Run `cursor-route health` (or `CURSOR_ROUTE_RELAXED=1` for headless). If the **target worker** is unhealthy, fix before spawning. If targeting **mid**, require `lane:mid` ✓ (or health JSON `lanes.mid.deepseek`) before spawn — `CURSOR_ROUTE_ALLOW_ANTHROPIC=1` is not DeepSeek proof.
@@ -82,7 +96,7 @@ EOF
82
96
  )"
83
97
  ```
84
98
 
85
- Or `--worker grok` / `--worker claude-ds` / `--worker deepseek` (experimental) / `--worker openrouter` (or `--lane easy`). Use `--no-tmux` only when tmux is unavailable.
99
+ Or `--worker grok` / `--worker claude-ds` / `--worker deepseek` (experimental) / `--worker opencode` (opt-in free) / `--worker openrouter` (or `--lane easy`). Use `--no-tmux` only when tmux is unavailable.
86
100
 
87
101
  4. Monitor: `cursor-route jobs --json` · `cursor-route capture <id>` · `cursor-route send <id> "…"` (tmux only).
88
102
  5. Summarize worker results with **verify evidence** — no status-only “done” (see Verify / claim closeout). If verify fails, reconsider the plan/definition (not only retry) — `send` a correction or spawn a follow-up; do not invent success.
@@ -111,6 +125,7 @@ Defaults on for workers. Opt out: `cursor-route start … --ask` or `CURSOR_ROUT
111
125
 
112
126
  - Do not paste API keys / private keys into prompts or `send`
113
127
  - Do not claim the official DeepSeek harness (`@deepseek-ai/dsh`) is the mid default — `--worker deepseek` is an opt-in experiment (cheap to abandon), not a product fork; mid stays **claude-ds**
128
+ - Do not claim OpenCode is the mid default — `--worker opencode` is opt-in for free Zen (or other) models; mid stays **claude-ds**
114
129
  - Do not fork a second mid harness
115
130
  - Do not open-source or dump private cemini `agent-toolkit` paths into public handoffs
116
131
  - Do not mark done without reading `capture` / exit status
@@ -4,12 +4,14 @@ import { grokAdapter } from "./grok.ts";
4
4
  import { claudeDsAdapter } from "./claude-ds.ts";
5
5
  import { openRouterAdapter } from "./openrouter.ts";
6
6
  import { deepseekAdapter } from "./deepseek.ts";
7
+ import { opencodeAdapter } from "./opencode.ts";
7
8
 
8
9
  const registry: Record<WorkerKind, Adapter> = {
9
10
  grok: grokAdapter,
10
11
  "claude-ds": claudeDsAdapter,
11
12
  openrouter: openRouterAdapter,
12
13
  deepseek: deepseekAdapter,
14
+ opencode: opencodeAdapter,
13
15
  };
14
16
 
15
17
  export function getAdapter(worker: WorkerKind): Adapter {
@@ -0,0 +1,238 @@
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 OpenCode Zen Big Pickle", () => {
15
+ const prev = process.env.CURSOR_ROUTE_OPENCODE_MODEL;
16
+ delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
17
+ try {
18
+ expect(openCodeModel()).toBe("opencode/big-pickle");
19
+ expect(openCodeModel("")).toBe(OPENCODE_DEFAULT_MODEL);
20
+ expect(openCodeModel("free")).toBe("opencode/big-pickle");
21
+ } finally {
22
+ if (prev === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
23
+ else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev;
24
+ }
25
+ });
26
+
27
+ test("env override wins for empty/free", () => {
28
+ const prev = process.env.CURSOR_ROUTE_OPENCODE_MODEL;
29
+ process.env.CURSOR_ROUTE_OPENCODE_MODEL = "opencode/hy3-free";
30
+ try {
31
+ expect(openCodeModel()).toBe("opencode/hy3-free");
32
+ expect(openCodeModel("free")).toBe("opencode/hy3-free");
33
+ } finally {
34
+ if (prev === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
35
+ else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev;
36
+ }
37
+ });
38
+
39
+ test("explicit provider/model wins over env", () => {
40
+ const prev = process.env.CURSOR_ROUTE_OPENCODE_MODEL;
41
+ process.env.CURSOR_ROUTE_OPENCODE_MODEL = "opencode/hy3-free";
42
+ try {
43
+ expect(openCodeModel("opencode/mimo-v2.5-free")).toBe("opencode/mimo-v2.5-free");
44
+ } finally {
45
+ if (prev === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
46
+ else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev;
47
+ }
48
+ });
49
+
50
+ test("accepts OpenRouter-style extra slash and :free", () => {
51
+ expect(assertOpenCodeModel("openrouter/qwen/qwen3-coder:free")).toBe(
52
+ "openrouter/qwen/qwen3-coder:free",
53
+ );
54
+ });
55
+
56
+ test("rejects injection / flash|pro aliases", () => {
57
+ expect(() => openCodeModel("flash")).toThrow(/provider\/model/);
58
+ expect(() => openCodeModel("x\n--evil")).toThrow(/provider\/model/);
59
+ expect(() => openCodeModel("opencode/big pickle")).toThrow(/provider\/model/);
60
+ });
61
+ });
62
+
63
+ describe("opencode adapter", () => {
64
+ const makeFake = (suffix: string) => {
65
+ const dir = join(tmpdir(), `cr-oc-${process.pid}-${suffix}`);
66
+ mkdirSync(dir, { recursive: true });
67
+ const bin = join(dir, "opencode");
68
+ writeFileSync(bin, "#!/bin/sh\necho fake-opencode\n");
69
+ chmodSync(bin, 0o755);
70
+ return { dir, bin };
71
+ };
72
+
73
+ const withEnv = (patch: Record<string, string | undefined>, fn: () => void) => {
74
+ const prev = new Map<string, string | undefined>();
75
+ for (const k of Object.keys(patch)) {
76
+ prev.set(k, process.env[k]);
77
+ const v = patch[k];
78
+ if (v === undefined) delete process.env[k];
79
+ else process.env[k] = v;
80
+ }
81
+ try {
82
+ fn();
83
+ } finally {
84
+ for (const [k, v] of prev) {
85
+ if (v === undefined) delete process.env[k];
86
+ else process.env[k] = v;
87
+ }
88
+ }
89
+ };
90
+
91
+ test("health: no binary → not ok, install hint names npm package", () => {
92
+ withEnv(
93
+ { CURSOR_ROUTE_OPENCODE_BIN: join(tmpdir(), `missing-oc-${process.pid}`) },
94
+ () => {
95
+ const h = opencodeAdapter.health();
96
+ expect(h.ok).toBe(false);
97
+ expect(h.detail).toMatch(/npm i -g opencode-ai/);
98
+ },
99
+ );
100
+ });
101
+
102
+ test("health: binary → ok (auth at first start)", () => {
103
+ const { bin } = makeFake("ok");
104
+ withEnv({ CURSOR_ROUTE_OPENCODE_BIN: bin }, () => {
105
+ const h = opencodeAdapter.health();
106
+ expect(h.ok).toBe(true);
107
+ expect(h.binary).toBe(bin);
108
+ expect(h.detail).toContain("opencode/big-pickle");
109
+ });
110
+ rmSync(join(tmpdir(), `cr-oc-${process.pid}-ok`), { recursive: true, force: true });
111
+ });
112
+
113
+ test("buildLaunch: default free model + --auto; prompt via cat", () => {
114
+ const { dir, bin } = makeFake("launch");
115
+ const promptFile = join(dir, "job.prompt");
116
+ writeFileSync(promptFile, "ping");
117
+ withEnv(
118
+ {
119
+ CURSOR_ROUTE_OPENCODE_BIN: bin,
120
+ CURSOR_ROUTE_OPENCODE_MODEL: undefined,
121
+ CURSOR_ROUTE_ASK: undefined,
122
+ },
123
+ () => {
124
+ const plan = opencodeAdapter.buildLaunch({
125
+ promptFile,
126
+ cwd: dir,
127
+ alwaysApprove: true,
128
+ });
129
+ expect(plan.command).toContain("run");
130
+ expect(plan.command).toContain("--auto");
131
+ expect(plan.command).toContain("opencode/big-pickle");
132
+ expect(plan.command).toContain("$(cat");
133
+ expect(plan.command).toContain("--dir");
134
+ expect(plan.alwaysApprove).toBe(true);
135
+ expect(plan.command).not.toContain("npx");
136
+ },
137
+ );
138
+ rmSync(dir, { recursive: true, force: true });
139
+ });
140
+
141
+ test("buildLaunch: alwaysApprove false (--ask) omits --auto", () => {
142
+ const { dir, bin } = makeFake("ask");
143
+ const promptFile = join(dir, "job.prompt");
144
+ writeFileSync(promptFile, "ping");
145
+ withEnv({ CURSOR_ROUTE_OPENCODE_BIN: bin }, () => {
146
+ const plan = opencodeAdapter.buildLaunch({
147
+ promptFile,
148
+ cwd: dir,
149
+ alwaysApprove: false,
150
+ modelId: "opencode/hy3-free",
151
+ });
152
+ expect(plan.command).toContain("opencode/hy3-free");
153
+ expect(plan.command).not.toContain("--auto");
154
+ expect(plan.alwaysApprove).toBe(false);
155
+ });
156
+ rmSync(dir, { recursive: true, force: true });
157
+ });
158
+
159
+ test("buildLaunch: CURSOR_ROUTE_ASK=1 opts out even when alwaysApprove true", () => {
160
+ const { dir, bin } = makeFake("ask-env");
161
+ const promptFile = join(dir, "job.prompt");
162
+ writeFileSync(promptFile, "ping");
163
+ withEnv({ CURSOR_ROUTE_OPENCODE_BIN: bin, CURSOR_ROUTE_ASK: "1" }, () => {
164
+ const plan = opencodeAdapter.buildLaunch({
165
+ promptFile,
166
+ cwd: dir,
167
+ alwaysApprove: true,
168
+ });
169
+ expect(plan.command).not.toContain("--auto");
170
+ expect(plan.alwaysApprove).toBe(false);
171
+ });
172
+ rmSync(dir, { recursive: true, force: true });
173
+ });
174
+
175
+ test("buildLaunch: missing binary falls back to plain `opencode` for dry-run", () => {
176
+ const { dir } = makeFake("nobin");
177
+ const promptFile = join(dir, "job.prompt");
178
+ writeFileSync(promptFile, "ping");
179
+ const empty = join(dir, "empty");
180
+ mkdirSync(empty, { recursive: true });
181
+ withEnv(
182
+ {
183
+ CURSOR_ROUTE_OPENCODE_BIN: undefined,
184
+ PATH: empty,
185
+ CURSOR_ROUTE_OPENCODE_MODEL: undefined,
186
+ },
187
+ () => {
188
+ const plan = opencodeAdapter.buildLaunch({
189
+ promptFile,
190
+ cwd: dir,
191
+ alwaysApprove: true,
192
+ dryRun: true,
193
+ });
194
+ expect(plan.command).toContain("'opencode' run");
195
+ expect(plan.command).toContain("--auto");
196
+ },
197
+ );
198
+ rmSync(dir, { recursive: true, force: true });
199
+ });
200
+
201
+ test("--worker opencode dry-run succeeds with fake binary", () => {
202
+ const dir = join(tmpdir(), `cr-oc-start-${process.pid}`);
203
+ mkdirSync(dir, { recursive: true });
204
+ const bin = join(dir, "opencode");
205
+ writeFileSync(bin, "#!/bin/sh\necho fake-opencode\n");
206
+ chmodSync(bin, 0o755);
207
+ const prev = {
208
+ bin: process.env.CURSOR_ROUTE_OPENCODE_BIN,
209
+ model: process.env.CURSOR_ROUTE_OPENCODE_MODEL,
210
+ jobs: process.env.CURSOR_ROUTE_JOBS_DIR,
211
+ };
212
+ process.env.CURSOR_ROUTE_OPENCODE_BIN = bin;
213
+ delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
214
+ process.env.CURSOR_ROUTE_JOBS_DIR = join(dir, "jobs");
215
+ try {
216
+ const result = startJob({
217
+ prompt: "ping",
218
+ worker: "opencode",
219
+ dryRun: true,
220
+ });
221
+ expect(result.ok).toBe(true);
222
+ if (!result.ok) return;
223
+ expect(result.job.worker).toBe("opencode");
224
+ expect(result.job.model).toBe("opencode/big-pickle");
225
+ expect(result.command).toContain("run");
226
+ expect(result.command).toContain("--auto");
227
+ expect(result.command).toContain("$(cat");
228
+ } finally {
229
+ if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_BIN;
230
+ else process.env.CURSOR_ROUTE_OPENCODE_BIN = prev.bin;
231
+ if (prev.model === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
232
+ else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev.model;
233
+ if (prev.jobs === undefined) delete process.env.CURSOR_ROUTE_JOBS_DIR;
234
+ else process.env.CURSOR_ROUTE_JOBS_DIR = prev.jobs;
235
+ rmSync(dir, { recursive: true, force: true });
236
+ }
237
+ });
238
+ });
@@ -0,0 +1,89 @@
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 } from "../config.ts";
6
+
7
+ /**
8
+ * Opt-in OpenCode worker (`opencode run`) — a coding agent that can use
9
+ * OpenCode Zen free models (default `opencode/big-pickle`) to cut Grok /
10
+ * DeepSeek usage. Not a lane default: mid stays claude-ds; easy stays
11
+ * OpenRouter 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
+ let defaultModel = OPENCODE_DEFAULT_MODEL;
51
+ try {
52
+ defaultModel = openCodeModel();
53
+ } catch {
54
+ /* invalid env — health still ok; startJob will fail loud */
55
+ }
56
+ return {
57
+ worker: "opencode",
58
+ ok: true,
59
+ binary,
60
+ detail: `ok (opencode run; default model ${defaultModel}; auth at first start — opencode auth login if jobs fail; mid default remains claude-ds)`,
61
+ };
62
+ },
63
+ buildLaunch({ promptFile, cwd, alwaysApprove, modelId }) {
64
+ // Missing binary is tolerated here so `--dry-run` can still print the
65
+ // command; real starts are gated by the health preflight.
66
+ const binary = findOpencode() || "opencode";
67
+ const id = openCodeModel(modelId);
68
+
69
+ const ask = process.env.CURSOR_ROUTE_ASK === "1";
70
+ const skip = alwaysApprove && !ask;
71
+
72
+ const parts = [
73
+ shellQuote(binary),
74
+ "run",
75
+ "--dir",
76
+ shellQuote(cwd),
77
+ "--model",
78
+ shellQuote(id),
79
+ ];
80
+ if (skip) parts.push("--auto");
81
+ parts.push(`"$(cat ${shellQuote(promptFile)})"`);
82
+
83
+ return {
84
+ worker: "opencode",
85
+ command: `cd ${shellQuote(cwd)} && ${parts.join(" ")}`,
86
+ alwaysApprove: skip,
87
+ };
88
+ },
89
+ };
@@ -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.10");
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.10", () => {
634
+ expect(config.version).toBe("0.1.10");
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 (default opencode/big-pickle) 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 (default: opencode/big-pickle); --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/big-pickle) 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,37 @@ export function openRouterBaseUrl(): string {
73
74
  return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
74
75
  }
75
76
 
77
+ /**
78
+ * Default OpenCode model: OpenCode Zen Big Pickle (free, limited-time).
79
+ * Override: CURSOR_ROUTE_OPENCODE_MODEL or `--model provider/model`.
80
+ * Alias `free` resolves to this default (or the env override).
81
+ */
82
+ export const OPENCODE_DEFAULT_MODEL = "opencode/big-pickle";
83
+
84
+ /** Whitelist OpenCode `provider/model` ids (no spaces / injection). */
85
+ export function assertOpenCodeModel(id: string): string {
86
+ if (!/^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._\-:]+)+$/i.test(id)) {
87
+ throw new Error(
88
+ `Invalid OpenCode model ${id}; expected provider/model (e.g. opencode/big-pickle) or free`,
89
+ );
90
+ }
91
+ return id;
92
+ }
93
+
94
+ /**
95
+ * Resolve OpenCode `--model` / env to a concrete `provider/model` id.
96
+ * Empty or `free` → CURSOR_ROUTE_OPENCODE_MODEL, else OPENCODE_DEFAULT_MODEL.
97
+ */
98
+ export function openCodeModel(raw?: string | null): string {
99
+ const v = (raw ?? "").trim();
100
+ if (!v || v.toLowerCase() === "free") {
101
+ const env = (process.env.CURSOR_ROUTE_OPENCODE_MODEL ?? "").trim();
102
+ if (!env || env.toLowerCase() === "free") return OPENCODE_DEFAULT_MODEL;
103
+ return assertOpenCodeModel(env);
104
+ }
105
+ return assertOpenCodeModel(v);
106
+ }
107
+
76
108
  function maxConcurrentJobsFromEnv(): number {
77
109
  const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
78
110
  if (raw) {
@@ -89,7 +121,7 @@ function maxConcurrentJobsFromEnv(): number {
89
121
  */
90
122
  export const config = {
91
123
  product: "cursor-route",
92
- version: "0.1.9",
124
+ version: "0.1.10",
93
125
  get jobsDir(): string {
94
126
  return defaultJobsDir();
95
127
  },
@@ -97,7 +129,7 @@ export const config = {
97
129
  defaultWorker: "grok" as WorkerKind,
98
130
  /**
99
131
  * Lane → default worker (Cemini /route public core).
100
- * `deepseek` is experimental only — mid stays on claude-ds.
132
+ * `deepseek` and `opencode` are opt-in only — mid stays on claude-ds.
101
133
  */
102
134
  laneWorkers: {
103
135
  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(