Files
fusion/packages/droid-cli/index.ts
gsxdsm 1f23a2e622 FN-6878: register Droid provider before probes
Register Droid provider startup without waiting on local binary probes.

- Register the Droid CLI provider synchronously with an empty model list, then refresh discovered models asynchronously.
- Harden Droid runtime probes to convert spawn errors and timeouts into unavailable sentinel results.
- Add coverage for non-blocking startup, failed probes, unavailable binaries, and process cleanup behavior.
- Add a patch changeset for the published Fusion CLI package.

Files changed:
 .changeset/fn-6878-droid-boot.md                   |  5 ++
 packages/droid-cli/index.ts                        | 73 +++++++++++-------
 packages/droid-cli/src/__tests__/index.test.ts     | 52 ++++++++++++-
 .../src/__tests__/process-manager.test.ts          | 58 +++++++--------
 .../src/__tests__/discover-models.test.ts          |  8 ++
 .../src/__tests__/probe.test.ts                    | 43 ++++++++++-
 .../src/__tests__/startup-probes.test.ts           | 86 ++++++++++++++++++++++
 plugins/fusion-plugin-droid-runtime/src/probe.ts   | 32 +++++---
 8 files changed, 283 insertions(+), 74 deletions(-)

Fusion-Task-Id: FN-6878

Fusion-Task-Lineage: 4e44a6e8-0eea-4867-a172-3bdc0c6368e1
2026-06-21 16:40:32 -07:00

150 lines
4.6 KiB
TypeScript

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
streamViaCli,
discoverDroidModels,
validateCliPresenceAsync,
validateCliAuthAsync,
killAllProcesses,
getCustomToolDefs,
toolsFromContext,
writeMcpConfig,
type McpToolDef,
} from "@fusion-plugin-examples/droid-runtime";
import { createHash } from "node:crypto";
process.on("exit", killAllProcesses);
const PROVIDER_ID = "droid-cli";
let cliValidationPromise: Promise<void> | undefined;
type DiscoveredModel = { id: string; name: string; reasoning: boolean; input: Array<"text" | "image">; cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow: number; maxTokens: number };
let discoveredModelsPromise: Promise<DiscoveredModel[]> | undefined;
type StreamSimpleHandler = NonNullable<Parameters<ExtensionAPI["registerProvider"]>[1]["streamSimple"]>;
function runCliValidationOnce(): Promise<void> {
if (cliValidationPromise) return cliValidationPromise;
cliValidationPromise = (async () => {
const presence = await validateCliPresenceAsync();
if (!presence.ok) {
console.warn(`[droid-cli] ${presence.error.message}`);
return;
}
await validateCliAuthAsync();
})();
return cliValidationPromise;
}
async function getDiscoveredModels() {
if (!discoveredModelsPromise) {
discoveredModelsPromise = (async () => {
try {
const ids = Array.from(new Set(await discoverDroidModels()));
if (ids.length === 0) return [];
return toProviderModels(ids);
} catch (error) {
console.warn("[droid-cli] model auto-discovery failed; registering provider with empty model list", error);
return [];
}
})();
}
return discoveredModelsPromise;
}
let cachedMcpConfig: { hash: string; configPath: string } | undefined;
function toProviderModels(ids: string[]): DiscoveredModel[] {
return ids.map((id) => ({
id,
name: id,
reasoning: true,
input: ["text", "image"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
}));
}
function ensureMcpConfig(
pi: ExtensionAPI,
contextTools?: ReadonlyArray<{
name: string;
description: string;
parameters: Record<string, unknown>;
}>,
): string | undefined {
try {
let toolDefs: McpToolDef[] = toolsFromContext(contextTools);
if (toolDefs.length === 0) {
const allTools = pi.getAllTools();
if (!Array.isArray(allTools)) return cachedMcpConfig?.configPath;
toolDefs = getCustomToolDefs(pi);
}
if (toolDefs.length === 0) {
cachedMcpConfig = undefined;
return undefined;
}
const hash = createHash("sha1").update(JSON.stringify(toolDefs)).digest("hex").slice(0, 12);
if (cachedMcpConfig?.hash === hash) return cachedMcpConfig.configPath;
const configPath = writeMcpConfig(toolDefs, hash);
cachedMcpConfig = { hash, configPath };
return configPath;
} catch {
return cachedMcpConfig?.configPath;
}
}
function registerDroidProvider(pi: ExtensionAPI, models: DiscoveredModel[]) {
pi.registerProvider(PROVIDER_ID, {
baseUrl: "droid-cli",
apiKey: "unused",
api: "droid-cli",
models,
streamSimple: ((model, context, options) => {
const configPath = ensureMcpConfig(
pi,
(context as { tools?: ReadonlyArray<{ name: string; description: string; parameters: Record<string, unknown> }> }).tools,
);
return streamViaCli(
model,
context as never,
{ ...(options ?? {}), mcpConfigPath: configPath } as never,
) as unknown as ReturnType<StreamSimpleHandler>;
}) as StreamSimpleHandler,
});
}
export default function (pi: ExtensionAPI) {
/*
FNXC:CliRuntime 2026-06-21-12:00:
Engine and dashboard startup must not wait for local Droid CLI probes. Register the provider synchronously with an empty model list, then launch presence/auth/model discovery as fire-and-forget bounded probes so a missing or wedged `droid` binary cannot stall extension loading.
*/
void runCliValidationOnce();
pi.on("session_start", async () => {
const allTools = pi.getAllTools();
if (Array.isArray(allTools)) {
pi.setActiveTools(allTools.map((t: { name: string }) => t.name));
}
});
try {
registerDroidProvider(pi, []);
} catch (err) {
console.error("[droid-cli] Failed to register provider:", err);
}
void (async () => {
const models = await getDiscoveredModels();
if (models.length === 0) return;
try {
registerDroidProvider(pi, models);
} catch (err) {
console.error("[droid-cli] Failed to refresh discovered provider models:", err);
}
})();
}