forge-delegate 1.0.5 → 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 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/mimo-v2.5-free" --targets "claude,codex,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
- - **Free `opencode/*` models** (e.g. `opencode/mimo-v2.5-free`) need no auth — zero setup.
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/mimo-v2.5-free" --targets "claude,codex"
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/hy3-free`, `opencode/nemotron-3.5-lightning-free`. Free tiers are
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/mimo-v2.5-free`, ...) plus
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
 
@@ -48,15 +48,20 @@ function installClaudeSkill(scope, projectDir) {
48
48
  console.log(` \u2713 delegate-work skill (${dest})`);
49
49
  }
50
50
 
51
- function registerClaude(launch, scope) {
51
+ // `claude mcp` writes .mcp.json relative to its own cwd, so a project-scope install
52
+ // must run from projectDir — otherwise it lands in whatever directory setup was
53
+ // invoked from, and installClaudeSkill (which does honour projectDir) disagrees
54
+ // with it.
55
+ function registerClaude(launch, scope, projectDir) {
56
+ const opts = scope === "project" ? { cwd: projectDir } : {};
52
57
  try {
53
- execSync(`claude mcp remove --scope ${scope} forge-delegate`, { stdio: "ignore" });
58
+ execSync(`claude mcp remove --scope ${scope} forge-delegate`, { ...opts, stdio: "ignore" });
54
59
  } catch {}
55
60
  try {
56
- execSync(`claude mcp remove forge-delegate`, { stdio: "ignore" });
61
+ execSync(`claude mcp remove forge-delegate`, { ...opts, stdio: "ignore" });
57
62
  } catch {}
58
- execSync(`claude mcp add --scope ${scope} forge-delegate -- ${launch}`, { stdio: "inherit" });
59
- console.log(` ✓ Claude Code (scope=${scope})`);
63
+ execSync(`claude mcp add --scope ${scope} forge-delegate -- ${launch}`, { ...opts, stdio: "inherit" });
64
+ console.log(` ✓ Claude Code (scope=${scope}${scope === "project" ? `, ${projectDir}` : ""})`);
60
65
  }
61
66
 
62
67
  function codexConfigPath(scope, projectDir) {
@@ -116,8 +121,20 @@ async function setup() {
116
121
  console.log("\n=== forge-delegate setup ===\n");
117
122
  if (interactive) console.log("Press Enter to accept the suggested [default], or type a value.\n");
118
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
+
119
136
  if (!model) {
120
- const def = current.defaultModel ?? "opencode/mimo-v2.5-free";
137
+ const def = current.defaultModel ?? "opencode/deepseek-v4.1-flash-free";
121
138
  model = interactive ? ((await ask(`Default model [${def}]: `)) ?? "").trim() || def : def;
122
139
  }
123
140
  if (!targets) targets = "claude,codex,opencode";
@@ -139,7 +156,7 @@ async function setup() {
139
156
  for (const target of targets) {
140
157
  try {
141
158
  if (target === "claude") {
142
- registerClaude(launch, scope);
159
+ registerClaude(launch, scope, projectDir);
143
160
  if (!flags["no-skill"]) installClaudeSkill(scope, projectDir);
144
161
  }
145
162
  else if (target === "codex") registerCodex(launchArr, scope, projectDir);
@@ -153,8 +170,7 @@ async function setup() {
153
170
  // 3. Check opencode logins
154
171
  let authCount = 0;
155
172
  try {
156
- const authPath = join(homedir(), ".local/share/opencode/auth.json");
157
- authCount = Object.keys(JSON.parse(readFileSync(authPath, "utf8"))).length;
173
+ authCount = Object.keys(await loadOpencodeAuth()).length;
158
174
  } catch {}
159
175
  if (authCount > 0) {
160
176
  console.log(`Found ${authCount} opencode provider login(s) — ready to use.`);
@@ -194,12 +210,14 @@ function configCmd(argv) {
194
210
  }
195
211
 
196
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
+ );
197
218
  let providers = [];
198
219
  try {
199
- const authPath = join(homedir(), ".local/share/opencode/auth.json");
200
- const { listAuthDirectProviders } = await import("./lib.mjs");
201
- const auth = JSON.parse(readFileSync(authPath, "utf8"));
202
- providers = Object.keys(auth);
220
+ providers = Object.keys(await loadOpencodeAuth());
203
221
  } catch {}
204
222
  console.log(`\nopencode logins: ${providers.length ? providers.join(", ") : "none (run 'opencode auth login <provider>')"}\n`);
205
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/mimo-v2.5-free" autocomplete="off">
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/mimo-v2.5-free) unless I say otherwise.';
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 loadOpencodeAuth() {
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
- export function opencodeArgs(model, prompt, { files, sessionId, variant, directory, agent, autoApprove } = {}) {
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
- args.push("-m", model);
101
- if (variant) args.push("--variant", variant);
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
- const [provider, ...rest] = model.split("/");
109
- const id = rest.join("/");
110
- if (!provider || !id || model.startsWith("ollama:")) return undefined;
111
- let variants = {};
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
- variants = await loadVariantMap();
165
+ catalog = await loadCatalog();
114
166
  } catch {
115
167
  return undefined;
116
168
  }
117
- const list = variants[`${provider}/${id}`];
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.5",
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": "./cli.mjs"
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": ["mcp", "claude", "claude-code", "ai", "deepseek", "gemini", "ollama", "delegation"],
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,
@@ -27,6 +28,51 @@ const RUNS_DIR = join(homedir(), ".forge-delegate", "runs");
27
28
  const here = dirname(fileURLToPath(import.meta.url));
28
29
  const VERSION = JSON.parse(readFileSync(join(here, "package.json"), "utf8")).version;
29
30
 
31
+ // The server auto-updates (npx re-resolves latest on each spawn) but the installed
32
+ // skill is a file on disk that only `setup` rewrites — so a published skill change
33
+ // goes unnoticed. Compare the two and say so, rather than running stale in silence.
34
+ function skillStatus() {
35
+ const src = join(here, "skills", "delegate-work", "SKILL.md");
36
+ if (!existsSync(src)) return null;
37
+ const dests = [
38
+ join(homedir(), ".claude", "skills", "delegate-work", "SKILL.md"),
39
+ join(process.cwd(), ".claude", "skills", "delegate-work", "SKILL.md"),
40
+ ];
41
+ const installed = dests.filter(existsSync);
42
+ if (!installed.length) return { state: "missing", detail: "not installed" };
43
+ const want = readFileSync(src, "utf8");
44
+ const stale = installed.filter((d) => readFileSync(d, "utf8") !== want);
45
+ return stale.length
46
+ ? { state: "stale", detail: `out of date: ${stale.join(", ")}` }
47
+ : { state: "ok", detail: "up to date" };
48
+ }
49
+
50
+ const SKILL_STATUS = skillStatus();
51
+ if (SKILL_STATUS && SKILL_STATUS.state !== "ok") {
52
+ // stderr only — stdout is the JSON-RPC channel.
53
+ process.stderr.write(
54
+ `forge-delegate: delegate-work skill ${SKILL_STATUS.detail}. Run: npx -y forge-delegate setup\n`
55
+ );
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
+
30
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.
31
77
 
32
78
  DELEGATE BY DEFAULT — do not hand-write these yourself:
@@ -37,8 +83,9 @@ DELEGATE BY DEFAULT — do not hand-write these yourself:
37
83
  - Ports and translations between languages, frameworks, or file formats.
38
84
  - The same edit repeated across 3 or more files.
39
85
  - Reading a large file or directory to answer one narrow question — delegate the reading, keep your context clean.
86
+ - The survey half of an audit ("does this follow X standards", "is this secure", "where is the tech debt", "how does X work"). Delegate the gathering, keep the verdict: ask for a structured digest with a file path behind every claim, then judge it yourself. A claim with no path is not a finding.
40
87
 
41
- KEEP FOR YOURSELF: architecture, debugging an unknown root cause, security-sensitive code, anything where being wrong is expensive, and the review of whatever comes back. Delegation does not transfer responsibility for the result.
88
+ KEEP FOR YOURSELF: architecture, debugging an unknown root cause, security-sensitive code, anything where being wrong is expensive, and the verdict on whatever comes back. Delegation does not transfer responsibility for the result.
42
89
 
43
90
  Routing:
44
91
  - One-shot question, no file access needed -> ask_model.
@@ -83,8 +130,11 @@ async function startBackgroundJob(model, task, opts = {}) {
83
130
  const logPath = join(RUNS_DIR, `${id}.log`);
84
131
  const out = openSync(logPath, "w");
85
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;
86
135
  const child = spawn("opencode", opencodeArgs(model, task, { ...opts, variant }), {
87
136
  cwd,
137
+ env,
88
138
  detached: true,
89
139
  stdio: ["ignore", out, out],
90
140
  });
@@ -119,7 +169,7 @@ server.tool(
119
169
  if (local.ollama.length) lines.push(` ollama: ${local.ollama.slice(0, 12).join(", ")}`);
120
170
  else lines.push(" ollama: not running");
121
171
  for (const [name, p] of Object.entries(local.config))
122
- 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)"}`);
123
173
 
124
174
  lines.push("\n== direct via opencode logins (ask_model, provider/model) ==");
125
175
  try {
@@ -162,9 +212,9 @@ server.tool(
162
212
 
163
213
  server.tool(
164
214
  "delegate",
165
- "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; or reading a large file/directory to answer one narrow question. 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.",
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.",
166
216
  {
167
- model: z.string().optional().describe("provider/model format, e.g. opencode/mimo-v2.5-free (defaults to config defaultModel)"),
217
+ model: z.string().optional().describe("provider/model format, e.g. opencode/deepseek-v4.1-flash-free (defaults to config defaultModel)"),
168
218
  task: z.string(),
169
219
  files: z.array(z.string()).optional().describe("specific files to attach (inline mode)"),
170
220
  background: z.boolean().optional().describe("run detached and return a job id (default false)"),
@@ -243,7 +293,8 @@ server.tool(
243
293
  const { profiles, ...rest } = cfg;
244
294
  const lines = Object.entries(rest).map(([k, v]) => ` ${k}: ${v ?? "(unset)"}`);
245
295
  const prof = Object.entries(profiles).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join("\n") || " (none)";
246
- return { content: [{ type: "text", text: `config: ${configPath()}\n${lines.join("\n")}\nprofiles:\n${prof}` }] };
296
+ const skill = SKILL_STATUS ? `\nskill (delegate-work): ${SKILL_STATUS.detail}${SKILL_STATUS.state === "ok" ? "" : " — run: npx -y forge-delegate setup"}` : "";
297
+ return { content: [{ type: "text", text: `config: ${configPath()}\n${lines.join("\n")}\nprofiles:\n${prof}${skill}` }] };
247
298
  }
248
299
  );
249
300
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: delegate-work
3
- description: Hand a coding task to a cheap external model via forge-delegate instead of writing it yourself. Load BEFORE starting work that is high-volume and low-judgment - writing tests for code that already exists, mechanical refactors (renames, signature changes, import rewrites, API migrations), fixing lint/type/build errors, boilerplate and scaffolding (CRUD endpoints, config files, schemas, adapters, fixtures), porting code between languages or frameworks, the same edit repeated across 3 or more files, or reading a large file or directory to answer one narrow question. Triggers on - "write tests for", "add tests", "rename X to Y everywhere", "refactor", "migrate", "port this to", "fix the lint errors", "fix the type errors", "scaffold", "boilerplate", "do the same for the other files".
3
+ description: Hand a coding task to a cheap external model via forge-delegate instead of writing it yourself. Load BEFORE starting work that is high-volume and low-judgment - writing tests for code that already exists, mechanical refactors (renames, signature changes, import rewrites, API migrations), fixing lint/type/build errors, boilerplate and scaffolding (CRUD endpoints, config files, schemas, adapters, fixtures), porting code between languages or frameworks, the same edit repeated across 3 or more files, or reading a large file or directory to answer one narrow question. ALSO load for audit and survey questions - "does my codebase follow X", "audit", "review the architecture", "is this idiomatic", "where is the tech debt", "is this secure", "explore the codebase", "how does X work", "find where" - where the reading is wide but the judgment is narrow: delegate the survey, keep the verdict. Triggers on - "write tests for", "add tests", "rename X to Y everywhere", "refactor", "migrate", "port this to", "fix the lint errors", "fix the type errors", "scaffold", "boilerplate", "do the same for the other files".
4
4
  ---
5
5
 
6
6
  # Delegating work to a cheap model
@@ -30,6 +30,28 @@ answer is already known, and the work is typing it out across files.
30
30
  Delegating does not transfer responsibility. Review what comes back before you
31
31
  report it as done.
32
32
 
33
+ ## Delegate the survey, keep the verdict
34
+
35
+ An audit looks like judgment work, so it reads as "keep" — but only the *verdict* is
36
+ judgment. The input gathering is mechanical, wide, and the single most expensive
37
+ thing you can do to your own context. Split them:
38
+
39
+ 1. **Delegate the survey.** Ask for a structured digest, not an opinion:
40
+ "Report which framework this repo uses. List every routing and data-fetching
41
+ entry point: path, 3-line summary. Paths only, no assessment."
42
+ 2. **Keep the verdict.** Judge the digest yourself. You are now reading a page
43
+ instead of a repo, and the expensive-if-wrong part never left your hands.
44
+
45
+ This covers "does this follow X standards", "is this secure", "where is the tech
46
+ debt", "how does X work", "explore the codebase" — broad read, narrow judgment.
47
+
48
+ **Demand paths as evidence.** A survey's worth depends on the cheap model reporting
49
+ *absence* honestly, and "there is no Next.js here" is exactly the answer a weak model
50
+ is least likely to give — it will produce a plausible audit of a framework you do not
51
+ use rather than contradict the question's premise. Require a file path for every
52
+ claim. A claim with no path attached is not a finding; spot-check two of them before
53
+ you trust the rest.
54
+
33
55
  ## Do it
34
56
 
35
57
  1. Do the judgment part yourself first — decide the interface, write the first
@@ -58,4 +80,5 @@ second opinion — use `ask_model` instead; it is a direct API call with no agen
58
80
  - Don't delegate a task you cannot describe precisely. Vague task, vague diff.
59
81
  - Don't delegate and walk away. Unreviewed delegated code is worse than no code.
60
82
  - Don't paste large files into the task text.
83
+ - Don't accept a survey finding with no file path behind it.
61
84
  - Don't override `model` upward because a task "seems hard" — split it instead.