@fusionkit/cli 0.1.0 → 0.1.1
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 +77 -0
- package/dist/cli.d.ts +0 -6
- package/dist/cli.js +16 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +136 -0
- package/dist/commands/fusion.js +70 -12
- package/dist/fusion-config.d.ts +28 -0
- package/dist/fusion-config.js +133 -0
- package/dist/fusion-init.d.ts +4 -0
- package/dist/fusion-init.js +119 -0
- package/dist/fusion-quickstart.d.ts +48 -0
- package/dist/fusion-quickstart.js +340 -131
- package/dist/gateway.d.ts +2 -0
- package/dist/gateway.js +16 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/dist/quiet-warnings.d.ts +1 -0
- package/dist/quiet-warnings.js +24 -0
- package/dist/shared/preflight.d.ts +1 -0
- package/dist/shared/preflight.js +1 -1
- package/dist/shared/proc.d.ts +36 -5
- package/dist/shared/proc.js +133 -25
- package/dist/test/fusion-config.test.d.ts +1 -0
- package/dist/test/fusion-config.test.js +80 -0
- package/dist/test/proc.test.js +23 -1
- package/dist/test/ui.test.d.ts +1 -0
- package/dist/test/ui.test.js +24 -0
- package/dist/ui/boot.d.ts +23 -0
- package/dist/ui/boot.js +56 -0
- package/dist/ui/index.d.ts +8 -0
- package/dist/ui/index.js +6 -0
- package/dist/ui/prompt.d.ts +30 -0
- package/dist/ui/prompt.js +178 -0
- package/dist/ui/runtime.d.ts +14 -0
- package/dist/ui/runtime.js +33 -0
- package/dist/ui/spinner.d.ts +31 -0
- package/dist/ui/spinner.js +102 -0
- package/dist/ui/steps.d.ts +38 -0
- package/dist/ui/steps.js +149 -0
- package/dist/ui/theme.d.ts +35 -0
- package/dist/ui/theme.js +52 -0
- package/package.json +9 -9
|
@@ -13,16 +13,19 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
15
|
import { execFileSync } from "node:child_process";
|
|
16
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
|
-
import { createInterface } from "node:readline";
|
|
20
19
|
import { fileURLToPath } from "node:url";
|
|
21
20
|
import { MlxBackend, startGateway } from "@fusionkit/model-gateway";
|
|
22
|
-
import { gatewaySetupSnippets, startFusionStepGateway } from "./gateway.js";
|
|
21
|
+
import { gatewaySetupSnippets, setGatewayChatter, startFusionStepGateway } from "./gateway.js";
|
|
23
22
|
import { claudeEnv, codexConfigToml } from "./local.js";
|
|
24
23
|
import { runPreflight } from "./shared/preflight.js";
|
|
25
|
-
import {
|
|
24
|
+
import { createBootView } from "./ui/boot.js";
|
|
25
|
+
import { confirm, select } from "./ui/prompt.js";
|
|
26
|
+
import { canPromptInteractively, isInteractive, uiStream } from "./ui/runtime.js";
|
|
27
|
+
import { bold, brandHeader, dim, glyph, gray, green } from "./ui/theme.js";
|
|
28
|
+
import { freePort, sleep, spawnLogged, spawnTool, terminate, waitForHttp, waitForOutput } from "./shared/proc.js";
|
|
26
29
|
export const FUSION_TOOLS = ["codex", "claude", "cursor", "serve"];
|
|
27
30
|
/** The model label the launched tool uses; the gateway ignores it for routing. */
|
|
28
31
|
const FUSION_MODEL_LABEL = "fusion-panel";
|
|
@@ -206,16 +209,51 @@ export async function startObservability(input) {
|
|
|
206
209
|
const scopeDir = findScopeAppDir();
|
|
207
210
|
const nextBin = join(scopeDir, "node_modules", ".bin", "next");
|
|
208
211
|
if (!existsSync(nextBin)) {
|
|
209
|
-
throw new Error(
|
|
212
|
+
throw new Error("the observability dashboard is not available in this install.\n" +
|
|
213
|
+
` It is a separate app and is not bundled with the npm package.\n` +
|
|
214
|
+
` To enable --observe, install its dependencies once: cd ${scopeDir} && pnpm install`);
|
|
210
215
|
}
|
|
211
216
|
const traceDir = mkdtempSync(join(tmpdir(), "fusion-trace-"));
|
|
212
217
|
const dbPath = join(traceDir, "scope.db");
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
218
|
+
// Child processes (next build / next start) load node:sqlite; keep their
|
|
219
|
+
// experimental warnings out of the log just like the parent CLI.
|
|
220
|
+
const childEnv = {
|
|
221
|
+
...process.env,
|
|
222
|
+
NODE_OPTIONS: [process.env.NODE_OPTIONS, "--disable-warning=ExperimentalWarning"].filter(Boolean).join(" "),
|
|
223
|
+
SCOPEKIT_DB: dbPath,
|
|
224
|
+
FUSION_TRACE_DIR: traceDir
|
|
225
|
+
};
|
|
226
|
+
if (input.report)
|
|
227
|
+
input.report({ kind: "dashboard.start" });
|
|
228
|
+
else
|
|
229
|
+
input.log("fusion: building observability dashboard (one-time)...");
|
|
230
|
+
// Rebuilding every run is slow; reuse a prior build when present. The build
|
|
231
|
+
// output is captured (never inherited) so it can't corrupt a live checklist.
|
|
232
|
+
const alreadyBuilt = existsSync(join(scopeDir, ".next", "BUILD_ID"));
|
|
233
|
+
if (!alreadyBuilt) {
|
|
234
|
+
try {
|
|
235
|
+
const buildOut = execFileSync(nextBin, ["build"], {
|
|
236
|
+
cwd: scopeDir,
|
|
237
|
+
env: childEnv,
|
|
238
|
+
encoding: "utf8",
|
|
239
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
240
|
+
});
|
|
241
|
+
if (input.logFile !== undefined)
|
|
242
|
+
appendFileSync(input.logFile, buildOut);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (input.logFile !== undefined) {
|
|
246
|
+
appendFileSync(input.logFile, String(error.stdout ?? "") + String(error.stderr ?? ""));
|
|
247
|
+
}
|
|
248
|
+
rmSync(traceDir, { recursive: true, force: true });
|
|
249
|
+
throw new Error("the observability dashboard failed to build. See the log for details" +
|
|
250
|
+
(input.logFile ? `: ${input.logFile}` : ""));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
216
253
|
const proc = spawnLogged(nextBin, ["start", "-p", String(SCOPE_DASHBOARD_PORT)], {
|
|
217
254
|
cwd: scopeDir,
|
|
218
|
-
|
|
255
|
+
...(input.logFile !== undefined ? { logFile: input.logFile } : {}),
|
|
256
|
+
env: childEnv
|
|
219
257
|
});
|
|
220
258
|
const url = `http://127.0.0.1:${SCOPE_DASHBOARD_PORT}`;
|
|
221
259
|
try {
|
|
@@ -226,6 +264,10 @@ export async function startObservability(input) {
|
|
|
226
264
|
rmSync(traceDir, { recursive: true, force: true });
|
|
227
265
|
throw error instanceof Error ? error : new Error(String(error));
|
|
228
266
|
}
|
|
267
|
+
if (input.report)
|
|
268
|
+
input.report({ kind: "dashboard.ready", detail: url });
|
|
269
|
+
else
|
|
270
|
+
input.log(`fusion: observability dashboard ready on ${url}`);
|
|
229
271
|
return {
|
|
230
272
|
url,
|
|
231
273
|
ingestUrl: `${url}/api/ingest`,
|
|
@@ -243,45 +285,73 @@ export async function startObservability(input) {
|
|
|
243
285
|
* `uv run` against a local checkout); Anthropic/OpenAI/Google calls go through
|
|
244
286
|
* FusionKit's provider clients.
|
|
245
287
|
*/
|
|
288
|
+
/**
|
|
289
|
+
* Heuristic: does the captured output indicate a permanent failure (bad key,
|
|
290
|
+
* inaccessible model) that a retry cannot fix? Used to fail fast with a clear
|
|
291
|
+
* message instead of burning the retry budget on a hopeless start.
|
|
292
|
+
*/
|
|
293
|
+
function looksPermanentFailure(log) {
|
|
294
|
+
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);
|
|
295
|
+
}
|
|
246
296
|
async function spawnCloudServer(input) {
|
|
247
|
-
const port = await freePort();
|
|
248
297
|
const keyEnv = input.spec.keyEnv ?? defaultKeyEnv(input.provider);
|
|
249
298
|
const runner = fusionkitPyCommand(input.fusionkitDir);
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
299
|
+
const label = `${input.spec.id} (${input.provider}:${input.spec.model})`;
|
|
300
|
+
const maxAttempts = 3;
|
|
301
|
+
let lastError;
|
|
302
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
303
|
+
const port = await freePort();
|
|
304
|
+
const args = [
|
|
305
|
+
...runner.prefix,
|
|
306
|
+
"serve-endpoint",
|
|
307
|
+
"--id",
|
|
308
|
+
input.spec.id,
|
|
309
|
+
"--model",
|
|
310
|
+
input.spec.model,
|
|
311
|
+
"--provider",
|
|
312
|
+
input.provider,
|
|
313
|
+
"--host",
|
|
314
|
+
"127.0.0.1",
|
|
315
|
+
"--port",
|
|
316
|
+
String(port),
|
|
317
|
+
...(input.spec.baseUrl !== undefined ? ["--base-url", input.spec.baseUrl] : []),
|
|
318
|
+
...(keyEnv !== undefined ? ["--api-key-env", keyEnv] : [])
|
|
319
|
+
];
|
|
320
|
+
input.log(attempt === 1
|
|
321
|
+
? `fusion: starting ${label}...`
|
|
322
|
+
: `fusion: retrying ${label} (attempt ${attempt}/${maxAttempts})...`);
|
|
323
|
+
const proc = spawnLogged(runner.command, args, {
|
|
324
|
+
...(runner.cwd !== undefined ? { cwd: runner.cwd } : {}),
|
|
325
|
+
...(input.logFile !== undefined ? { logFile: input.logFile } : {}),
|
|
326
|
+
env: input.env
|
|
277
327
|
});
|
|
328
|
+
const url = `http://127.0.0.1:${port}`;
|
|
329
|
+
try {
|
|
330
|
+
await waitForHttp(`${url}/v1/models`, proc, {
|
|
331
|
+
timeoutMs: 30_000,
|
|
332
|
+
label: `${input.spec.id} server`,
|
|
333
|
+
requireOk: true
|
|
334
|
+
});
|
|
335
|
+
input.log(`fusion: ${input.spec.id} ready on ${url}`);
|
|
336
|
+
return { url, child: proc.child };
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
terminate(proc.child);
|
|
340
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
341
|
+
// A missing runner binary or a provider-side rejection (bad key / model)
|
|
342
|
+
// will not be fixed by retrying — surface it immediately with guidance.
|
|
343
|
+
const permanent = proc.spawnError() !== undefined || looksPermanentFailure(proc.log());
|
|
344
|
+
if (permanent || attempt === maxAttempts) {
|
|
345
|
+
const keyHint = keyEnv !== undefined ? ` and that ${keyEnv} grants access` : "";
|
|
346
|
+
throw new Error(`panel model "${input.spec.id}" (${input.provider}:${input.spec.model}) could not start.\n` +
|
|
347
|
+
` Check the model name${keyHint}.\n` +
|
|
348
|
+
` ${lastError.message}`);
|
|
349
|
+
}
|
|
350
|
+
// Transient: brief backoff, then retry on a fresh port.
|
|
351
|
+
await sleep(500 * attempt);
|
|
352
|
+
}
|
|
278
353
|
}
|
|
279
|
-
|
|
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 };
|
|
354
|
+
throw lastError ?? new Error(`panel model "${input.spec.id}" could not start`);
|
|
285
355
|
}
|
|
286
356
|
/**
|
|
287
357
|
* Bring up one real model server per panel model and return an id -> base URL
|
|
@@ -290,11 +360,25 @@ async function spawnCloudServer(input) {
|
|
|
290
360
|
* verbatim and nothing is spawned.
|
|
291
361
|
*/
|
|
292
362
|
export async function startModelServers(options) {
|
|
293
|
-
const { specs } = options;
|
|
363
|
+
const { specs, report } = options;
|
|
294
364
|
const judge = specs[0];
|
|
295
365
|
if (judge === undefined)
|
|
296
366
|
throw new Error("at least one panel model is required");
|
|
297
367
|
const models = specs.map((spec) => ({ id: spec.id, model: spec.model }));
|
|
368
|
+
// Prefer structured events when a reporter is present; otherwise keep the
|
|
369
|
+
// plain line logs that non-interactive callers and tests rely on.
|
|
370
|
+
const announceStart = (id, label) => {
|
|
371
|
+
if (report)
|
|
372
|
+
report({ kind: "server.start", id, label });
|
|
373
|
+
else
|
|
374
|
+
options.log(`fusion: starting ${label}...`);
|
|
375
|
+
};
|
|
376
|
+
const announceReady = (id, detail) => {
|
|
377
|
+
if (report)
|
|
378
|
+
report({ kind: "server.ready", id, detail });
|
|
379
|
+
else
|
|
380
|
+
options.log(`fusion: ${id} ready on ${detail}`);
|
|
381
|
+
};
|
|
298
382
|
if (options.endpoints !== undefined) {
|
|
299
383
|
return {
|
|
300
384
|
endpoints: options.endpoints,
|
|
@@ -321,31 +405,55 @@ export async function startModelServers(options) {
|
|
|
321
405
|
await Promise.allSettled(gateways.map((gateway) => gateway.close()));
|
|
322
406
|
await Promise.allSettled(backends.map((backend) => backend.stop()));
|
|
323
407
|
};
|
|
408
|
+
const logFileFor = (id) => options.logsDir !== undefined ? join(options.logsDir, `${id}.log`) : undefined;
|
|
409
|
+
// MLX backends are memory-heavy (each loads a model into RAM), so they start
|
|
410
|
+
// sequentially. Cloud `serve-endpoint` servers are cheap and network-bound, so
|
|
411
|
+
// they start concurrently to cut perceived boot time.
|
|
412
|
+
const mlxSpecs = specs.filter((spec) => (spec.provider ?? "mlx") === "mlx");
|
|
413
|
+
const cloudSpecs = specs.filter((spec) => (spec.provider ?? "mlx") !== "mlx");
|
|
324
414
|
try {
|
|
325
|
-
for (const spec of
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
415
|
+
for (const spec of mlxSpecs) {
|
|
416
|
+
announceStart(spec.id, `${spec.id} (${spec.model})`);
|
|
417
|
+
const backend = new MlxBackend({ model: spec.model });
|
|
418
|
+
await backend.start();
|
|
419
|
+
const gateway = await startGateway({ backend });
|
|
420
|
+
backends.push(backend);
|
|
421
|
+
gateways.push(gateway);
|
|
422
|
+
endpoints[spec.id] = gateway.url();
|
|
423
|
+
announceReady(spec.id, gateway.url());
|
|
424
|
+
}
|
|
425
|
+
for (const spec of cloudSpecs) {
|
|
426
|
+
announceStart(spec.id, `${spec.id} (${spec.provider}:${spec.model})`);
|
|
427
|
+
}
|
|
428
|
+
const cloudResults = await Promise.allSettled(cloudSpecs.map((spec) => spawnCloudServer({
|
|
429
|
+
spec,
|
|
430
|
+
provider: (spec.provider ?? "mlx"),
|
|
431
|
+
...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
|
|
432
|
+
...(logFileFor(spec.id) !== undefined ? { logFile: logFileFor(spec.id) } : {}),
|
|
433
|
+
env: cloudEnv,
|
|
434
|
+
log: () => { }
|
|
435
|
+
}).then((started) => ({ id: spec.id, started }))));
|
|
436
|
+
const failures = [];
|
|
437
|
+
for (let index = 0; index < cloudResults.length; index++) {
|
|
438
|
+
const result = cloudResults[index];
|
|
439
|
+
const spec = cloudSpecs[index];
|
|
440
|
+
if (result === undefined || spec === undefined)
|
|
441
|
+
continue;
|
|
442
|
+
if (result.status === "fulfilled") {
|
|
443
|
+
children.push(result.value.started.child);
|
|
444
|
+
endpoints[result.value.id] = result.value.started.url;
|
|
445
|
+
announceReady(result.value.id, result.value.started.url);
|
|
336
446
|
}
|
|
337
447
|
else {
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
env: cloudEnv,
|
|
343
|
-
log: options.log
|
|
344
|
-
});
|
|
345
|
-
children.push(started.child);
|
|
346
|
-
endpoints[spec.id] = started.url;
|
|
448
|
+
const detail = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
449
|
+
if (report)
|
|
450
|
+
report({ kind: "server.fail", id: spec.id, detail });
|
|
451
|
+
failures.push(`${spec.id}: ${detail}`);
|
|
347
452
|
}
|
|
348
453
|
}
|
|
454
|
+
if (failures.length > 0) {
|
|
455
|
+
throw new Error(`panel server(s) failed to start:\n${failures.join("\n")}`);
|
|
456
|
+
}
|
|
349
457
|
}
|
|
350
458
|
catch (error) {
|
|
351
459
|
await closeAll();
|
|
@@ -386,7 +494,11 @@ export async function startSynthesisServer(input) {
|
|
|
386
494
|
writeFileSync(configPath, config);
|
|
387
495
|
input.log("fusion: starting synthesis backend (fusionkit serve)...");
|
|
388
496
|
const runner = fusionkitPyCommand(input.fusionkitDir);
|
|
389
|
-
const proc = spawnLogged(runner.command, [...runner.prefix, "serve", "--config", configPath, "--host", "127.0.0.1", "--port", String(port)], {
|
|
497
|
+
const proc = spawnLogged(runner.command, [...runner.prefix, "serve", "--config", configPath, "--host", "127.0.0.1", "--port", String(port)], {
|
|
498
|
+
...(runner.cwd !== undefined ? { cwd: runner.cwd } : {}),
|
|
499
|
+
...(input.logFile !== undefined ? { logFile: input.logFile } : {}),
|
|
500
|
+
env: input.env
|
|
501
|
+
});
|
|
390
502
|
// The temp config is only read at startup; drop it once the server exits.
|
|
391
503
|
proc.child.once("exit", () => rmSync(configDir, { recursive: true, force: true }));
|
|
392
504
|
const url = `http://127.0.0.1:${port}`;
|
|
@@ -405,10 +517,13 @@ export async function startSynthesisServer(input) {
|
|
|
405
517
|
return { child: proc.child, url };
|
|
406
518
|
}
|
|
407
519
|
export async function startFusionStack(options) {
|
|
520
|
+
const report = options.report;
|
|
408
521
|
const servers = await startModelServers({
|
|
409
522
|
specs: options.models,
|
|
410
523
|
...(options.endpoints !== undefined ? { endpoints: options.endpoints } : {}),
|
|
411
524
|
...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
|
|
525
|
+
...(options.logsDir !== undefined ? { logsDir: options.logsDir } : {}),
|
|
526
|
+
...(report !== undefined ? { report } : {}),
|
|
412
527
|
log: options.log
|
|
413
528
|
});
|
|
414
529
|
let synthesisChild;
|
|
@@ -422,16 +537,23 @@ export async function startFusionStack(options) {
|
|
|
422
537
|
if (options.fusionkitDir !== undefined) {
|
|
423
538
|
loadEnvFileInto(join(options.fusionkitDir, ".env"), cloudEnv);
|
|
424
539
|
}
|
|
540
|
+
if (report)
|
|
541
|
+
report({ kind: "synth.start" });
|
|
425
542
|
const synthesis = await startSynthesisServer({
|
|
426
543
|
...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
|
|
544
|
+
...(options.logsDir !== undefined ? { logFile: join(options.logsDir, "synthesis.log") } : {}),
|
|
427
545
|
judgeModel: options.judgeModel ?? servers.judgeModel,
|
|
428
546
|
judgeBaseUrl: servers.judgeUrl,
|
|
429
547
|
env: cloudEnv,
|
|
430
|
-
log: options.log
|
|
548
|
+
log: report ? () => { } : options.log
|
|
431
549
|
});
|
|
432
550
|
synthesisChild = synthesis.child;
|
|
433
551
|
synthesisUrl = synthesis.url;
|
|
552
|
+
if (report)
|
|
553
|
+
report({ kind: "synth.ready", detail: synthesis.url });
|
|
434
554
|
}
|
|
555
|
+
if (report)
|
|
556
|
+
report({ kind: "gateway.start" });
|
|
435
557
|
// The judge-streamed-trajectory front door: each panel model produces a
|
|
436
558
|
// trajectory and the judge emits the trajectory the user's tool executes.
|
|
437
559
|
const gatewayConfig = {
|
|
@@ -450,6 +572,8 @@ export async function startFusionStack(options) {
|
|
|
450
572
|
port: options.port ?? 0,
|
|
451
573
|
...(options.authToken !== undefined ? { authToken: options.authToken } : {})
|
|
452
574
|
});
|
|
575
|
+
if (report)
|
|
576
|
+
report({ kind: "gateway.ready", detail: gateway.url() });
|
|
453
577
|
return {
|
|
454
578
|
fusionUrl: gateway.url(),
|
|
455
579
|
endpoints: servers.endpoints,
|
|
@@ -499,6 +623,7 @@ export async function startCursorBridge(input) {
|
|
|
499
623
|
};
|
|
500
624
|
const proc = spawnLogged(process.execPath, ["dist/src/cli.js", "serve"], {
|
|
501
625
|
cwd: input.cursorKitDir,
|
|
626
|
+
...(input.logFile !== undefined ? { logFile: input.logFile } : {}),
|
|
502
627
|
env
|
|
503
628
|
});
|
|
504
629
|
try {
|
|
@@ -514,6 +639,8 @@ export async function startCursorBridge(input) {
|
|
|
514
639
|
export async function runFusion(tool, toolArgs, options = {}) {
|
|
515
640
|
const log = options.log ?? ((line) => console.error(line));
|
|
516
641
|
const root = mkdtempSync(join(tmpdir(), "fusionkit-fusion-"));
|
|
642
|
+
const logsDir = join(root, "logs");
|
|
643
|
+
mkdirSync(logsDir, { recursive: true });
|
|
517
644
|
// Default the fused repo to the current directory's git repo: the panel models
|
|
518
645
|
// and the launched harness must operate on the SAME codebase, and the launched
|
|
519
646
|
// tool runs in this repo (below). No hidden sample repo — if the user wants a
|
|
@@ -527,30 +654,118 @@ export async function runFusion(tool, toolArgs, options = {}) {
|
|
|
527
654
|
}
|
|
528
655
|
repo = toplevel;
|
|
529
656
|
}
|
|
657
|
+
// Load API keys from a project `.env` (cwd, then the repo root) so provider
|
|
658
|
+
// keys work without a manual `export`. Already-exported values always win, so
|
|
659
|
+
// an explicitly set (even empty) key is never overridden.
|
|
660
|
+
loadEnvFileInto(join(process.cwd(), ".env"), process.env);
|
|
661
|
+
if (repo !== process.cwd())
|
|
662
|
+
loadEnvFileInto(join(repo, ".env"), process.env);
|
|
530
663
|
const models = options.models ?? (options.local === true ? [...DEFAULT_TRIO] : [...DEFAULT_CLOUD_PANEL]);
|
|
531
664
|
// Fail fast on missing prerequisites before we start spawning a stack.
|
|
532
665
|
runPreflight(preflightRequirements(tool, models, options));
|
|
533
|
-
|
|
534
|
-
|
|
666
|
+
const judgeLabel = options.judgeModel ?? models[0]?.model ?? "(first panel model)";
|
|
667
|
+
// The live boot checklist only renders on an interactive TTY when the caller
|
|
668
|
+
// did not supply its own log sink (tests/programmatic callers stay on the
|
|
669
|
+
// plain line-log path so their output is deterministic).
|
|
670
|
+
const useBootView = options.log === undefined && isInteractive();
|
|
671
|
+
if (useBootView) {
|
|
672
|
+
uiStream().write(`\n${brandHeader()}\n`);
|
|
673
|
+
uiStream().write(`${dim("panel:")} ${models.map((model) => model.id).join(", ")} ` +
|
|
674
|
+
`${dim("judge:")} ${judgeLabel} ${dim("repo:")} ${repo}\n\n`);
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
log(`fusion: panel = ${models.map((model) => model.id).join(", ")}`);
|
|
678
|
+
log(`fusion: repo = ${repo}`);
|
|
679
|
+
}
|
|
680
|
+
// Teardown wiring is registered BEFORE the first spawn so a Ctrl+C during the
|
|
681
|
+
// (potentially slow) boot tears down whatever has already started, instead of
|
|
682
|
+
// orphaning detached child process groups. Resources push their disposer as
|
|
683
|
+
// soon as they exist; cleanup runs them in reverse order, exactly once.
|
|
684
|
+
const disposers = [];
|
|
685
|
+
let cleaned = false;
|
|
686
|
+
const cleanup = async () => {
|
|
687
|
+
if (cleaned)
|
|
688
|
+
return;
|
|
689
|
+
cleaned = true;
|
|
690
|
+
for (const dispose of disposers.reverse()) {
|
|
691
|
+
try {
|
|
692
|
+
await dispose();
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
// best-effort teardown; never let one disposer block the rest
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
let signalled = false;
|
|
700
|
+
const onSignal = () => {
|
|
701
|
+
if (signalled)
|
|
702
|
+
return;
|
|
703
|
+
signalled = true;
|
|
704
|
+
// Never wedge on shutdown: if cleanup stalls (a child ignoring SIGTERM),
|
|
705
|
+
// force-exit after a grace period.
|
|
706
|
+
const forced = setTimeout(() => process.exit(1), 10_000);
|
|
707
|
+
forced.unref();
|
|
708
|
+
void cleanup().then(() => process.exit(130));
|
|
709
|
+
};
|
|
710
|
+
process.once("SIGINT", onSignal);
|
|
711
|
+
process.once("SIGTERM", onSignal);
|
|
712
|
+
// Cost/scope confirmation: the default cloud panel fans every prompt out
|
|
713
|
+
// across multiple frontier models plus a judge. Make that explicit before we
|
|
714
|
+
// spend, unless --yes was passed or we are not on an interactive TTY.
|
|
715
|
+
const spawningCloud = options.endpoints === undefined && models.some((model) => (model.provider ?? "mlx") !== "mlx");
|
|
716
|
+
if (useBootView && spawningCloud && options.yes !== true && canPromptInteractively()) {
|
|
717
|
+
const proceed = await confirm({
|
|
718
|
+
message: `Run the cloud panel? Each prompt fans out across ${models.length} model(s) + a judge (provider usage applies).`,
|
|
719
|
+
defaultValue: true
|
|
720
|
+
});
|
|
721
|
+
if (!proceed) {
|
|
722
|
+
uiStream().write(`${gray("aborted — nothing was started.")}\n`);
|
|
723
|
+
return 130;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
// The live boot checklist, driven by structured stack events.
|
|
727
|
+
const boot = useBootView
|
|
728
|
+
? createBootView({
|
|
729
|
+
servers: options.endpoints === undefined
|
|
730
|
+
? models.map((model) => ({ id: model.id, label: `${model.id} · ${model.model}` }))
|
|
731
|
+
: [],
|
|
732
|
+
includeSynth: options.synthesisUrl === undefined,
|
|
733
|
+
includeDashboard: options.observe === true,
|
|
734
|
+
title: dim("booting the fusion stack")
|
|
735
|
+
})
|
|
736
|
+
: undefined;
|
|
737
|
+
if (boot !== undefined)
|
|
738
|
+
disposers.push(() => boot.stop());
|
|
739
|
+
const report = boot?.report;
|
|
535
740
|
// When --observe is set, boot the dashboard and export the trace env BEFORE
|
|
536
741
|
// anything starts, so the in-process gateway/ensemble/agent emitters and every
|
|
537
742
|
// spawned child (panel servers, synthesis serve, cursor bridge) inherit it.
|
|
538
743
|
// Without the flag, FUSION_TRACE_* stays unset and all emitters are no-ops.
|
|
539
744
|
let observability;
|
|
540
|
-
|
|
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
|
-
}
|
|
745
|
+
let bridge;
|
|
548
746
|
let stack;
|
|
549
747
|
try {
|
|
748
|
+
if (options.observe === true) {
|
|
749
|
+
observability = await startObservability({
|
|
750
|
+
log,
|
|
751
|
+
logFile: join(logsDir, "dashboard.log"),
|
|
752
|
+
...(report !== undefined ? { report } : {})
|
|
753
|
+
});
|
|
754
|
+
disposers.push(() => observability?.close() ?? Promise.resolve());
|
|
755
|
+
process.env.FUSION_TRACE_URL = observability.ingestUrl;
|
|
756
|
+
process.env.FUSION_TRACE_DIR = observability.traceDir;
|
|
757
|
+
if (boot === undefined) {
|
|
758
|
+
log(`fusion: observability dashboard at ${observability.url}`);
|
|
759
|
+
log(`fusion: trace events -> ${observability.ingestUrl} (jsonl fallback in ${observability.traceDir})`);
|
|
760
|
+
}
|
|
761
|
+
openUrl(observability.url);
|
|
762
|
+
}
|
|
550
763
|
stack = await startFusionStack({
|
|
551
764
|
repo,
|
|
552
765
|
outputRoot: join(root, "runs"),
|
|
553
766
|
models,
|
|
767
|
+
logsDir,
|
|
768
|
+
...(report !== undefined ? { report } : {}),
|
|
554
769
|
...(options.endpoints !== undefined ? { endpoints: options.endpoints } : {}),
|
|
555
770
|
...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
|
|
556
771
|
...(options.judgeModel !== undefined ? { judgeModel: options.judgeModel } : {}),
|
|
@@ -560,34 +775,37 @@ export async function runFusion(tool, toolArgs, options = {}) {
|
|
|
560
775
|
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
561
776
|
log
|
|
562
777
|
});
|
|
778
|
+
disposers.push(() => stack.close());
|
|
563
779
|
}
|
|
564
780
|
catch (error) {
|
|
565
|
-
if (
|
|
566
|
-
|
|
781
|
+
if (boot !== undefined)
|
|
782
|
+
boot.stop();
|
|
783
|
+
await cleanup();
|
|
567
784
|
throw error;
|
|
568
785
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
786
|
+
if (boot !== undefined) {
|
|
787
|
+
// Settle the checklist BEFORE the agent inherits the terminal: the launched
|
|
788
|
+
// coding tool owns the screen from here on, so no live UI may remain.
|
|
789
|
+
boot.stop();
|
|
790
|
+
uiStream().write(`${green(glyph.tick())} ${bold("fusion ready")} ${dim(stack.fusionUrl)} ${dim(`(model: ${FUSION_MODEL_LABEL})`)}\n`);
|
|
791
|
+
uiStream().write(`${dim(`logs: ${logsDir}`)}\n`);
|
|
792
|
+
if (observability !== undefined) {
|
|
793
|
+
uiStream().write(`${dim(`dashboard: ${observability.url}`)}\n`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
log(`fusion: gateway on ${stack.fusionUrl} (model: ${FUSION_MODEL_LABEL})`);
|
|
798
|
+
log(`fusion: logs in ${logsDir}`);
|
|
799
|
+
}
|
|
800
|
+
// Hand the terminal to the coding agent cleanly: silence the per-turn gateway
|
|
801
|
+
// chatter (it would corrupt a full-screen agent TUI; trace events still flow
|
|
802
|
+
// to --observe) and make sure the cursor is restored.
|
|
803
|
+
const prepareForPassthrough = () => {
|
|
804
|
+
setGatewayChatter(false);
|
|
805
|
+
const stream = uiStream();
|
|
806
|
+
if (stream.isTTY)
|
|
807
|
+
stream.write("\u001b[?25h");
|
|
588
808
|
};
|
|
589
|
-
process.once("SIGINT", onSignal);
|
|
590
|
-
process.once("SIGTERM", onSignal);
|
|
591
809
|
try {
|
|
592
810
|
switch (tool) {
|
|
593
811
|
case "serve": {
|
|
@@ -603,10 +821,12 @@ export async function runFusion(tool, toolArgs, options = {}) {
|
|
|
603
821
|
case "codex": {
|
|
604
822
|
const home = mkdtempSync(join(tmpdir(), "fusionkit-fusion-codex-"));
|
|
605
823
|
writeFileSync(join(home, "config.toml"), codexConfigToml(stack.fusionUrl, FUSION_MODEL_LABEL));
|
|
824
|
+
prepareForPassthrough();
|
|
606
825
|
log("fusion: launching codex (each prompt is a coding task fused across the panel)...");
|
|
607
826
|
return await spawnTool("codex", toolArgs, { CODEX_HOME: home }, repo);
|
|
608
827
|
}
|
|
609
828
|
case "claude": {
|
|
829
|
+
prepareForPassthrough();
|
|
610
830
|
log("fusion: launching claude...");
|
|
611
831
|
return await spawnTool("claude", toolArgs, claudeEnv(stack.fusionUrl, options.authToken), repo);
|
|
612
832
|
}
|
|
@@ -622,8 +842,18 @@ export async function runFusion(tool, toolArgs, options = {}) {
|
|
|
622
842
|
log(` cursor-agent --endpoint http://127.0.0.1:<bridge-port> --model ${FUSION_MODEL_LABEL}`);
|
|
623
843
|
return 1;
|
|
624
844
|
}
|
|
625
|
-
const started = await startCursorBridge({
|
|
845
|
+
const started = await startCursorBridge({
|
|
846
|
+
cursorKitDir,
|
|
847
|
+
fusionUrl: stack.fusionUrl,
|
|
848
|
+
logFile: join(logsDir, "cursor-bridge.log"),
|
|
849
|
+
log
|
|
850
|
+
});
|
|
626
851
|
bridge = started.child;
|
|
852
|
+
disposers.push(() => {
|
|
853
|
+
if (bridge !== undefined)
|
|
854
|
+
terminate(bridge);
|
|
855
|
+
});
|
|
856
|
+
prepareForPassthrough();
|
|
627
857
|
log("fusion: launching cursor-agent...");
|
|
628
858
|
return await spawnTool("cursor-agent", ["--endpoint", `http://127.0.0.1:${started.port}`, "--model", FUSION_MODEL_LABEL, ...toolArgs], {}, repo);
|
|
629
859
|
}
|
|
@@ -639,35 +869,14 @@ export async function runFusion(tool, toolArgs, options = {}) {
|
|
|
639
869
|
}
|
|
640
870
|
/** Interactive tool picker for when no `--tool` was provided on a TTY. */
|
|
641
871
|
export async function pickTool() {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
"
|
|
646
|
-
"
|
|
647
|
-
"
|
|
648
|
-
"
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
}
|
|
872
|
+
return select({
|
|
873
|
+
message: "Which coding agent should model fusion back?",
|
|
874
|
+
options: [
|
|
875
|
+
{ value: "codex", label: "codex", hint: "OpenAI Codex CLI" },
|
|
876
|
+
{ value: "claude", label: "claude", hint: "Claude Code" },
|
|
877
|
+
{ value: "cursor", label: "cursor", hint: "needs --cursor-kit-dir / FUSIONKIT_CURSORKIT_DIR" },
|
|
878
|
+
{ value: "serve", label: "serve", hint: "just run the gateway and print setup" }
|
|
879
|
+
],
|
|
880
|
+
defaultIndex: 0
|
|
881
|
+
});
|
|
673
882
|
}
|
package/dist/gateway.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export type GatewayRunnerConfig = {
|
|
|
20
20
|
fusionApiKey?: string;
|
|
21
21
|
modelEndpoints?: Record<string, string>;
|
|
22
22
|
};
|
|
23
|
+
/** Enable/disable the gateway's per-turn stderr chatter (default on). */
|
|
24
|
+
export declare function setGatewayChatter(enabled: boolean): void;
|
|
23
25
|
export declare function buildFrontDoorRunner(config: GatewayRunnerConfig): FrontDoorRunner;
|
|
24
26
|
export declare function buildAcpRunner(config: GatewayRunnerConfig): AcpRunner;
|
|
25
27
|
export declare function codexConfigSnippet(gatewayUrl: string): string;
|