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.
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +2 -0
- package/README.md +47 -11
- package/SECURITY.md +6 -5
- package/SUPPORT.md +2 -0
- package/dist/adapters/claude-ds.js +18 -0
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/opencode.js +83 -0
- package/dist/cli.js +33 -12
- package/dist/config.js +30 -3
- package/dist/health.js +17 -1
- package/dist/jobs.js +47 -2
- package/docs/briefs/WORKING.md +12 -4
- package/docs/demo-notes.md +3 -2
- package/docs/fixtures/hero-demo.log +3 -2
- package/llms.txt +8 -4
- package/package.json +3 -2
- package/skills/route-orch/SKILL.md +53 -14
- package/src/adapters/claude-ds.ts +19 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/opencode.test.ts +238 -0
- package/src/adapters/opencode.ts +89 -0
- package/src/adapters/types.ts +1 -1
- package/src/cli.test.ts +133 -5
- package/src/cli.ts +31 -10
- package/src/config.ts +38 -6
- package/src/health.ts +22 -1
- package/src/jobs.ts +71 -5
|
@@ -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
|
+
};
|
package/src/adapters/types.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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.
|
|
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("
|
|
620
|
-
|
|
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 =
|
|
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>
|
|
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
|
|
256
|
-
// ignore (do not validate) for
|
|
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(
|
|
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
|
|
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`
|
|
7
|
-
*
|
|
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.
|
|
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`
|
|
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
|
-
/**
|
|
41
|
-
model?:
|
|
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:
|
|
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
|
});
|