@fusionkit/cli 0.1.4 → 0.1.6
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 +26 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +4 -17
- package/dist/commands/ensemble-gateway.js +0 -2
- package/dist/commands/ensemble-records.d.ts +2 -1
- package/dist/commands/ensemble-records.js +3 -1
- package/dist/commands/ensemble.js +3 -4
- package/dist/commands/fusion.js +14 -15
- package/dist/commands/local.js +3 -3
- package/dist/cursor-acp.d.ts +18 -0
- package/dist/cursor-acp.js +206 -0
- package/dist/dashboard.d.ts +65 -0
- package/dist/dashboard.js +587 -0
- package/dist/fusion/env.d.ts +108 -0
- package/dist/fusion/env.js +98 -0
- package/dist/fusion/observability.d.ts +39 -0
- package/dist/fusion/observability.js +227 -0
- package/dist/fusion/preflight.d.ts +12 -0
- package/dist/fusion/preflight.js +42 -0
- package/dist/fusion/stack.d.ts +62 -0
- package/dist/fusion/stack.js +295 -0
- package/dist/fusion-config.d.ts +0 -1
- package/dist/fusion-config.js +0 -6
- package/dist/fusion-init.js +2 -11
- package/dist/fusion-quickstart.d.ts +11 -222
- package/dist/fusion-quickstart.js +57 -759
- package/dist/gateway.d.ts +0 -2
- package/dist/gateway.js +12 -2
- package/dist/local.d.ts +10 -17
- package/dist/local.js +50 -116
- package/dist/shared/options.d.ts +2 -1
- package/dist/shared/options.js +13 -19
- package/dist/shared/proc.d.ts +4 -70
- package/dist/shared/proc.js +3 -228
- package/dist/test/cli.test.js +32 -142
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +214 -0
- package/dist/test/gateway-e2e.test.js +13 -10
- package/dist/test/local.test.js +4 -4
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +25 -0
- package/package.json +14 -9
- package/scope/.next/BUILD_ID +1 -1
- package/scope/.next/app-build-manifest.json +12 -12
- package/scope/.next/app-path-routes-manifest.json +3 -3
- package/scope/.next/build-manifest.json +2 -2
- package/scope/.next/prerender-manifest.json +16 -16
- package/scope/.next/server/app/_not-found.html +1 -1
- package/scope/.next/server/app/_not-found.rsc +1 -1
- package/scope/.next/server/app/environments.html +1 -1
- package/scope/.next/server/app/environments.rsc +1 -1
- package/scope/.next/server/app/index.html +1 -1
- package/scope/.next/server/app/index.rsc +1 -1
- package/scope/.next/server/app/models.html +1 -1
- package/scope/.next/server/app/models.rsc +1 -1
- package/scope/.next/server/app-paths-manifest.json +3 -3
- package/scope/.next/server/functions-config-manifest.json +2 -2
- package/scope/.next/server/pages/404.html +1 -1
- package/scope/.next/server/pages/500.html +1 -1
- package/scope/.next/server/server-reference-manifest.json +1 -1
- package/dist/commands/init.d.ts +0 -2
- package/dist/commands/init.js +0 -24
- package/dist/commands/lifecycle.d.ts +0 -2
- package/dist/commands/lifecycle.js +0 -124
- package/dist/commands/plane.d.ts +0 -2
- package/dist/commands/plane.js +0 -38
- package/dist/commands/run.d.ts +0 -2
- package/dist/commands/run.js +0 -149
- package/dist/commands/runner.d.ts +0 -2
- package/dist/commands/runner.js +0 -33
- package/dist/commands/secrets.d.ts +0 -2
- package/dist/commands/secrets.js +0 -21
- /package/scope/.next/static/{5tnFLuvnSbNZNtqRgoot8 → x7wPUCpgS31-5ZHJkcKsU}/_buildManifest.js +0 -0
- /package/scope/.next/static/{5tnFLuvnSbNZNtqRgoot8 → x7wPUCpgS31-5ZHJkcKsU}/_ssgManifest.js +0 -0
|
@@ -10,713 +10,38 @@
|
|
|
10
10
|
*
|
|
11
11
|
* No mocks: the panel is real models, candidates are real patches verified by
|
|
12
12
|
* really running the repo's tests, and the judge is a real model.
|
|
13
|
+
*
|
|
14
|
+
* This module is the run orchestrator; the supporting pieces live in `./fusion/`
|
|
15
|
+
* (env + defaults, the observability dashboard, the model stack, and preflight)
|
|
16
|
+
* and are re-exported here so existing import paths keep working.
|
|
13
17
|
*/
|
|
14
|
-
import {
|
|
15
|
-
import { execFileSync } from "node:child_process";
|
|
16
|
-
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { mkdirSync, mkdtempSync } from "node:fs";
|
|
17
19
|
import { tmpdir } from "node:os";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import { claudeEnv, codexConfigToml } from "./local.js";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { FUSION_PANEL_MODEL } from "@fusionkit/tools";
|
|
22
|
+
import { gatewaySetupSnippets, setGatewayChatter } from "./gateway.js";
|
|
23
|
+
import { toolRegistry } from "./tools.js";
|
|
23
24
|
import { createPortlessSession } from "./shared/portless.js";
|
|
24
25
|
import { runPreflight } from "./shared/preflight.js";
|
|
25
26
|
import { createBootView } from "./ui/boot.js";
|
|
26
27
|
import { confirm, select } from "./ui/prompt.js";
|
|
27
28
|
import { canPromptInteractively, isInteractive, uiStream } from "./ui/runtime.js";
|
|
28
29
|
import { bold, brandHeader, dim, glyph, gray, green } from "./ui/theme.js";
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*/
|
|
38
|
-
export const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
* `ANTHROPIC_API_KEY` set. The judge defaults to the first entry.
|
|
42
|
-
*/
|
|
43
|
-
export const DEFAULT_CLOUD_PANEL = [
|
|
44
|
-
{ id: "gpt", model: "gpt-5.5", provider: "openai" },
|
|
45
|
-
{ id: "sonnet", model: "claude-sonnet-4-6", provider: "anthropic" }
|
|
30
|
+
import { DEFAULT_CLOUD_PANEL, DEFAULT_TRIO, gitToplevel, loadEnvFileInto } from "./fusion/env.js";
|
|
31
|
+
import { openUrl, startObservability } from "./fusion/observability.js";
|
|
32
|
+
import { startFusionStack } from "./fusion/stack.js";
|
|
33
|
+
import { preflightRequirements } from "./fusion/preflight.js";
|
|
34
|
+
export * from "./fusion/env.js";
|
|
35
|
+
export * from "./fusion/observability.js";
|
|
36
|
+
export * from "./fusion/stack.js";
|
|
37
|
+
export * from "./fusion/preflight.js";
|
|
38
|
+
/** Launchable fusion tools (registry-derived) plus the `serve` pseudo-tool. */
|
|
39
|
+
export const FUSION_TOOLS = [
|
|
40
|
+
...toolRegistry.launchableFusion().map((tool) => tool.id),
|
|
41
|
+
"serve"
|
|
46
42
|
];
|
|
47
|
-
/** The
|
|
48
|
-
|
|
49
|
-
{ id: "qwen", model: "mlx-community/Qwen3-1.7B-4bit", provider: "mlx" },
|
|
50
|
-
{ id: "gemma", model: "mlx-community/gemma-3-1b-it-4bit", provider: "mlx" },
|
|
51
|
-
{ id: "llama", model: "mlx-community/Llama-3.2-1B-Instruct-4bit", provider: "mlx" }
|
|
52
|
-
];
|
|
53
|
-
/**
|
|
54
|
-
* How to invoke the `fusionkit` Python CLI: from PyPI via `uvx` by default
|
|
55
|
-
* (no checkout), or from a local checkout via `uv run` when `fusionkitDir` is
|
|
56
|
-
* given (a dev override). Returns the command plus the argv prefix that
|
|
57
|
-
* precedes the subcommand (`serve`, `serve-endpoint`, ...).
|
|
58
|
-
*/
|
|
59
|
-
export function fusionkitPyCommand(fusionkitDir) {
|
|
60
|
-
if (fusionkitDir !== undefined) {
|
|
61
|
-
return { command: "uv", prefix: ["run", "fusionkit"], cwd: fusionkitDir };
|
|
62
|
-
}
|
|
63
|
-
return { command: "uvx", prefix: [`fusionkit@${FUSIONKIT_PYPI_VERSION}`] };
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Parse a `.env` file (KEY=VALUE lines, `#` comments, optional `export`,
|
|
67
|
-
* single/double quotes) and fill any keys not already present in `env`.
|
|
68
|
-
* Existing env values win, so an explicitly exported key is never overridden.
|
|
69
|
-
*/
|
|
70
|
-
export function loadEnvFileInto(path, env) {
|
|
71
|
-
if (!existsSync(path))
|
|
72
|
-
return;
|
|
73
|
-
for (const rawLine of readFileSync(path, "utf8").split("\n")) {
|
|
74
|
-
const line = rawLine.trim();
|
|
75
|
-
if (line.length === 0 || line.startsWith("#"))
|
|
76
|
-
continue;
|
|
77
|
-
const withoutExport = line.startsWith("export ") ? line.slice("export ".length) : line;
|
|
78
|
-
const eq = withoutExport.indexOf("=");
|
|
79
|
-
if (eq <= 0)
|
|
80
|
-
continue;
|
|
81
|
-
const key = withoutExport.slice(0, eq).trim();
|
|
82
|
-
let value = withoutExport.slice(eq + 1).trim();
|
|
83
|
-
if (value.length >= 2 &&
|
|
84
|
-
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
85
|
-
value = value.slice(1, -1);
|
|
86
|
-
}
|
|
87
|
-
if (env[key] === undefined)
|
|
88
|
-
env[key] = value;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
/** Default env var holding the API key for each cloud provider. */
|
|
92
|
-
export function defaultKeyEnv(provider) {
|
|
93
|
-
switch (provider) {
|
|
94
|
-
case "openai":
|
|
95
|
-
return "OPENAI_API_KEY";
|
|
96
|
-
case "anthropic":
|
|
97
|
-
return "ANTHROPIC_API_KEY";
|
|
98
|
-
case "google":
|
|
99
|
-
return "GEMINI_API_KEY";
|
|
100
|
-
case "openai-compatible":
|
|
101
|
-
case "mlx":
|
|
102
|
-
return undefined;
|
|
103
|
-
default: {
|
|
104
|
-
const exhaustive = provider;
|
|
105
|
-
throw new Error(`unknown provider ${String(exhaustive)}`);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
/** The git repository root containing `dir`, or undefined if it is not in a repo. */
|
|
110
|
-
export function gitToplevel(dir) {
|
|
111
|
-
try {
|
|
112
|
-
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
113
|
-
cwd: dir,
|
|
114
|
-
encoding: "utf8",
|
|
115
|
-
// Don't leak git's "fatal: not a git repository" to our stderr; we surface
|
|
116
|
-
// a clearer message ourselves.
|
|
117
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
118
|
-
}).trim();
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return undefined;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
/** The PATH binary each coding agent launches as. `serve` launches nothing. */
|
|
125
|
-
function agentBinary(tool) {
|
|
126
|
-
switch (tool) {
|
|
127
|
-
case "codex":
|
|
128
|
-
return "codex";
|
|
129
|
-
case "claude":
|
|
130
|
-
return "claude";
|
|
131
|
-
case "cursor":
|
|
132
|
-
return "cursor-agent";
|
|
133
|
-
case "serve":
|
|
134
|
-
return undefined;
|
|
135
|
-
default: {
|
|
136
|
-
const exhaustive = tool;
|
|
137
|
-
throw new Error(`unknown fusion tool: ${String(exhaustive)}`);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Compute the binaries and API keys the run requires given the tool, panel, and
|
|
143
|
-
* options. Pre-running endpoints (`--model-endpoint`) and a pre-running
|
|
144
|
-
* `--synthesis-url` drop the corresponding requirements.
|
|
145
|
-
*/
|
|
146
|
-
export function preflightRequirements(tool, models, options) {
|
|
147
|
-
const requiredBins = [];
|
|
148
|
-
const requiredEnv = [];
|
|
149
|
-
const endpointsProvided = options.endpoints !== undefined;
|
|
150
|
-
const spawnsServers = !endpointsProvided;
|
|
151
|
-
const spawnsSynthesizer = options.synthesisUrl === undefined;
|
|
152
|
-
// The FusionKit Python CLI is fetched via uvx (or run from a local checkout).
|
|
153
|
-
if (spawnsServers || spawnsSynthesizer) {
|
|
154
|
-
requiredBins.push(options.fusionkitDir !== undefined ? "uv" : "uvx");
|
|
155
|
-
}
|
|
156
|
-
const agent = agentBinary(tool);
|
|
157
|
-
if (agent !== undefined)
|
|
158
|
-
requiredBins.push(agent);
|
|
159
|
-
// Cloud panel members need their provider key when we front them ourselves.
|
|
160
|
-
if (spawnsServers) {
|
|
161
|
-
for (const spec of models) {
|
|
162
|
-
const provider = spec.provider ?? "mlx";
|
|
163
|
-
if (provider === "mlx")
|
|
164
|
-
continue;
|
|
165
|
-
const keyEnv = spec.keyEnv ?? defaultKeyEnv(provider);
|
|
166
|
-
if (keyEnv !== undefined)
|
|
167
|
-
requiredEnv.push(keyEnv);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
return { requiredBins, requiredEnv };
|
|
171
|
-
}
|
|
172
|
-
/** Fixed port for the local observability dashboard (the scope app). */
|
|
173
|
-
export const SCOPE_DASHBOARD_PORT = 4317;
|
|
174
|
-
/**
|
|
175
|
-
* Locate the isolated scope dashboard app (handoffkit/apps/scope) by walking up
|
|
176
|
-
* from this module. Works from both the compiled dist and src layouts. Only the
|
|
177
|
-
* monorepo dev fallback uses this — published installs ship a prebuilt bundle.
|
|
178
|
-
*/
|
|
179
|
-
export function findScopeAppDir() {
|
|
180
|
-
let dir = dirname(fileURLToPath(import.meta.url));
|
|
181
|
-
for (let depth = 0; depth < 8; depth++) {
|
|
182
|
-
const candidate = join(dir, "apps", "scope");
|
|
183
|
-
if (existsSync(join(candidate, "package.json")))
|
|
184
|
-
return candidate;
|
|
185
|
-
const parent = dirname(dir);
|
|
186
|
-
if (parent === dir)
|
|
187
|
-
break;
|
|
188
|
-
dir = parent;
|
|
189
|
-
}
|
|
190
|
-
throw new Error("could not locate apps/scope relative to the handoffkit CLI");
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* Path to the prebuilt, self-contained dashboard server staged into the CLI
|
|
194
|
-
* package (`scope/server.js`, a sibling of `dist/`), or undefined when it is
|
|
195
|
-
* absent — i.e. a monorepo dev checkout where the bundle was never staged. Both
|
|
196
|
-
* the compiled `dist/fusion-quickstart.js` and the `src/` layout resolve to the
|
|
197
|
-
* same `<cli-package>/scope/server.js`.
|
|
198
|
-
*/
|
|
199
|
-
export function bundledScopeServer() {
|
|
200
|
-
const serverJs = join(dirname(fileURLToPath(import.meta.url)), "..", "scope", "server.js");
|
|
201
|
-
return existsSync(serverJs) ? serverJs : undefined;
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* Inject the portless CA so spawned Node children (dashboard, cursor bridge,
|
|
205
|
-
* launched agents) trust the proxy's HTTPS routes. Only `NODE_EXTRA_CA_CERTS` is
|
|
206
|
-
* set: it *extends* Node's trust store. We deliberately do NOT set Python's
|
|
207
|
-
* `SSL_CERT_FILE`/`REQUESTS_CA_BUNDLE`, because those *replace* the bundle — and
|
|
208
|
-
* pointing them at the portless CA alone breaks the router's outbound HTTPS to
|
|
209
|
-
* real providers (api.openai.com, etc.). The router never calls a portless HTTPS
|
|
210
|
-
* URL (providers go direct; MLX is loopback), so it needs no portless CA. If a
|
|
211
|
-
* Python process ever must reach a portless HTTPS URL, build a combined
|
|
212
|
-
* certifi+portless bundle rather than replacing the bundle here. A no-op when
|
|
213
|
-
* portless is off (no CA path).
|
|
214
|
-
*/
|
|
215
|
-
function withCaEnv(env, caCertPath) {
|
|
216
|
-
if (caCertPath === undefined)
|
|
217
|
-
return env;
|
|
218
|
-
return {
|
|
219
|
-
...env,
|
|
220
|
-
NODE_EXTRA_CA_CERTS: env.NODE_EXTRA_CA_CERTS ?? caCertPath
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
/** Best-effort: open a URL in the default browser (no-op on failure). */
|
|
224
|
-
function openUrl(url) {
|
|
225
|
-
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
226
|
-
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
227
|
-
try {
|
|
228
|
-
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
229
|
-
child.on("error", () => { });
|
|
230
|
-
child.unref();
|
|
231
|
-
}
|
|
232
|
-
catch {
|
|
233
|
-
// opening a browser is a convenience, never required
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* Spawn the prebuilt standalone dashboard server (`node scope/server.js`) on the
|
|
238
|
-
* fixed port. The Next standalone entrypoint reads PORT/HOSTNAME from the env
|
|
239
|
-
* (there is no `-p` flag). This is the path every npm-installed user takes.
|
|
240
|
-
*/
|
|
241
|
-
function startBundledDashboard(input) {
|
|
242
|
-
return spawnLogged(process.execPath, [input.serverJs], {
|
|
243
|
-
cwd: dirname(input.serverJs),
|
|
244
|
-
...(input.logFile !== undefined ? { logFile: input.logFile } : {}),
|
|
245
|
-
env: { ...input.env, PORT: String(SCOPE_DASHBOARD_PORT), HOSTNAME: "127.0.0.1" }
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
/**
|
|
249
|
-
* Monorepo dev fallback: build the scope app once (reusing a prior build) and
|
|
250
|
-
* `next start` it on the fixed port. Only reached in a handoffkit checkout where
|
|
251
|
-
* no bundle was staged; published installs always use {@link startBundledDashboard}.
|
|
252
|
-
*/
|
|
253
|
-
function startDevDashboard(input) {
|
|
254
|
-
const scopeDir = findScopeAppDir();
|
|
255
|
-
const nextBin = join(scopeDir, "node_modules", ".bin", "next");
|
|
256
|
-
if (!existsSync(nextBin)) {
|
|
257
|
-
throw new Error("the observability dashboard is not available in this checkout.\n" +
|
|
258
|
-
` Install its dependencies once: cd ${scopeDir} && pnpm install`);
|
|
259
|
-
}
|
|
260
|
-
// Rebuilding every run is slow; reuse a prior build when present. The build
|
|
261
|
-
// output is captured (never inherited) so it can't corrupt a live checklist.
|
|
262
|
-
const alreadyBuilt = existsSync(join(scopeDir, ".next", "BUILD_ID"));
|
|
263
|
-
if (!alreadyBuilt) {
|
|
264
|
-
try {
|
|
265
|
-
const buildOut = execFileSync(nextBin, ["build"], {
|
|
266
|
-
cwd: scopeDir,
|
|
267
|
-
env: input.env,
|
|
268
|
-
encoding: "utf8",
|
|
269
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
270
|
-
});
|
|
271
|
-
if (input.logFile !== undefined)
|
|
272
|
-
appendFileSync(input.logFile, buildOut);
|
|
273
|
-
}
|
|
274
|
-
catch (error) {
|
|
275
|
-
if (input.logFile !== undefined) {
|
|
276
|
-
appendFileSync(input.logFile, String(error.stdout ?? "") + String(error.stderr ?? ""));
|
|
277
|
-
}
|
|
278
|
-
throw new Error("the observability dashboard failed to build. See the log for details" +
|
|
279
|
-
(input.logFile ? `: ${input.logFile}` : ""));
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
return spawnLogged(nextBin, ["start", "-p", String(SCOPE_DASHBOARD_PORT)], {
|
|
283
|
-
cwd: scopeDir,
|
|
284
|
-
...(input.logFile !== undefined ? { logFile: input.logFile } : {}),
|
|
285
|
-
env: input.env
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
/**
|
|
289
|
-
* Start the scope dashboard on the fixed port, backed by a fresh per-run SQLite
|
|
290
|
-
* file and trace dir, and return the URLs the caller injects (as
|
|
291
|
-
* FUSION_TRACE_URL / FUSION_TRACE_DIR) into every spawned process. Prefers the
|
|
292
|
-
* prebuilt bundle shipped inside the npm package; falls back to building the
|
|
293
|
-
* app from source in a monorepo dev checkout.
|
|
294
|
-
*/
|
|
295
|
-
/** Identity token of a reusable scope dashboard (any healthy instance qualifies). */
|
|
296
|
-
const SCOPE_IDENTITY = "scope-dashboard";
|
|
297
|
-
export async function startObservability(input) {
|
|
298
|
-
const traceDir = mkdtempSync(join(tmpdir(), "fusion-trace-"));
|
|
299
|
-
const dbPath = join(traceDir, "scope.db");
|
|
300
|
-
// The dashboard server loads node:sqlite; keep its experimental warnings out
|
|
301
|
-
// of the log just like the parent CLI. The per-run db/trace dir isolate state.
|
|
302
|
-
const childEnv = withCaEnv({
|
|
303
|
-
...process.env,
|
|
304
|
-
NODE_OPTIONS: [process.env.NODE_OPTIONS, "--disable-warning=ExperimentalWarning"].filter(Boolean).join(" "),
|
|
305
|
-
SCOPEKIT_DB: dbPath,
|
|
306
|
-
FUSION_TRACE_DIR: traceDir
|
|
307
|
-
}, input.portless.caCertPath);
|
|
308
|
-
const spawnDashboard = async () => {
|
|
309
|
-
const bundled = bundledScopeServer();
|
|
310
|
-
if (input.report)
|
|
311
|
-
input.report({ kind: "dashboard.start" });
|
|
312
|
-
else if (bundled !== undefined)
|
|
313
|
-
input.log("fusion: starting observability dashboard...");
|
|
314
|
-
else
|
|
315
|
-
input.log("fusion: building observability dashboard (one-time)...");
|
|
316
|
-
let proc;
|
|
317
|
-
try {
|
|
318
|
-
proc =
|
|
319
|
-
bundled !== undefined
|
|
320
|
-
? startBundledDashboard({
|
|
321
|
-
serverJs: bundled,
|
|
322
|
-
env: childEnv,
|
|
323
|
-
...(input.logFile !== undefined ? { logFile: input.logFile } : {})
|
|
324
|
-
})
|
|
325
|
-
: startDevDashboard({
|
|
326
|
-
env: childEnv,
|
|
327
|
-
traceDir,
|
|
328
|
-
...(input.logFile !== undefined ? { logFile: input.logFile } : {})
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
catch (error) {
|
|
332
|
-
throw error instanceof Error ? error : new Error(String(error));
|
|
333
|
-
}
|
|
334
|
-
try {
|
|
335
|
-
await waitForHttp(`http://127.0.0.1:${SCOPE_DASHBOARD_PORT}`, proc, {
|
|
336
|
-
timeoutMs: 60_000,
|
|
337
|
-
label: "dashboard"
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
catch (error) {
|
|
341
|
-
terminate(proc.child);
|
|
342
|
-
throw error instanceof Error ? error : new Error(String(error));
|
|
343
|
-
}
|
|
344
|
-
return {
|
|
345
|
-
port: SCOPE_DASHBOARD_PORT,
|
|
346
|
-
...(proc.child.pid !== undefined ? { pid: proc.child.pid } : {}),
|
|
347
|
-
close: () => terminate(proc.child)
|
|
348
|
-
};
|
|
349
|
-
};
|
|
350
|
-
let resolved;
|
|
351
|
-
try {
|
|
352
|
-
resolved = await input.portless.discoverOrSpawn({
|
|
353
|
-
name: "scope",
|
|
354
|
-
identity: SCOPE_IDENTITY,
|
|
355
|
-
healthCheck: async (loopbackUrl) => {
|
|
356
|
-
try {
|
|
357
|
-
const response = await fetch(loopbackUrl, { signal: AbortSignal.timeout(2000) });
|
|
358
|
-
return response.ok ? SCOPE_IDENTITY : undefined;
|
|
359
|
-
}
|
|
360
|
-
catch {
|
|
361
|
-
return undefined;
|
|
362
|
-
}
|
|
363
|
-
},
|
|
364
|
-
spawn: spawnDashboard
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
catch (error) {
|
|
368
|
-
rmSync(traceDir, { recursive: true, force: true });
|
|
369
|
-
throw error instanceof Error ? error : new Error(String(error));
|
|
370
|
-
}
|
|
371
|
-
if (input.report)
|
|
372
|
-
input.report({ kind: "dashboard.ready", detail: resolved.url });
|
|
373
|
-
else
|
|
374
|
-
input.log(`fusion: observability dashboard ready on ${resolved.url}`);
|
|
375
|
-
return {
|
|
376
|
-
url: resolved.url,
|
|
377
|
-
// Trace events post over loopback (the in-process emitters do not carry the
|
|
378
|
-
// portless CA), so ingest uses the raw port; the named URL is for humans.
|
|
379
|
-
ingestUrl: `${resolved.loopbackUrl}/api/ingest`,
|
|
380
|
-
traceDir,
|
|
381
|
-
close: async () => {
|
|
382
|
-
await resolved.close();
|
|
383
|
-
rmSync(traceDir, { recursive: true, force: true });
|
|
384
|
-
}
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
/**
|
|
388
|
-
* Spawn FusionKit's single-endpoint OpenAI-compatible server for one cloud
|
|
389
|
-
* model, so the per-candidate coding harness can call it like any other
|
|
390
|
-
* OpenAI-compatible backend. Runs `fusionkit serve-endpoint` via `uvx` (or
|
|
391
|
-
* `uv run` against a local checkout); Anthropic/OpenAI/Google calls go through
|
|
392
|
-
* FusionKit's provider clients.
|
|
393
|
-
*/
|
|
394
|
-
/**
|
|
395
|
-
* Heuristic: does the captured output indicate a permanent failure (bad key,
|
|
396
|
-
* inaccessible model) that a retry cannot fix? Used to fail fast with a clear
|
|
397
|
-
* message instead of burning the retry budget on a hopeless start.
|
|
398
|
-
*/
|
|
399
|
-
function looksPermanentFailure(log) {
|
|
400
|
-
return /401|403|invalid[ _-]?api[ _-]?key|unauthorized|forbidden|authentication|permission|model[^\n]*(not found|does not exist)|no such model|model_not_found/i.test(log);
|
|
401
|
-
}
|
|
402
|
-
/**
|
|
403
|
-
* Default provider base URL when a cloud spec carries no explicit `baseUrl`.
|
|
404
|
-
* fusionkit's `ModelEndpoint` requires `base_url`, so the router config must
|
|
405
|
-
* always set it (the `serve-endpoint` shim filled this in for us before). Mirrors
|
|
406
|
-
* `PROVIDER_DEFAULT_BASE_URL` in fusionkit's openai_endpoint.py.
|
|
407
|
-
*/
|
|
408
|
-
function providerDefaultBaseUrl(provider) {
|
|
409
|
-
switch (provider) {
|
|
410
|
-
case "openai":
|
|
411
|
-
return "https://api.openai.com";
|
|
412
|
-
case "anthropic":
|
|
413
|
-
return "https://api.anthropic.com";
|
|
414
|
-
case "google":
|
|
415
|
-
return "https://generativelanguage.googleapis.com";
|
|
416
|
-
case "openai-compatible":
|
|
417
|
-
return "http://127.0.0.1";
|
|
418
|
-
default: {
|
|
419
|
-
const exhaustive = provider;
|
|
420
|
-
throw new Error(`unknown provider ${String(exhaustive)}`);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
/** Pick the panel spec that backs the judge (by model name), else the first. */
|
|
425
|
-
function judgeSpecFor(specs, judgeModel) {
|
|
426
|
-
const first = specs[0];
|
|
427
|
-
if (first === undefined)
|
|
428
|
-
throw new Error("at least one panel model is required");
|
|
429
|
-
if (judgeModel === undefined)
|
|
430
|
-
return first;
|
|
431
|
-
return specs.find((spec) => spec.model === judgeModel) ?? first;
|
|
432
|
-
}
|
|
433
|
-
/**
|
|
434
|
-
* Build the `fusionkit serve` config (YAML) for the consolidated router: one
|
|
435
|
-
* endpoint per panel model. Cloud models call their provider directly (keyed by
|
|
436
|
-
* `api_key_env`); MLX models are fronted as `openai-compatible` endpoints
|
|
437
|
-
* pointing at their in-process gateway loopback URL. The judge endpoint doubles
|
|
438
|
-
* as the synthesizer. Values are JSON-quoted (valid YAML flow scalars).
|
|
439
|
-
*/
|
|
440
|
-
function routerConfigYaml(input) {
|
|
441
|
-
const lines = ["endpoints:"];
|
|
442
|
-
for (const spec of input.specs) {
|
|
443
|
-
const provider = spec.provider ?? "mlx";
|
|
444
|
-
lines.push(` - id: ${JSON.stringify(spec.id)}`);
|
|
445
|
-
lines.push(` model: ${JSON.stringify(spec.model)}`);
|
|
446
|
-
if (provider === "mlx") {
|
|
447
|
-
lines.push(" provider: openai-compatible");
|
|
448
|
-
lines.push(` base_url: ${JSON.stringify(input.mlxUrls[spec.id] ?? "")}`);
|
|
449
|
-
lines.push(" api_key: not-needed");
|
|
450
|
-
}
|
|
451
|
-
else {
|
|
452
|
-
// `base_url` is required by fusionkit's ModelEndpoint, so always emit one
|
|
453
|
-
// (the spec's, or the provider default).
|
|
454
|
-
const baseUrl = spec.baseUrl ?? providerDefaultBaseUrl(provider);
|
|
455
|
-
lines.push(` provider: ${provider}`);
|
|
456
|
-
lines.push(` base_url: ${JSON.stringify(baseUrl)}`);
|
|
457
|
-
const keyEnv = spec.keyEnv ?? defaultKeyEnv(provider);
|
|
458
|
-
if (keyEnv !== undefined)
|
|
459
|
-
lines.push(` api_key_env: ${JSON.stringify(keyEnv)}`);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
lines.push(`default_model: ${JSON.stringify(input.judgeId)}`);
|
|
463
|
-
lines.push(`judge_model: ${JSON.stringify(input.judgeId)}`);
|
|
464
|
-
lines.push(`synthesizer_model: ${JSON.stringify(input.judgeId)}`);
|
|
465
|
-
// Generous budget: reasoning models (gpt-5.x) spend tokens on reasoning before
|
|
466
|
-
// producing content, so a small cap can yield an empty answer.
|
|
467
|
-
lines.push("sampling: {temperature: 0.2, top_p: 0.9, max_tokens: 8192}");
|
|
468
|
-
lines.push("");
|
|
469
|
-
return lines.join("\n");
|
|
470
|
-
}
|
|
471
|
-
/**
|
|
472
|
-
* Spawn the single `fusionkit serve` router fronting every panel model + the
|
|
473
|
-
* synthesizer. MLX specs first get an in-process OpenAI-compatible gateway
|
|
474
|
-
* (loopback) that the router proxies to; cloud specs call their provider
|
|
475
|
-
* directly. Returns the router URL, an id->routerUrl endpoint map, and a close
|
|
476
|
-
* that tears down the router process and any MLX gateways it fronts.
|
|
477
|
-
*/
|
|
478
|
-
export async function startRouter(options) {
|
|
479
|
-
const { specs, report } = options;
|
|
480
|
-
const judgeSpec = judgeSpecFor(specs, options.judgeModel);
|
|
481
|
-
const models = specs.map((spec) => ({ id: spec.id, model: spec.model }));
|
|
482
|
-
const identity = specs.map((spec) => spec.id).sort().join(",");
|
|
483
|
-
const announceStart = (label) => {
|
|
484
|
-
if (report)
|
|
485
|
-
report({ kind: "server.start", id: "router", label });
|
|
486
|
-
else
|
|
487
|
-
options.log(`fusion: starting ${label}...`);
|
|
488
|
-
};
|
|
489
|
-
const announceReady = (detail) => {
|
|
490
|
-
if (report)
|
|
491
|
-
report({ kind: "server.ready", id: "router", detail });
|
|
492
|
-
else
|
|
493
|
-
options.log(`fusion: router ready on ${detail}`);
|
|
494
|
-
};
|
|
495
|
-
announceStart(`router · ${specs.map((spec) => spec.id).join(", ")}`);
|
|
496
|
-
// The router inherits the parent env plus the FusionKit checkout's `.env` (so
|
|
497
|
-
// provider keys load seamlessly), without overriding anything already exported.
|
|
498
|
-
// It calls providers directly and MLX over loopback (never a portless HTTPS
|
|
499
|
-
// URL), so it needs no portless CA — and must keep its default certifi bundle
|
|
500
|
-
// intact to verify real provider certificates.
|
|
501
|
-
const env = { ...process.env };
|
|
502
|
-
if (options.fusionkitDir !== undefined) {
|
|
503
|
-
loadEnvFileInto(join(options.fusionkitDir, ".env"), env);
|
|
504
|
-
}
|
|
505
|
-
const backends = [];
|
|
506
|
-
const gateways = [];
|
|
507
|
-
const mlxUrls = {};
|
|
508
|
-
const closeBackends = async () => {
|
|
509
|
-
await Promise.allSettled(gateways.map((gateway) => gateway.close()));
|
|
510
|
-
await Promise.allSettled(backends.map((backend) => backend.stop()));
|
|
511
|
-
};
|
|
512
|
-
try {
|
|
513
|
-
// MLX backends are memory-heavy (each loads a model into RAM), so they start
|
|
514
|
-
// sequentially before the router that fronts them.
|
|
515
|
-
for (const spec of specs) {
|
|
516
|
-
if ((spec.provider ?? "mlx") !== "mlx")
|
|
517
|
-
continue;
|
|
518
|
-
const backend = new MlxBackend({ model: spec.model });
|
|
519
|
-
await backend.start();
|
|
520
|
-
const gateway = await startGateway({ backend });
|
|
521
|
-
backends.push(backend);
|
|
522
|
-
gateways.push(gateway);
|
|
523
|
-
mlxUrls[spec.id] = gateway.url();
|
|
524
|
-
}
|
|
525
|
-
const config = routerConfigYaml({ specs, mlxUrls, judgeId: judgeSpec.id });
|
|
526
|
-
const configDir = mkdtempSync(join(tmpdir(), "fusion-router-"));
|
|
527
|
-
const configPath = join(configDir, "router.yaml");
|
|
528
|
-
writeFileSync(configPath, config);
|
|
529
|
-
const runner = fusionkitPyCommand(options.fusionkitDir);
|
|
530
|
-
const port = await freePort();
|
|
531
|
-
const proc = spawnLogged(runner.command, [...runner.prefix, "serve", "--config", configPath, "--host", "127.0.0.1", "--port", String(port)], {
|
|
532
|
-
...(runner.cwd !== undefined ? { cwd: runner.cwd } : {}),
|
|
533
|
-
...(options.logsDir !== undefined ? { logFile: join(options.logsDir, "router.log") } : {}),
|
|
534
|
-
env
|
|
535
|
-
});
|
|
536
|
-
proc.child.once("exit", () => rmSync(configDir, { recursive: true, force: true }));
|
|
537
|
-
const url = `http://127.0.0.1:${port}`;
|
|
538
|
-
try {
|
|
539
|
-
await waitForHttp(`${url}/v1/models`, proc, {
|
|
540
|
-
timeoutMs: 60_000,
|
|
541
|
-
label: "fusion router",
|
|
542
|
-
requireOk: true
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
catch (error) {
|
|
546
|
-
terminate(proc.child);
|
|
547
|
-
// A provider-side rejection (bad key / model) will not be fixed by a
|
|
548
|
-
// retry, so surface the distilled cause with guidance.
|
|
549
|
-
const hint = looksPermanentFailure(proc.log()) ? " (check model names and provider API keys)" : "";
|
|
550
|
-
throw new Error(`${error instanceof Error ? error.message : String(error)}${hint}`);
|
|
551
|
-
}
|
|
552
|
-
announceReady(url);
|
|
553
|
-
const endpoints = Object.fromEntries(specs.map((spec) => [spec.id, url]));
|
|
554
|
-
return {
|
|
555
|
-
url,
|
|
556
|
-
port,
|
|
557
|
-
...(proc.child.pid !== undefined ? { pid: proc.child.pid } : {}),
|
|
558
|
-
endpoints,
|
|
559
|
-
models,
|
|
560
|
-
judgeModel: judgeSpec.id,
|
|
561
|
-
identity,
|
|
562
|
-
close: async () => {
|
|
563
|
-
terminate(proc.child);
|
|
564
|
-
await closeBackends();
|
|
565
|
-
}
|
|
566
|
-
};
|
|
567
|
-
}
|
|
568
|
-
catch (error) {
|
|
569
|
-
await closeBackends();
|
|
570
|
-
throw error;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
export async function startFusionStack(options) {
|
|
574
|
-
const report = options.report;
|
|
575
|
-
const portless = options.portless ?? (await createPortlessSession({ enabled: false }));
|
|
576
|
-
// Full override (pre-running per-model endpoints + a pre-running synthesis
|
|
577
|
-
// URL, e.g. tests): use them verbatim and spawn no router.
|
|
578
|
-
const override = options.endpoints !== undefined && options.synthesisUrl !== undefined;
|
|
579
|
-
const judgeModelName = options.judgeModel ?? options.models[0]?.model ?? "";
|
|
580
|
-
const models = options.models.map((spec) => ({ id: spec.id, model: spec.model }));
|
|
581
|
-
let modelEndpoints;
|
|
582
|
-
let fusionBackendUrl;
|
|
583
|
-
let routerClose = () => { };
|
|
584
|
-
if (override) {
|
|
585
|
-
modelEndpoints = options.endpoints;
|
|
586
|
-
fusionBackendUrl = options.synthesisUrl;
|
|
587
|
-
}
|
|
588
|
-
else {
|
|
589
|
-
// Discover-or-spawn the single router (models + synthesis), reusing a
|
|
590
|
-
// compatible running instance (same endpoint id set) across runs.
|
|
591
|
-
const expectedIdentity = options.models.map((spec) => spec.id).sort().join(",");
|
|
592
|
-
const resolved = await portless.discoverOrSpawn({
|
|
593
|
-
name: "router",
|
|
594
|
-
identity: expectedIdentity,
|
|
595
|
-
healthCheck: async (loopbackUrl) => {
|
|
596
|
-
try {
|
|
597
|
-
const response = await fetch(`${loopbackUrl}/v1/models`, { signal: AbortSignal.timeout(2000) });
|
|
598
|
-
if (!response.ok)
|
|
599
|
-
return undefined;
|
|
600
|
-
const body = (await response.json());
|
|
601
|
-
return (body.data ?? [])
|
|
602
|
-
.map((entry) => entry.id)
|
|
603
|
-
.filter((id) => typeof id === "string" && id !== "fusionkit/router")
|
|
604
|
-
.sort()
|
|
605
|
-
.join(",");
|
|
606
|
-
}
|
|
607
|
-
catch {
|
|
608
|
-
return undefined;
|
|
609
|
-
}
|
|
610
|
-
},
|
|
611
|
-
spawn: async () => {
|
|
612
|
-
if (report)
|
|
613
|
-
report({ kind: "server.start", id: "router", label: "router" });
|
|
614
|
-
const router = await startRouter({
|
|
615
|
-
specs: options.models,
|
|
616
|
-
...(options.judgeModel !== undefined ? { judgeModel: options.judgeModel } : {}),
|
|
617
|
-
...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
|
|
618
|
-
...(options.logsDir !== undefined ? { logsDir: options.logsDir } : {}),
|
|
619
|
-
...(report !== undefined ? { report } : {}),
|
|
620
|
-
log: options.log
|
|
621
|
-
});
|
|
622
|
-
return {
|
|
623
|
-
port: router.port,
|
|
624
|
-
...(router.pid !== undefined ? { pid: router.pid } : {}),
|
|
625
|
-
close: router.close
|
|
626
|
-
};
|
|
627
|
-
}
|
|
628
|
-
});
|
|
629
|
-
// The harness + the in-process step call reach the router over loopback (the
|
|
630
|
-
// portless name is for humans); see the CA-at-startup note in portless.ts.
|
|
631
|
-
modelEndpoints = Object.fromEntries(options.models.map((spec) => [spec.id, resolved.loopbackUrl]));
|
|
632
|
-
fusionBackendUrl = resolved.loopbackUrl;
|
|
633
|
-
routerClose = resolved.close;
|
|
634
|
-
}
|
|
635
|
-
try {
|
|
636
|
-
if (report)
|
|
637
|
-
report({ kind: "gateway.start" });
|
|
638
|
-
// The judge-streamed-trajectory front door: each panel model produces a
|
|
639
|
-
// trajectory and the judge emits the trajectory the user's tool executes.
|
|
640
|
-
const gatewayConfig = {
|
|
641
|
-
fusionBackendUrl,
|
|
642
|
-
repo: options.repo,
|
|
643
|
-
outputRoot: options.outputRoot,
|
|
644
|
-
harnesses: ["agent"],
|
|
645
|
-
models,
|
|
646
|
-
judgeModel: judgeModelName,
|
|
647
|
-
modelEndpoints,
|
|
648
|
-
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
|
|
649
|
-
};
|
|
650
|
-
const gateway = await startFusionStepGateway({
|
|
651
|
-
config: gatewayConfig,
|
|
652
|
-
host: options.host ?? "127.0.0.1",
|
|
653
|
-
port: options.port ?? 0,
|
|
654
|
-
...(options.authToken !== undefined ? { authToken: options.authToken } : {})
|
|
655
|
-
});
|
|
656
|
-
// The gateway runs in-process (dies with this CLI), so it gets a per-run
|
|
657
|
-
// portless name rather than being a cross-run singleton.
|
|
658
|
-
const fusionUrl = portless.register("gateway", gateway.port());
|
|
659
|
-
if (report)
|
|
660
|
-
report({ kind: "gateway.ready", detail: fusionUrl });
|
|
661
|
-
return {
|
|
662
|
-
fusionUrl,
|
|
663
|
-
endpoints: modelEndpoints,
|
|
664
|
-
close: async () => {
|
|
665
|
-
await gateway.close();
|
|
666
|
-
portless.unregister("gateway");
|
|
667
|
-
await routerClose();
|
|
668
|
-
}
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
catch (error) {
|
|
672
|
-
await routerClose();
|
|
673
|
-
throw error;
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
function scrubbedBridgeEnv() {
|
|
677
|
-
const env = {};
|
|
678
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
679
|
-
if (value === undefined)
|
|
680
|
-
continue;
|
|
681
|
-
if (key.startsWith("BRIDGE_") || key.startsWith("MODEL_") || key.startsWith("E2E_") || key.startsWith("CURSOR_UPSTREAM")) {
|
|
682
|
-
continue;
|
|
683
|
-
}
|
|
684
|
-
env[key] = value;
|
|
685
|
-
}
|
|
686
|
-
return env;
|
|
687
|
-
}
|
|
688
|
-
/**
|
|
689
|
-
* Start the Cursorkit bridge with its local-model backend pointed at the fusion
|
|
690
|
-
* gateway, and resolve once it is listening. Returns the child and its port.
|
|
691
|
-
*/
|
|
692
|
-
export async function startCursorBridge(input) {
|
|
693
|
-
const port = await freePort();
|
|
694
|
-
const env = {
|
|
695
|
-
...withCaEnv(scrubbedBridgeEnv(), input.caCertPath),
|
|
696
|
-
BRIDGE_PORT: String(port),
|
|
697
|
-
BRIDGE_ROUTE_INVENTORY: "true",
|
|
698
|
-
CURSOR_UPSTREAM_BASE_URL: "https://api2.cursor.sh",
|
|
699
|
-
MODEL_BASE_URL: `${input.fusionUrl}/v1`,
|
|
700
|
-
MODEL_API_KEY: "local",
|
|
701
|
-
MODEL_NAME: FUSION_MODEL_LABEL,
|
|
702
|
-
MODEL_PROVIDER_MODEL: FUSION_MODEL_LABEL,
|
|
703
|
-
MODEL_CONTEXT_TOKEN_LIMIT: "128000"
|
|
704
|
-
};
|
|
705
|
-
const proc = spawnLogged(process.execPath, ["dist/src/cli.js", "serve"], {
|
|
706
|
-
cwd: input.cursorKitDir,
|
|
707
|
-
...(input.logFile !== undefined ? { logFile: input.logFile } : {}),
|
|
708
|
-
env
|
|
709
|
-
});
|
|
710
|
-
try {
|
|
711
|
-
await waitForOutput(proc, /bridge listening/, { timeoutMs: 20_000, label: "Cursorkit bridge" });
|
|
712
|
-
}
|
|
713
|
-
catch (error) {
|
|
714
|
-
terminate(proc.child);
|
|
715
|
-
throw error instanceof Error ? error : new Error(String(error));
|
|
716
|
-
}
|
|
717
|
-
input.log(`fusion: Cursorkit bridge listening on http://127.0.0.1:${port}`);
|
|
718
|
-
return { child: proc.child, port };
|
|
719
|
-
}
|
|
43
|
+
/** The model label the launched tool uses; the gateway ignores it for routing. */
|
|
44
|
+
const FUSION_MODEL_LABEL = FUSION_PANEL_MODEL;
|
|
720
45
|
/** Whether portless is enabled: explicit flag/config wins, else on unless PORTLESS=0. */
|
|
721
46
|
export function portlessEnabled(options) {
|
|
722
47
|
if (options.portless !== undefined)
|
|
@@ -836,7 +161,6 @@ export async function runFusion(tool, toolArgs, options = {}) {
|
|
|
836
161
|
// spawned child (panel servers, synthesis serve, cursor bridge) inherit it.
|
|
837
162
|
// Without the flag, FUSION_TRACE_* stays unset and all emitters are no-ops.
|
|
838
163
|
let observability;
|
|
839
|
-
let bridge;
|
|
840
164
|
let stack;
|
|
841
165
|
try {
|
|
842
166
|
if (options.observe === true) {
|
|
@@ -916,64 +240,36 @@ export async function runFusion(tool, toolArgs, options = {}) {
|
|
|
916
240
|
stream.write("\u001b[?25h");
|
|
917
241
|
};
|
|
918
242
|
try {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
return 0;
|
|
929
|
-
}
|
|
930
|
-
case "codex": {
|
|
931
|
-
const home = mkdtempSync(join(tmpdir(), "fusionkit-fusion-codex-"));
|
|
932
|
-
writeFileSync(join(home, "config.toml"), codexConfigToml(stack.fusionUrl, FUSION_MODEL_LABEL));
|
|
933
|
-
prepareForPassthrough();
|
|
934
|
-
log("fusion: launching codex (each prompt is a coding task fused across the panel)...");
|
|
935
|
-
return await spawnTool("codex", toolArgs, { CODEX_HOME: home }, repo);
|
|
936
|
-
}
|
|
937
|
-
case "claude": {
|
|
938
|
-
prepareForPassthrough();
|
|
939
|
-
log("fusion: launching claude...");
|
|
940
|
-
return await spawnTool("claude", toolArgs, claudeEnv(stack.fusionUrl, options.authToken), repo);
|
|
941
|
-
}
|
|
942
|
-
case "cursor": {
|
|
943
|
-
const cursorKitDir = options.cursorKitDir ?? process.env.FUSIONKIT_CURSORKIT_DIR ?? process.env.WARRANT_CURSORKIT_DIR;
|
|
944
|
-
if (cursorKitDir === undefined || cursorKitDir.length === 0) {
|
|
945
|
-
log("");
|
|
946
|
-
log("Cursor needs a built Cursorkit checkout. Re-run with --cursor-kit-dir <dir>");
|
|
947
|
-
log("(or set FUSIONKIT_CURSORKIT_DIR), then this command spawns the bridge and");
|
|
948
|
-
log("launches cursor-agent pre-wired to the gateway. Manual setup:");
|
|
949
|
-
log(` MODEL_BASE_URL=${stack.fusionUrl}/v1 MODEL_NAME=${FUSION_MODEL_LABEL} \\`);
|
|
950
|
-
log(" MODEL_PROVIDER_MODEL=fusion-panel node dist/src/cli.js serve # in cursorkit");
|
|
951
|
-
log(` cursor-agent --endpoint http://127.0.0.1:<bridge-port> --model ${FUSION_MODEL_LABEL}`);
|
|
952
|
-
return 1;
|
|
953
|
-
}
|
|
954
|
-
const started = await startCursorBridge({
|
|
955
|
-
cursorKitDir,
|
|
956
|
-
fusionUrl: stack.fusionUrl,
|
|
957
|
-
logFile: join(logsDir, "cursor-bridge.log"),
|
|
958
|
-
...(portless.caCertPath !== undefined ? { caCertPath: portless.caCertPath } : {}),
|
|
959
|
-
log
|
|
960
|
-
});
|
|
961
|
-
bridge = started.child;
|
|
962
|
-
const bridgeUrl = portless.register("cursor", started.port);
|
|
963
|
-
disposers.push(() => {
|
|
964
|
-
portless.unregister("cursor");
|
|
965
|
-
if (bridge !== undefined)
|
|
966
|
-
terminate(bridge);
|
|
967
|
-
});
|
|
968
|
-
prepareForPassthrough();
|
|
969
|
-
log("fusion: launching cursor-agent...");
|
|
970
|
-
return await spawnTool("cursor-agent", ["--endpoint", bridgeUrl, "--model", FUSION_MODEL_LABEL, ...toolArgs], {}, repo);
|
|
971
|
-
}
|
|
972
|
-
default: {
|
|
973
|
-
const unreachable = tool;
|
|
974
|
-
throw new Error(`unknown fusion tool: ${String(unreachable)}`);
|
|
975
|
-
}
|
|
243
|
+
if (tool === "serve") {
|
|
244
|
+
log("");
|
|
245
|
+
log(gatewaySetupSnippets(stack.fusionUrl, "http://127.0.0.1:<cursorkit-port>"));
|
|
246
|
+
log("");
|
|
247
|
+
log("Gateway is running. Point any tool at it, or Ctrl+C to stop.");
|
|
248
|
+
await new Promise(() => {
|
|
249
|
+
/* run until interrupted */
|
|
250
|
+
});
|
|
251
|
+
return 0;
|
|
976
252
|
}
|
|
253
|
+
const integration = toolRegistry.get(tool);
|
|
254
|
+
if (integration === undefined || !integration.modes.includes("fusion")) {
|
|
255
|
+
throw new Error(`unknown fusion tool: ${String(tool)}`);
|
|
256
|
+
}
|
|
257
|
+
const ctx = {
|
|
258
|
+
mode: "fusion",
|
|
259
|
+
gatewayUrl: stack.fusionUrl,
|
|
260
|
+
modelLabel: FUSION_MODEL_LABEL,
|
|
261
|
+
toolArgs,
|
|
262
|
+
repo,
|
|
263
|
+
...(options.authToken !== undefined ? { authToken: options.authToken } : {}),
|
|
264
|
+
...(portless.caCertPath !== undefined ? { caCertPath: portless.caCertPath } : {}),
|
|
265
|
+
logsDir,
|
|
266
|
+
log,
|
|
267
|
+
prepareForPassthrough,
|
|
268
|
+
registerPort: (name, port) => portless.register(name, port),
|
|
269
|
+
unregisterPort: (name) => portless.unregister(name),
|
|
270
|
+
registerDisposer: (dispose) => disposers.push(dispose)
|
|
271
|
+
};
|
|
272
|
+
return await integration.launch(ctx);
|
|
977
273
|
}
|
|
978
274
|
finally {
|
|
979
275
|
await cleanup();
|
|
@@ -984,9 +280,11 @@ export async function pickTool() {
|
|
|
984
280
|
return select({
|
|
985
281
|
message: "Which coding agent should model fusion back?",
|
|
986
282
|
options: [
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
283
|
+
...toolRegistry.launchableFusion().map((tool) => ({
|
|
284
|
+
value: tool.id,
|
|
285
|
+
label: tool.id,
|
|
286
|
+
hint: tool.pickerHint
|
|
287
|
+
})),
|
|
990
288
|
{ value: "serve", label: "serve", hint: "just run the gateway and print setup" }
|
|
991
289
|
],
|
|
992
290
|
defaultIndex: 0
|