forge-delegate 1.0.0

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/lib.mjs ADDED
@@ -0,0 +1,357 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { homedir } from "node:os";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ export const OPENCODE_TIMEOUT = Number(process.env.FORGE_OPENCODE_TIMEOUT ?? 900_000);
9
+ const CONFIG_DIR = join(homedir(), ".forge-delegate");
10
+ const AUTH_PATH = join(homedir(), ".local/share/opencode/auth.json");
11
+ const OPENCODE_CONFIG_PATH = join(homedir(), ".config/opencode/opencode.json");
12
+ const CATALOG_CACHE = join(CONFIG_DIR, "models-dev.json");
13
+ const CATALOG_TTL = 24 * 3600 * 1000;
14
+ const VARIANTS_CACHE = join(CONFIG_DIR, "variants.json");
15
+ const OLLAMA_BASE = process.env.OLLAMA_URL ?? "http://localhost:11434";
16
+
17
+ function loadOpencodeAuth() {
18
+ try {
19
+ return JSON.parse(readFileSync(AUTH_PATH, "utf8"));
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ async function loadCatalog() {
26
+ try {
27
+ const cached = JSON.parse(readFileSync(CATALOG_CACHE, "utf8"));
28
+ if (Date.now() - cached.fetchedAt < CATALOG_TTL && cached.data) return cached.data;
29
+ } catch {}
30
+ const res = await fetch("https://models.dev/api.json");
31
+ if (!res.ok) throw new Error(`models.dev ${res.status}`);
32
+ const data = await res.json();
33
+ try {
34
+ mkdirSync(CONFIG_DIR, { recursive: true });
35
+ writeFileSync(CATALOG_CACHE, JSON.stringify({ fetchedAt: Date.now(), data }));
36
+ } catch {}
37
+ return data;
38
+ }
39
+
40
+ export function logUsage(entry) {
41
+ try {
42
+ mkdirSync(CONFIG_DIR, { recursive: true });
43
+ appendFileSync(join(CONFIG_DIR, "usage.jsonl"), JSON.stringify({ ts: Date.now(), ...entry }) + "\n");
44
+ } catch {}
45
+ }
46
+
47
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
48
+
49
+ export function defaultConfig() {
50
+ return {
51
+ defaultModel: null,
52
+ defaultAgent: null,
53
+ defaultVariant: null,
54
+ autoApprove: true,
55
+ timeoutMs: Number(process.env.FORGE_OPENCODE_TIMEOUT ?? 900_000),
56
+ defaultDirectory: null,
57
+ profiles: {},
58
+ };
59
+ }
60
+
61
+ export function loadConfig() {
62
+ try {
63
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
64
+ return { ...defaultConfig(), ...raw, profiles: raw.profiles ?? {} };
65
+ } catch {
66
+ return defaultConfig();
67
+ }
68
+ }
69
+
70
+ export function saveConfig(patch = {}) {
71
+ mkdirSync(CONFIG_DIR, { recursive: true });
72
+ const next = { ...loadConfig(), ...patch };
73
+ writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2) + "\n");
74
+ return next;
75
+ }
76
+
77
+ export function configPath() {
78
+ return CONFIG_PATH;
79
+ }
80
+
81
+ // Merge a single call's options with the active profile (if any) and config defaults.
82
+ export function resolveConfig(overrides = {}) {
83
+ const cfg = loadConfig();
84
+ const profile = overrides.profile ? cfg.profiles?.[overrides.profile] ?? {} : {};
85
+ const o = { ...profile, ...overrides };
86
+ return {
87
+ model: o.model ?? cfg.defaultModel ?? null,
88
+ agent: o.agent ?? cfg.defaultAgent ?? null,
89
+ variant: o.variant ?? cfg.defaultVariant ?? null,
90
+ autoApprove: o.autoApprove ?? cfg.autoApprove,
91
+ timeoutMs: o.timeoutMs ?? cfg.timeoutMs,
92
+ directory: o.directory ?? cfg.defaultDirectory ?? null,
93
+ };
94
+ }
95
+
96
+ export function opencodeArgs(model, prompt, { files, sessionId, variant, directory, agent, autoApprove } = {}) {
97
+ const args = ["run", "--format", "json"];
98
+ if (autoApprove) args.push("--auto");
99
+ if (sessionId) args.push("-s", sessionId);
100
+ if (directory) args.push("--dir", directory);
101
+ if (agent) args.push("--agent", agent);
102
+ args.push("-m", model);
103
+ if (variant) args.push("--variant", variant);
104
+ for (const f of files ?? []) args.push("-f", f);
105
+ args.push(prompt);
106
+ return args;
107
+ }
108
+
109
+ export async function pickVariant(model) {
110
+ const [provider, ...rest] = model.split("/");
111
+ const id = rest.join("/");
112
+ if (!provider || !id || model.startsWith("ollama:")) return undefined;
113
+ let variants = {};
114
+ try {
115
+ variants = await loadVariantMap();
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ const list = variants[`${provider}/${id}`];
120
+ if (!Array.isArray(list) || list.length === 0) return undefined;
121
+ return list.length === 1 ? list[0] : list[list.length - 2];
122
+ }
123
+
124
+ // Variant names per provider/model, discovered from `GET /config/providers`
125
+ // on a short-lived local opencode server. Cached on disk for 24h.
126
+ async function loadVariantMap() {
127
+ try {
128
+ const cached = JSON.parse(readFileSync(VARIANTS_CACHE, "utf8"));
129
+ if (Date.now() - cached.fetchedAt < CATALOG_TTL && cached.data) return cached.data;
130
+ } catch {}
131
+ const child = spawn("opencode", ["serve", "--port", "0"], { stdio: ["ignore", "pipe", "pipe"] });
132
+ try {
133
+ const baseUrl = await new Promise((resolvePromise, rejectPromise) => {
134
+ let out = "";
135
+ const timer = setTimeout(() => rejectPromise(new Error("opencode serve timed out")), 20_000);
136
+ child.stdout.on("data", (d) => {
137
+ out += d;
138
+ const m = out.match(/listening on (https?:\/\/\S+)/);
139
+ if (m) {
140
+ clearTimeout(timer);
141
+ resolvePromise(m[1]);
142
+ }
143
+ });
144
+ child.on("error", (e) => {
145
+ clearTimeout(timer);
146
+ rejectPromise(e);
147
+ });
148
+ child.on("exit", () => {
149
+ clearTimeout(timer);
150
+ rejectPromise(new Error("opencode serve exited before listening"));
151
+ });
152
+ });
153
+ const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/config/providers`, { signal: AbortSignal.timeout(15_000) });
154
+ if (!res.ok) throw new Error(`config/providers ${res.status}`);
155
+ const body = await res.json();
156
+ const providers = body.providers ?? body;
157
+ const map = {};
158
+ for (const p of Array.isArray(providers) ? providers : Object.values(providers)) {
159
+ for (const [mid, m] of Object.entries(p.models ?? {})) {
160
+ if (m?.variants) map[`${p.id}/${mid}`] = Object.keys(m.variants);
161
+ }
162
+ }
163
+ try {
164
+ mkdirSync(CONFIG_DIR, { recursive: true });
165
+ writeFileSync(VARIANTS_CACHE, JSON.stringify({ fetchedAt: Date.now(), data: map }));
166
+ } catch {}
167
+ return map;
168
+ } finally {
169
+ child.kill("SIGKILL");
170
+ }
171
+ }
172
+
173
+ export function parseEvents(stdout) {
174
+ let text = "";
175
+ let sessionId = null;
176
+ let tokens = 0;
177
+ for (const line of stdout.split("\n")) {
178
+ if (!line.startsWith("{")) continue;
179
+ try {
180
+ const ev = JSON.parse(line);
181
+ if (!sessionId && ev.sessionID) sessionId = ev.sessionID;
182
+ if (ev.type === "text" && ev.part?.text) text += (text ? "\n" : "") + ev.part.text;
183
+ if (ev.type === "step_finish" && ev.part?.tokens)
184
+ tokens += (ev.part.tokens.input ?? 0) + (ev.part.tokens.output ?? 0);
185
+ } catch {}
186
+ }
187
+ return { text: text.trim(), sessionId, tokens };
188
+ }
189
+
190
+ export async function opencodeRun(model, prompt, opts = {}) {
191
+ const started = Date.now();
192
+ const variant = opts.variant ?? (await pickVariant(model));
193
+ const timeoutMs = opts.timeoutMs ?? OPENCODE_TIMEOUT;
194
+ const cwd = opts.directory ?? process.cwd();
195
+ const stdout = await new Promise((resolvePromise, rejectPromise) => {
196
+ const child = spawn("opencode", opencodeArgs(model, prompt, { ...opts, variant }), {
197
+ cwd,
198
+ stdio: ["ignore", "pipe", "pipe"],
199
+ });
200
+ let out = "";
201
+ let errOut = "";
202
+ const timer = setTimeout(() => {
203
+ child.kill("SIGKILL");
204
+ rejectPromise(new Error(`opencode timed out after ${timeoutMs / 1000}s`));
205
+ }, timeoutMs);
206
+ child.stdout.on("data", (d) => (out += d));
207
+ child.stderr.on("data", (d) => (errOut += d));
208
+ child.on("error", (e) => {
209
+ clearTimeout(timer);
210
+ rejectPromise(e);
211
+ });
212
+ child.on("exit", (code) => {
213
+ clearTimeout(timer);
214
+ if (code === 0 || out.trim()) resolvePromise(out);
215
+ else rejectPromise(new Error(`opencode exited ${code}: ${errOut.slice(-500) || "no output"}`));
216
+ });
217
+ });
218
+ const { text, sessionId, tokens } = parseEvents(stdout);
219
+ return { text: text || "(no output)", sessionId, tokens, ms: Date.now() - started };
220
+ }
221
+
222
+ export async function callDirect(baseUrl, model, messages, apiKey, extraBody = {}) {
223
+ const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/chat/completions`, {
224
+ method: "POST",
225
+ headers: {
226
+ "Content-Type": "application/json",
227
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
228
+ },
229
+ body: JSON.stringify({ model, messages, ...extraBody }),
230
+ });
231
+ if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
232
+ const data = await res.json();
233
+ return data.choices[0].message.content;
234
+ }
235
+
236
+ export async function listAuthDirectProviders() {
237
+ const auth = loadOpencodeAuth();
238
+ let catalog = {};
239
+ try {
240
+ catalog = await loadCatalog();
241
+ } catch {}
242
+ return Object.entries(auth)
243
+ .filter(([, v]) => v?.key)
244
+ .map(([name]) => ({
245
+ name,
246
+ models: Object.keys(catalog[name]?.models ?? {}).slice(0, 10),
247
+ baseUrl: catalog[name]?.api ?? "(unknown)",
248
+ }));
249
+ }
250
+
251
+ // Custom providers defined in opencode.json (npm @ai-sdk/openai-compatible + baseURL)
252
+ function loadOpencodeConfigProviders() {
253
+ try {
254
+ const cfg = JSON.parse(readFileSync(OPENCODE_CONFIG_PATH, "utf8"));
255
+ const out = {};
256
+ for (const [name, p] of Object.entries(cfg.provider ?? {})) {
257
+ const baseUrl = p?.options?.baseURL;
258
+ if (!baseUrl || (p.npm && p.npm !== "@ai-sdk/openai-compatible")) continue;
259
+ const models = {};
260
+ for (const [id, m] of Object.entries(p.models ?? {})) {
261
+ models[id] = m?.options ?? {};
262
+ }
263
+ out[name.toLowerCase()] = { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey: p.options?.apiKey, models };
264
+ }
265
+ return out;
266
+ } catch {
267
+ return {};
268
+ }
269
+ }
270
+
271
+ async function ollamaModels() {
272
+ try {
273
+ const res = await fetch(`${OLLAMA_BASE}/api/tags`, { signal: AbortSignal.timeout(3000) });
274
+ if (!res.ok) return [];
275
+ const data = await res.json();
276
+ return (data.models ?? []).map((m) => m.name);
277
+ } catch {
278
+ return [];
279
+ }
280
+ }
281
+
282
+ export async function listLocalProviders() {
283
+ const ollama = await ollamaModels();
284
+ const config = loadOpencodeConfigProviders();
285
+ return { ollama, config };
286
+ }
287
+
288
+ async function runOllama(model, prompt) {
289
+ const res = await fetch(`${OLLAMA_BASE}/v1/chat/completions`, {
290
+ method: "POST",
291
+ headers: { "Content-Type": "application/json" },
292
+ body: JSON.stringify({
293
+ model,
294
+ messages: [{ role: "user", content: prompt }],
295
+ }),
296
+ });
297
+ if (!res.ok) throw new Error(`Ollama ${res.status}: ${await res.text()}`);
298
+ const data = await res.json();
299
+ return data.choices[0].message.content;
300
+ }
301
+
302
+ export async function runAuthDirect(model, prompt) {
303
+ const slashIdx = model.indexOf("/");
304
+ if (slashIdx <= 0) throw new Error(`expected provider/model format, got '${model}'`);
305
+ const prov = model.slice(0, slashIdx);
306
+ const id = model.slice(slashIdx + 1);
307
+ const auth = loadOpencodeAuth();
308
+ if (!auth[prov]?.key)
309
+ throw new Error(`no opencode login for '${prov}' — run: opencode auth login ${prov}`);
310
+ const catalog = await loadCatalog();
311
+ const info = catalog[prov];
312
+ if (!info?.api || info.npm !== "@ai-sdk/openai-compatible")
313
+ throw new Error(`provider '${prov}' is not OpenAI-compatible for direct calls — use 'opencode:${prov}/${id}' instead`);
314
+ const text = await callDirect(info.api, id, [{ role: "user", content: prompt }], auth[prov].key);
315
+ return { text, tokens: null, backend: `opencode-auth:${prov}` };
316
+ }
317
+
318
+ export async function runOne(model, prompt) {
319
+ if (model.startsWith("opencode:")) {
320
+ const r = await opencodeRun(model.slice("opencode:".length), prompt);
321
+ return { text: r.text, tokens: r.tokens, backend: "opencode" };
322
+ }
323
+
324
+ if (model.startsWith("ollama:") || model.startsWith("local:") || model.startsWith("ollama/")) {
325
+ const id = model.replace(/^(ollama:|local:|ollama\/)/, "");
326
+ const text = await runOllama(id, prompt);
327
+ return { text, tokens: null, backend: "ollama" };
328
+ }
329
+
330
+ // provider/model → opencode auth (catalog) or opencode.json custom providers
331
+ const slashIdx = model.indexOf("/");
332
+ if (slashIdx > 0) {
333
+ const prov = model.slice(0, slashIdx).toLowerCase();
334
+ const id = model.slice(slashIdx + 1);
335
+ const custom = loadOpencodeConfigProviders()[prov];
336
+ if (custom) {
337
+ const extra = custom.models[id] ?? {};
338
+ const text = await callDirect(custom.baseUrl, id, [{ role: "user", content: prompt }], custom.apiKey, extra);
339
+ return { text, tokens: null, backend: `config:${prov}` };
340
+ }
341
+ return runAuthDirect(model, prompt);
342
+ }
343
+
344
+ throw new Error(`unknown model '${model}' — see list_models`);
345
+ }
346
+
347
+ export async function gitDiff() { try {
348
+ const { stdout } = await execFileAsync("git", ["diff", "HEAD"], {
349
+ cwd: process.cwd(),
350
+ maxBuffer: 5 * 1024 * 1024,
351
+ });
352
+ return stdout.trim();
353
+ } catch {
354
+ return null;
355
+ }
356
+ }
357
+
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "forge-delegate",
3
+ "version": "1.0.0",
4
+ "description": "MCP server that relays coding work to other AI models (DeepSeek, Gemini, OpenAI, Ollama) so Claude Code saves tokens",
5
+ "type": "module",
6
+ "bin": {
7
+ "forge-delegate": "./cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "setup": "node cli.mjs setup",
11
+ "start": "node cli.mjs serve",
12
+ "prepublishOnly": "node --check cli.mjs && node --check server.mjs && node --check lib.mjs"
13
+ },
14
+ "files": [
15
+ "cli.mjs",
16
+ "server.mjs",
17
+ "lib.mjs",
18
+ "install.sh",
19
+ "docs/",
20
+ "README.md"
21
+ ],
22
+ "keywords": ["mcp", "claude", "claude-code", "ai", "deepseek", "gemini", "ollama", "delegation"],
23
+ "license": "MIT",
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.12.0",
29
+ "zod": "^3.24.0"
30
+ }
31
+ }
package/server.mjs ADDED
@@ -0,0 +1,260 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { execFile, spawn } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, openSync, readdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import {
10
+ logUsage,
11
+ opencodeRun,
12
+ runOne,
13
+ gitDiff,
14
+ opencodeArgs,
15
+ parseEvents,
16
+ pickVariant,
17
+ listAuthDirectProviders,
18
+ listLocalProviders,
19
+ loadConfig,
20
+ saveConfig,
21
+ configPath,
22
+ resolveConfig,
23
+ } from "./lib.mjs";
24
+
25
+ const execFileAsync = promisify(execFile);
26
+ const RUNS_DIR = join(homedir(), ".forge-delegate", "runs");
27
+
28
+ const INSTRUCTIONS = `forge-delegate gives you a crew of external AI models to delegate coding work to. Hand off the high-volume, low-risk work (boilerplate, tests, lint/type fixes, mechanical edits) to a cheap or free model and keep the expensive agent focused on architecture and judgment calls.
29
+
30
+ COST POLICY — MANDATORY, NON-NEGOTIABLE:
31
+ The user pays per delegated call on a budget plan. You MUST pick only cheap or free models for EVERY delegation (ask_model, delegate_task, delegate_agent). NEVER pick top-tier, premium, flagship, or frontier models — not even "just this once" or for hard tasks. If a task seems too hard for a cheap model, split it into smaller delegated steps instead of upgrading. Run list_models and prefer free/cheap models (deepseek flash tiers, free opencode models, local ollama).
32
+
33
+ Routing:
34
+ - Quick one-shot questions → ask_model (direct API, no file access).
35
+ - Multi-file edits/refactors/tests → delegate_task (synchronous) or delegate_agent (background).
36
+ - delegate_agent returns a job id instantly; poll check_delegation for the result.
37
+ - Follow-ups on a delegated area: pass the returned session id as continue_session.
38
+ - Omit model/directory to use the configured defaults (get_delegate_config); change them anytime with set_delegate_config — never reinstall.
39
+ - Never paste large files into your own context for delegation — delegate and let the agent read files itself.
40
+
41
+ The per-tool descriptions are the authoritative reference.
42
+ `;
43
+
44
+ const server = new McpServer(
45
+ { name: "forge-delegate", version: "1.0.0" },
46
+ { instructions: INSTRUCTIONS }
47
+ );
48
+
49
+ // Uniform wrapper: times the call, records usage, converts errors to tool errors.
50
+ async function withUsage(tool, model, fn) {
51
+ const started = Date.now();
52
+ try {
53
+ const text = await fn();
54
+ logUsage({ tool, model, ms: Date.now() - started, ok: true });
55
+ return { content: [{ type: "text", text }] };
56
+ } catch (e) {
57
+ logUsage({ tool, model, ms: Date.now() - started, ok: false });
58
+ return { content: [{ type: "text", text: `ERROR: ${e.message}` }], isError: true };
59
+ }
60
+ }
61
+
62
+ function sessionFooter(sessionId, tokens) {
63
+ const parts = [];
64
+ if (tokens) parts.push(`~${tokens} tokens used`);
65
+ parts.push(`session: ${sessionId}`);
66
+ return "\n" + parts.join(" · ");
67
+ }
68
+
69
+ async function startBackgroundJob(model, task, opts = {}) {
70
+ const variant = await pickVariant(model);
71
+ mkdirSync(RUNS_DIR, { recursive: true });
72
+ const id = `job_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
73
+ const logPath = join(RUNS_DIR, `${id}.log`);
74
+ const out = openSync(logPath, "w");
75
+ const cwd = opts.directory ?? process.cwd();
76
+ const child = spawn("opencode", opencodeArgs(model, task, { ...opts, variant }), {
77
+ cwd,
78
+ detached: true,
79
+ stdio: ["ignore", out, out],
80
+ });
81
+ child.unref();
82
+ writeFileSync(join(RUNS_DIR, `${id}.json`), JSON.stringify({ id, pid: child.pid, model, directory: cwd, started: Date.now(), logPath }));
83
+ return id;
84
+ }
85
+
86
+ server.tool(
87
+ "list_models",
88
+ "List models available for delegation: local/keyless ones, fast direct-API models from opencode logins, and agentic models from the local opencode installation.",
89
+ {},
90
+ async () =>
91
+ withUsage("list_models", "-", async () => {
92
+ const lines = ["== local / keyless (ask_model) =="];
93
+ const local = await listLocalProviders();
94
+ if (local.ollama.length) lines.push(` ollama: ${local.ollama.slice(0, 12).join(", ")}`);
95
+ else lines.push(" ollama: not running");
96
+ for (const [name, p] of Object.entries(local.config))
97
+ lines.push(` ${name} (opencode.json): ${p.models.join(", ") || "(models defined ad hoc)"}`);
98
+
99
+ lines.push("\n== direct via opencode logins (ask_model, provider/model) ==");
100
+ try {
101
+ const authed = await listAuthDirectProviders();
102
+ if (!authed.length) lines.push(" none — run 'opencode auth login <provider>'");
103
+ for (const p of authed)
104
+ lines.push(` ${p.name}: ${p.models.join(", ") || "(model list unavailable — still usable)"}`);
105
+ } catch (e) {
106
+ lines.push(` (unavailable: ${e.message})`);
107
+ }
108
+
109
+ lines.push("\n== agentic via opencode (delegate_* tools) ==");
110
+ try {
111
+ const { stdout } = await execFileAsync("opencode", ["models"], { timeout: 15_000, maxBuffer: 1024 * 1024 });
112
+ lines.push(stdout.trim());
113
+ } catch {
114
+ lines.push("opencode not found");
115
+ }
116
+
117
+ return lines.join("\n");
118
+ })
119
+ );
120
+
121
+ server.tool(
122
+ "ask_model",
123
+ "Send a one-shot prompt to an external AI model and get its raw response. Fast direct API call; the model has NO file access and CANNOT take actions. For work requiring tools/files use delegate_agent instead. model defaults to the configured defaultModel.",
124
+ {
125
+ model: z.string().optional().describe("provider/model, e.g. deepseek/deepseek-v4-flash, ollama:llama3 (defaults to config defaultModel)"),
126
+ prompt: z.string(),
127
+ system: z.string().optional(),
128
+ },
129
+ async ({ model, prompt, system }) => {
130
+ const r = resolveConfig({ model });
131
+ if (!r.model) return { content: [{ type: "text", text: "ERROR: no model specified and no defaultModel configured. Call set_delegate_config or pass model." }], isError: true };
132
+ return withUsage("ask_model", r.model, () =>
133
+ runOne(r.model, system ? `${system}\n\n---\n\n${prompt}` : prompt).then((x) => x.text)
134
+ );
135
+ }
136
+ );
137
+
138
+ server.tool(
139
+ "delegate_task",
140
+ "Delegate a coding task to another AI model running as a full agent (via local opencode): it can read your project and answer with concrete code. Optionally attach specific files. model/directory/agent/autoApprove default from config.",
141
+ {
142
+ model: z.string().optional().describe("provider/model format, e.g. opencode/mimo-v2.5-free (defaults to config defaultModel)"),
143
+ task: z.string(),
144
+ files: z.array(z.string()).optional(),
145
+ directory: z.string().optional().describe("absolute path to run in (defaults to config defaultDirectory, else server cwd)"),
146
+ agent: z.string().optional().describe("opencode agent to use (defaults to config defaultAgent)"),
147
+ autoApprove: z.boolean().optional().describe("auto-approve permissions (defaults to config autoApprove, true)"),
148
+ profile: z.string().optional().describe("named config profile to apply"),
149
+ },
150
+ async ({ model, task, files, directory, agent, autoApprove, profile }) => {
151
+ const r = resolveConfig({ model, directory, agent, autoApprove, profile });
152
+ if (!r.model) return { content: [{ type: "text", text: "ERROR: no model specified and no defaultModel configured. Call set_delegate_config or pass model." }], isError: true };
153
+ return withUsage("delegate_task", r.model, async () => {
154
+ const res = await opencodeRun(r.model, task, {
155
+ files,
156
+ directory: r.directory,
157
+ agent: r.agent,
158
+ autoApprove: r.autoApprove,
159
+ timeoutMs: r.timeoutMs,
160
+ });
161
+ return `${res.text || "(no summary — agent may have only edited files)"}${sessionFooter(res.sessionId, res.tokens)} — pass session id as continue_session to follow up`;
162
+ });
163
+ }
164
+ );
165
+
166
+ server.tool(
167
+ "delegate_agent",
168
+ "Fully delegate a coding task to another AI model working autonomously on this project (via local opencode): it explores, reads, edits and creates files in its own loop — invisible to the caller's context. Runs in the BACKGROUND: returns a job id instantly, poll with check_delegation to get the result. Pass continue_session (returned by a previous delegation) to keep the same agent session instead of re-exploring. model/directory/agent/autoApprove default from config.",
169
+ {
170
+ model: z.string().optional().describe("provider/model format, e.g. opencode/mimo-v2.5-free (defaults to config defaultModel)"),
171
+ task: z.string(),
172
+ continue_session: z.string().optional().describe("session id returned by a previous delegation"),
173
+ directory: z.string().optional().describe("absolute path to run in (defaults to config defaultDirectory, else server cwd)"),
174
+ agent: z.string().optional().describe("opencode agent to use (defaults to config defaultAgent)"),
175
+ autoApprove: z.boolean().optional().describe("auto-approve permissions (defaults to config autoApprove, true)"),
176
+ profile: z.string().optional().describe("named config profile to apply"),
177
+ },
178
+ async ({ model, task, continue_session, directory, agent, autoApprove, profile }) => {
179
+ const r = resolveConfig({ model, directory, agent, autoApprove, profile });
180
+ if (!r.model) return { content: [{ type: "text", text: "ERROR: no model specified and no defaultModel configured. Call set_delegate_config or pass model." }], isError: true };
181
+ return withUsage("delegate_agent", r.model, async () => {
182
+ const id = await startBackgroundJob(r.model, task, {
183
+ sessionId: continue_session,
184
+ directory: r.directory,
185
+ agent: r.agent,
186
+ autoApprove: r.autoApprove,
187
+ });
188
+ return `Started background job ${id}. Poll with check_delegation(id: "${id}").`;
189
+ });
190
+ }
191
+ );
192
+
193
+ server.tool(
194
+ "check_delegation",
195
+ "Check status of a background delegation job: running/completed plus output so far. Call with no id to list all jobs.",
196
+ { id: z.string().optional() },
197
+ async ({ id }) =>
198
+ withUsage("check_delegation", id ?? "-", async () => {
199
+ if (!existsSync(RUNS_DIR)) return "no jobs yet";
200
+ if (!id) {
201
+ const jobs = readdirSync(RUNS_DIR)
202
+ .filter((f) => f.endsWith(".json"))
203
+ .map((f) => JSON.parse(readFileSync(join(RUNS_DIR, f), "utf8")))
204
+ .map((m) => `${m.id} [${m.model}] started ${new Date(m.started).toLocaleTimeString()}`);
205
+ return jobs.join("\n") || "no jobs yet";
206
+ }
207
+ const metaPath = join(RUNS_DIR, `${id}.json`);
208
+ if (!existsSync(metaPath)) return `unknown job ${id}`;
209
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"));
210
+ let running = false;
211
+ try {
212
+ process.kill(meta.pid, 0);
213
+ running = true;
214
+ } catch {}
215
+ const log = existsSync(meta.logPath) ? readFileSync(meta.logPath, "utf8") : "";
216
+ const parsed = parseEvents(log);
217
+ const tail = parsed.text || "(agent still thinking, no text output yet)";
218
+ const footer = parsed.sessionId ? ` · session: ${parsed.sessionId}` : "";
219
+ return `[${running ? "RUNNING" : "DONE"}] ${id} (${meta.model}, ${Math.round((Date.now() - meta.started) / 1000)}s)${footer}\n\n${tail.slice(-8000)}`;
220
+ })
221
+ );
222
+
223
+ server.tool(
224
+ "get_delegate_config",
225
+ "Read the current forge-delegate configuration: defaultModel, defaultAgent, defaultVariant, autoApprove, timeoutMs, defaultDirectory, and named profiles. Call this to see what will be used when a model/directory is omitted.",
226
+ {},
227
+ async () => {
228
+ const cfg = loadConfig();
229
+ const { profiles, ...rest } = cfg;
230
+ const lines = Object.entries(rest).map(([k, v]) => ` ${k}: ${v ?? "(unset)"}`);
231
+ const prof = Object.entries(profiles).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join("\n") || " (none)";
232
+ return `config: ${configPath()}\n${lines.join("\n")}\nprofiles:\n${prof}`;
233
+ }
234
+ );
235
+
236
+ server.tool(
237
+ "set_delegate_config",
238
+ "Change forge-delegate configuration at runtime — no reinstall. Pass any subset of {defaultModel, defaultAgent, defaultVariant, autoApprove, timeoutMs, defaultDirectory, profiles}. Set a field to null to clear it. To edit a profile, pass the whole profiles object (e.g. profiles: {tests: {model: \"...\"}}); set a profile to null to delete it. Only provided keys change; others are preserved.",
239
+ {
240
+ defaultModel: z.string().nullable().optional(),
241
+ defaultAgent: z.string().nullable().optional(),
242
+ defaultVariant: z.string().nullable().optional(),
243
+ autoApprove: z.boolean().optional(),
244
+ timeoutMs: z.number().optional(),
245
+ defaultDirectory: z.string().nullable().optional(),
246
+ profiles: z.record(z.any()).optional(),
247
+ },
248
+ async (patch) => {
249
+ try {
250
+ saveConfig(patch);
251
+ const cfg = loadConfig();
252
+ return `Updated. defaultModel=${cfg.defaultModel ?? "(unset)"}, autoApprove=${cfg.autoApprove}, defaultDirectory=${cfg.defaultDirectory ?? "(unset)"}, profiles=${Object.keys(cfg.profiles).join(",") || "none"}`;
253
+ } catch (e) {
254
+ return { content: [{ type: "text", text: `ERROR: ${e.message}` }], isError: true };
255
+ }
256
+ }
257
+ );
258
+
259
+ const transport = new StdioServerTransport();
260
+ await server.connect(transport);