cursor-route 0.1.10 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@
4
4
 
5
5
  _(none)_
6
6
 
7
+ ## 0.1.11 — 2026-08-21
8
+
9
+ - `--worker opencode --model free` now **ranks the live OpenCode Zen catalog** (`GET https://opencode.ai/zen/v1/models`, cached ~15 min) instead of hardcoding `opencode/big-pickle`. Ox Alpha (`opencode/x-preview-f-free`) wins while it is listed and free; coding `-free` models next; `big-pickle` last among non-contributor free. Fetch-fail fallback is Ox Alpha.
10
+ - Pin with `--model provider/model` or `CURSOR_ROUTE_OPENCODE_MODEL`. Tests/CI: `CURSOR_ROUTE_ZEN_OFFLINE=1` or `CURSOR_ROUTE_ZEN_CATALOG_JSON` (health never fetches — cache or fallback only).
11
+ - Mid stays `claude-ds`; easy stays OpenRouter chat. OpenCode is still opt-in, not a lane default.
12
+
7
13
  ## 0.1.10 — 2026-08-21
8
14
 
9
15
  - Opt-in `--worker opencode`: OpenCode `run` as a coding agent that defaults to Zen free model `opencode/big-pickle` (alias `--model free`). Not a lane default — mid stays `claude-ds`, easy stays OpenRouter chat.
package/README.md CHANGED
@@ -29,7 +29,7 @@ If you already live in Cursor, X Premium (Grok CLI), and DeepSeek — stop payin
29
29
  | Grok CLI | X Premium | `--lane hard` implement |
30
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
- | OpenCode (opt-in) | OpenCode Zen free models | `--worker opencode` implement on free Zen (default `opencode/big-pickle`) |
32
+ | OpenCode (opt-in) | OpenCode Zen free models | `--worker opencode` implement on live Zen free pick (Ox Alpha while listed) |
33
33
  | Codex / extra Claude | Optional | Not required for v0 |
34
34
 
35
35
  Exact dollars vary — the point is **reuse subscriptions you already have**.
@@ -227,18 +227,20 @@ key-shaped material in `start` / `send`, and the runner re-checks the prompt fil
227
227
  ## OpenCode setup (opt-in free coding worker)
228
228
 
229
229
  `--worker opencode` runs [OpenCode](https://opencode.ai) as a **coding agent** on
230
- OpenCode Zen free models (default `opencode/big-pickle`). This is **not** a lane
231
- default mid stays `claude-ds`; easy stays OpenRouter chat (no tools). Use it
232
- to burn fewer Grok / DeepSeek tokens on implement work.
230
+ live OpenCode Zen free models (`--model free` fetches the catalog and ranks,
231
+ same idea as the OpenRouter free picker). Ox Alpha (`opencode/x-preview-f-free`)
232
+ wins while it is listed and free. This is **not** a lane default — mid stays
233
+ `claude-ds`; easy stays OpenRouter chat (no tools). Use it to burn fewer Grok /
234
+ DeepSeek tokens on implement work.
233
235
 
234
236
  ```bash
235
237
  npm i -g opencode-ai # or: brew install opencode
236
238
  opencode auth login # connect OpenCode Zen (or another provider)
237
- # optional:
238
- export CURSOR_ROUTE_OPENCODE_MODEL=opencode/big-pickle # default; alias --model free
239
+ # optional pin (skips the live catalog pick):
240
+ export CURSOR_ROUTE_OPENCODE_MODEL=opencode/hy3-free
239
241
 
240
242
  cursor-route health # worker:opencode should be ✓
241
- cursor-route start --worker opencode "…" # Big Pickle (free)
243
+ cursor-route start --worker opencode "…" # live Zen free pick
242
244
  cursor-route start --worker opencode --model free "…"
243
245
  cursor-route start --worker opencode --model opencode/hy3-free "…"
244
246
  ```
@@ -247,7 +249,8 @@ The adapter launches `opencode run --dir <cwd> --model <id>` with the prompt via
247
249
  `cat` (never interpolated). Always-approve maps to `--auto` (explicit `"deny"`
248
250
  rules still apply); `--ask` omits `--auto`. It never rewrites
249
251
  `~/.config/opencode/opencode.json`, so parallel jobs don't race. Override the
250
- binary with `CURSOR_ROUTE_OPENCODE_BIN`.
252
+ binary with `CURSOR_ROUTE_OPENCODE_BIN`. `--model free` caches the ranked catalog
253
+ for ~15 minutes (`CURSOR_ROUTE_ZEN_CACHE_MINUTES`).
251
254
 
252
255
  **Non-secret prompts:** several Zen free models may log or train on prompts during
253
256
  their free period (see [OpenCode Zen](https://opencode.ai/docs/zen/) privacy notes).
@@ -280,7 +283,7 @@ No. The mid worker is DeepSeek. Claude Code is the harness, configured with `ANT
280
283
  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.
281
284
 
282
285
  **Is it free?**
283
- 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`). `--worker opencode` can run OpenCode Zen free models (default `opencode/big-pickle`).
286
+ 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`). `--worker opencode` can run live OpenCode Zen free models (`--model free` ranks the catalog).
284
287
 
285
288
  ## Related
286
289
 
package/SECURITY.md CHANGED
@@ -15,7 +15,7 @@ Do **not** open a public issue for credential leaks or RCE-class bugs.
15
15
 
16
16
  1. **No secrets in prompts or job files.** Do not paste API keys, SSH private keys, Discord tokens, or `.env` contents into `cursor-route start` / `send` prompts.
17
17
  2. **Free OpenRouter easy-lane models may log prompts.** `--lane easy` / `--worker openrouter` is for **non-secret** wording/drafts only. The refuse gate (`start` / `send`, plus the runner's re-check) applies to every lane — never send key-shaped material to the easy lane.
18
- 3. **OpenCode Zen free models may log or train during their free period.** `--worker opencode` defaults to `opencode/big-pickle`. Treat it like the easy lane for secrets. Prefer `opencode/x-preview-f-free` (Ox Alpha) when you need the zero-retention free option.
18
+ 3. **OpenCode Zen free models may log or train during their free period.** `--worker opencode --model free` picks from the live Zen catalog (Ox Alpha / `x-preview-f-free` currently ranks first and is zero-retention). Treat other free Zen models like the easy lane for secrets. Pin `CURSOR_ROUTE_OPENCODE_MODEL` when you need a specific id.
19
19
  4. **Always-approve is powerful.** Default headless workers skip interactive permission prompts (`--always-approve` / `--dangerously-skip-permissions` / OpenCode `--auto`). Opt out with `--ask` / `CURSOR_ROUTE_ASK=1` when you need HITL.
20
20
  5. **Install from known channels only.** Prefer `npm i -g cursor-route` or a git clone of this repo. Do not pipe unknown `curl | bash` installers as the primary path.
21
21
  6. **Workers inherit your auth.** Grok CLI, Claude Code / claude-ds, and OpenCode use *your* local login. Treat worker panes like you would treat your own shell.
@@ -1,12 +1,12 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { shellQuote } from "../util.js";
4
- import { openCodeModel, OPENCODE_DEFAULT_MODEL } from "../config.js";
4
+ import { openCodeModel, OPENCODE_DEFAULT_MODEL, cachedZenFreePick } from "../config.js";
5
5
  /**
6
- * Opt-in OpenCode worker (`opencode run`) — a coding agent that can use
7
- * OpenCode Zen free models (default `opencode/big-pickle`) to cut Grok /
8
- * DeepSeek usage. Not a lane default: mid stays claude-ds; easy stays
9
- * OpenRouter chat (no tools).
6
+ * Opt-in OpenCode worker (`opencode run`) — a coding agent on live OpenCode
7
+ * Zen free models (`--model free` ranks the catalog; Ox Alpha wins while
8
+ * listed). Not a lane default: mid stays claude-ds; easy stays OpenRouter
9
+ * chat (no tools).
10
10
  *
11
11
  * We never rewrite ~/.config/opencode/opencode.json (parallel jobs would
12
12
  * race). Always-approve maps to `opencode run --auto` (still honors explicit
@@ -42,18 +42,13 @@ export const opencodeAdapter = {
42
42
  detail: "opencode not found — install: npm i -g opencode-ai (or brew install opencode). Then: opencode auth login. Mid default remains claude-ds.",
43
43
  };
44
44
  }
45
- let defaultModel = OPENCODE_DEFAULT_MODEL;
46
- try {
47
- defaultModel = openCodeModel();
48
- }
49
- catch {
50
- /* invalid env — health still ok; startJob will fail loud */
51
- }
45
+ // Health stays offline: show a fresh cache hit, else the Ox Alpha fallback.
46
+ const pick = cachedZenFreePick() || OPENCODE_DEFAULT_MODEL;
52
47
  return {
53
48
  worker: "opencode",
54
49
  ok: true,
55
50
  binary,
56
- detail: `ok (opencode run; default model ${defaultModel}; auth at first start — opencode auth login if jobs fail; mid default remains claude-ds)`,
51
+ 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
52
  };
58
53
  },
59
54
  buildLaunch({ promptFile, cwd, alwaysApprove, modelId }) {
package/dist/cli.js CHANGED
@@ -31,7 +31,7 @@ Usage:
31
31
  Start options:
32
32
  --worker <grok|claude-ds|openrouter|deepseek|opencode> Worker adapter (default: grok; deepseek/opencode = opt-in)
33
33
  --lane <easy|mid|hard> Lane → worker (easy=openrouter, mid=claude-ds, hard=grok)
34
- --model <flash|pro|free|provider/model> claude-ds/deepseek: flash|pro. opencode: free (default opencode/big-pickle) or provider/model
34
+ --model <flash|pro|free|provider/model> claude-ds/deepseek: flash|pro. opencode: free (live Zen catalog pick) or provider/model
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
@@ -53,7 +53,7 @@ Env:
53
53
  OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
54
54
  CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
55
55
  OPENROUTER_BASE_URL OpenRouter API base (default: https://openrouter.ai/api/v1)
56
- CURSOR_ROUTE_OPENCODE_MODEL OpenCode model (default: opencode/big-pickle); --model overrides
56
+ CURSOR_ROUTE_OPENCODE_MODEL OpenCode model pin (unset or free = live Zen free pick); --model overrides
57
57
  `);
58
58
  process.exit(exitCode);
59
59
  }
