forge-delegate 1.0.7 → 1.0.8
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/README.md +9 -6
- package/cli.mjs +21 -8
- package/docs/index.html +2 -2
- package/lib.mjs +84 -65
- package/package.json +12 -3
- package/server.mjs +24 -2
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ so you're picking from actual available models rather than guessing.
|
|
|
27
27
|
**npx (recommended — works everywhere, no bash needed):**
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
npx -y forge-delegate setup --model "opencode/
|
|
30
|
+
npx -y forge-delegate setup --model "opencode/deepseek-v4.1-flash-free" --targets "claude,codex,opencode"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
That's it — npx pulls the latest package and `setup` registers the MCP server with the agents
|
|
@@ -56,8 +56,11 @@ Then restart your agent and confirm the server is connected: `/mcp` in Claude Co
|
|
|
56
56
|
|
|
57
57
|
### What you need
|
|
58
58
|
|
|
59
|
-
- **Node.js ≥ 18** and **opencode** installed (the installer can fetch opencode for you).
|
|
60
|
-
|
|
59
|
+
- **Node.js ≥ 18** and **opencode v2 or newer** installed (the installer can fetch opencode for you).
|
|
60
|
+
v2 removed the CLI flags older versions relied on for working directories and variants, and an
|
|
61
|
+
unknown flag makes `opencode run` print help and exit successfully — so a pre-v2 binary fails
|
|
62
|
+
silently. The server checks at startup and warns instead.
|
|
63
|
+
- **Free `opencode/*` models** (e.g. `opencode/deepseek-v4.1-flash-free`) need no auth — zero setup.
|
|
61
64
|
- **Any other provider** (deepseek, openai, google, ollama, …) must be logged into **your**
|
|
62
65
|
opencode first — that's a step *you* do in your own opencode, not something forge-delegate
|
|
63
66
|
handles: `opencode auth login <provider>`. forge-delegate reuses your opencode's credentials
|
|
@@ -80,10 +83,10 @@ Use the `npx` route — no bash needed (works in cmd/PowerShell). Drop `codex` f
|
|
|
80
83
|
opencode ships free no-auth models under `opencode/`:
|
|
81
84
|
|
|
82
85
|
```bash
|
|
83
|
-
npx -y forge-delegate setup --model "opencode/
|
|
86
|
+
npx -y forge-delegate setup --model "opencode/deepseek-v4.1-flash-free" --targets "claude,codex"
|
|
84
87
|
```
|
|
85
88
|
|
|
86
|
-
Other free options: `opencode/
|
|
89
|
+
Other free options: `opencode/mimo-v2.6-flash-free`, `opencode/nemotron-3.5-lightning-free`. Free tiers are
|
|
87
90
|
rate-limited; switch anytime with `forge-delegate config set --model <m>`.
|
|
88
91
|
|
|
89
92
|
## Tools
|
|
@@ -152,7 +155,7 @@ Every delegation tool also accepts per-call `model`, `directory`, `agent`, `auto
|
|
|
152
155
|
|
|
153
156
|
## Models
|
|
154
157
|
|
|
155
|
-
- **opencode provider** — free no-auth models (`opencode/
|
|
158
|
+
- **opencode provider** — free no-auth models (`opencode/deepseek-v4.1-flash-free`, ...) plus
|
|
156
159
|
anything you have logged in via `opencode auth login <provider>`.
|
|
157
160
|
- **Direct API from opencode logins** — `provider/model` for OpenAI-compatible providers.
|
|
158
161
|
- **Custom providers** — OpenAI-compatible providers defined in `opencode.json`
|
package/cli.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { execSync } from "node:child_process";
|
|
8
|
-
import { loadConfig, saveConfig, configPath } from "./lib.mjs";
|
|
8
|
+
import { loadConfig, saveConfig, configPath, detectOpencode, loadOpencodeAuth } from "./lib.mjs";
|
|
9
9
|
|
|
10
10
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
|
|
@@ -121,8 +121,20 @@ async function setup() {
|
|
|
121
121
|
console.log("\n=== forge-delegate setup ===\n");
|
|
122
122
|
if (interactive) console.log("Press Enter to accept the suggested [default], or type a value.\n");
|
|
123
123
|
|
|
124
|
+
const oc = await detectOpencode();
|
|
125
|
+
if (!oc.found) {
|
|
126
|
+
console.log(" ✗ opencode not found on PATH — install it from https://opencode.ai, then re-run setup.\n");
|
|
127
|
+
} else if (!oc.supported) {
|
|
128
|
+
console.log(
|
|
129
|
+
` ✗ opencode ${oc.version?.raw ?? "(unknown version)"} is too old — forge-delegate requires opencode 2 or newer.\n` +
|
|
130
|
+
" Run: opencode upgrade\n"
|
|
131
|
+
);
|
|
132
|
+
} else {
|
|
133
|
+
console.log(` ✓ opencode ${oc.version.raw}\n`);
|
|
134
|
+
}
|
|
135
|
+
|
|
124
136
|
if (!model) {
|
|
125
|
-
const def = current.defaultModel ?? "opencode/
|
|
137
|
+
const def = current.defaultModel ?? "opencode/deepseek-v4.1-flash-free";
|
|
126
138
|
model = interactive ? ((await ask(`Default model [${def}]: `)) ?? "").trim() || def : def;
|
|
127
139
|
}
|
|
128
140
|
if (!targets) targets = "claude,codex,opencode";
|
|
@@ -158,8 +170,7 @@ async function setup() {
|
|
|
158
170
|
// 3. Check opencode logins
|
|
159
171
|
let authCount = 0;
|
|
160
172
|
try {
|
|
161
|
-
|
|
162
|
-
authCount = Object.keys(JSON.parse(readFileSync(authPath, "utf8"))).length;
|
|
173
|
+
authCount = Object.keys(await loadOpencodeAuth()).length;
|
|
163
174
|
} catch {}
|
|
164
175
|
if (authCount > 0) {
|
|
165
176
|
console.log(`Found ${authCount} opencode provider login(s) — ready to use.`);
|
|
@@ -199,12 +210,14 @@ function configCmd(argv) {
|
|
|
199
210
|
}
|
|
200
211
|
|
|
201
212
|
async function status() {
|
|
213
|
+
const oc = await detectOpencode();
|
|
214
|
+
console.log(
|
|
215
|
+
`\nopencode: ${oc.found ? (oc.version?.raw ?? "unknown version") : "not found on PATH"}` +
|
|
216
|
+
`${oc.found && !oc.supported ? " (too old — requires v2 or newer)" : ""}`
|
|
217
|
+
);
|
|
202
218
|
let providers = [];
|
|
203
219
|
try {
|
|
204
|
-
|
|
205
|
-
const { listAuthDirectProviders } = await import("./lib.mjs");
|
|
206
|
-
const auth = JSON.parse(readFileSync(authPath, "utf8"));
|
|
207
|
-
providers = Object.keys(auth);
|
|
220
|
+
providers = Object.keys(await loadOpencodeAuth());
|
|
208
221
|
} catch {}
|
|
209
222
|
console.log(`\nopencode logins: ${providers.length ? providers.join(", ") : "none (run 'opencode auth login <provider>')"}\n`);
|
|
210
223
|
try {
|
package/docs/index.html
CHANGED
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
|
|
97
97
|
<div class="field picker">
|
|
98
98
|
<label for="model">Default model (provider/model)</label>
|
|
99
|
-
<input type="text" id="model" placeholder="type to search, e.g. opencode/
|
|
99
|
+
<input type="text" id="model" placeholder="type to search, e.g. opencode/deepseek-v4.1-flash-free" autocomplete="off">
|
|
100
100
|
<div class="filters">
|
|
101
101
|
<select id="provider" title="Provider">
|
|
102
102
|
<option value="">all providers</option>
|
|
@@ -330,7 +330,7 @@
|
|
|
330
330
|
var cmd = command();
|
|
331
331
|
var modelLine = model
|
|
332
332
|
? 'Use "' + model + '" as the default model unless I say otherwise.'
|
|
333
|
-
: 'Use a cheap/free default model (e.g. opencode/
|
|
333
|
+
: 'Use a cheap/free default model (e.g. opencode/deepseek-v4.1-flash-free) unless I say otherwise.';
|
|
334
334
|
return [
|
|
335
335
|
"Install and register the forge-delegate MCP server for me. " + modelLine +
|
|
336
336
|
" Register it with " + targets + ", and use \"" + scope + "\" scope.",
|
package/lib.mjs
CHANGED
|
@@ -1,18 +1,63 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
|
|
6
6
|
export const OPENCODE_TIMEOUT = Number(process.env.FORGE_OPENCODE_TIMEOUT ?? 900_000);
|
|
7
|
+
|
|
8
|
+
// forge-delegate targets opencode v2+. v2 removed the `--dir` and `--variant`
|
|
9
|
+
// flags from `run` — and treats an unknown flag as a help request, exiting 0 —
|
|
10
|
+
// so an older binary fails silently. Detecting the version is the only way to
|
|
11
|
+
// tell the user instead of returning empty results.
|
|
12
|
+
export function parseOpencodeVersion(text) {
|
|
13
|
+
const m = String(text ?? "").match(/(\d+)\.(\d+)\.(\d+)/);
|
|
14
|
+
if (!m) return null;
|
|
15
|
+
return { major: +m[1], minor: +m[2], patch: +m[3], raw: m[0] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function detectOpencode() {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
execFile("opencode", ["--version"], { timeout: 10_000 }, (err, stdout, stderr) => {
|
|
21
|
+
if (err) return resolve({ found: false, version: null, supported: false });
|
|
22
|
+
const version = parseOpencodeVersion(`${stdout}\n${stderr}`);
|
|
23
|
+
resolve({ found: true, version, supported: Boolean(version) && version.major >= 2 });
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
7
28
|
const CONFIG_DIR = join(homedir(), ".forge-delegate");
|
|
8
29
|
const AUTH_PATH = join(homedir(), ".local/share/opencode/auth.json");
|
|
9
30
|
const OPENCODE_CONFIG_PATH = join(homedir(), ".config/opencode/opencode.json");
|
|
10
31
|
const CATALOG_CACHE = join(CONFIG_DIR, "models-dev.json");
|
|
11
32
|
const CATALOG_TTL = 24 * 3600 * 1000;
|
|
12
|
-
const VARIANTS_CACHE = join(CONFIG_DIR, "variants.json");
|
|
13
33
|
const OLLAMA_BASE = process.env.OLLAMA_URL ?? "http://localhost:11434";
|
|
14
34
|
|
|
15
|
-
function
|
|
35
|
+
function execFileText(cmd, args, opts = {}) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
execFile(cmd, args, { timeout: 15_000, maxBuffer: 4 * 1024 * 1024, ...opts }, (err, stdout) => {
|
|
38
|
+
if (err) reject(err);
|
|
39
|
+
else resolve(stdout);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// opencode v2 moved credentials into its own store; auth.json is frozen at
|
|
45
|
+
// whatever v1 last wrote there. Reading the file on v2 therefore hands back
|
|
46
|
+
// stale keys, which surface as 401s that look like the user's fault. Ask v2
|
|
47
|
+
// for its credentials instead, and fall back to the file on v1 (where the
|
|
48
|
+
// `auth export` command does not exist).
|
|
49
|
+
export async function loadOpencodeAuth() {
|
|
50
|
+
try {
|
|
51
|
+
const entries = JSON.parse(await execFileText("opencode", ["auth", "export"]));
|
|
52
|
+
if (Array.isArray(entries)) {
|
|
53
|
+
const auth = {};
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (e?.integrationID && e?.value?.type === "key" && e.value.key)
|
|
56
|
+
auth[e.integrationID] = { type: "api", key: e.value.key };
|
|
57
|
+
}
|
|
58
|
+
return auth;
|
|
59
|
+
}
|
|
60
|
+
} catch {}
|
|
16
61
|
try {
|
|
17
62
|
return JSON.parse(readFileSync(AUTH_PATH, "utf8"));
|
|
18
63
|
} catch {
|
|
@@ -91,83 +136,43 @@ export function resolveConfig(overrides = {}) {
|
|
|
91
136
|
};
|
|
92
137
|
}
|
|
93
138
|
|
|
94
|
-
|
|
139
|
+
// opencode v2 selects the run directory via the child's cwd (there is no `--dir`)
|
|
140
|
+
// and the reasoning variant inline as `provider/model#variant` (there is no
|
|
141
|
+
// `--variant`). Passing the old flags makes `run` print its help and exit 0 —
|
|
142
|
+
// a silent no-op, so do not reintroduce them.
|
|
143
|
+
export function opencodeArgs(model, prompt, { files, sessionId, variant, agent, autoApprove } = {}) {
|
|
95
144
|
const args = ["run", "--format", "json"];
|
|
96
145
|
if (autoApprove) args.push("--auto");
|
|
97
146
|
if (sessionId) args.push("-s", sessionId);
|
|
98
|
-
if (directory) args.push("--dir", directory);
|
|
99
147
|
if (agent) args.push("--agent", agent);
|
|
100
|
-
|
|
101
|
-
|
|
148
|
+
const ref = variant && !model.includes("#") ? `${model}#${variant}` : model;
|
|
149
|
+
args.push("-m", ref);
|
|
102
150
|
for (const f of files ?? []) args.push("-f", f);
|
|
103
151
|
args.push(prompt);
|
|
104
152
|
return args;
|
|
105
153
|
}
|
|
106
154
|
|
|
155
|
+
// Variant (reasoning effort) names come from the model catalog's
|
|
156
|
+
// `reasoning_options`. opencode v2 no longer exposes them over the server API.
|
|
107
157
|
export async function pickVariant(model) {
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
111
|
-
|
|
158
|
+
if (model.includes("#") || model.startsWith("ollama:")) return undefined;
|
|
159
|
+
const slash = model.indexOf("/");
|
|
160
|
+
if (slash <= 0) return undefined;
|
|
161
|
+
const provider = model.slice(0, slash);
|
|
162
|
+
const id = model.slice(slash + 1);
|
|
163
|
+
let catalog;
|
|
112
164
|
try {
|
|
113
|
-
|
|
165
|
+
catalog = await loadCatalog();
|
|
114
166
|
} catch {
|
|
115
167
|
return undefined;
|
|
116
168
|
}
|
|
117
|
-
const
|
|
169
|
+
const options = catalog?.[provider]?.models?.[id]?.reasoning_options;
|
|
170
|
+
const effort = Array.isArray(options) ? options.find((o) => o?.type === "effort") : null;
|
|
171
|
+
const list = effort?.values;
|
|
118
172
|
if (!Array.isArray(list) || list.length === 0) return undefined;
|
|
119
173
|
return list.length === 1 ? list[0] : list[list.length - 2];
|
|
120
174
|
}
|
|
121
175
|
|
|
122
|
-
// Variant names per provider/model, discovered from `GET /config/providers`
|
|
123
|
-
// on a short-lived local opencode server. Cached on disk for 24h.
|
|
124
|
-
async function loadVariantMap() {
|
|
125
|
-
try {
|
|
126
|
-
const cached = JSON.parse(readFileSync(VARIANTS_CACHE, "utf8"));
|
|
127
|
-
if (Date.now() - cached.fetchedAt < CATALOG_TTL && cached.data) return cached.data;
|
|
128
|
-
} catch {}
|
|
129
|
-
const child = spawn("opencode", ["serve", "--port", "0"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
130
|
-
try {
|
|
131
|
-
const baseUrl = await new Promise((resolvePromise, rejectPromise) => {
|
|
132
|
-
let out = "";
|
|
133
|
-
const timer = setTimeout(() => rejectPromise(new Error("opencode serve timed out")), 20_000);
|
|
134
|
-
child.stdout.on("data", (d) => {
|
|
135
|
-
out += d;
|
|
136
|
-
const m = out.match(/listening on (https?:\/\/\S+)/);
|
|
137
|
-
if (m) {
|
|
138
|
-
clearTimeout(timer);
|
|
139
|
-
resolvePromise(m[1]);
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
child.on("error", (e) => {
|
|
143
|
-
clearTimeout(timer);
|
|
144
|
-
rejectPromise(e);
|
|
145
|
-
});
|
|
146
|
-
child.on("exit", () => {
|
|
147
|
-
clearTimeout(timer);
|
|
148
|
-
rejectPromise(new Error("opencode serve exited before listening"));
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/config/providers`, { signal: AbortSignal.timeout(15_000) });
|
|
152
|
-
if (!res.ok) throw new Error(`config/providers ${res.status}`);
|
|
153
|
-
const body = await res.json();
|
|
154
|
-
const providers = body.providers ?? body;
|
|
155
|
-
const map = {};
|
|
156
|
-
for (const p of Array.isArray(providers) ? providers : Object.values(providers)) {
|
|
157
|
-
for (const [mid, m] of Object.entries(p.models ?? {})) {
|
|
158
|
-
if (m?.variants) map[`${p.id}/${mid}`] = Object.keys(m.variants);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
try {
|
|
162
|
-
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
163
|
-
writeFileSync(VARIANTS_CACHE, JSON.stringify({ fetchedAt: Date.now(), data: map }));
|
|
164
|
-
} catch {}
|
|
165
|
-
return map;
|
|
166
|
-
} finally {
|
|
167
|
-
child.kill("SIGKILL");
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
176
|
export function parseEvents(stdout) {
|
|
172
177
|
let text = "";
|
|
173
178
|
let sessionId = null;
|
|
@@ -190,9 +195,14 @@ export async function opencodeRun(model, prompt, opts = {}) {
|
|
|
190
195
|
const variant = opts.variant ?? (await pickVariant(model));
|
|
191
196
|
const timeoutMs = opts.timeoutMs ?? OPENCODE_TIMEOUT;
|
|
192
197
|
const cwd = opts.directory ?? process.cwd();
|
|
198
|
+
// opencode v2 picks the project from the PWD environment variable, not from the
|
|
199
|
+
// child's cwd — so a delegation with `directory` set would otherwise run against
|
|
200
|
+
// the server's directory while looking like it honoured the option.
|
|
201
|
+
const env = opts.directory ? { ...process.env, PWD: opts.directory } : process.env;
|
|
193
202
|
const stdout = await new Promise((resolvePromise, rejectPromise) => {
|
|
194
203
|
const child = spawn("opencode", opencodeArgs(model, prompt, { ...opts, variant }), {
|
|
195
204
|
cwd,
|
|
205
|
+
env,
|
|
196
206
|
stdio: ["ignore", "pipe", "pipe"],
|
|
197
207
|
});
|
|
198
208
|
let out = "";
|
|
@@ -232,7 +242,7 @@ export async function callDirect(baseUrl, model, messages, apiKey, extraBody = {
|
|
|
232
242
|
}
|
|
233
243
|
|
|
234
244
|
export async function listAuthDirectProviders() {
|
|
235
|
-
const auth = loadOpencodeAuth();
|
|
245
|
+
const auth = await loadOpencodeAuth();
|
|
236
246
|
let catalog = {};
|
|
237
247
|
try {
|
|
238
248
|
catalog = await loadCatalog();
|
|
@@ -302,9 +312,18 @@ export async function runAuthDirect(model, prompt) {
|
|
|
302
312
|
if (slashIdx <= 0) throw new Error(`expected provider/model format, got '${model}'`);
|
|
303
313
|
const prov = model.slice(0, slashIdx);
|
|
304
314
|
const id = model.slice(slashIdx + 1);
|
|
305
|
-
const auth = loadOpencodeAuth();
|
|
306
|
-
if (!auth[prov]?.key)
|
|
315
|
+
const auth = await loadOpencodeAuth();
|
|
316
|
+
if (!auth[prov]?.key) {
|
|
317
|
+
// opencode's own free tier rejects direct HTTP ("can only be used from within
|
|
318
|
+
// OpenCode"), so these models are reachable only through the CLI.
|
|
319
|
+
if (prov === "opencode" || prov === "opencode-go")
|
|
320
|
+
throw new Error(
|
|
321
|
+
`'${model}' is hosted by opencode, whose free tier only works from inside opencode — ` +
|
|
322
|
+
`ask_model cannot call it directly. Use the delegate tool instead, or prefix the model ` +
|
|
323
|
+
`with 'opencode:' to route it through the CLI.`
|
|
324
|
+
);
|
|
307
325
|
throw new Error(`no opencode login for '${prov}' — run: opencode auth login ${prov}`);
|
|
326
|
+
}
|
|
308
327
|
const catalog = await loadCatalog();
|
|
309
328
|
const info = catalog[prov];
|
|
310
329
|
if (!info?.api || info.npm !== "@ai-sdk/openai-compatible")
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forge-delegate",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "MCP server that relays coding work to other AI models (DeepSeek, Gemini, OpenAI, Ollama) so Claude Code saves tokens",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"forge-delegate": "
|
|
7
|
+
"forge-delegate": "cli.mjs"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"setup": "node cli.mjs setup",
|
|
@@ -20,7 +20,16 @@
|
|
|
20
20
|
"docs/",
|
|
21
21
|
"README.md"
|
|
22
22
|
],
|
|
23
|
-
"keywords": [
|
|
23
|
+
"keywords": [
|
|
24
|
+
"mcp",
|
|
25
|
+
"claude",
|
|
26
|
+
"claude-code",
|
|
27
|
+
"ai",
|
|
28
|
+
"deepseek",
|
|
29
|
+
"gemini",
|
|
30
|
+
"ollama",
|
|
31
|
+
"delegation"
|
|
32
|
+
],
|
|
24
33
|
"license": "MIT",
|
|
25
34
|
"engines": {
|
|
26
35
|
"node": ">=18"
|
package/server.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
opencodeArgs,
|
|
15
15
|
parseEvents,
|
|
16
16
|
pickVariant,
|
|
17
|
+
detectOpencode,
|
|
17
18
|
listAuthDirectProviders,
|
|
18
19
|
listLocalProviders,
|
|
19
20
|
loadConfig,
|
|
@@ -54,6 +55,24 @@ if (SKILL_STATUS && SKILL_STATUS.state !== "ok") {
|
|
|
54
55
|
);
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
// Same channel rule. Non-blocking so a slow or missing `opencode` can never delay
|
|
59
|
+
// the MCP handshake; if the binary is absent or pre-v2 the user hears about it
|
|
60
|
+
// instead of getting silent empty delegations later.
|
|
61
|
+
detectOpencode()
|
|
62
|
+
.then((oc) => {
|
|
63
|
+
if (!oc.found) {
|
|
64
|
+
process.stderr.write(
|
|
65
|
+
"forge-delegate: opencode not found on PATH — delegations will fail. Install: https://opencode.ai\n"
|
|
66
|
+
);
|
|
67
|
+
} else if (!oc.supported) {
|
|
68
|
+
process.stderr.write(
|
|
69
|
+
`forge-delegate: opencode ${oc.version?.raw ?? "(unknown version)"} is too old — requires opencode 2 or newer ` +
|
|
70
|
+
`(v2 removed the CLI flags forge-delegate uses). Run: opencode upgrade\n`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
.catch(() => {});
|
|
75
|
+
|
|
57
76
|
const INSTRUCTIONS = `forge-delegate hands coding work to cheap external models running as full agents. They read the project and write the code in their own loop, so the work never passes through your context. Spend your context on judgment; delegate the typing.
|
|
58
77
|
|
|
59
78
|
DELEGATE BY DEFAULT — do not hand-write these yourself:
|
|
@@ -111,8 +130,11 @@ async function startBackgroundJob(model, task, opts = {}) {
|
|
|
111
130
|
const logPath = join(RUNS_DIR, `${id}.log`);
|
|
112
131
|
const out = openSync(logPath, "w");
|
|
113
132
|
const cwd = opts.directory ?? process.cwd();
|
|
133
|
+
// opencode v2 resolves the project from PWD, not the child's cwd (see lib.mjs).
|
|
134
|
+
const env = opts.directory ? { ...process.env, PWD: opts.directory } : process.env;
|
|
114
135
|
const child = spawn("opencode", opencodeArgs(model, task, { ...opts, variant }), {
|
|
115
136
|
cwd,
|
|
137
|
+
env,
|
|
116
138
|
detached: true,
|
|
117
139
|
stdio: ["ignore", out, out],
|
|
118
140
|
});
|
|
@@ -147,7 +169,7 @@ server.tool(
|
|
|
147
169
|
if (local.ollama.length) lines.push(` ollama: ${local.ollama.slice(0, 12).join(", ")}`);
|
|
148
170
|
else lines.push(" ollama: not running");
|
|
149
171
|
for (const [name, p] of Object.entries(local.config))
|
|
150
|
-
lines.push(` ${name} (opencode.json): ${p.models.join(", ") || "(models defined ad hoc)"}`);
|
|
172
|
+
lines.push(` ${name} (opencode.json): ${Object.keys(p.models).join(", ") || "(models defined ad hoc)"}`);
|
|
151
173
|
|
|
152
174
|
lines.push("\n== direct via opencode logins (ask_model, provider/model) ==");
|
|
153
175
|
try {
|
|
@@ -192,7 +214,7 @@ server.tool(
|
|
|
192
214
|
"delegate",
|
|
193
215
|
"USE THIS INSTEAD OF WRITING THE CODE YOURSELF when the task is: tests for code that already exists; a mechanical refactor (rename, signature change, import rewrite, API migration); fixing lint/type/build errors; boilerplate or scaffolding (CRUD endpoints, config, schemas, adapters, fixtures); a port between languages or frameworks; the same edit repeated across 3 or more files; reading a large file/directory to answer one narrow question; or the survey half of an audit (delegate the gathering, keep the verdict — demand a file path behind every claim). Hands the task to another AI model running as a full agent via local opencode: it reads your project, explores, and writes concrete code in its own tool loop — none of which enters your context. Keep architecture, unknown-root-cause debugging, and security-sensitive code for yourself, and always review what comes back. Runs INLINE by default (result returned when done; Claude Code auto-backgrounds calls past 2 minutes). Pass background:true to run detached and get a job id instantly, then poll check_delegation — use that for parallel fan-out or hosts that block on long calls. Pass continue_session (returned by a previous delegation) to keep the same agent session instead of re-exploring. Never paste file contents into task: name the paths and let the agent read them. model/directory/agent/autoApprove default from config — omit them.",
|
|
194
216
|
{
|
|
195
|
-
model: z.string().optional().describe("provider/model format, e.g. opencode/
|
|
217
|
+
model: z.string().optional().describe("provider/model format, e.g. opencode/deepseek-v4.1-flash-free (defaults to config defaultModel)"),
|
|
196
218
|
task: z.string(),
|
|
197
219
|
files: z.array(z.string()).optional().describe("specific files to attach (inline mode)"),
|
|
198
220
|
background: z.boolean().optional().describe("run detached and return a job id (default false)"),
|