@fusionkit/cli 0.1.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/dist/cli.d.ts +8 -0
- package/dist/cli.js +34 -0
- package/dist/commands/ensemble-gateway.d.ts +2 -0
- package/dist/commands/ensemble-gateway.js +114 -0
- package/dist/commands/ensemble-records.d.ts +33 -0
- package/dist/commands/ensemble-records.js +207 -0
- package/dist/commands/ensemble.d.ts +2 -0
- package/dist/commands/ensemble.js +254 -0
- package/dist/commands/fusion.d.ts +2 -0
- package/dist/commands/fusion.js +112 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +24 -0
- package/dist/commands/lifecycle.d.ts +2 -0
- package/dist/commands/lifecycle.js +124 -0
- package/dist/commands/local.d.ts +2 -0
- package/dist/commands/local.js +25 -0
- package/dist/commands/plane.d.ts +2 -0
- package/dist/commands/plane.js +30 -0
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +149 -0
- package/dist/commands/runner.d.ts +2 -0
- package/dist/commands/runner.js +33 -0
- package/dist/commands/secrets.d.ts +2 -0
- package/dist/commands/secrets.js +21 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +69 -0
- package/dist/fusion-quickstart.d.ts +182 -0
- package/dist/fusion-quickstart.js +673 -0
- package/dist/gateway.d.ts +63 -0
- package/dist/gateway.js +304 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +28 -0
- package/dist/local.d.ts +40 -0
- package/dist/local.js +144 -0
- package/dist/render.d.ts +7 -0
- package/dist/render.js +131 -0
- package/dist/shared/errors.d.ts +6 -0
- package/dist/shared/errors.js +9 -0
- package/dist/shared/options.d.ts +24 -0
- package/dist/shared/options.js +106 -0
- package/dist/shared/plane.d.ts +13 -0
- package/dist/shared/plane.js +46 -0
- package/dist/shared/preflight.d.ts +15 -0
- package/dist/shared/preflight.js +48 -0
- package/dist/shared/proc.d.ts +41 -0
- package/dist/shared/proc.js +122 -0
- package/dist/test/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +867 -0
- package/dist/test/e2e.test.d.ts +1 -0
- package/dist/test/e2e.test.js +250 -0
- package/dist/test/fusion-quickstart.test.d.ts +1 -0
- package/dist/test/fusion-quickstart.test.js +189 -0
- package/dist/test/gateway-e2e.test.d.ts +1 -0
- package/dist/test/gateway-e2e.test.js +606 -0
- package/dist/test/handoff.test.d.ts +1 -0
- package/dist/test/handoff.test.js +212 -0
- package/dist/test/local.test.d.ts +1 -0
- package/dist/test/local.test.js +39 -0
- package/dist/test/proc.test.d.ts +1 -0
- package/dist/test/proc.test.js +22 -0
- package/package.json +48 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fusionkit <tool>` — one command, everything real.
|
|
3
|
+
*
|
|
4
|
+
* Spawns a real model panel (a cloud trio by default, or the local MLX trio
|
|
5
|
+
* with `--local`), starts the Fusion Harness Gateway over a real model-backed
|
|
6
|
+
* coding harness (each panel model produces a real candidate patch in its own
|
|
7
|
+
* git worktree on a real repo) with real judge synthesis (FusionKit, run via
|
|
8
|
+
* `uvx`), then launches the chosen coding agent (Codex / Claude Code / Cursor)
|
|
9
|
+
* pre-wired to the gateway. One Ctrl+C tears the whole stack down.
|
|
10
|
+
*
|
|
11
|
+
* No mocks: the panel is real models, candidates are real patches verified by
|
|
12
|
+
* really running the repo's tests, and the judge is a real model.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { createInterface } from "node:readline";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { MlxBackend, startGateway } from "@fusionkit/model-gateway";
|
|
22
|
+
import { gatewaySetupSnippets, startFusionStepGateway } from "./gateway.js";
|
|
23
|
+
import { claudeEnv, codexConfigToml } from "./local.js";
|
|
24
|
+
import { runPreflight } from "./shared/preflight.js";
|
|
25
|
+
import { freePort, spawnLogged, spawnTool, terminate, waitForHttp, waitForOutput } from "./shared/proc.js";
|
|
26
|
+
export const FUSION_TOOLS = ["codex", "claude", "cursor", "serve"];
|
|
27
|
+
/** The model label the launched tool uses; the gateway ignores it for routing. */
|
|
28
|
+
const FUSION_MODEL_LABEL = "fusion-panel";
|
|
29
|
+
/**
|
|
30
|
+
* The PyPI version of the `fusionkit` Python distribution that provides the
|
|
31
|
+
* synthesizer (`fusionkit serve`) and the single-model OpenAI shim
|
|
32
|
+
* (`fusionkit serve-endpoint`). Pinned so `uvx` resolves a reproducible build.
|
|
33
|
+
*/
|
|
34
|
+
export const FUSIONKIT_PYPI_VERSION = "0.1.0";
|
|
35
|
+
/**
|
|
36
|
+
* Default cloud panel — works cross-platform with only `OPENAI_API_KEY` and
|
|
37
|
+
* `ANTHROPIC_API_KEY` set. The judge defaults to the first entry.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_CLOUD_PANEL = [
|
|
40
|
+
{ id: "gpt", model: "gpt-5.5", provider: "openai" },
|
|
41
|
+
{ id: "sonnet", model: "claude-sonnet-4-6", provider: "anthropic" }
|
|
42
|
+
];
|
|
43
|
+
/** The locally cached MLX trio (Apple Silicon only) used behind `--local`. */
|
|
44
|
+
export const DEFAULT_TRIO = [
|
|
45
|
+
{ id: "qwen", model: "mlx-community/Qwen3-1.7B-4bit", provider: "mlx" },
|
|
46
|
+
{ id: "gemma", model: "mlx-community/gemma-3-1b-it-4bit", provider: "mlx" },
|
|
47
|
+
{ id: "llama", model: "mlx-community/Llama-3.2-1B-Instruct-4bit", provider: "mlx" }
|
|
48
|
+
];
|
|
49
|
+
/**
|
|
50
|
+
* How to invoke the `fusionkit` Python CLI: from PyPI via `uvx` by default
|
|
51
|
+
* (no checkout), or from a local checkout via `uv run` when `fusionkitDir` is
|
|
52
|
+
* given (a dev override). Returns the command plus the argv prefix that
|
|
53
|
+
* precedes the subcommand (`serve`, `serve-endpoint`, ...).
|
|
54
|
+
*/
|
|
55
|
+
export function fusionkitPyCommand(fusionkitDir) {
|
|
56
|
+
if (fusionkitDir !== undefined) {
|
|
57
|
+
return { command: "uv", prefix: ["run", "fusionkit"], cwd: fusionkitDir };
|
|
58
|
+
}
|
|
59
|
+
return { command: "uvx", prefix: [`fusionkit@${FUSIONKIT_PYPI_VERSION}`] };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Parse a `.env` file (KEY=VALUE lines, `#` comments, optional `export`,
|
|
63
|
+
* single/double quotes) and fill any keys not already present in `env`.
|
|
64
|
+
* Existing env values win, so an explicitly exported key is never overridden.
|
|
65
|
+
*/
|
|
66
|
+
export function loadEnvFileInto(path, env) {
|
|
67
|
+
if (!existsSync(path))
|
|
68
|
+
return;
|
|
69
|
+
for (const rawLine of readFileSync(path, "utf8").split("\n")) {
|
|
70
|
+
const line = rawLine.trim();
|
|
71
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
72
|
+
continue;
|
|
73
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length) : line;
|
|
74
|
+
const eq = withoutExport.indexOf("=");
|
|
75
|
+
if (eq <= 0)
|
|
76
|
+
continue;
|
|
77
|
+
const key = withoutExport.slice(0, eq).trim();
|
|
78
|
+
let value = withoutExport.slice(eq + 1).trim();
|
|
79
|
+
if (value.length >= 2 &&
|
|
80
|
+
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
81
|
+
value = value.slice(1, -1);
|
|
82
|
+
}
|
|
83
|
+
if (env[key] === undefined)
|
|
84
|
+
env[key] = value;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Default env var holding the API key for each cloud provider. */
|
|
88
|
+
export function defaultKeyEnv(provider) {
|
|
89
|
+
switch (provider) {
|
|
90
|
+
case "openai":
|
|
91
|
+
return "OPENAI_API_KEY";
|
|
92
|
+
case "anthropic":
|
|
93
|
+
return "ANTHROPIC_API_KEY";
|
|
94
|
+
case "google":
|
|
95
|
+
return "GEMINI_API_KEY";
|
|
96
|
+
case "openai-compatible":
|
|
97
|
+
case "mlx":
|
|
98
|
+
return undefined;
|
|
99
|
+
default: {
|
|
100
|
+
const exhaustive = provider;
|
|
101
|
+
throw new Error(`unknown provider ${String(exhaustive)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** The git repository root containing `dir`, or undefined if it is not in a repo. */
|
|
106
|
+
export function gitToplevel(dir) {
|
|
107
|
+
try {
|
|
108
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
109
|
+
cwd: dir,
|
|
110
|
+
encoding: "utf8",
|
|
111
|
+
// Don't leak git's "fatal: not a git repository" to our stderr; we surface
|
|
112
|
+
// a clearer message ourselves.
|
|
113
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
114
|
+
}).trim();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** The PATH binary each coding agent launches as. `serve` launches nothing. */
|
|
121
|
+
function agentBinary(tool) {
|
|
122
|
+
switch (tool) {
|
|
123
|
+
case "codex":
|
|
124
|
+
return "codex";
|
|
125
|
+
case "claude":
|
|
126
|
+
return "claude";
|
|
127
|
+
case "cursor":
|
|
128
|
+
return "cursor-agent";
|
|
129
|
+
case "serve":
|
|
130
|
+
return undefined;
|
|
131
|
+
default: {
|
|
132
|
+
const exhaustive = tool;
|
|
133
|
+
throw new Error(`unknown fusion tool: ${String(exhaustive)}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Compute the binaries and API keys the run requires given the tool, panel, and
|
|
139
|
+
* options. Pre-running endpoints (`--model-endpoint`) and a pre-running
|
|
140
|
+
* `--synthesis-url` drop the corresponding requirements.
|
|
141
|
+
*/
|
|
142
|
+
export function preflightRequirements(tool, models, options) {
|
|
143
|
+
const requiredBins = [];
|
|
144
|
+
const requiredEnv = [];
|
|
145
|
+
const endpointsProvided = options.endpoints !== undefined;
|
|
146
|
+
const spawnsServers = !endpointsProvided;
|
|
147
|
+
const spawnsSynthesizer = options.synthesisUrl === undefined;
|
|
148
|
+
// The FusionKit Python CLI is fetched via uvx (or run from a local checkout).
|
|
149
|
+
if (spawnsServers || spawnsSynthesizer) {
|
|
150
|
+
requiredBins.push(options.fusionkitDir !== undefined ? "uv" : "uvx");
|
|
151
|
+
}
|
|
152
|
+
const agent = agentBinary(tool);
|
|
153
|
+
if (agent !== undefined)
|
|
154
|
+
requiredBins.push(agent);
|
|
155
|
+
// Cloud panel members need their provider key when we front them ourselves.
|
|
156
|
+
if (spawnsServers) {
|
|
157
|
+
for (const spec of models) {
|
|
158
|
+
const provider = spec.provider ?? "mlx";
|
|
159
|
+
if (provider === "mlx")
|
|
160
|
+
continue;
|
|
161
|
+
const keyEnv = spec.keyEnv ?? defaultKeyEnv(provider);
|
|
162
|
+
if (keyEnv !== undefined)
|
|
163
|
+
requiredEnv.push(keyEnv);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { requiredBins, requiredEnv };
|
|
167
|
+
}
|
|
168
|
+
/** Fixed port for the local observability dashboard (the scope app). */
|
|
169
|
+
export const SCOPE_DASHBOARD_PORT = 4317;
|
|
170
|
+
/**
|
|
171
|
+
* Locate the isolated scope dashboard app (handoffkit/apps/scope) by walking up
|
|
172
|
+
* from this module. Works from both the compiled dist and src layouts.
|
|
173
|
+
*/
|
|
174
|
+
export function findScopeAppDir() {
|
|
175
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
176
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
177
|
+
const candidate = join(dir, "apps", "scope");
|
|
178
|
+
if (existsSync(join(candidate, "package.json")))
|
|
179
|
+
return candidate;
|
|
180
|
+
const parent = dirname(dir);
|
|
181
|
+
if (parent === dir)
|
|
182
|
+
break;
|
|
183
|
+
dir = parent;
|
|
184
|
+
}
|
|
185
|
+
throw new Error("could not locate apps/scope relative to the handoffkit CLI");
|
|
186
|
+
}
|
|
187
|
+
/** Best-effort: open a URL in the default browser (no-op on failure). */
|
|
188
|
+
function openUrl(url) {
|
|
189
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
190
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
191
|
+
try {
|
|
192
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
193
|
+
child.on("error", () => { });
|
|
194
|
+
child.unref();
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// opening a browser is a convenience, never required
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Build the scope dashboard once and `next start` it on the fixed port, backed
|
|
202
|
+
* by a fresh per-run SQLite file and trace dir. Returns the URLs the caller
|
|
203
|
+
* injects (as FUSION_TRACE_URL / FUSION_TRACE_DIR) into every spawned process.
|
|
204
|
+
*/
|
|
205
|
+
export async function startObservability(input) {
|
|
206
|
+
const scopeDir = findScopeAppDir();
|
|
207
|
+
const nextBin = join(scopeDir, "node_modules", ".bin", "next");
|
|
208
|
+
if (!existsSync(nextBin)) {
|
|
209
|
+
throw new Error(`scope dashboard dependencies are not installed.\n Run: cd ${scopeDir} && pnpm install`);
|
|
210
|
+
}
|
|
211
|
+
const traceDir = mkdtempSync(join(tmpdir(), "fusion-trace-"));
|
|
212
|
+
const dbPath = join(traceDir, "scope.db");
|
|
213
|
+
input.log("fusion: building observability dashboard (one-time)...");
|
|
214
|
+
execFileSync(nextBin, ["build"], { cwd: scopeDir, stdio: "inherit", env: { ...process.env } });
|
|
215
|
+
input.log("fusion: starting observability dashboard...");
|
|
216
|
+
const proc = spawnLogged(nextBin, ["start", "-p", String(SCOPE_DASHBOARD_PORT)], {
|
|
217
|
+
cwd: scopeDir,
|
|
218
|
+
env: { ...process.env, SCOPEKIT_DB: dbPath, FUSION_TRACE_DIR: traceDir }
|
|
219
|
+
});
|
|
220
|
+
const url = `http://127.0.0.1:${SCOPE_DASHBOARD_PORT}`;
|
|
221
|
+
try {
|
|
222
|
+
await waitForHttp(url, proc, { timeoutMs: 60_000, label: "dashboard" });
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
terminate(proc.child);
|
|
226
|
+
rmSync(traceDir, { recursive: true, force: true });
|
|
227
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
url,
|
|
231
|
+
ingestUrl: `${url}/api/ingest`,
|
|
232
|
+
traceDir,
|
|
233
|
+
close: async () => {
|
|
234
|
+
terminate(proc.child);
|
|
235
|
+
rmSync(traceDir, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Spawn FusionKit's single-endpoint OpenAI-compatible server for one cloud
|
|
241
|
+
* model, so the per-candidate coding harness can call it like any other
|
|
242
|
+
* OpenAI-compatible backend. Runs `fusionkit serve-endpoint` via `uvx` (or
|
|
243
|
+
* `uv run` against a local checkout); Anthropic/OpenAI/Google calls go through
|
|
244
|
+
* FusionKit's provider clients.
|
|
245
|
+
*/
|
|
246
|
+
async function spawnCloudServer(input) {
|
|
247
|
+
const port = await freePort();
|
|
248
|
+
const keyEnv = input.spec.keyEnv ?? defaultKeyEnv(input.provider);
|
|
249
|
+
const runner = fusionkitPyCommand(input.fusionkitDir);
|
|
250
|
+
const args = [
|
|
251
|
+
...runner.prefix,
|
|
252
|
+
"serve-endpoint",
|
|
253
|
+
"--id",
|
|
254
|
+
input.spec.id,
|
|
255
|
+
"--model",
|
|
256
|
+
input.spec.model,
|
|
257
|
+
"--provider",
|
|
258
|
+
input.provider,
|
|
259
|
+
"--host",
|
|
260
|
+
"127.0.0.1",
|
|
261
|
+
"--port",
|
|
262
|
+
String(port),
|
|
263
|
+
...(input.spec.baseUrl !== undefined ? ["--base-url", input.spec.baseUrl] : []),
|
|
264
|
+
...(keyEnv !== undefined ? ["--api-key-env", keyEnv] : [])
|
|
265
|
+
];
|
|
266
|
+
input.log(`fusion: starting ${input.spec.id} (${input.provider}:${input.spec.model})...`);
|
|
267
|
+
const proc = spawnLogged(runner.command, args, {
|
|
268
|
+
...(runner.cwd !== undefined ? { cwd: runner.cwd } : {}),
|
|
269
|
+
env: input.env
|
|
270
|
+
});
|
|
271
|
+
const url = `http://127.0.0.1:${port}`;
|
|
272
|
+
try {
|
|
273
|
+
await waitForHttp(`${url}/v1/models`, proc, {
|
|
274
|
+
timeoutMs: 30_000,
|
|
275
|
+
label: `${input.spec.id} server`,
|
|
276
|
+
requireOk: true
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
terminate(proc.child);
|
|
281
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
282
|
+
}
|
|
283
|
+
input.log(`fusion: ${input.spec.id} ready on ${url}`);
|
|
284
|
+
return { url, child: proc.child };
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Bring up one real model server per panel model and return an id -> base URL
|
|
288
|
+
* map. `mlx` specs run locally; cloud specs are fronted by FusionKit. When
|
|
289
|
+
* `endpoints` is supplied (pre-running servers or tests), those are used
|
|
290
|
+
* verbatim and nothing is spawned.
|
|
291
|
+
*/
|
|
292
|
+
export async function startModelServers(options) {
|
|
293
|
+
const { specs } = options;
|
|
294
|
+
const judge = specs[0];
|
|
295
|
+
if (judge === undefined)
|
|
296
|
+
throw new Error("at least one panel model is required");
|
|
297
|
+
const models = specs.map((spec) => ({ id: spec.id, model: spec.model }));
|
|
298
|
+
if (options.endpoints !== undefined) {
|
|
299
|
+
return {
|
|
300
|
+
endpoints: options.endpoints,
|
|
301
|
+
judgeUrl: options.endpoints[judge.id] ?? Object.values(options.endpoints)[0] ?? "",
|
|
302
|
+
judgeModel: judge.model,
|
|
303
|
+
models,
|
|
304
|
+
close: async () => { }
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
// Cloud servers inherit the parent env plus the FusionKit checkout's `.env`
|
|
308
|
+
// (so OPENAI_API_KEY / ANTHROPIC_API_KEY load seamlessly), without overriding
|
|
309
|
+
// anything already exported.
|
|
310
|
+
const cloudEnv = { ...process.env };
|
|
311
|
+
if (options.fusionkitDir !== undefined) {
|
|
312
|
+
loadEnvFileInto(join(options.fusionkitDir, ".env"), cloudEnv);
|
|
313
|
+
}
|
|
314
|
+
const gateways = [];
|
|
315
|
+
const backends = [];
|
|
316
|
+
const children = [];
|
|
317
|
+
const endpoints = {};
|
|
318
|
+
const closeAll = async () => {
|
|
319
|
+
for (const child of children)
|
|
320
|
+
terminate(child);
|
|
321
|
+
await Promise.allSettled(gateways.map((gateway) => gateway.close()));
|
|
322
|
+
await Promise.allSettled(backends.map((backend) => backend.stop()));
|
|
323
|
+
};
|
|
324
|
+
try {
|
|
325
|
+
for (const spec of specs) {
|
|
326
|
+
const provider = spec.provider ?? "mlx";
|
|
327
|
+
if (provider === "mlx") {
|
|
328
|
+
options.log(`fusion: loading ${spec.id} (${spec.model})...`);
|
|
329
|
+
const backend = new MlxBackend({ model: spec.model });
|
|
330
|
+
await backend.start();
|
|
331
|
+
const gateway = await startGateway({ backend });
|
|
332
|
+
backends.push(backend);
|
|
333
|
+
gateways.push(gateway);
|
|
334
|
+
endpoints[spec.id] = gateway.url();
|
|
335
|
+
options.log(`fusion: ${spec.id} ready on ${gateway.url()}`);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const started = await spawnCloudServer({
|
|
339
|
+
spec,
|
|
340
|
+
provider,
|
|
341
|
+
...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
|
|
342
|
+
env: cloudEnv,
|
|
343
|
+
log: options.log
|
|
344
|
+
});
|
|
345
|
+
children.push(started.child);
|
|
346
|
+
endpoints[spec.id] = started.url;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
await closeAll();
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
endpoints,
|
|
356
|
+
judgeUrl: endpoints[judge.id] ?? Object.values(endpoints)[0] ?? "",
|
|
357
|
+
judgeModel: judge.model,
|
|
358
|
+
models,
|
|
359
|
+
close: closeAll
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Spawn a `fusionkit serve` as the trajectory-synthesis backend, configured
|
|
364
|
+
* with the judge model. FusionKit owns synthesis, so the agent harness fuses
|
|
365
|
+
* its trajectories through this server's `/v1/fusion/trajectories:fuse`.
|
|
366
|
+
*/
|
|
367
|
+
export async function startSynthesisServer(input) {
|
|
368
|
+
const port = await freePort();
|
|
369
|
+
const config = [
|
|
370
|
+
"endpoints:",
|
|
371
|
+
" - id: judge",
|
|
372
|
+
" provider: openai-compatible",
|
|
373
|
+
` model: ${JSON.stringify(input.judgeModel)}`,
|
|
374
|
+
` base_url: ${JSON.stringify(input.judgeBaseUrl)}`,
|
|
375
|
+
" api_key: not-needed",
|
|
376
|
+
"default_model: judge",
|
|
377
|
+
"judge_model: judge",
|
|
378
|
+
"synthesizer_model: judge",
|
|
379
|
+
// Generous budget: reasoning models (gpt-5.x) spend tokens on reasoning
|
|
380
|
+
// before producing content, so a small cap can yield an empty answer.
|
|
381
|
+
"sampling: {temperature: 0.2, top_p: 0.9, max_tokens: 8192}",
|
|
382
|
+
""
|
|
383
|
+
].join("\n");
|
|
384
|
+
const configDir = mkdtempSync(join(tmpdir(), "fusion-synth-"));
|
|
385
|
+
const configPath = join(configDir, "synthesis.yaml");
|
|
386
|
+
writeFileSync(configPath, config);
|
|
387
|
+
input.log("fusion: starting synthesis backend (fusionkit serve)...");
|
|
388
|
+
const runner = fusionkitPyCommand(input.fusionkitDir);
|
|
389
|
+
const proc = spawnLogged(runner.command, [...runner.prefix, "serve", "--config", configPath, "--host", "127.0.0.1", "--port", String(port)], { ...(runner.cwd !== undefined ? { cwd: runner.cwd } : {}), env: input.env });
|
|
390
|
+
// The temp config is only read at startup; drop it once the server exits.
|
|
391
|
+
proc.child.once("exit", () => rmSync(configDir, { recursive: true, force: true }));
|
|
392
|
+
const url = `http://127.0.0.1:${port}`;
|
|
393
|
+
try {
|
|
394
|
+
await waitForHttp(`${url}/v1/models`, proc, {
|
|
395
|
+
timeoutMs: 60_000,
|
|
396
|
+
label: "synthesis backend",
|
|
397
|
+
requireOk: true
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
terminate(proc.child);
|
|
402
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
403
|
+
}
|
|
404
|
+
input.log(`fusion: synthesis backend ready on ${url}`);
|
|
405
|
+
return { child: proc.child, url };
|
|
406
|
+
}
|
|
407
|
+
export async function startFusionStack(options) {
|
|
408
|
+
const servers = await startModelServers({
|
|
409
|
+
specs: options.models,
|
|
410
|
+
...(options.endpoints !== undefined ? { endpoints: options.endpoints } : {}),
|
|
411
|
+
...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
|
|
412
|
+
log: options.log
|
|
413
|
+
});
|
|
414
|
+
let synthesisChild;
|
|
415
|
+
let synthesisUrl = options.synthesisUrl ?? servers.judgeUrl;
|
|
416
|
+
try {
|
|
417
|
+
// Trajectory fusion needs a FusionKit synthesizer; spawn one (via `uvx
|
|
418
|
+
// fusionkit serve`, or a local checkout if --fusionkit-dir is given) unless
|
|
419
|
+
// the caller supplied a pre-running server via --synthesis-url.
|
|
420
|
+
if (options.synthesisUrl === undefined) {
|
|
421
|
+
const cloudEnv = { ...process.env };
|
|
422
|
+
if (options.fusionkitDir !== undefined) {
|
|
423
|
+
loadEnvFileInto(join(options.fusionkitDir, ".env"), cloudEnv);
|
|
424
|
+
}
|
|
425
|
+
const synthesis = await startSynthesisServer({
|
|
426
|
+
...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
|
|
427
|
+
judgeModel: options.judgeModel ?? servers.judgeModel,
|
|
428
|
+
judgeBaseUrl: servers.judgeUrl,
|
|
429
|
+
env: cloudEnv,
|
|
430
|
+
log: options.log
|
|
431
|
+
});
|
|
432
|
+
synthesisChild = synthesis.child;
|
|
433
|
+
synthesisUrl = synthesis.url;
|
|
434
|
+
}
|
|
435
|
+
// The judge-streamed-trajectory front door: each panel model produces a
|
|
436
|
+
// trajectory and the judge emits the trajectory the user's tool executes.
|
|
437
|
+
const gatewayConfig = {
|
|
438
|
+
fusionBackendUrl: synthesisUrl,
|
|
439
|
+
repo: options.repo,
|
|
440
|
+
outputRoot: options.outputRoot,
|
|
441
|
+
harnesses: ["agent"],
|
|
442
|
+
models: servers.models,
|
|
443
|
+
judgeModel: options.judgeModel ?? servers.judgeModel,
|
|
444
|
+
modelEndpoints: servers.endpoints,
|
|
445
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
|
|
446
|
+
};
|
|
447
|
+
const gateway = await startFusionStepGateway({
|
|
448
|
+
config: gatewayConfig,
|
|
449
|
+
host: options.host ?? "127.0.0.1",
|
|
450
|
+
port: options.port ?? 0,
|
|
451
|
+
...(options.authToken !== undefined ? { authToken: options.authToken } : {})
|
|
452
|
+
});
|
|
453
|
+
return {
|
|
454
|
+
fusionUrl: gateway.url(),
|
|
455
|
+
endpoints: servers.endpoints,
|
|
456
|
+
close: async () => {
|
|
457
|
+
await gateway.close();
|
|
458
|
+
if (synthesisChild !== undefined)
|
|
459
|
+
terminate(synthesisChild);
|
|
460
|
+
await servers.close();
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
if (synthesisChild !== undefined)
|
|
466
|
+
terminate(synthesisChild);
|
|
467
|
+
await servers.close();
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
function scrubbedBridgeEnv() {
|
|
472
|
+
const env = {};
|
|
473
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
474
|
+
if (value === undefined)
|
|
475
|
+
continue;
|
|
476
|
+
if (key.startsWith("BRIDGE_") || key.startsWith("MODEL_") || key.startsWith("E2E_") || key.startsWith("CURSOR_UPSTREAM")) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
env[key] = value;
|
|
480
|
+
}
|
|
481
|
+
return env;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Start the Cursorkit bridge with its local-model backend pointed at the fusion
|
|
485
|
+
* gateway, and resolve once it is listening. Returns the child and its port.
|
|
486
|
+
*/
|
|
487
|
+
export async function startCursorBridge(input) {
|
|
488
|
+
const port = await freePort();
|
|
489
|
+
const env = {
|
|
490
|
+
...scrubbedBridgeEnv(),
|
|
491
|
+
BRIDGE_PORT: String(port),
|
|
492
|
+
BRIDGE_ROUTE_INVENTORY: "true",
|
|
493
|
+
CURSOR_UPSTREAM_BASE_URL: "https://api2.cursor.sh",
|
|
494
|
+
MODEL_BASE_URL: `${input.fusionUrl}/v1`,
|
|
495
|
+
MODEL_API_KEY: "local",
|
|
496
|
+
MODEL_NAME: FUSION_MODEL_LABEL,
|
|
497
|
+
MODEL_PROVIDER_MODEL: FUSION_MODEL_LABEL,
|
|
498
|
+
MODEL_CONTEXT_TOKEN_LIMIT: "128000"
|
|
499
|
+
};
|
|
500
|
+
const proc = spawnLogged(process.execPath, ["dist/src/cli.js", "serve"], {
|
|
501
|
+
cwd: input.cursorKitDir,
|
|
502
|
+
env
|
|
503
|
+
});
|
|
504
|
+
try {
|
|
505
|
+
await waitForOutput(proc, /bridge listening/, { timeoutMs: 20_000, label: "Cursorkit bridge" });
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
terminate(proc.child);
|
|
509
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
510
|
+
}
|
|
511
|
+
input.log(`fusion: Cursorkit bridge listening on http://127.0.0.1:${port}`);
|
|
512
|
+
return { child: proc.child, port };
|
|
513
|
+
}
|
|
514
|
+
export async function runFusion(tool, toolArgs, options = {}) {
|
|
515
|
+
const log = options.log ?? ((line) => console.error(line));
|
|
516
|
+
const root = mkdtempSync(join(tmpdir(), "fusionkit-fusion-"));
|
|
517
|
+
// Default the fused repo to the current directory's git repo: the panel models
|
|
518
|
+
// and the launched harness must operate on the SAME codebase, and the launched
|
|
519
|
+
// tool runs in this repo (below). No hidden sample repo — if the user wants a
|
|
520
|
+
// different repo they pass --repo.
|
|
521
|
+
let repo = options.repo;
|
|
522
|
+
if (repo === undefined) {
|
|
523
|
+
const toplevel = gitToplevel(process.cwd());
|
|
524
|
+
if (toplevel === undefined) {
|
|
525
|
+
throw new Error("no --repo given and the current directory is not a git repository; " +
|
|
526
|
+
"cd into your project (or pass --repo <dir>) so the panel fuses over the code you're working on");
|
|
527
|
+
}
|
|
528
|
+
repo = toplevel;
|
|
529
|
+
}
|
|
530
|
+
const models = options.models ?? (options.local === true ? [...DEFAULT_TRIO] : [...DEFAULT_CLOUD_PANEL]);
|
|
531
|
+
// Fail fast on missing prerequisites before we start spawning a stack.
|
|
532
|
+
runPreflight(preflightRequirements(tool, models, options));
|
|
533
|
+
log(`fusion: panel = ${models.map((model) => model.id).join(", ")}`);
|
|
534
|
+
log(`fusion: repo = ${repo}`);
|
|
535
|
+
// When --observe is set, boot the dashboard and export the trace env BEFORE
|
|
536
|
+
// anything starts, so the in-process gateway/ensemble/agent emitters and every
|
|
537
|
+
// spawned child (panel servers, synthesis serve, cursor bridge) inherit it.
|
|
538
|
+
// Without the flag, FUSION_TRACE_* stays unset and all emitters are no-ops.
|
|
539
|
+
let observability;
|
|
540
|
+
if (options.observe === true) {
|
|
541
|
+
observability = await startObservability({ log });
|
|
542
|
+
process.env.FUSION_TRACE_URL = observability.ingestUrl;
|
|
543
|
+
process.env.FUSION_TRACE_DIR = observability.traceDir;
|
|
544
|
+
log(`fusion: observability dashboard at ${observability.url}`);
|
|
545
|
+
log(`fusion: trace events -> ${observability.ingestUrl} (jsonl fallback in ${observability.traceDir})`);
|
|
546
|
+
openUrl(observability.url);
|
|
547
|
+
}
|
|
548
|
+
let stack;
|
|
549
|
+
try {
|
|
550
|
+
stack = await startFusionStack({
|
|
551
|
+
repo,
|
|
552
|
+
outputRoot: join(root, "runs"),
|
|
553
|
+
models,
|
|
554
|
+
...(options.endpoints !== undefined ? { endpoints: options.endpoints } : {}),
|
|
555
|
+
...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
|
|
556
|
+
...(options.judgeModel !== undefined ? { judgeModel: options.judgeModel } : {}),
|
|
557
|
+
...(options.synthesisUrl !== undefined ? { synthesisUrl: options.synthesisUrl } : {}),
|
|
558
|
+
...(options.authToken !== undefined ? { authToken: options.authToken } : {}),
|
|
559
|
+
...(options.port !== undefined ? { port: options.port } : {}),
|
|
560
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
561
|
+
log
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
if (observability !== undefined)
|
|
566
|
+
await observability.close().catch(() => { });
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
log(`fusion: gateway on ${stack.fusionUrl} (model: ${FUSION_MODEL_LABEL})`);
|
|
570
|
+
let bridge;
|
|
571
|
+
let cleaned = false;
|
|
572
|
+
const cleanup = async () => {
|
|
573
|
+
if (cleaned)
|
|
574
|
+
return;
|
|
575
|
+
cleaned = true;
|
|
576
|
+
if (bridge !== undefined)
|
|
577
|
+
terminate(bridge);
|
|
578
|
+
await stack.close().catch(() => { });
|
|
579
|
+
if (observability !== undefined)
|
|
580
|
+
await observability.close().catch(() => { });
|
|
581
|
+
};
|
|
582
|
+
const onSignal = () => {
|
|
583
|
+
// Never wedge on shutdown: if cleanup stalls (a child ignoring SIGTERM),
|
|
584
|
+
// force-exit after a grace period.
|
|
585
|
+
const forced = setTimeout(() => process.exit(1), 10_000);
|
|
586
|
+
forced.unref();
|
|
587
|
+
void cleanup().then(() => process.exit(0));
|
|
588
|
+
};
|
|
589
|
+
process.once("SIGINT", onSignal);
|
|
590
|
+
process.once("SIGTERM", onSignal);
|
|
591
|
+
try {
|
|
592
|
+
switch (tool) {
|
|
593
|
+
case "serve": {
|
|
594
|
+
log("");
|
|
595
|
+
log(gatewaySetupSnippets(stack.fusionUrl, "http://127.0.0.1:<cursorkit-port>"));
|
|
596
|
+
log("");
|
|
597
|
+
log("Gateway is running. Point any tool at it, or Ctrl+C to stop.");
|
|
598
|
+
await new Promise(() => {
|
|
599
|
+
/* run until interrupted */
|
|
600
|
+
});
|
|
601
|
+
return 0;
|
|
602
|
+
}
|
|
603
|
+
case "codex": {
|
|
604
|
+
const home = mkdtempSync(join(tmpdir(), "fusionkit-fusion-codex-"));
|
|
605
|
+
writeFileSync(join(home, "config.toml"), codexConfigToml(stack.fusionUrl, FUSION_MODEL_LABEL));
|
|
606
|
+
log("fusion: launching codex (each prompt is a coding task fused across the panel)...");
|
|
607
|
+
return await spawnTool("codex", toolArgs, { CODEX_HOME: home }, repo);
|
|
608
|
+
}
|
|
609
|
+
case "claude": {
|
|
610
|
+
log("fusion: launching claude...");
|
|
611
|
+
return await spawnTool("claude", toolArgs, claudeEnv(stack.fusionUrl, options.authToken), repo);
|
|
612
|
+
}
|
|
613
|
+
case "cursor": {
|
|
614
|
+
const cursorKitDir = options.cursorKitDir ?? process.env.FUSIONKIT_CURSORKIT_DIR ?? process.env.WARRANT_CURSORKIT_DIR;
|
|
615
|
+
if (cursorKitDir === undefined || cursorKitDir.length === 0) {
|
|
616
|
+
log("");
|
|
617
|
+
log("Cursor needs a built Cursorkit checkout. Re-run with --cursor-kit-dir <dir>");
|
|
618
|
+
log("(or set FUSIONKIT_CURSORKIT_DIR), then this command spawns the bridge and");
|
|
619
|
+
log("launches cursor-agent pre-wired to the gateway. Manual setup:");
|
|
620
|
+
log(` MODEL_BASE_URL=${stack.fusionUrl}/v1 MODEL_NAME=${FUSION_MODEL_LABEL} \\`);
|
|
621
|
+
log(" MODEL_PROVIDER_MODEL=fusion-panel node dist/src/cli.js serve # in cursorkit");
|
|
622
|
+
log(` cursor-agent --endpoint http://127.0.0.1:<bridge-port> --model ${FUSION_MODEL_LABEL}`);
|
|
623
|
+
return 1;
|
|
624
|
+
}
|
|
625
|
+
const started = await startCursorBridge({ cursorKitDir, fusionUrl: stack.fusionUrl, log });
|
|
626
|
+
bridge = started.child;
|
|
627
|
+
log("fusion: launching cursor-agent...");
|
|
628
|
+
return await spawnTool("cursor-agent", ["--endpoint", `http://127.0.0.1:${started.port}`, "--model", FUSION_MODEL_LABEL, ...toolArgs], {}, repo);
|
|
629
|
+
}
|
|
630
|
+
default: {
|
|
631
|
+
const unreachable = tool;
|
|
632
|
+
throw new Error(`unknown fusion tool: ${String(unreachable)}`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
finally {
|
|
637
|
+
await cleanup();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/** Interactive tool picker for when no `--tool` was provided on a TTY. */
|
|
641
|
+
export async function pickTool() {
|
|
642
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
643
|
+
try {
|
|
644
|
+
process.stderr.write([
|
|
645
|
+
"Which coding agent should model fusion back?",
|
|
646
|
+
" 1) codex — OpenAI Codex CLI",
|
|
647
|
+
" 2) claude — Claude Code",
|
|
648
|
+
" 3) cursor — cursor-agent (needs --cursor-kit-dir / FUSIONKIT_CURSORKIT_DIR)",
|
|
649
|
+
" 4) serve — just run the gateway and print setup",
|
|
650
|
+
""
|
|
651
|
+
].join("\n"));
|
|
652
|
+
const answer = (await new Promise((resolve) => rl.question("Choose [1-4]: ", resolve))).trim().toLowerCase();
|
|
653
|
+
switch (answer) {
|
|
654
|
+
case "1":
|
|
655
|
+
case "codex":
|
|
656
|
+
return "codex";
|
|
657
|
+
case "2":
|
|
658
|
+
case "claude":
|
|
659
|
+
return "claude";
|
|
660
|
+
case "3":
|
|
661
|
+
case "cursor":
|
|
662
|
+
return "cursor";
|
|
663
|
+
case "4":
|
|
664
|
+
case "serve":
|
|
665
|
+
return "serve";
|
|
666
|
+
default:
|
|
667
|
+
return "codex";
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
finally {
|
|
671
|
+
rl.close();
|
|
672
|
+
}
|
|
673
|
+
}
|