@@ -156,7 +156,7 @@ function asOpenCodeModel(v) {
156
156
  if (v === undefined || v === true)
157
157
  return undefined;
158
158
  if (typeof v !== "string") {
159
- throw new Error(`Invalid --model; expected provider/model (e.g. opencode/big-pickle) or free`);
159
+ throw new Error(`Invalid --model; expected provider/model (e.g. opencode/x-preview-f-free) or free`);
160
160
  }
161
161
  return openCodeModel(v);
162
162
  }
package/dist/config.js CHANGED
@@ -49,33 +49,7 @@ export function openRouterModel() {
49
49
  export function openRouterBaseUrl() {
50
50
  return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
51
51
  }
52
- /**
53
- * Default OpenCode model: OpenCode Zen Big Pickle (free, limited-time).
54
- * Override: CURSOR_ROUTE_OPENCODE_MODEL or `--model provider/model`.
55
- * Alias `free` resolves to this default (or the env override).
56
- */
57
- export const OPENCODE_DEFAULT_MODEL = "opencode/big-pickle";
58
- /** Whitelist OpenCode `provider/model` ids (no spaces / injection). */
59
- export function assertOpenCodeModel(id) {
60
- if (!/^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._\-:]+)+$/i.test(id)) {
61
- throw new Error(`Invalid OpenCode model ${id}; expected provider/model (e.g. opencode/big-pickle) or free`);
62
- }
63
- return id;
64
- }
65
- /**
66
- * Resolve OpenCode `--model` / env to a concrete `provider/model` id.
67
- * Empty or `free` → CURSOR_ROUTE_OPENCODE_MODEL, else OPENCODE_DEFAULT_MODEL.
68
- */
69
- export function openCodeModel(raw) {
70
- const v = (raw ?? "").trim();
71
- if (!v || v.toLowerCase() === "free") {
72
- const env = (process.env.CURSOR_ROUTE_OPENCODE_MODEL ?? "").trim();
73
- if (!env || env.toLowerCase() === "free")
74
- return OPENCODE_DEFAULT_MODEL;
75
- return assertOpenCodeModel(env);
76
- }
77
- return assertOpenCodeModel(v);
78
- }
52
+ export { OPENCODE_DEFAULT_MODEL, assertOpenCodeModel, openCodeModel, cachedZenFreePick, } from "./zen-free.js";
79
53
  function maxConcurrentJobsFromEnv() {
80
54
  const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
81
55
  if (raw) {
@@ -92,7 +66,7 @@ function maxConcurrentJobsFromEnv() {
92
66
  */
93
67
  export const config = {
94
68
  product: "cursor-route",
95
- version: "0.1.10",
69
+ version: "0.1.11",
96
70
  get jobsDir() {
97
71
  return defaultJobsDir();
98
72
  },
@@ -0,0 +1,237 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ /**
6
+ * Live OpenCode Zen free-model pick — same idea as agent-toolkit
7
+ * `select-openrouter-free-model.ps1`: probe the catalog, keep $0 text/coding
8
+ * models, rank, cache ~15 min. Hardcoded ids are fallback only.
9
+ *
10
+ * Catalog: GET https://opencode.ai/zen/v1/models (no key). Zen ids are
11
+ * `big-pickle` / `x-preview-f-free`; the CLI wants `opencode/<id>`.
12
+ */
13
+ export const ZEN_MODELS_URL_DEFAULT = "https://opencode.ai/zen/v1/models";
14
+ /** Offline / fetch-fail fallback — Ox Alpha (zero-retention, currently top-tier free). */
15
+ export const OPENCODE_DEFAULT_MODEL = "opencode/x-preview-f-free";
16
+ const EXCLUDE_RE = /lyria|whisper|tts|embed|embedding|image|vision-only|audio|diffusion|flux|stable-diffusion|moderation/i;
17
+ const CODING_RE = /coder|instruct|chat|laguna|nemotron|glm|qwen|kimi|deepseek|mimo|hy3|gpt-oss|north|gemma/i;
18
+ const OX_ALPHA_RE = /x-preview|ox-alpha|oxalpha/i;
19
+ const CONTRIBUTOR_RE = /contributor-free/i;
20
+ function zenModelsUrl() {
21
+ return (process.env.CURSOR_ROUTE_ZEN_MODELS_URL || ZEN_MODELS_URL_DEFAULT).trim();
22
+ }
23
+ function cacheMinutes() {
24
+ const n = Number(process.env.CURSOR_ROUTE_ZEN_CACHE_MINUTES);
25
+ return Number.isFinite(n) && n > 0 ? n : 15;
26
+ }
27
+ function cachePath() {
28
+ return process.env.CURSOR_ROUTE_ZEN_CACHE_PATH?.trim() ||
29
+ join(tmpdir(), "cursor-route-zen-best-free.json");
30
+ }
31
+ export function assertOpenCodeModel(id) {
32
+ if (!/^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._\-:]+)+$/i.test(id)) {
33
+ throw new Error(`Invalid OpenCode model ${id}; expected provider/model (e.g. opencode/x-preview-f-free) or free`);
34
+ }
35
+ return id;
36
+ }
37
+ /** Prefix a Zen catalog id (`x-preview-f-free`) for `opencode run --model`. */
38
+ export function asOpenCodeZenId(raw) {
39
+ const id = raw.trim();
40
+ if (!id)
41
+ throw new Error("empty Zen model id");
42
+ return id.includes("/") ? assertOpenCodeModel(id) : assertOpenCodeModel(`opencode/${id}`);
43
+ }
44
+ function num(v) {
45
+ if (typeof v === "number" && Number.isFinite(v))
46
+ return v;
47
+ if (typeof v === "string" && v.trim()) {
48
+ const n = Number(v);
49
+ if (Number.isFinite(n))
50
+ return n;
51
+ }
52
+ return NaN;
53
+ }
54
+ export function isZenFreeModel(m) {
55
+ const id = (m.id || "").trim();
56
+ if (!id)
57
+ return false;
58
+ if (EXCLUDE_RE.test(id) || EXCLUDE_RE.test(m.name || ""))
59
+ return false;
60
+ const prompt = num(m.pricing?.prompt);
61
+ const completion = num(m.pricing?.completion);
62
+ const pricedFree = prompt === 0 && completion === 0;
63
+ const tagged = id.endsWith("-free") ||
64
+ id.endsWith(":free") ||
65
+ /(^|\/)big-pickle$/i.test(id);
66
+ return tagged || pricedFree;
67
+ }
68
+ /** Higher boost wins. Ox Alpha first while it is in the free catalog. */
69
+ export function zenFreeBoost(id) {
70
+ const s = id.toLowerCase();
71
+ if (OX_ALPHA_RE.test(s))
72
+ return 40;
73
+ if (CONTRIBUTOR_RE.test(s))
74
+ return 1;
75
+ if (CODING_RE.test(s))
76
+ return 20;
77
+ if (s.endsWith("-free") || s.endsWith(":free"))
78
+ return 10;
79
+ if (/(^|\/)big-pickle$/.test(s))
80
+ return 5;
81
+ return 0;
82
+ }
83
+ export function rankZenFreeModels(models) {
84
+ const out = [];
85
+ for (const m of models) {
86
+ if (!isZenFreeModel(m))
87
+ continue;
88
+ const raw = (m.id || "").trim();
89
+ let id;
90
+ try {
91
+ id = asOpenCodeZenId(raw);
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ const ctx = Number.isFinite(Number(m.context_length)) ? Number(m.context_length) : 0;
97
+ out.push({
98
+ id,
99
+ name: (m.name || raw).trim(),
100
+ context_length: ctx,
101
+ boost: zenFreeBoost(id),
102
+ });
103
+ }
104
+ out.sort((a, b) => {
105
+ if (b.boost !== a.boost)
106
+ return b.boost - a.boost;
107
+ if (b.context_length !== a.context_length)
108
+ return b.context_length - a.context_length;
109
+ return a.id.localeCompare(b.id);
110
+ });
111
+ return out;
112
+ }
113
+ function readCache() {
114
+ const p = cachePath();
115
+ if (!existsSync(p))
116
+ return null;
117
+ try {
118
+ const j = JSON.parse(readFileSync(p, "utf8"));
119
+ if (!j?.id)
120
+ return null;
121
+ const ageMs = Date.now() - Date.parse(j.picked_at);
122
+ if (!Number.isFinite(ageMs) || ageMs < 0)
123
+ return null;
124
+ if (ageMs > cacheMinutes() * 60_000)
125
+ return null;
126
+ assertOpenCodeModel(j.id);
127
+ return j;
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ function writeCache(pick) {
134
+ try {
135
+ writeFileSync(cachePath(), JSON.stringify(pick), { mode: 0o600 });
136
+ }
137
+ catch {
138
+ /* cache is optional */
139
+ }
140
+ }
141
+ function httpGetSync(url, timeoutMs = 4000) {
142
+ const sec = Math.max(1, Math.ceil(timeoutMs / 1000));
143
+ const curl = spawnSync("curl", ["-fsS", "--max-time", String(sec), "-H", "Accept: application/json", url], { encoding: "utf8", timeout: timeoutMs + 500, env: { ...process.env } });
144
+ if (curl.status === 0 && curl.stdout?.trim())
145
+ return curl.stdout;
146
+ return null;
147
+ }
148
+ function parseCatalog(raw) {
149
+ const j = JSON.parse(raw);
150
+ if (Array.isArray(j))
151
+ return j;
152
+ if (Array.isArray(j.data))
153
+ return j.data;
154
+ return [];
155
+ }
156
+ export function loadZenCatalog() {
157
+ const inline = process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON?.trim();
158
+ if (inline)
159
+ return parseCatalog(inline);
160
+ if (process.env.CURSOR_ROUTE_ZEN_OFFLINE === "1")
161
+ return [];
162
+ const body = httpGetSync(zenModelsUrl());
163
+ if (!body)
164
+ return [];
165
+ try {
166
+ return parseCatalog(body);
167
+ }
168
+ catch {
169
+ return [];
170
+ }
171
+ }
172
+ /** Last live pick if the cache is still fresh — health-safe (no network). */
173
+ export function cachedZenFreePick() {
174
+ try {
175
+ return readCache()?.id ?? null;
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
181
+ /**
182
+ * Best live Zen free model, or OPENCODE_DEFAULT_MODEL if the catalog is empty/offline.
183
+ * `CURSOR_ROUTE_ZEN_REFRESH=1` skips cache. Tests: `CURSOR_ROUTE_ZEN_OFFLINE=1` or
184
+ * `CURSOR_ROUTE_ZEN_CATALOG_JSON`.
185
+ */
186
+ export function pickZenFreeModel(opts) {
187
+ const refresh = opts?.refresh ||
188
+ process.env.CURSOR_ROUTE_ZEN_REFRESH === "1";
189
+ if (!refresh && opts?.catalog === undefined) {
190
+ const cached = readCache();
191
+ if (cached)
192
+ return cached.id;
193
+ }
194
+ const models = opts?.catalog ?? loadZenCatalog();
195
+ const ranked = rankZenFreeModels(models);
196
+ const winner = ranked[0];
197
+ if (!winner) {
198
+ const fallback = {
199
+ id: OPENCODE_DEFAULT_MODEL,
200
+ name: "Ox Alpha Free (fallback)",
201
+ context_length: 0,
202
+ boost: 0,
203
+ picked_at: new Date().toISOString(),
204
+ candidates: 0,
205
+ source: "fallback",
206
+ };
207
+ if (opts?.catalog === undefined)
208
+ writeCache(fallback);
209
+ return fallback.id;
210
+ }
211
+ const pick = {
212
+ id: winner.id,
213
+ name: winner.name,
214
+ context_length: winner.context_length,
215
+ boost: winner.boost,
216
+ picked_at: new Date().toISOString(),
217
+ candidates: ranked.length,
218
+ source: "ranked-free",
219
+ };
220
+ if (opts?.catalog === undefined)
221
+ writeCache(pick);
222
+ return pick.id;
223
+ }
224
+ /**
225
+ * Resolve OpenCode `--model` / env to a concrete `provider/model` id.
226
+ * Empty or `free` → CURSOR_ROUTE_OPENCODE_MODEL pin, else live Zen free pick.
227
+ */
228
+ export function openCodeModel(raw) {
229
+ const v = (raw ?? "").trim();
230
+ if (!v || v.toLowerCase() === "free") {
231
+ const env = (process.env.CURSOR_ROUTE_OPENCODE_MODEL ?? "").trim();
232
+ if (env && env.toLowerCase() !== "free")
233
+ return assertOpenCodeModel(env);
234
+ return pickZenFreeModel();
235
+ }
236
+ return assertOpenCodeModel(v);
237
+ }
@@ -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.10 (unreleased OpenCode worker)
4
+ npm: cursor-route@0.1.11 (LIVE on npm latest)
5
5
  created: 2026-08-12
6
6
  updated: 2026-08-21
7
7
  ---
@@ -21,11 +21,11 @@ Cursor Agent plans. Workers run in tmux via `cursor-route`:
21
21
  | `easy` | OpenRouter free | Wording / drafts — non-secret prompts only |
22
22
  | `mid` | claude-ds (DeepSeek behind Claude Code) | Default implement (**Flash**; `--model pro` when needed) |
23
23
  | `hard` | Grok CLI | Hard implement |
24
- | opt-in | OpenCode | `--worker opencode` coding agent on Zen free models (default `opencode/big-pickle`) |
24
+ | opt-in | OpenCode | `--worker opencode` coding agent; `--model free` ranks live Zen catalog (Ox Alpha first while listed) |
25
25
 
26
26
  Always-approve on for coding worktrees (`--ask` / `CURSOR_ROUTE_ASK=1` to opt out) — not LIVE Discord/trading. Jobs live in `~/.local/share/cursor-route/jobs`, not in this clone.
27
27
 
28
- Install: `npm i -g cursor-route` → **0.1.9** live; **0.1.10** unreleased (OpenCode worker). Release notes: [CHANGELOG.md](../../CHANGELOG.md).
28
+ Install: `npm i -g cursor-route` → **0.1.11**. Release notes: [CHANGELOG.md](../../CHANGELOG.md).
29
29
 
30
30
  ## Open (edit / check off)
31
31
 
@@ -39,7 +39,8 @@ Install: `npm i -g cursor-route` → **0.1.9** live; **0.1.10** unreleased (Open
39
39
  - [x] **Experimental `--worker deepseek`** — 0.1.8: real dsh adapter (`dsh --profile headless` + per-job Cordis patch pins the model; never writes `~/.dsh/settings.yaml`). Always-approve → `DSH_PERMISSION_MODE=danger-full-access`, `--ask` → `workspace-write`; key via env only. Health ✓ needs `dsh` + `DEEPSEEK_API_KEY` (override `CURSOR_ROUTE_DSH_BIN`). Mid stays **claude-ds**.
40
40
  - [x] **route-orch brief steals (2026-08-14)** — AutoDesign / misevolution / Vero habits into the public skill: **Verify / claim closeout** (external eval contract; activity ≠ verification), **Eval & skill hygiene** (mid-run Verify-rewrite ban; skill misevolution HITL — no auto-promotion of worker-trajectory variants; verify-fail → reconsider plan/definition + stage attribution spawn/execute/verify); handoff shape Success criteria + Verify + NEVER; skills synced; mid stays claude-ds.
41
41
  - [x] **Health proves mid DeepSeek + evidence tree** — 0.1.9: `lane:mid` ✓ only when DeepSeek is proven (shim or DeepSeek `ANTHROPIC_BASE_URL`; Anthropic hatch is not proof); health JSON `lanes.mid`; `status --json` evidence tree (`spawn` / `execute` / `verify.claim=unverified`); skill health-before-mid + evidence-tree closeout. Mid stays **claude-ds**.
42
- - [x] **Opt-in `--worker opencode`** — 0.1.10: `opencode run --dir` + `--model` (default Zen free `opencode/big-pickle` / `--model free`); always-approve → `--auto`; `--ask` omits it; never rewrites `~/.config/opencode/opencode.json`. Health ✓ needs `opencode` on PATH (`CURSOR_ROUTE_OPENCODE_BIN`). Mid stays **claude-ds**; easy stays OpenRouter chat. Free Zen may log/train — non-secret prompts.
42
+ - [x] **Opt-in `--worker opencode`** — 0.1.10: `opencode run --dir` + `--model`; always-approve → `--auto`; `--ask` omits it; never rewrites `~/.config/opencode/opencode.json`. Health ✓ needs `opencode` on PATH (`CURSOR_ROUTE_OPENCODE_BIN`). Mid stays **claude-ds**; easy stays OpenRouter chat. Free Zen may log/train — non-secret prompts.
43
+ - [x] **Live Zen free pick** — 0.1.11: `--model free` ranks `GET https://opencode.ai/zen/v1/models` (Ox Alpha first while listed; ~15 min cache; Ox Alpha fallback if fetch fails). Pin with `CURSOR_ROUTE_OPENCODE_MODEL`. Health stays offline (cache/fallback only).
43
44
  - [ ] **Hero GIF** — still outstanding; dry-run fixture ships as the substitute for now (`docs/fixtures/hero-demo.log` — see `docs/DEMO_GIF.md`)
44
45
  - [x] **Do not** paste private `ROUTE_KIT`, SIP, prod paths, or hang-watchdog env into this public repo
45
46
 
@@ -53,6 +54,7 @@ Install: `npm i -g cursor-route` → **0.1.9** live; **0.1.10** unreleased (Open
53
54
  | `src/adapters/grok.ts` | Hard worker |
54
55
  | `src/adapters/openrouter.ts` | Easy worker |
55
56
  | `src/adapters/opencode.ts` | Opt-in OpenCode worker (`--worker opencode`; mid stays claude-ds) |
57
+ | `src/zen-free.ts` | Live Zen catalog rank for `--model free` |
56
58
  | `skills/route-orch/SKILL.md` | Cursor skill — spawn CLI, do not implement in-session |
57
59
  | `CHANGELOG.md` | Release notes |
58
60
  | `SECURITY.md` | Secret refuse gate |
@@ -71,4 +73,5 @@ Install: `npm i -g cursor-route` → **0.1.9** live; **0.1.10** unreleased (Open
71
73
  | 2026-08-14 | Experimental `--worker deepseek` wired to official dsh (headless + per-job patch + `DSH_PERMISSION_MODE` + key-via-env); `--model` applies to claude-ds + deepseek; mid stays claude-ds → 0.1.8 LIVE. |
72
74
  | 2026-08-14 | route-orch brief steals (AutoDesign / misevolution / Vero): **Verify / claim closeout** + **Eval & skill hygiene**; handoff Success criteria + Verify + NEVER; both skill copies synced; mid stays claude-ds. Docs-only, no version bump. |
73
75
  | 2026-08-18 | Health `lane:mid` + `lanes.mid` prove DeepSeek; status evidence tree (`verify.claim` stays unverified); skill health-before-mid + closeout tree; mid stays claude-ds → 0.1.9 LIVE. |
74
- | 2026-08-21 | Opt-in `--worker opencode` (Zen free `opencode/big-pickle`, `--auto`, no config rewrite); mid stays claude-ds → 0.1.10. |
76
+ | 2026-08-21 | Opt-in `--worker opencode` (Zen free `opencode/big-pickle`, `--auto`, no config rewrite); mid stays claude-ds → 0.1.10 LIVE. |
77
+ | 2026-08-21 | Live Zen free pick (Ox Alpha first while listed; OpenRouter-style catalog rank) → 0.1.11 LIVE. |
package/llms.txt CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  > Cursor stays the planner. DeepSeek (mid), Grok CLI (hard), and OpenRouter free models (easy) run parallel coding workers in tmux. `--worker opencode` is an opt-in coding agent on OpenCode Zen free models.
4
4
 
5
- MIT CLI + Cursor skill. npm: https://www.npmjs.com/package/cursor-route (latest **0.1.10**)
5
+ MIT CLI + Cursor skill. npm: https://www.npmjs.com/package/cursor-route (latest **0.1.11**)
6
6
  GitHub: https://github.com/cemini23/cursor-route
7
7
 
8
8
  ## FAQ
9
9
 
10
10
  ### What is cursor-route?
11
- cursor-route is a public MIT CLI and Cursor skill that runs parallel coding workers in tmux while Cursor remains the planner. DeepSeek handles the mid lane, Grok CLI handles the hard lane, and OpenRouter free models handle the easy lane (wording/drafts, non-secret prompts only). `--worker opencode` is an opt-in coding agent (default `opencode/big-pickle`) — not a lane default.
11
+ cursor-route is a public MIT CLI and Cursor skill that runs parallel coding workers in tmux while Cursor remains the planner. DeepSeek handles the mid lane, Grok CLI handles the hard lane, and OpenRouter free models handle the easy lane (wording/drafts, non-secret prompts only). `--worker opencode` is an opt-in coding agent (`--model free` ranks the live Zen catalog; Ox Alpha wins while listed) — not a lane default.
12
12
 
13
13
  ### How is this different from Codex orchestrator?
14
14
  It uses the familiar strategist and worker-pane shape, but it is not a Codex clone. cursor-route uses Cursor as the planner and DeepSeek plus Grok CLI as workers. Codex is not required.
@@ -23,7 +23,7 @@ No. The mid worker is DeepSeek. Claude Code is the harness, configured with `ANT
23
23
  | `--model pro` | `deepseek-v4-pro` | Harder mid / Grok usage stand-in |
24
24
 
25
25
  ### How do I use OpenCode free models?
26
- Install OpenCode (`npm i -g opencode-ai`), run `opencode auth login`, then `cursor-route start --worker opencode "…"`. Default model is `opencode/big-pickle` (`--model free`). Mid stays `claude-ds`.
26
+ Install OpenCode (`npm i -g opencode-ai`), run `opencode auth login`, then `cursor-route start --worker opencode "…"`. `--model free` ranks live Zen free models (Ox Alpha first while listed). Mid stays `claude-ds`.
27
27
 
28
28
  ### How do I install?
29
29
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-route",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter easy lane + OpenCode (opt-in free) are the parallel army \u2014 lane-aware /route orchestration in tmux.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -71,7 +71,7 @@ cursor-route start --worker opencode --model free --dir "$PWD" "…"
71
71
  cursor-route start --worker opencode --model opencode/hy3-free --dir "$PWD" "…"
72
72
  ```
73
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).
74
+ Health ✓ needs `opencode` on PATH (override `CURSOR_ROUTE_OPENCODE_BIN`). `--model free` / unset ranks the live OpenCode Zen catalog (Ox Alpha first while listed; pin with `CURSOR_ROUTE_OPENCODE_MODEL` or `--model provider/model`). 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). Ox Alpha (`opencode/x-preview-f-free`) is the zero-retention free option.
75
75
 
76
76
  ## Workflow
77
77
 
@@ -11,16 +11,36 @@ import {
11
11
  } from "../config.ts";
12
12
 
13
13
  describe("openCodeModel", () => {
14
- test("defaults to OpenCode Zen Big Pickle", () => {
15
- const prev = process.env.CURSOR_ROUTE_OPENCODE_MODEL;
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`);
16
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";
17
28
  try {
18
- expect(openCodeModel()).toBe("opencode/big-pickle");
29
+ expect(openCodeModel()).toBe("opencode/x-preview-f-free");
19
30
  expect(openCodeModel("")).toBe(OPENCODE_DEFAULT_MODEL);
20
- expect(openCodeModel("free")).toBe("opencode/big-pickle");
31
+ expect(openCodeModel("free")).toBe("opencode/x-preview-f-free");
21
32
  } finally {
22
- if (prev === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
23
- else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev;
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 });
24
44
  }
25
45
  });
26
46
 
@@ -100,14 +120,21 @@ describe("opencode adapter", () => {
100
120
  });
101
121
 
102
122
  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 });
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 });
111
138
  });
112
139
 
113
140
  test("buildLaunch: default free model + --auto; prompt via cat", () => {
@@ -119,6 +146,10 @@ describe("opencode adapter", () => {
119
146
  CURSOR_ROUTE_OPENCODE_BIN: bin,
120
147
  CURSOR_ROUTE_OPENCODE_MODEL: undefined,
121
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,
122
153
  },
123
154
  () => {
124
155
  const plan = opencodeAdapter.buildLaunch({
@@ -128,7 +159,7 @@ describe("opencode adapter", () => {
128
159
  });
129
160
  expect(plan.command).toContain("run");
130
161
  expect(plan.command).toContain("--auto");
131
- expect(plan.command).toContain("opencode/big-pickle");
162
+ expect(plan.command).toContain("opencode/x-preview-f-free");
132
163
  expect(plan.command).toContain("$(cat");
133
164
  expect(plan.command).toContain("--dir");
134
165
  expect(plan.alwaysApprove).toBe(true);
@@ -160,15 +191,24 @@ describe("opencode adapter", () => {
160
191
  const { dir, bin } = makeFake("ask-env");
161
192
  const promptFile = join(dir, "job.prompt");
162
193
  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
- });
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
+ );
172
212
  rmSync(dir, { recursive: true, force: true });
173
213
  });
174
214
 
@@ -183,6 +223,9 @@ describe("opencode adapter", () => {
183
223
  CURSOR_ROUTE_OPENCODE_BIN: undefined,
184
224
  PATH: empty,
185
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",
186
229
  },
187
230
  () => {
188
231
  const plan = opencodeAdapter.buildLaunch({
@@ -208,10 +251,18 @@ describe("opencode adapter", () => {
208
251
  bin: process.env.CURSOR_ROUTE_OPENCODE_BIN,
209
252
  model: process.env.CURSOR_ROUTE_OPENCODE_MODEL,
210
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,
211
258
  };
212
259
  process.env.CURSOR_ROUTE_OPENCODE_BIN = bin;
213
260
  delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
261
+ delete process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON;
214
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";
215
266
  try {
216
267
  const result = startJob({
217
268
  prompt: "ping",
@@ -221,7 +272,7 @@ describe("opencode adapter", () => {
221
272
  expect(result.ok).toBe(true);
222
273
  if (!result.ok) return;
223
274
  expect(result.job.worker).toBe("opencode");
224
- expect(result.job.model).toBe("opencode/big-pickle");
275
+ expect(result.job.model).toBe("opencode/x-preview-f-free");
225
276
  expect(result.command).toContain("run");
226
277
  expect(result.command).toContain("--auto");
227
278
  expect(result.command).toContain("$(cat");
@@ -232,6 +283,14 @@ describe("opencode adapter", () => {
232
283
  else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev.model;
233
284
  if (prev.jobs === undefined) delete process.env.CURSOR_ROUTE_JOBS_DIR;
234
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;
235
294
  rmSync(dir, { recursive: true, force: true });
236
295
  }
237
296
  });
@@ -2,13 +2,13 @@ import { execSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import type { Adapter, WorkerHealth } from "./types.ts";
4
4
  import { shellQuote } from "../util.ts";
5
- import { openCodeModel, OPENCODE_DEFAULT_MODEL } from "../config.ts";
5
+ import { openCodeModel, OPENCODE_DEFAULT_MODEL, cachedZenFreePick } from "../config.ts";
6
6
 
7
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).
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
12
  *
13
13
  * We never rewrite ~/.config/opencode/opencode.json (parallel jobs would
14
14
  * race). Always-approve maps to `opencode run --auto` (still honors explicit
@@ -47,17 +47,13 @@ export const opencodeAdapter: Adapter = {
47
47
  "opencode not found — install: npm i -g opencode-ai (or brew install opencode). Then: opencode auth login. Mid default remains claude-ds.",
48
48
  };
49
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
- }
50
+ // Health stays offline: show a fresh cache hit, else the Ox Alpha fallback.
51
+ const pick = cachedZenFreePick() || OPENCODE_DEFAULT_MODEL;
56
52
  return {
57
53
  worker: "opencode",
58
54
  ok: true,
59
55
  binary,
60
- detail: `ok (opencode run; default model ${defaultModel}; auth at first start — opencode auth login if jobs fail; mid default remains claude-ds)`,
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)`,
61
57
  };
62
58
  },
63
59
  buildLaunch({ promptFile, cwd, alwaysApprove, modelId }) {
package/src/cli.test.ts CHANGED
@@ -618,7 +618,7 @@ describe("health", () => {
618
618
  test("returns structured report", () => {
619
619
  const r = runHealth();
620
620
  expect(r.product).toBe("cursor-route");
621
- expect(r.version).toBe("0.1.10");
621
+ expect(r.version).toBe("0.1.11");
622
622
  expect(r.checks.length).toBeGreaterThan(3);
623
623
  expect(r.checks.some((c) => c.name === "tmux")).toBe(true);
624
624
  expect(r.checks.some((c) => c.name === "cursor_cli")).toBe(true);
@@ -630,8 +630,8 @@ describe("health", () => {
630
630
  expect(r.checks.some((c) => c.name === "worker:deepseek")).toBe(true);
631
631
  });
632
632
 
633
- test("config version is 0.1.10", () => {
634
- expect(config.version).toBe("0.1.10");
633
+ test("config version is 0.1.11", () => {
634
+ expect(config.version).toBe("0.1.11");
635
635
  });
636
636
 
637
637
  test("OR-gate: ok can be true while worker:opencode is false", () => {
package/src/cli.ts CHANGED
@@ -56,7 +56,7 @@ Usage:
56
56
  Start options:
57
57
  --worker <grok|claude-ds|openrouter|deepseek|opencode> Worker adapter (default: grok; deepseek/opencode = opt-in)
58
58
  --lane <easy|mid|hard> Lane → worker (easy=openrouter, mid=claude-ds, hard=grok)
59
- --model <flash|pro|free|provider/model> claude-ds/deepseek: flash|pro. opencode: free (default opencode/big-pickle) or provider/model
59
+ --model <flash|pro|free|provider/model> claude-ds/deepseek: flash|pro. opencode: free (live Zen catalog pick) or provider/model
60
60
  --dir <path> Working directory (default: cwd)
61
61
  --ask Disable always-approve for this job
62
62
  --dry-run Print launch command; do not start
@@ -78,7 +78,7 @@ Env:
78
78
  OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
79
79
  CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
80
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
81
+ CURSOR_ROUTE_OPENCODE_MODEL OpenCode model pin (unset or free = live Zen free pick); --model overrides
82
82
  `);
83
83
  process.exit(exitCode);
84
84
  }
@@ -177,7 +177,7 @@ function asDsModelChoice(v: unknown): { alias: DsModelAlias; id: string } | unde
177
177
  function asOpenCodeModel(v: unknown): string | undefined {
178
178
  if (v === undefined || v === true) return undefined;
179
179
  if (typeof v !== "string") {
180
- throw new Error(`Invalid --model; expected provider/model (e.g. opencode/big-pickle) or free`);
180
+ throw new Error(`Invalid --model; expected provider/model (e.g. opencode/x-preview-f-free) or free`);
181
181
  }
182
182
  return openCodeModel(v);
183
183
  }
package/src/config.ts CHANGED
@@ -74,36 +74,12 @@ export function openRouterBaseUrl(): string {
74
74
  return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
75
75
  }
76
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
- }
77
+ export {
78
+ OPENCODE_DEFAULT_MODEL,
79
+ assertOpenCodeModel,
80
+ openCodeModel,
81
+ cachedZenFreePick,
82
+ } from "./zen-free.ts";
107
83
 
108
84
  function maxConcurrentJobsFromEnv(): number {
109
85
  const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
@@ -121,7 +97,7 @@ function maxConcurrentJobsFromEnv(): number {
121
97
  */
122
98
  export const config = {
123
99
  product: "cursor-route",
124
- version: "0.1.10",
100
+ version: "0.1.11",
125
101
  get jobsDir(): string {
126
102
  return defaultJobsDir();
127
103
  },
@@ -0,0 +1,137 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import {
6
+ asOpenCodeZenId,
7
+ isZenFreeModel,
8
+ openCodeModel,
9
+ OPENCODE_DEFAULT_MODEL,
10
+ pickZenFreeModel,
11
+ rankZenFreeModels,
12
+ zenFreeBoost,
13
+ type ZenModel,
14
+ } from "./zen-free.ts";
15
+
16
+ const CATALOG: ZenModel[] = [
17
+ { id: "big-pickle", name: "Big Pickle" },
18
+ { id: "hy3-free", name: "HY3 Free" },
19
+ { id: "claude-opus-4-6", name: "Claude Opus 4.6" },
20
+ { id: "muse-spark-1.2-contributor-free", name: "Muse Spark Contributor" },
21
+ { id: "laguna-s-2.1-free", name: "Laguna" },
22
+ { id: "x-preview-f-free", name: "Ox Alpha Free" },
23
+ { id: "whisper-free", name: "Whisper" },
24
+ ];
25
+
26
+ describe("zen free catalog", () => {
27
+ test("tags -free / big-pickle as free; skips paid and audio", () => {
28
+ expect(isZenFreeModel({ id: "x-preview-f-free" })).toBe(true);
29
+ expect(isZenFreeModel({ id: "big-pickle" })).toBe(true);
30
+ expect(isZenFreeModel({ id: "openrouter/qwen/qwen3-coder:free" })).toBe(true);
31
+ expect(isZenFreeModel({ id: "claude-opus-4-6" })).toBe(false);
32
+ expect(isZenFreeModel({ id: "whisper-free" })).toBe(false);
33
+ expect(
34
+ isZenFreeModel({
35
+ id: "some-zero-price",
36
+ pricing: { prompt: 0, completion: 0 },
37
+ }),
38
+ ).toBe(true);
39
+ });
40
+
41
+ test("Ox Alpha outranks coding free, which outranks big-pickle and contributor", () => {
42
+ expect(zenFreeBoost("opencode/x-preview-f-free")).toBe(40);
43
+ expect(zenFreeBoost("opencode/laguna-s-2.1-free")).toBe(20);
44
+ expect(zenFreeBoost("opencode/hy3-free")).toBe(20);
45
+ expect(zenFreeBoost("opencode/big-pickle")).toBe(5);
46
+ expect(zenFreeBoost("opencode/muse-spark-1.2-contributor-free")).toBe(1);
47
+ });
48
+
49
+ test("rank: Ox Alpha first while listed; paid/whisper dropped", () => {
50
+ const ranked = rankZenFreeModels(CATALOG);
51
+ expect(ranked[0]?.id).toBe("opencode/x-preview-f-free");
52
+ expect(ranked.map((r) => r.id)).not.toContain("opencode/claude-opus-4-6");
53
+ expect(ranked.map((r) => r.id)).not.toContain("opencode/whisper-free");
54
+ expect(ranked.at(-1)?.id).toBe("opencode/muse-spark-1.2-contributor-free");
55
+ });
56
+
57
+ test("rank without Ox: coding free beats big-pickle", () => {
58
+ const ranked = rankZenFreeModels([
59
+ { id: "big-pickle" },
60
+ { id: "laguna-s-2.1-free" },
61
+ { id: "muse-spark-1.2-contributor-free" },
62
+ ]);
63
+ expect(ranked[0]?.id).toBe("opencode/laguna-s-2.1-free");
64
+ expect(ranked[1]?.id).toBe("opencode/big-pickle");
65
+ });
66
+
67
+ test("asOpenCodeZenId prefixes bare Zen ids", () => {
68
+ expect(asOpenCodeZenId("x-preview-f-free")).toBe("opencode/x-preview-f-free");
69
+ expect(asOpenCodeZenId("opencode/big-pickle")).toBe("opencode/big-pickle");
70
+ });
71
+
72
+ test("pickZenFreeModel uses the ranked catalog winner", () => {
73
+ expect(pickZenFreeModel({ catalog: CATALOG })).toBe("opencode/x-preview-f-free");
74
+ });
75
+
76
+ test("offline / empty catalog falls back to Ox Alpha", () => {
77
+ expect(pickZenFreeModel({ catalog: [] })).toBe(OPENCODE_DEFAULT_MODEL);
78
+ expect(OPENCODE_DEFAULT_MODEL).toBe("opencode/x-preview-f-free");
79
+ });
80
+ });
81
+
82
+ describe("openCodeModel live pick", () => {
83
+ const isolate = (fn: () => void) => {
84
+ const dir = join(tmpdir(), `cr-zen-${process.pid}-${Math.random().toString(16).slice(2)}`);
85
+ mkdirSync(dir, { recursive: true });
86
+ const prev = {
87
+ model: process.env.CURSOR_ROUTE_OPENCODE_MODEL,
88
+ offline: process.env.CURSOR_ROUTE_ZEN_OFFLINE,
89
+ json: process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON,
90
+ cache: process.env.CURSOR_ROUTE_ZEN_CACHE_PATH,
91
+ refresh: process.env.CURSOR_ROUTE_ZEN_REFRESH,
92
+ };
93
+ delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
94
+ process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = join(dir, "cache.json");
95
+ process.env.CURSOR_ROUTE_ZEN_REFRESH = "1";
96
+ try {
97
+ fn();
98
+ } finally {
99
+ for (const [k, v] of Object.entries(prev)) {
100
+ const envKey = {
101
+ model: "CURSOR_ROUTE_OPENCODE_MODEL",
102
+ offline: "CURSOR_ROUTE_ZEN_OFFLINE",
103
+ json: "CURSOR_ROUTE_ZEN_CATALOG_JSON",
104
+ cache: "CURSOR_ROUTE_ZEN_CACHE_PATH",
105
+ refresh: "CURSOR_ROUTE_ZEN_REFRESH",
106
+ }[k]!;
107
+ if (v === undefined) delete process.env[envKey];
108
+ else process.env[envKey] = v;
109
+ }
110
+ rmSync(dir, { recursive: true, force: true });
111
+ }
112
+ };
113
+
114
+ test("free/empty + offline → Ox Alpha fallback", () => {
115
+ isolate(() => {
116
+ process.env.CURSOR_ROUTE_ZEN_OFFLINE = "1";
117
+ expect(openCodeModel()).toBe("opencode/x-preview-f-free");
118
+ expect(openCodeModel("free")).toBe("opencode/x-preview-f-free");
119
+ });
120
+ });
121
+
122
+ test("free + injected catalog → Ox Alpha", () => {
123
+ isolate(() => {
124
+ process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON = JSON.stringify({ data: CATALOG });
125
+ expect(openCodeModel("free")).toBe("opencode/x-preview-f-free");
126
+ });
127
+ });
128
+
129
+ test("CURSOR_ROUTE_OPENCODE_MODEL pin wins over catalog", () => {
130
+ isolate(() => {
131
+ process.env.CURSOR_ROUTE_OPENCODE_MODEL = "opencode/hy3-free";
132
+ process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON = JSON.stringify({ data: CATALOG });
133
+ expect(openCodeModel()).toBe("opencode/hy3-free");
134
+ expect(openCodeModel("free")).toBe("opencode/hy3-free");
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,258 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ /**
7
+ * Live OpenCode Zen free-model pick — same idea as agent-toolkit
8
+ * `select-openrouter-free-model.ps1`: probe the catalog, keep $0 text/coding
9
+ * models, rank, cache ~15 min. Hardcoded ids are fallback only.
10
+ *
11
+ * Catalog: GET https://opencode.ai/zen/v1/models (no key). Zen ids are
12
+ * `big-pickle` / `x-preview-f-free`; the CLI wants `opencode/<id>`.
13
+ */
14
+
15
+ export const ZEN_MODELS_URL_DEFAULT = "https://opencode.ai/zen/v1/models";
16
+
17
+ /** Offline / fetch-fail fallback — Ox Alpha (zero-retention, currently top-tier free). */
18
+ export const OPENCODE_DEFAULT_MODEL = "opencode/x-preview-f-free";
19
+
20
+ export interface ZenModel {
21
+ id?: string;
22
+ name?: string;
23
+ context_length?: number;
24
+ pricing?: { prompt?: number | string; completion?: number | string };
25
+ }
26
+
27
+ export interface ZenFreePick {
28
+ id: string;
29
+ name: string;
30
+ context_length: number;
31
+ boost: number;
32
+ picked_at: string;
33
+ candidates: number;
34
+ source: "ranked-free" | "fallback";
35
+ }
36
+
37
+ const EXCLUDE_RE =
38
+ /lyria|whisper|tts|embed|embedding|image|vision-only|audio|diffusion|flux|stable-diffusion|moderation/i;
39
+ const CODING_RE =
40
+ /coder|instruct|chat|laguna|nemotron|glm|qwen|kimi|deepseek|mimo|hy3|gpt-oss|north|gemma/i;
41
+ const OX_ALPHA_RE = /x-preview|ox-alpha|oxalpha/i;
42
+ const CONTRIBUTOR_RE = /contributor-free/i;
43
+
44
+ function zenModelsUrl(): string {
45
+ return (process.env.CURSOR_ROUTE_ZEN_MODELS_URL || ZEN_MODELS_URL_DEFAULT).trim();
46
+ }
47
+
48
+ function cacheMinutes(): number {
49
+ const n = Number(process.env.CURSOR_ROUTE_ZEN_CACHE_MINUTES);
50
+ return Number.isFinite(n) && n > 0 ? n : 15;
51
+ }
52
+
53
+ function cachePath(): string {
54
+ return process.env.CURSOR_ROUTE_ZEN_CACHE_PATH?.trim() ||
55
+ join(tmpdir(), "cursor-route-zen-best-free.json");
56
+ }
57
+
58
+ export function assertOpenCodeModel(id: string): string {
59
+ if (!/^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._\-:]+)+$/i.test(id)) {
60
+ throw new Error(
61
+ `Invalid OpenCode model ${id}; expected provider/model (e.g. opencode/x-preview-f-free) or free`,
62
+ );
63
+ }
64
+ return id;
65
+ }
66
+
67
+ /** Prefix a Zen catalog id (`x-preview-f-free`) for `opencode run --model`. */
68
+ export function asOpenCodeZenId(raw: string): string {
69
+ const id = raw.trim();
70
+ if (!id) throw new Error("empty Zen model id");
71
+ return id.includes("/") ? assertOpenCodeModel(id) : assertOpenCodeModel(`opencode/${id}`);
72
+ }
73
+
74
+ function num(v: unknown): number {
75
+ if (typeof v === "number" && Number.isFinite(v)) return v;
76
+ if (typeof v === "string" && v.trim()) {
77
+ const n = Number(v);
78
+ if (Number.isFinite(n)) return n;
79
+ }
80
+ return NaN;
81
+ }
82
+
83
+ export function isZenFreeModel(m: ZenModel): boolean {
84
+ const id = (m.id || "").trim();
85
+ if (!id) return false;
86
+ if (EXCLUDE_RE.test(id) || EXCLUDE_RE.test(m.name || "")) return false;
87
+ const prompt = num(m.pricing?.prompt);
88
+ const completion = num(m.pricing?.completion);
89
+ const pricedFree = prompt === 0 && completion === 0;
90
+ const tagged =
91
+ id.endsWith("-free") ||
92
+ id.endsWith(":free") ||
93
+ /(^|\/)big-pickle$/i.test(id);
94
+ return tagged || pricedFree;
95
+ }
96
+
97
+ /** Higher boost wins. Ox Alpha first while it is in the free catalog. */
98
+ export function zenFreeBoost(id: string): number {
99
+ const s = id.toLowerCase();
100
+ if (OX_ALPHA_RE.test(s)) return 40;
101
+ if (CONTRIBUTOR_RE.test(s)) return 1;
102
+ if (CODING_RE.test(s)) return 20;
103
+ if (s.endsWith("-free") || s.endsWith(":free")) return 10;
104
+ if (/(^|\/)big-pickle$/.test(s)) return 5;
105
+ return 0;
106
+ }
107
+
108
+ export function rankZenFreeModels(models: ZenModel[]): Array<{
109
+ id: string;
110
+ name: string;
111
+ context_length: number;
112
+ boost: number;
113
+ }> {
114
+ const out: Array<{ id: string; name: string; context_length: number; boost: number }> = [];
115
+ for (const m of models) {
116
+ if (!isZenFreeModel(m)) continue;
117
+ const raw = (m.id || "").trim();
118
+ let id: string;
119
+ try {
120
+ id = asOpenCodeZenId(raw);
121
+ } catch {
122
+ continue;
123
+ }
124
+ const ctx = Number.isFinite(Number(m.context_length)) ? Number(m.context_length) : 0;
125
+ out.push({
126
+ id,
127
+ name: (m.name || raw).trim(),
128
+ context_length: ctx,
129
+ boost: zenFreeBoost(id),
130
+ });
131
+ }
132
+ out.sort((a, b) => {
133
+ if (b.boost !== a.boost) return b.boost - a.boost;
134
+ if (b.context_length !== a.context_length) return b.context_length - a.context_length;
135
+ return a.id.localeCompare(b.id);
136
+ });
137
+ return out;
138
+ }
139
+
140
+ function readCache(): ZenFreePick | null {
141
+ const p = cachePath();
142
+ if (!existsSync(p)) return null;
143
+ try {
144
+ const j = JSON.parse(readFileSync(p, "utf8")) as ZenFreePick;
145
+ if (!j?.id) return null;
146
+ const ageMs = Date.now() - Date.parse(j.picked_at);
147
+ if (!Number.isFinite(ageMs) || ageMs < 0) return null;
148
+ if (ageMs > cacheMinutes() * 60_000) return null;
149
+ assertOpenCodeModel(j.id);
150
+ return j;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function writeCache(pick: ZenFreePick): void {
157
+ try {
158
+ writeFileSync(cachePath(), JSON.stringify(pick), { mode: 0o600 });
159
+ } catch {
160
+ /* cache is optional */
161
+ }
162
+ }
163
+
164
+ function httpGetSync(url: string, timeoutMs = 4000): string | null {
165
+ const sec = Math.max(1, Math.ceil(timeoutMs / 1000));
166
+ const curl = spawnSync(
167
+ "curl",
168
+ ["-fsS", "--max-time", String(sec), "-H", "Accept: application/json", url],
169
+ { encoding: "utf8", timeout: timeoutMs + 500, env: { ...process.env } },
170
+ );
171
+ if (curl.status === 0 && curl.stdout?.trim()) return curl.stdout;
172
+ return null;
173
+ }
174
+
175
+ function parseCatalog(raw: string): ZenModel[] {
176
+ const j = JSON.parse(raw) as { data?: ZenModel[] } | ZenModel[];
177
+ if (Array.isArray(j)) return j;
178
+ if (Array.isArray(j.data)) return j.data;
179
+ return [];
180
+ }
181
+
182
+ export function loadZenCatalog(): ZenModel[] {
183
+ const inline = process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON?.trim();
184
+ if (inline) return parseCatalog(inline);
185
+ if (process.env.CURSOR_ROUTE_ZEN_OFFLINE === "1") return [];
186
+ const body = httpGetSync(zenModelsUrl());
187
+ if (!body) return [];
188
+ try {
189
+ return parseCatalog(body);
190
+ } catch {
191
+ return [];
192
+ }
193
+ }
194
+
195
+ /** Last live pick if the cache is still fresh — health-safe (no network). */
196
+ export function cachedZenFreePick(): string | null {
197
+ try {
198
+ return readCache()?.id ?? null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Best live Zen free model, or OPENCODE_DEFAULT_MODEL if the catalog is empty/offline.
206
+ * `CURSOR_ROUTE_ZEN_REFRESH=1` skips cache. Tests: `CURSOR_ROUTE_ZEN_OFFLINE=1` or
207
+ * `CURSOR_ROUTE_ZEN_CATALOG_JSON`.
208
+ */
209
+ export function pickZenFreeModel(opts?: { catalog?: ZenModel[]; refresh?: boolean }): string {
210
+ const refresh =
211
+ opts?.refresh ||
212
+ process.env.CURSOR_ROUTE_ZEN_REFRESH === "1";
213
+ if (!refresh && opts?.catalog === undefined) {
214
+ const cached = readCache();
215
+ if (cached) return cached.id;
216
+ }
217
+ const models = opts?.catalog ?? loadZenCatalog();
218
+ const ranked = rankZenFreeModels(models);
219
+ const winner = ranked[0];
220
+ if (!winner) {
221
+ const fallback: ZenFreePick = {
222
+ id: OPENCODE_DEFAULT_MODEL,
223
+ name: "Ox Alpha Free (fallback)",
224
+ context_length: 0,
225
+ boost: 0,
226
+ picked_at: new Date().toISOString(),
227
+ candidates: 0,
228
+ source: "fallback",
229
+ };
230
+ if (opts?.catalog === undefined) writeCache(fallback);
231
+ return fallback.id;
232
+ }
233
+ const pick: ZenFreePick = {
234
+ id: winner.id,
235
+ name: winner.name,
236
+ context_length: winner.context_length,
237
+ boost: winner.boost,
238
+ picked_at: new Date().toISOString(),
239
+ candidates: ranked.length,
240
+ source: "ranked-free",
241
+ };
242
+ if (opts?.catalog === undefined) writeCache(pick);
243
+ return pick.id;
244
+ }
245
+
246
+ /**
247
+ * Resolve OpenCode `--model` / env to a concrete `provider/model` id.
248
+ * Empty or `free` → CURSOR_ROUTE_OPENCODE_MODEL pin, else live Zen free pick.
249
+ */
250
+ export function openCodeModel(raw?: string | null): string {
251
+ const v = (raw ?? "").trim();
252
+ if (!v || v.toLowerCase() === "free") {
253
+ const env = (process.env.CURSOR_ROUTE_OPENCODE_MODEL ?? "").trim();
254
+ if (env && env.toLowerCase() !== "free") return assertOpenCodeModel(env);
255
+ return pickZenFreeModel();
256
+ }
257
+ return assertOpenCodeModel(v);
258
+ }