cursor-route 0.1.8 → 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.
@@ -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
@@ -10,12 +10,17 @@ import {
10
10
  } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { tmpdir } from "node:os";
13
- import { resolveWorker, startJob } from "./jobs.ts";
13
+ import { resolveWorker, startJob, jobEvidence, type Job } from "./jobs.ts";
14
14
  import { config, resolveDsModel, resolveDsModelAlias } from "./config.ts";
15
15
  import { shellQuote, newJobId } from "./util.ts";
16
16
  import { runHealth } from "./health.ts";
17
17
  import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
18
- import { isDeepSeekRouted, isDeepSeekBaseUrl, claudeDsAdapter } from "./adapters/claude-ds.ts";
18
+ import {
19
+ isDeepSeekRouted,
20
+ isDeepSeekBaseUrl,
21
+ claudeDsAdapter,
22
+ isMidDeepSeekProven,
23
+ } from "./adapters/claude-ds.ts";
19
24
  import { deepseekAdapter, patchPathForPrompt } from "./adapters/deepseek.ts";
20
25
  import { grokAdapter } from "./adapters/grok.ts";
21
26
 
@@ -36,6 +41,9 @@ describe("resolveWorker", () => {
36
41
  expect(resolveWorker({ prompt: "x", lane: "mid", worker: "openrouter" })).toBe(
37
42
  "openrouter",
38
43
  );
44
+ expect(resolveWorker({ prompt: "x", lane: "mid", worker: "opencode" })).toBe(
45
+ "opencode",
46
+ );
39
47
  });
