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 +9 -6
- package/cli.mjs +32 -14
- package/docs/index.html +2 -2
- package/lib.mjs +84 -65
- package/package.json +12 -3
- package/server.mjs +56 -5
- package/skills/delegate-work/SKILL.md +24 -1
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
|
|
|
@@ -48,15 +48,20 @@ function installClaudeSkill(scope, projectDir) {
|
|
|
48
48
|
console.log(` \u2713 delegate-work skill (${dest})`);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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,
|
|
@@ -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
|
|
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;
|
|
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/
|
|
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
|
-
|
|
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.
|