40
48
  test("default worker", () => {
41
49
  expect(resolveWorker({ prompt: "x" })).toBe(config.defaultWorker);
@@ -610,14 +618,40 @@ describe("health", () => {
610
618
  test("returns structured report", () => {
611
619
  const r = runHealth();
612
620
  expect(r.product).toBe("cursor-route");
613
- expect(r.version).toBe("0.1.8");
621
+ expect(r.version).toBe("0.1.10");
614
622
  expect(r.checks.length).toBeGreaterThan(3);
615
623
  expect(r.checks.some((c) => c.name === "tmux")).toBe(true);
616
624
  expect(r.checks.some((c) => c.name === "cursor_cli")).toBe(true);
625
+ const mid = r.checks.find((c) => c.name === "lane:mid");
626
+ expect(mid).toBeDefined();
627
+ expect(r.lanes.mid.worker).toBe("claude-ds");
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");
617
635
  });
618
636
 
619
- test("config version is 0.1.8", () => {
620
- expect(config.version).toBe("0.1.8");
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
+ }
621
655
  });
622
656
 
623
657
  test("OR-gate: ok can be true while worker:deepseek is false", () => {
@@ -657,4 +691,98 @@ describe("health", () => {
657
691
  else process.env.CURSOR_ROUTE_RELAXED = prev;
658
692
  }
659
693
  });
694
+
695
+ test("CURSOR_ROUTE_CLAUDE_DS_BIN pointing at an existing file proves mid DeepSeek", () => {
696
+ const prev = process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
697
+ const fake = join(tmpdir(), `cr-mid-proof-${process.pid}`);
698
+ writeFileSync(fake, "#!/bin/sh\necho ok\n");
699
+ chmodSync(fake, 0o755);
700
+ process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = fake;
701
+ try {
702
+ expect(isMidDeepSeekProven()).toBe(true);
703
+ } finally {
704
+ if (prev === undefined) delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
705
+ else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev;
706
+ rmSync(fake, { force: true });
707
+ }
708
+ });
709
+
710
+ test("Anthropic hatch is not DeepSeek proof (shim on PATH still is)", () => {
711
+ const shimDir = join(tmpdir(), `cr-mid-hatch-${process.pid}`);
712
+ mkdirSync(shimDir, { recursive: true });
713
+ const claude = join(shimDir, "claude");
714
+ writeFileSync(claude, "#!/bin/sh\necho ok\n");
715
+ chmodSync(claude, 0o755);
716
+
717
+ const prev = {
718
+ bin: process.env.CURSOR_ROUTE_CLAUDE_DS_BIN,
719
+ allow: process.env.CURSOR_ROUTE_ALLOW_ANTHROPIC,
720
+ base: process.env.ANTHROPIC_BASE_URL,
721
+ crBase: process.env.CURSOR_ROUTE_ANTHROPIC_BASE_URL,
722
+ path: process.env.PATH,
723
+ };
724
+ delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
725
+ delete process.env.CURSOR_ROUTE_ANTHROPIC_BASE_URL;
726
+ process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com";
727
+ process.env.CURSOR_ROUTE_ALLOW_ANTHROPIC = "1";
728
+ process.env.PATH = `${shimDir}:/usr/bin:/bin`;
729
+ try {
730
+ const detail = claudeDsAdapter.health().detail;
731
+ if (detail.includes("Anthropic")) {
732
+ expect(isMidDeepSeekProven()).toBe(false);
733
+ const r = runHealth();
734
+ const mid = r.checks.find((c) => c.name === "lane:mid");
735
+ expect(mid?.ok).toBe(false);
736
+ expect(r.lanes.mid.deepseek).toBe(false);
737
+ } else {
738
+ // Real claude-ds / deepseek-claude on PATH is proof (hatch never engages).
739
+ expect(isMidDeepSeekProven()).toBe(true);
740
+ }
741
+ } finally {
742
+ if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
743
+ else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev.bin;
744
+ if (prev.allow === undefined) delete process.env.CURSOR_ROUTE_ALLOW_ANTHROPIC;
745
+ else process.env.CURSOR_ROUTE_ALLOW_ANTHROPIC = prev.allow;
746
+ if (prev.base === undefined) delete process.env.ANTHROPIC_BASE_URL;
747
+ else process.env.ANTHROPIC_BASE_URL = prev.base;
748
+ if (prev.crBase === undefined) delete process.env.CURSOR_ROUTE_ANTHROPIC_BASE_URL;
749
+ else process.env.CURSOR_ROUTE_ANTHROPIC_BASE_URL = prev.crBase;
750
+ process.env.PATH = prev.path || "";
751
+ rmSync(shimDir, { recursive: true, force: true });
752
+ }
753
+ });
754
+ });
755
+
756
+ describe("jobEvidence", () => {
757
+ test("verify.claim is always unverified; spawn/execute keys exist", () => {
758
+ const job: Job = {
759
+ id: "abcd1234",
760
+ schema: "cursor-route.job.v1",
761
+ status: "running",
762
+ worker: "claude-ds",
763
+ lane: "mid",
764
+ model: "flash",
765
+ prompt: "ping",
766
+ cwd: "/tmp",
767
+ alwaysApprove: true,
768
+ tmuxSession: "cursor-route-abcd1234",
769
+ createdAt: "2026-08-18T00:00:00.000Z",
770
+ startedAt: "2026-08-18T00:00:01.000Z",
771
+ logBytes: 42,
772
+ };
773
+ const ev = jobEvidence(job, true);
774
+ expect(ev.verify.claim).toBe("unverified");
775
+ expect(ev.verify.captureHint).toBe("cursor-route capture abcd1234");
776
+ expect(ev.verify.logBytes).toBe(42);
777
+ expect(ev.spawn.jobId).toBe("abcd1234");
778
+ expect(ev.spawn.worker).toBe("claude-ds");
779
+ expect(ev.spawn.lane).toBe("mid");
780
+ expect(ev.spawn.model).toBe("flash");
781
+ expect(ev.spawn.startedAt).toBe("2026-08-18T00:00:01.000Z");
782
+ expect(ev.execute.status).toBe("running");
783
+ expect(ev.execute.exitCode).toBe(null);
784
+ expect(ev.execute.tmuxSession).toBe("cursor-route-abcd1234");
785
+ expect(ev.execute.pid).toBe(null);
786
+ expect(ev.execute.sessionAlive).toBe(true);
787
+ });
660
788
  });
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,
@@ -22,6 +23,7 @@ import {
22
23
  cleanJobs,
23
24
  jobPaths,
24
25
  refreshStatus,
26
+ jobEvidence,
25
27
  } from "./jobs.ts";
26
28
  import {
27
29
  capturePane,
@@ -35,15 +37,15 @@ import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
35
37
  function usage(exitCode = 0): never {
36
38
  console.log(`cursor-route v${config.version}
37
39
 
38
- 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.
39
41
 
40
42
  Usage:
41
43
  cursor-route --version
42
- cursor-route health [--json]
44
+ cursor-route health [--json] # JSON includes lanes.mid (DeepSeek proof)
43
45
  cursor-route start <prompt> [options]
44
46
  cursor-route start --prompt-file <path> [options]
45
47
  cursor-route jobs [--json] [--limit N]
46
- cursor-route status <jobId> [--json]
48
+ cursor-route status <jobId> [--json] # JSON includes evidence spawn/execute/verify
47
49
  cursor-route capture <jobId> [lines]
48
50
  cursor-route send <jobId> <message>
49
51
  cursor-route attach <jobId>
@@ -52,9 +54,9 @@ Usage:
52
54
  cursor-route clean [--days N]
53
55
 
54
56
  Start options:
55
- --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)
56
58
  --lane <easy|mid|hard> Lane → worker (easy=openrouter, mid=claude-ds, hard=grok)
57
- --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
58
60
  --dir <path> Working directory (default: cwd)
59
61
  --ask Disable always-approve for this job
60
62
  --dry-run Print launch command; do not start
@@ -71,10 +73,12 @@ Env:
71
73
  CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
72
74
  CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
73
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)
74
77
  DEEPSEEK_API_KEY DeepSeek API key (required for --worker deepseek)
75
78
  OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
76
79
  CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
77
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
78
82
  `);
79
83
  process.exit(exitCode);
80
84
  }
@@ -170,6 +174,14 @@ function asDsModelChoice(v: unknown): { alias: DsModelAlias; id: string } | unde
170
174
  return resolveDsModel(v);
171
175
  }
172
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
+
173
185
  function refuseSecrets(text: string, context: string): void {
174
186
  if (looksLikeSecretMaterial(text)) {
175
187
  console.error(
@@ -252,8 +264,8 @@ async function main() {
252
264
 
253
265
  let model: DsModelAlias | undefined;
254
266
  let modelId: string | undefined;
255
- // --model is DeepSeek-only (claude-ds mid lane + experimental deepseek worker);
256
- // 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
257
269
  if (f.model !== undefined && (resolvedWorker === "claude-ds" || resolvedWorker === "deepseek")) {
258
270
  try {
259
271
  const choice = asDsModelChoice(f.model);
@@ -265,6 +277,13 @@ async function main() {
265
277
  console.error((e as Error).message);
266
278
  process.exit(2);
267
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
+ }
268
287
  }
269
288
 
270
289
  let prompt = "";
@@ -358,7 +377,7 @@ async function main() {
358
377
  } else {
359
378
  for (const j of jobs) {
360
379
  const age = j.startedAt || j.createdAt;
361
- const modelCol = j.model ? j.model.padEnd(6) : "".padEnd(6);
380
+ const modelCol = j.model ? j.model.padEnd(22) : "".padEnd(22);
362
381
  console.log(
363
382
  `${j.id} ${j.status.padEnd(10)} ${j.worker.padEnd(10)} ${modelCol} ${age} ${j.prompt.slice(0, 48).replace(/\n/g, " ")}`,
364
383
  );
@@ -389,12 +408,14 @@ async function main() {
389
408
  }
390
409
  })())
391
410
  : sessionExists(job.tmuxSession);
392
- const view = { ...job, sessionAlive: alive };
411
+ const evidence = jobEvidence(job, alive);
412
+ const view = { ...job, sessionAlive: alive, evidence };
393
413
  if (json) console.log(JSON.stringify(view, null, 2));
394
414
  else {
395
415
  console.log(
396
416
  `${job.id} ${job.status} worker=${job.worker}${job.model ? ` model=${job.model}` : ""} sessionAlive=${alive}`,
397
417
  );
418
+ console.log("evidence: spawn/execute/verify (claim=unverified)");
398
419
  if (job.error) console.log(`error: ${job.error}`);
399
420
  }
400
421
  return;
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.8",
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
@@ -1,5 +1,6 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { allAdapters } from "./adapters/index.ts";
3
+ import { isMidDeepSeekProven, midDeepSeekProofDetail } from "./adapters/claude-ds.ts";
3
4
  import { config } from "./config.ts";
4
5
  import { isTmuxAvailable } from "./tmux.ts";
5
6
  import { commandExists } from "./util.ts";
@@ -13,6 +14,9 @@ export interface HealthReport {
13
14
  ok: boolean;
14
15
  detail: string;
15
16
  }>;
17
+ lanes: {
18
+ mid: { worker: "claude-ds"; deepseek: boolean; detail: string };
19
+ };
16
20
  }
17
21
 
18
22
  export function runHealth(): HealthReport {
@@ -58,6 +62,15 @@ export function runHealth(): HealthReport {
58
62
  });
59
63
  }
60
64
 
65
+ // Informational: mid is proven DeepSeek (not the overall OR-gate).
66
+ const midOk = isMidDeepSeekProven();
67
+ const midDetail = midDeepSeekProofDetail();
68
+ checks.push({
69
+ name: "lane:mid",
70
+ ok: midOk,
71
+ detail: midDetail,
72
+ });
73
+
61
74
  // Optional supervisor probe (v0 skill-only; Cursor CLI agent is informational)
62
75
  const agentBin =
63
76
  (commandExists("agent") && "agent") ||
@@ -99,6 +112,9 @@ export function runHealth(): HealthReport {
99
112
  product: config.product,
100
113
  version: config.version,
101
114
  checks,
115
+ lanes: {
116
+ mid: { worker: "claude-ds", deepseek: midOk, detail: midDetail },
117
+ },
102
118
  };
103
119
  }
104
120
 
@@ -117,6 +133,11 @@ export function printHealth(report: HealthReport, asJson: boolean): void {
117
133
  if (!report.ok) {
118
134
  console.log("");
119
135
  console.log("Fix the ✗ items, then re-run: cursor-route health");
120
- 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
+ } else if (report.checks.some((c) => c.name === "lane:mid" && !c.ok)) {
138
+ console.log("");
139
+ console.log(
140
+ "Tip: health OK without a DeepSeek mid — --lane mid will fail until lane:mid is ✓.",
141
+ );
121
142
  }
122
143
  }
package/src/jobs.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  sessionName,
18
18
  defaultDsModelFromEnv,
19
19
  DS_MODEL_IDS,
20
+ openCodeModel,
20
21
  type Lane,
21
22
  type WorkerKind,
22
23
  type DsModelAlias,
@@ -37,8 +38,8 @@ export interface Job {
37
38
  status: JobStatus;
38
39
  worker: WorkerKind;
39
40
  lane?: Lane;
40
- /** Mid-lane DeepSeek model alias (flash|pro). Set for claude-ds and deepseek. */
41
- model?: DsModelAlias;
41
+ /** Worker model: flash|pro for claude-ds/deepseek; provider/model for opencode. */
42
+ model?: string;
42
43
  prompt: string;
43
44
  cwd: string;
44
45
  alwaysApprove: boolean;
@@ -105,6 +106,53 @@ export function writeJob(job: Job): void {
105
106
  writeSecure(jobPaths(job.id).json, JSON.stringify(job, null, 2));
106
107
  }
107
108
 
109
+ /** Evidence tree for status --json. Parent must capture; claim is never auto-green. */
110
+ export interface JobEvidence {
111
+ spawn: {
112
+ jobId: string;
113
+ worker: WorkerKind;
114
+ lane: Lane | null;
115
+ model: string | null;
116
+ startedAt: string;
117
+ };
118
+ execute: {
119
+ status: JobStatus;
120
+ exitCode: number | null;
121
+ tmuxSession: string;
122
+ pid: number | null;
123
+ sessionAlive: boolean;
124
+ };
125
+ verify: {
126
+ captureHint: string;
127
+ logBytes: number | null;
128
+ claim: "unverified";
129
+ };
130
+ }
131
+
132
+ export function jobEvidence(job: Job, sessionAlive: boolean): JobEvidence {
133
+ return {
134
+ spawn: {
135
+ jobId: job.id,
136
+ worker: job.worker,
137
+ lane: job.lane ?? null,
138
+ model: job.model ?? null,
139
+ startedAt: job.startedAt ?? job.createdAt,
140
+ },
141
+ execute: {
142
+ status: job.status,
143
+ exitCode: job.exitCode ?? null,
144
+ tmuxSession: job.tmuxSession,
145
+ pid: job.pid ?? null,
146
+ sessionAlive,
147
+ },
148
+ verify: {
149
+ captureHint: `cursor-route capture ${job.id}`,
150
+ logBytes: job.logBytes ?? null,
151
+ claim: "unverified",
152
+ },
153
+ };
154
+ }
155
+
108
156
  function pidAlive(pid: number): boolean {
109
157
  try {
110
158
  process.kill(pid, 0);
@@ -118,6 +166,8 @@ function pidAlive(pid: number): boolean {
118
166
  encoding: "utf8",
119
167
  stdio: ["ignore", "pipe", "ignore"],
120
168
  });
169
+ // spawnSync does not throw on EPERM (macOS sandbox); empty stdout is not "dead".
170
+ if (r.error || r.status !== 0) return true;
121
171
  const state = (r.stdout || "").trim();
122
172
  return state !== "" && !state.startsWith("Z");
123
173
  } catch {
@@ -233,7 +283,7 @@ export interface StartOptions {
233
283
  lane?: Lane;
234
284
  /** Mid-lane DeepSeek: flash (default) | pro (claude-ds + deepseek). Ignored by grok/openrouter. */
235
285
  model?: DsModelAlias;
236
- /** Concrete DeepSeek id (preserves pro[1m]). Derived from --model / env when unset. */
286
+ /** Concrete DeepSeek id (preserves pro[1m]) or OpenCode provider/model. Derived from --model / env when unset. */
237
287
  modelId?: string;
238
288
  cwd?: string;
239
289
  alwaysApprove?: boolean;
@@ -282,15 +332,18 @@ export function startJob(opts: StartOptions): {
282
332
  const paths = jobPaths(id);
283
333
  writeSecure(paths.prompt, opts.prompt);
284
334
 
285
- let model: DsModelAlias | undefined;
335
+ let model: string | undefined;
286
336
  let modelId: string | undefined;
337
+ let dsAlias: DsModelAlias | undefined;
287
338
  if (worker === "claude-ds" || worker === "deepseek") {
288
339
  if (opts.model) {
340
+ dsAlias = opts.model;
289
341
  model = opts.model;
290
342
  modelId = opts.modelId ?? DS_MODEL_IDS[opts.model];
291
343
  } else {
292
344
  try {
293
345
  const choice = defaultDsModelFromEnv();
346
+ dsAlias = choice.alias;
294
347
  model = choice.alias;
295
348
  modelId = opts.modelId ?? choice.id;
296
349
  } catch (e) {
@@ -302,6 +355,19 @@ export function startJob(opts: StartOptions): {
302
355
  return { ok: false, error: (e as Error).message };
303
356
  }
304
357
  }
358
+ } else if (worker === "opencode") {
359
+ try {
360
+ const ocModel = openCodeModel(opts.modelId);
361
+ model = ocModel;
362
+ modelId = ocModel;
363
+ } catch (e) {
364
+ try {
365
+ unlinkSync(paths.prompt);
366
+ } catch {
367
+ /* ignore */
368
+ }
369
+ return { ok: false, error: (e as Error).message };
370
+ }
305
371
  }
306
372
 
307
373
  let plan;
@@ -310,7 +376,7 @@ export function startJob(opts: StartOptions): {
310
376
  promptFile: paths.prompt,
311
377
  cwd,
312
378
  alwaysApprove,
313
- model,
379
+ model: dsAlias,
314
380
  modelId,
315
381
  dryRun: Boolean(opts.dryRun),
316
382
  });