feat(FN-3328): refactor droid-cli into compatibility shim backed by fusion-

Merged FN-3328: Refactored the droid integration by converting `droid-cli` into a lightweight compatibility shim that delegates to `fusion-plugin-droid-runtime`, reducing droid-cli by ~5,400 lines of code while moving the actual runtime logic into the plugin scaffold. Updated the plugin loader to al

Fusion-Task-Id: FN-3328
This commit is contained in:
Fusion
2026-05-04 17:04:31 -07:00
committed by gsxdsm
parent 6ea1fb01e4
commit 7e1768b1b1
21 changed files with 310 additions and 5700 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,82 +1 @@
/**
* Control protocol handler for Droid CLI stream-json communication.
*
* Processes control_request messages from Droid CLI stdout and returns a
* control_response decision object.
*
* - Custom MCP tools (mcp__custom-tools__*): DENIED — pi executes these
* - Everything else (user MCP tools, internal tools): ALLOWED — Claude handles
*/
import type { ClaudeControlRequest } from "./types";
import { CUSTOM_TOOLS_MCP_PREFIX } from "./tool-mapping.js";
export const TOOL_EXECUTION_DENIED_MESSAGE =
"Tool execution is unavailable in this environment.";
/** Prefix for MCP (Model Context Protocol) tool names. */
export const MCP_PREFIX = "mcp__";
interface ControlResponse {
type: "control_response";
request_id: string;
response: {
subtype: "success";
response: {
behavior: "allow" | "deny";
message?: string;
};
};
}
/**
* Handle a control_request from the Droid CLI.
*
* Denies custom MCP tools (mcp__custom-tools__*) so pi can execute them.
* Allows everything else (user MCP tools, internal Claude tools).
*
* Pure function: no side effects and no stdin writes.
*
* @returns Decision payload with allow/deny result and serialized response object
*/
export function handleControlRequest(
msg: ClaudeControlRequest,
): { allowed: boolean; response: ControlResponse } {
if (!msg.request_id || !msg.request) {
console.error(
"[droid-cli] Malformed control_request: missing request_id or request object",
msg,
);
return {
allowed: false,
response: {
type: "control_response",
request_id: msg.request_id ?? "",
response: {
subtype: "success",
response: {
behavior: "deny",
message: TOOL_EXECUTION_DENIED_MESSAGE,
},
},
},
};
}
const toolName = msg.request?.tool_name ?? "";
const isCustomTool = toolName.startsWith(CUSTOM_TOOLS_MCP_PREFIX);
const response: ControlResponse = {
type: "control_response",
request_id: msg.request_id,
response: {
subtype: "success",
response: isCustomTool
? { behavior: "deny", message: TOOL_EXECUTION_DENIED_MESSAGE }
: { behavior: "allow" },
},
};
return { allowed: !isCustomTool, response };
}
export * from "../../../plugins/fusion-plugin-droid-runtime/src/control-handler.js";

View File

@@ -1,397 +1 @@
import type { ClaudeApiEvent, TrackedContentBlock } from "./types";
import { calculateCost } from "@mariozechner/pi-ai";
import type {
Api,
AssistantMessage,
AssistantMessageEventStream,
Model,
TextContent,
ThinkingContent,
ToolCall,
} from "@mariozechner/pi-ai";
import {
mapDroidToolNameToPi,
translateDroidArgsToPi,
isPiKnownDroidTool,
} from "./tool-mapping.js";
/**
* Extended tracking for tool_use content blocks during streaming.
* Stores the Claude tool name for argument translation at block_stop.
*/
interface TrackedToolBlock {
type: "tool_use";
index: number;
id: string;
name: string; // Already mapped to pi name
claudeName: string; // Original Claude name for arg translation
arguments: Record<string, unknown>;
partialJson: string;
}
/** Union of tracked block types for the blocks array. */
type TrackedBlock = TrackedContentBlock | TrackedToolBlock;
/**
* The event bridge interface returned by createEventBridge.
* handleEvent processes each Claude API streaming event and pushes
* the appropriate pi events to the stream.
* getOutput returns the accumulated AssistantMessage.
*/
export interface EventBridge {
handleEvent(event: ClaudeApiEvent): void;
getOutput(): AssistantMessage;
}
/**
* Map Claude API stop reasons to pi's stop reason format.
*/
function mapStopReason(
reason: string | undefined,
): "stop" | "length" | "toolUse" {
switch (reason) {
case "tool_use":
return "toolUse";
case "max_tokens":
return "length";
case "end_turn":
default:
return "stop";
}
}
/**
* Create an event bridge that translates Claude API streaming events
* into pi's AssistantMessageEventStream events.
*
* The bridge maintains internal state to track content blocks and
* accumulate the final AssistantMessage. It handles:
* - text content blocks (start/delta/stop -> text_start/text_delta/text_end)
* - message lifecycle (message_start for usage, message_delta for stop reason, message_stop for done)
* - unsupported block types (tool_use, thinking) with warnings
*/
export function createEventBridge(
stream: AssistantMessageEventStream,
model: Model<Api>,
): EventBridge {
// Tracked content blocks indexed by Claude's content_block index
const blocks: TrackedBlock[] = [];
// The accumulated output message
const output: AssistantMessage = {
role: "assistant" as const,
content: [] as (TextContent | ThinkingContent | ToolCall)[],
api: "droid-cli",
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop" as const,
timestamp: Date.now(),
};
let started = false;
function handleEvent(event: ClaudeApiEvent): void {
// Emit start event on first message — tells pi to begin incremental rendering
if (!started) {
stream.push({ type: "start", partial: output });
started = true;
}
switch (event.type) {
case "message_start":
handleMessageStart(event);
break;
case "content_block_start":
handleContentBlockStart(event);
break;
case "content_block_delta":
handleContentBlockDelta(event);
break;
case "content_block_stop":
handleContentBlockStop(event);
break;
case "message_delta":
handleMessageDelta(event);
break;
case "message_stop":
handleMessageStop();
break;
// Unknown event types are silently ignored
}
}
function handleMessageStart(event: ClaudeApiEvent): void {
const usage = event.message?.usage;
if (usage) {
output.usage.input = usage.input_tokens ?? 0;
output.usage.output = usage.output_tokens ?? 0;
output.usage.cacheRead = usage.cache_read_input_tokens ?? 0;
output.usage.cacheWrite = usage.cache_creation_input_tokens ?? 0;
output.usage.totalTokens =
output.usage.input +
output.usage.output +
output.usage.cacheRead +
output.usage.cacheWrite;
calculateCost(model, output.usage);
}
}
function handleContentBlockStart(event: ClaudeApiEvent): void {
const blockType = event.content_block?.type;
if (blockType === "text") {
const block: TrackedContentBlock = {
type: "text",
text: "",
index: event.index ?? 0,
};
blocks.push(block);
output.content.push({ type: "text" as const, text: "" });
stream.push({
type: "text_start",
contentIndex: output.content.length - 1,
partial: output,
});
} else if (blockType === "thinking") {
const block: TrackedContentBlock = {
type: "thinking",
text: "",
index: event.index ?? 0,
};
blocks.push(block);
output.content.push({
type: "thinking" as const,
thinking: "",
thinkingSignature: "",
});
stream.push({
type: "thinking_start",
contentIndex: output.content.length - 1,
partial: output,
});
} else if (blockType === "tool_use") {
const claudeName = event.content_block!.name!;
// Skip internal Claude Code tools (ToolSearch, Task, Agent, etc.)
// that pi cannot execute — only emit pi-known tools
if (!isPiKnownDroidTool(claudeName)) {
return;
}
const piName = mapDroidToolNameToPi(claudeName);
const id = event.content_block!.id!;
const block: TrackedToolBlock = {
type: "tool_use",
index: event.index ?? 0,
id,
name: piName,
claudeName,
arguments: {},
partialJson: "",
};
blocks.push(block);
output.content.push({
type: "toolCall" as const,
id,
name: piName,
arguments: {},
} as ToolCall);
stream.push({
type: "toolcall_start",
contentIndex: output.content.length - 1,
partial: output,
});
}
// Unknown block types silently ignored
}
function handleContentBlockDelta(event: ClaudeApiEvent): void {
const deltaType = event.delta?.type;
if (deltaType === "text_delta" && event.delta!.text != null) {
const idx = blocks.findIndex((b) => b.index === event.index);
if (idx === -1) return;
const block = blocks[idx];
if (block.type === "text") {
block.text += event.delta!.text;
const contentBlock = output.content[idx] as TextContent;
contentBlock.text = block.text;
stream.push({
type: "text_delta",
contentIndex: idx,
delta: event.delta!.text,
partial: output,
});
}
} else if (
deltaType === "thinking_delta" &&
event.delta!.thinking != null
) {
const idx = blocks.findIndex((b) => b.index === event.index);
if (idx === -1) return;
const block = blocks[idx];
if (block.type === "thinking") {
block.text += event.delta!.thinking;
const contentBlock = output.content[idx] as ThinkingContent;
contentBlock.thinking = block.text;
stream.push({
type: "thinking_delta",
contentIndex: idx,
delta: event.delta!.thinking,
partial: output,
});
}
} else if (
deltaType === "input_json_delta" &&
event.delta!.partial_json != null
) {
const idx = blocks.findIndex((b) => b.index === event.index);
if (idx === -1) return;
const block = blocks[idx];
if (block.type === "tool_use") {
block.partialJson += event.delta!.partial_json;
// Try to parse accumulated JSON -- on success update args, on failure keep previous
try {
block.arguments = JSON.parse(block.partialJson);
(output.content[idx] as ToolCall).arguments = block.arguments as Record<string, unknown>;
} catch {
// Partial JSON not yet parseable -- keep previous arguments
}
stream.push({
type: "toolcall_delta",
contentIndex: idx,
delta: event.delta!.partial_json,
partial: output,
});
}
} else if (
deltaType === "signature_delta" &&
event.delta!.signature != null
) {
// Accumulate signature on the thinking block
const idx = blocks.findIndex((b) => b.index === event.index);
if (idx === -1) return;
const block = blocks[idx];
if (block.type === "thinking") {
const contentBlock = output.content[idx] as ThinkingContent;
contentBlock.thinkingSignature =
(contentBlock.thinkingSignature || "") + event.delta!.signature;
}
}
}
function handleContentBlockStop(event: ClaudeApiEvent): void {
const idx = blocks.findIndex((b) => b.index === event.index);
if (idx === -1) return;
const block = blocks[idx];
// Clean up the tracking index from the block (no longer needed)
delete (block as unknown as Record<string, unknown>).index;
if (block.type === "text") {
stream.push({
type: "text_end",
contentIndex: idx,
content: block.text,
partial: output,
});
} else if (block.type === "thinking") {
stream.push({
type: "thinking_end",
contentIndex: idx,
content: block.text,
partial: output,
});
} else if (block.type === "tool_use") {
// Final JSON parse with fallback to raw string.
// Special case: parameterless MCP tools (e.g. fn_review_spec, schema
// `{type:"object", properties:{}}`) emit ZERO input_json_delta events,
// so `partialJson` stays "". Without this guard we'd JSON.parse("")
// → throw → fall through to `finalArgs = ""` (raw string), and pi's
// TypeBox validator then rejects with "root: must be object" because
// an empty string is not an object. Default to `{}` so the call lands.
let finalArgs: Record<string, unknown> | string;
const trimmedJson = block.partialJson.trim();
if (trimmedJson === "") {
finalArgs = {};
} else {
try {
const parsed = JSON.parse(trimmedJson);
finalArgs = translateDroidArgsToPi(block.claudeName, parsed);
} catch {
finalArgs = block.partialJson;
}
}
// Update output.content with final arguments
const contentBlock = output.content[idx] as ToolCall;
// ToolCall.arguments is typed as Record<string, any> in pi-ai, but we
// intentionally emit a raw string when JSON parse fails completely.
// Pi handles string arguments gracefully at runtime.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- finalArgs may be a raw string when JSON parse fails; pi-ai handles it at runtime
(contentBlock as any).arguments = finalArgs;
const toolCall = {
type: "toolCall" as const,
id: block.id,
name: block.name,
arguments: finalArgs,
} as ToolCall;
stream.push({
type: "toolcall_end",
contentIndex: idx,
toolCall,
partial: output,
});
}
}
function handleMessageDelta(event: ClaudeApiEvent): void {
if (event.delta?.stop_reason) {
output.stopReason = mapStopReason(event.delta.stop_reason);
}
const usage = event.usage;
if (usage) {
if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
if (usage.output_tokens != null)
output.usage.output = usage.output_tokens;
output.usage.totalTokens =
output.usage.input +
output.usage.output +
output.usage.cacheRead +
output.usage.cacheWrite;
calculateCost(model, output.usage);
}
}
function handleMessageStop(): void {
// No-op: done event is pushed by the provider after readline closes.
// Pushing done here (synchronously) prevents pi from executing tools.
}
return {
handleEvent,
getOutput: () => output,
};
}
export * from "../../../plugins/fusion-plugin-droid-runtime/src/event-bridge.js";

View File

@@ -1,144 +1 @@
/**
* Custom tool discovery and MCP config file generation.
*
* Discovers non-built-in tools from pi, writes their schemas to a temp file,
* and generates an MCP config that points to the schema-only MCP server.
*/
import { writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
/**
* A single tool descriptor returned by pi.getAllTools().
*/
interface PiToolInfo {
name: string;
description: string;
parameters: Record<string, unknown>;
}
/**
* Minimal duck-type interface for the pi ExtensionAPI instance.
* We only call getAllTools(), so we only declare that method.
* The return type is unknown to accommodate defensive runtime checks.
*/
interface PiInstance {
getAllTools(): unknown;
}
/** The 6 built-in tools that pi handles natively (match pi tool names). */
const BUILT_IN_TOOL_NAMES = new Set([
"read",
"write",
"edit",
"bash",
"grep",
"find",
]);
/** A custom tool definition with MCP-compatible schema. */
export interface McpToolDef {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
/**
* Get custom tool definitions from pi, filtering out built-in tools.
*
* @param pi - The pi ExtensionAPI instance
* @returns Array of custom tool definitions (empty if all tools are built-in)
*/
export function getCustomToolDefs(pi: PiInstance): McpToolDef[] {
const allTools = pi.getAllTools();
if (!Array.isArray(allTools)) {
return [];
}
return (allTools as PiToolInfo[])
.filter((tool) => !BUILT_IN_TOOL_NAMES.has(tool.name))
.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.parameters,
}));
}
/** Minimal pi-ai Tool shape (the subset we need from `Context.tools`). */
interface PiAiToolLike {
name: string;
description: string;
parameters: Record<string, unknown>;
}
/**
* Convert the pi-ai `Context.tools` array (the authoritative per-session tool
* list pi-coding-agent passes to streamSimple) into MCP tool defs, filtering
* out the 6 built-ins that pi handles natively.
*/
export function toolsFromContext(
contextTools: ReadonlyArray<PiAiToolLike> | undefined,
): McpToolDef[] {
if (!Array.isArray(contextTools)) return [];
return contextTools
.filter((tool) => !BUILT_IN_TOOL_NAMES.has(tool.name))
.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.parameters,
}));
}
/**
* Write MCP config and tool schemas to temp files.
*
* Creates two temp files:
* 1. Schema file: JSON array of tool definitions
* 2. Config file: MCP config pointing to the schema-only server
*
* @param toolDefs - Array of custom tool definitions
* @param cacheKey - Optional suffix appended to filenames so that distinct
* tool sets (e.g. session-scoped tool registrations) get distinct files
* and don't race on a single shared path.
* @returns Path to the MCP config file
*/
export function writeMcpConfig(
toolDefs: McpToolDef[],
cacheKey?: string,
): string {
const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`;
// Write tool schemas to temp file
const schemaFilePath = join(
tmpdir(),
`droid-cli-mcp-schemas-${suffix}.json`,
);
writeFileSync(schemaFilePath, JSON.stringify(toolDefs));
// Resolve path to the schema server .cjs file (sibling of this module)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const serverPath = join(__dirname, "mcp-schema-server.cjs");
// Build MCP config
const config = {
mcpServers: {
"custom-tools": {
command: "node",
args: [serverPath, schemaFilePath],
},
},
};
// Write config to temp file
const configFilePath = join(
tmpdir(),
`droid-cli-mcp-config-${suffix}.json`,
);
writeFileSync(configFilePath, JSON.stringify(config));
return configFilePath;
}
export * from "../../../plugins/fusion-plugin-droid-runtime/src/mcp-config.js";

View File

@@ -1,358 +1 @@
/**
* Process manager for spawning and managing Droid CLI subprocesses.
*
* Handles subprocess lifecycle: spawn with correct CLI flags, write NDJSON
* messages to stdin, force-kill after result (CLI hangs bug), and stderr capture.
* Also provides startup validation for CLI presence and authentication.
*/
import { execSync, spawn, type ChildProcess } from "node:child_process";
import { writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
function debugLog(message: string): void {
if (process.env.PI_DROID_CLI_DEBUG !== "1") return;
console.error(`[droid-cli] ${message}`);
}
/**
* Spawn a Droid CLI subprocess with all required flags for stream-json communication.
*
* @param modelId - The model ID to pass via --model flag
* @param systemPrompt - Optional system prompt appended via --append-system-prompt
* @param options - Optional cwd, AbortSignal, and effort level
* @returns The spawned ChildProcess with piped stdin/stdout/stderr
*/
export function buildDroidSpawnArgs(
modelId: string,
systemPrompt?: string,
options?: {
effort?: string;
mcpConfigPath?: string;
resumeSessionId?: string;
newSessionId?: string;
},
): string[] {
const args = [
"-p",
"--input-format",
"stream-json",
"--output-format",
"stream-json",
"--verbose",
"--include-partial-messages",
"--model",
modelId,
];
if (options?.resumeSessionId) {
// Resume an existing session — CLI loads prior conversation from disk
args.push("--resume", options.resumeSessionId);
} else if (options?.newSessionId) {
// First turn: create session with this ID so subsequent turns can --resume it
args.push("--session-id", options.newSessionId);
}
if (systemPrompt) {
// Write system prompt to a temp file to avoid ENAMETOOLONG on Windows.
// Droid CLI's --append-system-prompt accepts a file path or literal text.
const tmpFile = join(
tmpdir(),
`droid-cli-sysprompt-${process.pid}.txt`,
);
writeFileSync(tmpFile, systemPrompt, "utf-8");
args.push("--append-system-prompt", tmpFile);
}
if (options?.effort) {
args.push("--effort", options.effort);
}
if (options?.mcpConfigPath) {
args.push("--mcp-config", options.mcpConfigPath);
}
return args;
}
export function spawnDroid(
modelId: string,
systemPrompt?: string,
options?: {
cwd?: string;
signal?: AbortSignal;
effort?: string;
mcpConfigPath?: string;
resumeSessionId?: string;
newSessionId?: string;
},
): ChildProcess {
const args = buildDroidSpawnArgs(modelId, systemPrompt, {
effort: options?.effort,
mcpConfigPath: options?.mcpConfigPath,
resumeSessionId: options?.resumeSessionId,
newSessionId: options?.newSessionId,
});
const proc = spawn("droid", args, {
stdio: ["pipe", "pipe", "pipe"],
cwd: options?.cwd ?? process.cwd(),
});
debugLog(`spawnDroid: pid=${proc.pid} model=${modelId}`);
return proc as ChildProcess;
}
/**
* Clean up the temp system prompt file created by spawnDroid.
* Safe to call multiple times or when no file exists.
*/
export function cleanupSystemPromptFile(): void {
try {
unlinkSync(join(tmpdir(), `droid-cli-sysprompt-${process.pid}.txt`));
} catch {
// File doesn't exist or already deleted — ignore
}
}
/**
* Write a user message to the subprocess stdin as NDJSON.
* Calls stdin.end() after writing the user message to signal EOF, allowing
* Droid CLI to process the input and start generating.
*
* Accepts both string (text-only prompt) and array (ContentBlock[] with images)
* content. JSON.stringify handles both natively. The stream-json protocol
* supports either format in the content field.
*
* @param proc - The Claude subprocess
* @param prompt - The prompt text or ContentBlock[] to send
*/
export function writeUserMessage(
proc: ChildProcess,
prompt: string | unknown[],
): void {
const message = {
type: "user",
message: {
role: "user",
content: prompt,
},
};
proc.stdin!.write(JSON.stringify(message) + "\n");
proc.stdin!.end();
}
/**
* Force-kill a subprocess immediately via SIGKILL.
* No-ops if the process is already dead (killed or exited).
* Cross-platform safe: Node.js treats SIGKILL as forceful termination on Windows.
*
* @param proc - The subprocess to force-kill
*/
export function forceKillProcess(proc: ChildProcess): void {
if (proc.killed || proc.exitCode !== null) return;
proc.kill("SIGKILL");
}
/** Registry of active subprocesses for cleanup on teardown. */
const activeProcesses = new Set<ChildProcess>();
/**
* Register a subprocess in the global process registry.
* The process is automatically removed from the registry when it exits.
*
* @param proc - The subprocess to track
*/
export function registerProcess(proc: ChildProcess): void {
activeProcesses.add(proc);
proc.on("exit", () => activeProcesses.delete(proc));
}
/**
* Force-kill all registered subprocesses and clear the registry.
* Safe to call multiple times -- no-ops on already-dead processes.
*/
export function killAllProcesses(): void {
for (const proc of activeProcesses) {
forceKillProcess(proc);
}
activeProcesses.clear();
}
/**
* Force-kill the subprocess after a 500ms grace period.
* The Droid CLI hangs after emitting the result message (known bug).
* Brief grace period allows final stdout flushing before force-kill.
*
* @param proc - The Claude subprocess to clean up
*/
export function cleanupProcess(proc: ChildProcess): void {
setTimeout(() => {
forceKillProcess(proc);
}, 500);
}
/**
* Attach a data listener to stderr and accumulate output into a buffer.
*
* @param proc - The Claude subprocess
* @returns A function that returns the accumulated stderr string
*/
export function captureStderr(proc: ChildProcess): () => string {
let buffer = "";
proc.stderr!.on("data", (data: Buffer) => {
buffer += data.toString();
});
return () => buffer;
}
/**
* Validate that the Droid CLI is installed and on PATH.
* Throws with install instructions if not found.
*/
export function validateCliPresence(): void {
try {
execSync("droid --version", { stdio: "pipe", timeout: 45000 });
} catch {
throw new Error(
"Droid CLI not found on PATH. Install Droid CLI and then run: droid auth login",
);
}
}
/**
* Validate that the Droid CLI is authenticated.
* Returns false and warns if not authenticated.
*
* @returns true if authenticated, false otherwise
*/
export function validateCliAuth(): boolean {
try {
execSync("droid auth status", { stdio: "pipe", timeout: 45000 });
return true;
} catch {
console.warn(
"[droid-cli] Droid CLI is not authenticated. " +
"Run 'droid auth login' to authenticate.",
);
return false;
}
}
/**
* Run a one-shot `droid <args>` and resolve to the exit code.
*
* Why: the sync execSync variants block the Node event loop for the duration
* of a Droid CLI cold start (13s, occasionally longer). When droid-cli's
* factory is invoked from a per-request createFnAgent path (Fusion dashboard
* does this on every chat send), those sync probes freeze every other request.
* This async variant uses spawn so the loop keeps turning while the subprocess
* starts up.
*/
function runDroidProbe(args: string[], timeoutMs = 45000): Promise<number> {
return new Promise((resolve) => {
const proc = spawn("droid", args, { stdio: "ignore" });
const timer = setTimeout(() => {
try {
proc.kill("SIGKILL");
} catch {
// already dead
}
resolve(124);
}, timeoutMs);
proc.once("error", () => {
clearTimeout(timer);
resolve(127);
});
proc.once("exit", (code) => {
clearTimeout(timer);
resolve(code ?? 1);
});
});
}
/**
* Async, non-blocking variant of validateCliPresence.
* Resolves with `{ok: true}` on success, `{ok: false, error}` on failure —
* never rejects, so callers can fire-and-forget without unhandled rejections.
*/
export async function validateCliPresenceAsync(): Promise<
{ ok: true } | { ok: false; error: Error }
> {
const code = await runDroidProbe(["--version"]);
if (code === 0) return { ok: true };
return {
ok: false,
error: new Error(
"Droid CLI not found on PATH. Install Droid CLI and then run: droid auth login",
),
};
}
/**
* Async, non-blocking variant of validateCliAuth.
* Returns true if authenticated. Logs a warning (does not throw) otherwise.
*/
export async function validateCliAuthAsync(): Promise<boolean> {
const code = await runDroidProbe(["auth", "status"]);
if (code === 0) return true;
console.warn(
"[droid-cli] Droid CLI is not authenticated. " +
"Run 'droid auth login' to authenticate.",
);
return false;
}
export async function discoverDroidModels(): Promise<string[]> {
const attempts: string[][] = [["models", "--json"], ["model", "list", "--json"], ["models"]];
for (const args of attempts) {
const models = await new Promise<string[] | null>((resolve) => {
const proc = spawn("droid", args, { stdio: ["ignore", "pipe", "ignore"] });
let out = "";
proc.stdout?.on("data", (chunk: Buffer) => {
out += chunk.toString();
});
proc.once("error", () => resolve(null));
proc.once("exit", (code) => {
if (code !== 0) return resolve(null);
const trimmed = out.trim();
if (!trimmed) return resolve([]);
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return resolve(
parsed
.map((entry) =>
typeof entry === "string"
? entry
: typeof entry?.id === "string"
? entry.id
: typeof entry?.name === "string"
? entry.name
: undefined,
)
.filter((id): id is string => Boolean(id)),
);
}
} catch {
// not json, fall through to line parsing
}
resolve(
trimmed
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean),
);
});
});
if (models && models.length > 0) {
return Array.from(new Set(models));
}
}
return [];
}
export * from "../../../plugins/fusion-plugin-droid-runtime/src/process-manager.js";

View File

@@ -1,629 +1 @@
/**
* Prompt builder for flattening pi conversation history into a labeled text prompt.
*
* Follows the reference project's buildPromptBlocks() pattern:
* - USER: / ASSISTANT: / TOOL RESULT: labels
* - Content blocks serialized by type
* - Images in the final user message are translated to Anthropic API format (HIST-02)
* - Images in non-final messages get placeholder text with console.warn
*/
import { existsSync, readFileSync } from "node:fs";
import { resolve, join, dirname } from "node:path";
import { homedir } from "node:os";
/**
* Minimal message shape that prompt-builder accepts.
* Uses a wide `role: string` discriminant so tests can pass plain objects
* without literal type annotations. Content is typed broadly as
* `string | unknown[]` since helper functions narrow at runtime.
*/
export interface PiMessage {
role: string;
content: string | unknown[];
toolName?: string;
}
/**
* Minimal pi-ai Tool shape — the subset we read from `Context.tools` to build
* the deferred-tools system-prompt addendum.
*/
export interface PiToolLike {
name: string;
description?: string;
}
export type PiContext = {
systemPrompt?: string;
messages: PiMessage[];
tools?: ReadonlyArray<PiToolLike>;
};
import {
mapPiToolNameToDroid,
translatePiArgsToDroid,
isCustomToolName,
} from "./tool-mapping.js";
/**
* Anthropic API content block types for image passthrough.
* Used when the final user message contains images that need to be
* translated from pi-ai format to Anthropic format.
*/
type AnthropicContentBlock =
| { type: "text"; text: string }
| {
type: "image";
source: { type: "base64"; media_type: string; data: string };
};
/**
* Flattens a pi conversation context's messages array into a labeled text prompt
* suitable for sending to the Droid CLI subprocess.
*
* Each message is labeled with its role:
* - USER: for user messages
* - ASSISTANT: for assistant messages
* - TOOL RESULT ({toolName}): for tool result messages
*/
/** Module-level counter for placeholder images, reset per buildPrompt call. */
let placeholderImageCount = 0;
/**
* Translate a pi-ai image block to Anthropic API format.
* Returns null if the block is missing required data/mimeType fields.
*
* pi-ai format: { type: "image", data: string (base64), mimeType: string }
* Anthropic format: { type: "image", source: { type: "base64", media_type: string, data: string } }
*/
function translateImageBlock(piBlock: unknown): AnthropicContentBlock | null {
const block = piBlock as Record<string, unknown>;
if (typeof block.data === "string" && typeof block.mimeType === "string") {
return {
type: "image",
source: {
type: "base64",
media_type: block.mimeType,
data: block.data,
},
};
}
return null; // Invalid image block, will fall back to placeholder
}
/**
* Build content blocks for the final user message, translating images
* from pi-ai format to Anthropic API format.
*
* @returns Array of AnthropicContentBlock with text and translated images
*/
function buildFinalUserContent(
content: string | unknown[],
): AnthropicContentBlock[] {
if (typeof content === "string") {
return [{ type: "text", text: content }];
}
if (!Array.isArray(content)) {
return [{ type: "text", text: "" }];
}
const blocks: AnthropicContentBlock[] = [];
for (const rawBlock of content) {
const block = rawBlock as Record<string, unknown>;
if (block.type === "text") {
blocks.push({ type: "text", text: typeof block.text === "string" ? block.text : "" });
} else if (block.type === "image") {
const translated = translateImageBlock(block);
if (translated) {
blocks.push(translated);
} else {
// Invalid image block: fall back to placeholder text
blocks.push({
type: "text",
text: "[An image was shared here but could not be included]",
});
placeholderImageCount++;
}
}
// Unknown block types silently skipped
}
return blocks;
}
/**
* Check if a message content array contains image blocks.
*/
function contentHasImages(content: string | unknown[]): boolean {
if (typeof content === "string" || !Array.isArray(content)) return false;
return content.some((block) => (block as Record<string, unknown>).type === "image");
}
/**
* Check if the conversation ends with a custom tool result.
* If so, build a simplified prompt that presents the result directly
* instead of replaying the full conversation history with tool labels.
*/
function buildCustomToolResultPrompt(messages: PiMessage[]): string | null {
if (messages.length < 3) return null;
const last = messages[messages.length - 1];
if (last.role !== "toolResult") return null;
if (!last.toolName || !isCustomToolName(last.toolName)) return null;
// Find the original user message (scan backwards past assistant + toolResult)
let userMessage: string | null = null;
for (let i = messages.length - 3; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "user") {
userMessage = userContentToText(msg.content);
break;
}
}
if (!userMessage) return null;
const toolResult = toolResultContentToText(last.content);
return `${userMessage}\n\n[The ${last.toolName} tool was called and returned the following result]\n${toolResult}\n\nRespond to the user using the tool result above.`;
}
/**
* Build a prompt for a resumed session.
*
* When resuming via --resume, the CLI already has the full conversation history
* up through (and including) the most recent assistant turn that it produced.
* We only need to send the *delta* since that turn: any trailing tool results
* for the last assistant tool_use, and/or a new user message.
*
* Why anchor on the last assistant message (not the last user message)?
* Pi's tool-use loop appends `[user, assistant(toolUse), toolResult,
* assistant(toolUse), toolResult, ...]` — the only `user` entry stays at index
* 0 across many provider invocations. Anchoring on the last user message and
* walking forward (the prior implementation) re-sent the entire transcript on
* every tool-loop iteration, so each --resume turn appended a duplicate of the
* original query plus a growing stack of tool results to the on-disk session.
*
* Returns "" when there's nothing new to send (e.g. only an assistant message
* exists in the context — can happen mid-shutdown).
*/
export function buildResumePrompt(context: PiContext): string | AnthropicContentBlock[] {
const messages = context.messages;
if (messages.length === 0) return "";
let lastAssistantIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") {
lastAssistantIdx = i;
break;
}
}
const newMessages = messages.slice(lastAssistantIdx + 1);
if (newMessages.length === 0) return "";
const parts: string[] = [];
for (const msg of newMessages) {
if (msg.role === "toolResult") {
if (msg.toolName && isCustomToolName(msg.toolName)) {
parts.push(`TOOL RESULT (${msg.toolName}):`);
} else {
const claudeToolName = msg.toolName
? mapPiToolNameToDroid(msg.toolName)
: "unknown";
parts.push(`TOOL RESULT (${claudeToolName}):`);
}
parts.push(toolResultContentToText(msg.content));
} else if (msg.role === "user") {
if (contentHasImages(msg.content)) {
const textSoFar = parts.join("\n");
const userContent = buildFinalUserContent(msg.content);
const result: AnthropicContentBlock[] = [];
if (textSoFar) {
result.push({ type: "text", text: textSoFar });
}
result.push(...userContent);
return result;
}
parts.push(userContentToText(msg.content));
}
}
return parts.join("\n") || "";
}
export function buildPrompt(context: PiContext): string | AnthropicContentBlock[] {
// Reset placeholder counter for each call
placeholderImageCount = 0;
// Special case: when conversation ends with a custom tool result,
// present it directly instead of complex history replay
const customToolPrompt = buildCustomToolResultPrompt(context.messages);
if (customToolPrompt) {
// customToolPrompt calls userContentToText which may increment placeholderImageCount
if (placeholderImageCount > 0) {
console.warn(
`[droid-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
);
}
return customToolPrompt;
}
// Determine if any message has images worth passing through
const finalUserIndex = findFinalUserMessageIndex(context.messages);
const finalUserMsg = finalUserIndex >= 0 ? context.messages[finalUserIndex] : undefined;
const finalUserHasImages =
finalUserMsg !== undefined &&
finalUserMsg.role === "user" &&
contentHasImages(finalUserMsg.content);
const anyToolResultHasImages = context.messages.some(
(m) => m.role === "toolResult" && toolResultHasImages(m.content),
);
if (finalUserHasImages || anyToolResultHasImages) {
// Build history as text (all messages except the final user message)
const historyParts: string[] = [];
const toolResultImageBlocks: AnthropicContentBlock[] = [];
for (let i = 0; i < context.messages.length; i++) {
if (i === finalUserIndex) continue; // Skip final user message -- handled separately
const message = context.messages[i];
if (message.role === "user") {
historyParts.push("USER:");
historyParts.push(userContentToText(message.content));
} else if (message.role === "assistant") {
historyParts.push("ASSISTANT:");
historyParts.push(contentToText(message.content));
} else if (message.role === "toolResult") {
if (message.toolName && isCustomToolName(message.toolName)) {
historyParts.push(`TOOL RESULT (${message.toolName}):`);
} else {
const claudeToolName = message.toolName
? mapPiToolNameToDroid(message.toolName)
: "unknown";
historyParts.push(`TOOL RESULT (${claudeToolName}):`);
}
// Extract text portion of tool result
historyParts.push(toolResultContentToText(message.content));
// Collect image blocks from tool results for passthrough
if (Array.isArray(message.content)) {
for (const rawBlock of message.content) {
const block = rawBlock as Record<string, unknown>;
if (block.type === "image") {
const translated = translateImageBlock(block);
if (translated) {
toolResultImageBlocks.push(translated);
// Undo the placeholder count from toolResultContentToText since we're passing through
placeholderImageCount--;
}
}
}
}
}
}
// Build final user message content blocks
const finalUserContent =
finalUserMsg?.role === "user"
? buildFinalUserContent(finalUserMsg.content)
: [];
// Combine: history text + tool result images + final user content blocks
const result: AnthropicContentBlock[] = [];
const historyText = historyParts.join("\n");
if (historyText) {
result.push({ type: "text", text: historyText });
}
// Insert tool result images after history text (Claude sees them in context)
result.push(...toolResultImageBlocks);
result.push(...finalUserContent);
if (placeholderImageCount > 0) {
console.warn(
`[droid-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
);
}
return result;
}
// No images in final user message: standard text-only path
const parts: string[] = [];
for (const message of context.messages) {
if (message.role === "user") {
parts.push("USER:");
parts.push(userContentToText(message.content));
} else if (message.role === "assistant") {
parts.push("ASSISTANT:");
parts.push(contentToText(message.content));
} else if (message.role === "toolResult") {
if (message.toolName && isCustomToolName(message.toolName)) {
// Custom tools: don't reference MCP tool name. Present result plainly.
parts.push(`TOOL RESULT (${message.toolName}):`);
} else {
const claudeToolName = message.toolName
? mapPiToolNameToDroid(message.toolName)
: "unknown";
parts.push(`TOOL RESULT (${claudeToolName}):`);
}
parts.push(toolResultContentToText(message.content));
}
}
if (placeholderImageCount > 0) {
console.warn(
`[droid-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
);
}
return parts.join("\n") || "";
}
/**
* Find the index of the last user message in the messages array.
* Returns -1 if no user message found.
*/
function findFinalUserMessageIndex(messages: PiMessage[]): number {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return i;
}
return -1;
}
/**
* Builds the system prompt from the context's systemPrompt field,
* appending AGENTS.md content if found (walking up from cwd, then global fallback).
* Sanitizes .pi references to .claude for Claude Code compatibility.
*/
export function buildSystemPrompt(
context: PiContext,
cwd: string,
): string {
const parts: string[] = [];
if (context.systemPrompt) {
parts.push(rewriteCustomToolReferences(context.systemPrompt, context.tools));
}
// Look for AGENTS.md
const agentsPath = resolveAgentsMdPath(cwd);
if (agentsPath) {
try {
const content = readFileSync(agentsPath, "utf-8");
const sanitized = sanitizeAgentsContent(content);
parts.push(sanitized);
} catch {
// If we can't read it, skip silently
}
}
// When conversation history has tool results, instruct Claude to use them
// instead of trying to re-call tools (which may not be available).
if (context.messages?.some((m) => m.role === "toolResult")) {
parts.push(
"IMPORTANT: The conversation history below contains tool results from previously executed tools. " +
"Use these results to answer the user's question. Do NOT attempt to re-call tools that already have results.",
);
}
const customToolsAddendum = buildCustomToolsAddendum(context.tools);
if (customToolsAddendum) {
parts.push(customToolsAddendum);
}
return parts.join("\n\n");
}
/** Pi built-in tool names — these go through pi's wrapped built-ins, not MCP. */
const BUILT_IN_PI_TOOLS = new Set([
"read",
"write",
"edit",
"bash",
"grep",
"find",
]);
/**
* Rewrite bare references to custom pi tool names (e.g. `fn_review_spec`,
* `fn_review_spec()`) in the system prompt so they appear as their
* MCP-prefixed names (`mcp__custom-tools__fn_review_spec`). Engine prompts are
* written for direct API tool calls; under droid-cli the same tools are
* reachable only through the MCP shim. Without this rewrite, models like
* Sonnet 4.6 inconsistently translate the names — sometimes calling MCP
* variants, sometimes silently skipping the call (observed in triage where
* `fn_review_spec` was never invoked even though the prompt said "MUST call").
*
* Only rewrites whole-word matches anchored to a non-identifier boundary, so
* substrings inside other identifiers stay intact. Skips already-prefixed
* occurrences (`mcp__custom-tools__fn_review_spec`) and pi built-ins.
*/
function rewriteCustomToolReferences(
prompt: string,
tools: ReadonlyArray<PiToolLike> | undefined,
): string {
if (!prompt || !tools || tools.length === 0) {
return prompt;
}
let result = prompt;
for (const tool of tools) {
if (BUILT_IN_PI_TOOLS.has(tool.name)) continue;
// \b doesn't treat `_` as a word boundary the way we want here, so anchor
// the match between either start-of-string/non-identifier-char and either
// end-of-string/non-identifier-char. Also negative-lookbehind for
// `mcp__custom-tools__` so we don't double-prefix.
const escaped = tool.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(
`(?<![A-Za-z0-9_])(?<!mcp__custom-tools__)${escaped}(?![A-Za-z0-9_])`,
"g",
);
result = result.replace(pattern, `mcp__custom-tools__${tool.name}`);
}
return result;
}
/**
* Build a system-prompt addendum that maps each custom pi tool to its
* MCP-exposed name (`mcp__custom-tools__<name>`) and tells Claude to call
* those names directly. We intentionally avoid a ToolSearch prerequisite:
* requiring an internal discovery step can send the model into long internal
* tool loops before it emits actionable pi tool calls.
*
* Returns an empty string when there are no custom tools so the addendum
* doesn't pollute prompts on plain chat sessions with only built-ins.
*/
function buildCustomToolsAddendum(
tools: ReadonlyArray<PiToolLike> | undefined,
): string {
if (!tools || tools.length === 0) return "";
const customNames = tools
.map((t) => t.name)
.filter((name) => !BUILT_IN_PI_TOOLS.has(name));
if (customNames.length === 0) return "";
const lines = customNames
.sort()
.map((name) => `- \`${name}\` is exposed as \`mcp__custom-tools__${name}\``);
return [
"## Custom tool naming (MCP)",
"",
"The following pi extension tools are available under MCP-prefixed",
"names. When a system prompt or task instruction asks you to call one",
"of these by its short name, call the MCP-prefixed name directly.",
"",
...lines,
].join("\n");
}
/**
* Converts user message content to text.
* Handles string content and array of content blocks.
* Image blocks are replaced with placeholder text (HIST-02).
* Increments the module-level placeholderImageCount for each image.
*/
function userContentToText(content: string | unknown[]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const texts: string[] = [];
for (const rawBlock of content) {
const block = rawBlock as Record<string, unknown>;
if (block.type === "text") {
texts.push(typeof block.text === "string" ? block.text : "");
} else if (block.type === "image") {
texts.push("[An image was shared here but could not be included]");
placeholderImageCount++;
}
// Unknown block types silently skipped
}
return texts.join("\n");
}
/**
* Converts assistant message content to text.
* Handles string content and array of content blocks (text, thinking, toolCall).
*/
function contentToText(content: string | unknown[]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((rawBlock) => {
const block = rawBlock as Record<string, unknown>;
if (block.type === "text") return typeof block.text === "string" ? block.text : "";
if (block.type === "thinking") return ""; // Skip thinking — internal reasoning, not conversation
if (block.type === "toolCall") {
const name = typeof block.name === "string" ? block.name : "";
const rawArgs = block.arguments;
// A toolCall may carry either parsed args (object) or the raw unparsed
// string that pi produced — preserve the raw string verbatim so callers
// can see what the model actually sent.
const argsObject =
rawArgs && typeof rawArgs === "object" ? (rawArgs as Record<string, unknown>) : undefined;
const isCustom = isCustomToolName(name);
if (isCustom) {
// Custom tools: don't reference the MCP tool name — Claude might try to re-call it.
// Just note what was done. The result follows as a TOOL RESULT message.
const argsStr = argsObject
? JSON.stringify(argsObject)
: typeof rawArgs === "string"
? JSON.stringify(rawArgs)
: "{}";
return `[Used ${name} tool with args: ${argsStr}]`;
}
const claudeName = mapPiToolNameToDroid(name);
const claudeArgs = argsObject ? translatePiArgsToDroid(name, argsObject) : undefined;
const argsStr = claudeArgs
? JSON.stringify(claudeArgs)
: typeof rawArgs === "string"
? JSON.stringify(rawArgs)
: "{}";
return `[Prior tool call — already executed; result follows in TOOL RESULT (${claudeName}):] args=${argsStr}`;
}
// Unknown block types are represented as a placeholder
return `[${String(block.type)}]`;
})
.join("\n");
}
/**
* Converts tool result content to text.
* Handles string content and array of content blocks.
* Image blocks get placeholder text (actual image passthrough handled separately).
*/
function toolResultContentToText(content: string | unknown[]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const texts: string[] = [];
for (const rawBlock of content) {
const block = rawBlock as Record<string, unknown>;
if (block.type === "text") {
texts.push(typeof block.text === "string" ? block.text : "");
} else if (block.type === "image") {
texts.push("[An image was shared here but could not be included]");
placeholderImageCount++;
}
}
return texts.join("\n");
}
/**
* Check if a tool result content array contains image blocks.
*/
function toolResultHasImages(content: string | unknown[]): boolean {
if (typeof content === "string" || !Array.isArray(content)) return false;
return content.some((block) => (block as Record<string, unknown>).type === "image");
}
/**
* Walk up from cwd looking for AGENTS.md, fall back to ~/.pi/agent/AGENTS.md.
*/
function resolveAgentsMdPath(cwd: string): string | undefined {
let current = resolve(cwd);
while (true) {
const candidate = join(current, "AGENTS.md");
if (existsSync(candidate)) return candidate;
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
// Fall back to global path
const globalHome = process.env.HOME || process.env.USERPROFILE || homedir();
const globalPath = join(globalHome, ".pi", "agent", "AGENTS.md");
if (existsSync(globalPath)) return globalPath;
return undefined;
}
/**
* Sanitize .pi references to .claude in AGENTS.md content
* for Claude Code compatibility.
*/
function sanitizeAgentsContent(content: string): string {
let sanitized = content;
// ~/.pi -> ~/.claude
sanitized = sanitized.replace(/~\/\.pi\b/gi, "~/.claude");
// .pi/ -> .claude/ (at word boundary or after whitespace/quotes)
sanitized = sanitized.replace(/(^|[\s'"`])\.pi\//g, "$1.claude/");
// Remaining standalone .pi references
sanitized = sanitized.replace(/\b\.pi\b/gi, ".claude");
return sanitized;
}
export * from "../../../plugins/fusion-plugin-droid-runtime/src/prompt-builder.js";

View File

@@ -1,447 +1 @@
/**
* Provider orchestration for bridging pi requests to the Droid CLI subprocess.
*
* streamViaCli is the core function that:
* 1. Builds the prompt from conversation context
* 2. Spawns a Droid CLI subprocess with correct flags
* 3. Writes the user message to stdin as NDJSON
* 4. Reads stdout line-by-line, parsing NDJSON
* 5. Routes stream events through the event bridge to pi's stream
* 6. Handles result/error messages and cleans up the subprocess
* 7. Implements break-early: kills subprocess at message_stop when
* built-in or custom-tools MCP tool_use blocks are seen
* 8. Hardened lifecycle: inactivity timeout, subprocess exit handler,
* streamEnded guard, abort via SIGKILL, process registry
*/
import { createInterface } from "node:readline";
import {
AssistantMessageEventStream,
type Api,
type Model,
type SimpleStreamOptions,
type TextContent,
type ThinkingContent,
type ToolCall,
} from "@mariozechner/pi-ai";
import {
buildPrompt,
buildSystemPrompt,
buildResumePrompt,
type PiContext,
} from "./prompt-builder.js";
import {
spawnDroid,
writeUserMessage,
cleanupProcess,
captureStderr,
forceKillProcess,
registerProcess,
cleanupSystemPromptFile,
buildDroidSpawnArgs,
} from "./process-manager.js";
import { parseLine } from "./stream-parser.js";
import { createEventBridge } from "./event-bridge.js";
import { mapThinkingEffort } from "./thinking-config.js";
import { isPiKnownDroidTool } from "./tool-mapping.js";
/**
* Inactivity safety net for the Droid CLI subprocess.
*
* Set very high (30 minutes) because the caller is the authoritative source of
* truth for "this session is stuck": Fusion's engine runs a `StuckTaskDetector`
* with a configurable heartbeat (default 1 hour) and aborts the session via
* `AbortSignal` when it decides the agent has gone quiet. droid-cli already
* forwards that signal to the subprocess (`forceKillProcess` on `signal.abort`).
*
* A short timeout here was racing the engine: Sonnet 4.6 with extended thinking
* on the triage prompt (~40k chars) routinely goes >3 minutes between thinking
* deltas, and we were killing those subprocesses before they could write
* PROMPT.md and call `fn_review_spec`. The half-hour ceiling is just a
* last-resort guard for catastrophically hung processes when no abort signal
* arrives (e.g. someone embeds droid-cli without a stuck detector).
*/
const INACTIVITY_TIMEOUT_MS = 30 * 60_000;
/**
* Cold-start ceiling: kill the subprocess if it hasn't produced a single line
* of stdout within this window. Distinct from INACTIVITY_TIMEOUT_MS so a hung
* binary (no output ever) is reported with a clear cause instead of being
* indistinguishable from a slow-thinking turn. Observed cold-start on a healthy
* droid is ~20s; 60s gives 3x headroom for slow machines / cold caches.
*/
const FIRST_LINE_TIMEOUT_MS = 60_000;
function isDebugStreamEnabled(): boolean {
return process.env.PI_DROID_CLI_DEBUG === "1";
}
function debugLog(message: string): void {
if (!isDebugStreamEnabled()) return;
console.error(`[droid-cli] ${message}`);
}
/** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
type StreamViaCLiOptions = SimpleStreamOptions & {
cwd?: string;
mcpConfigPath?: string;
};
/**
* Stream a response from Droid CLI as an AssistantMessageEventStream.
*
* Orchestrates the full subprocess lifecycle: spawn, write prompt, parse NDJSON,
* bridge events, handle result, and clean up. Implements break-early pattern:
* at message_stop, if any built-in or custom-tools MCP tool was seen, kills
* the subprocess before Droid CLI can auto-execute the tools.
*
* Hardened with: inactivity timeout (180s), subprocess exit handler with stderr
* surfacing, streamEnded guard against double errors, abort via SIGKILL, and
* process registry integration for teardown cleanup.
*
* @param model - The model to use (from pi's model catalog)
* @param context - The conversation context with messages and system prompt
* @param options - Optional cwd, abort signal, reasoning level, thinking budgets, and mcpConfigPath
* @returns An AssistantMessageEventStream that receives bridged events
*/
export function streamViaCli(
model: Model<Api>,
context: PiContext,
options?: StreamViaCLiOptions,
): AssistantMessageEventStream {
// @ts-expect-error — tsc can't verify AssistantMessageEventStream is a value
// through pi-ai's `export *` re-export chain. The class constructor exists at runtime.
const stream = new AssistantMessageEventStream();
(async () => {
let proc: ReturnType<typeof spawnDroid> | undefined;
let abortHandler: (() => void) | undefined;
try {
const cwd = options?.cwd ?? process.cwd();
// Resume if pi provides a session ID AND this isn't the first turn.
// Pi passes sessionId on every call (including first), but we can only
// --resume a CLI session that already exists on disk from a prior turn.
const resumeSessionId =
options?.sessionId && context.messages.length > 1
? options.sessionId
: undefined;
// Build prompt: if resuming, only send the latest user turn;
// otherwise build the full flattened conversation history
const prompt = resumeSessionId
? buildResumePrompt(context)
: buildPrompt(context);
const systemPrompt = resumeSessionId
? undefined
: buildSystemPrompt(context, cwd);
// Compute effort level from reasoning options
const effort = mapThinkingEffort(
options?.reasoning,
model.id,
options?.thinkingBudgets,
);
const spawnOptions = {
cwd,
signal: options?.signal,
effort,
mcpConfigPath: options?.mcpConfigPath,
resumeSessionId,
newSessionId: !resumeSessionId ? options?.sessionId : undefined,
};
// Spawn subprocess
proc = spawnDroid(model.id, systemPrompt || undefined, spawnOptions);
const getStderr = captureStderr(proc);
// Register in global process registry for teardown cleanup
registerProcess(proc);
const spawnArgs = buildDroidSpawnArgs(model.id, undefined, {
effort,
mcpConfigPath: options?.mcpConfigPath,
resumeSessionId,
newSessionId: !resumeSessionId ? options?.sessionId : undefined,
});
debugLog(
`spawned droid subprocess pid=${proc.pid ?? "unknown"} args=${JSON.stringify(spawnArgs)}`,
);
// Write user message to subprocess stdin
writeUserMessage(proc, prompt);
debugLog("user message written to stdin, stdin.end() called");
// Create event bridge (before endStreamWithError so bridge is in scope)
const bridge = createEventBridge(stream, model);
// Guard against double stream.end() and double error events.
// First error path wins; subsequent ones are no-ops.
let streamEnded = false;
/**
* End the stream with an error, using a "done" event instead of "error".
*
* Why "done" not "error": AssistantMessageEventStream.extractResult()
* returns event.error (a string) for error events, but agent-loop.js
* then calls message.content.filter() on the result, crashing because
* a string has no .content property. By pushing "done" with a valid
* AssistantMessage (content:[]), pi gets a well-formed object.
*/
function endStreamWithError(errMsg: string) {
if (streamEnded || broken) return;
streamEnded = true;
const output = bridge.getOutput();
const errorMessage = {
...output,
content: output.content?.length
? output.content
: [{ type: "text" as const, text: `Error: ${errMsg}` }],
stopReason: "stop" as const,
};
stream.push({
type: "done",
reason: "stop",
message: errorMessage,
});
stream.end();
}
// Inactivity timeout: kill subprocess if no stdout for INACTIVITY_TIMEOUT_MS
let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
function resetInactivityTimer() {
if (inactivityTimer !== undefined) clearTimeout(inactivityTimer);
inactivityTimer = setTimeout(() => {
forceKillProcess(proc!);
endStreamWithError(
`Droid CLI subprocess timed out: no output for ${INACTIVITY_TIMEOUT_MS / 1000} seconds`,
);
}, INACTIVITY_TIMEOUT_MS);
}
// Set up abort signal handler -- uses SIGKILL for immediate force-kill
if (options?.signal) {
abortHandler = () => {
if (proc) {
forceKillProcess(proc);
}
};
if (options.signal.aborted) {
abortHandler();
return;
}
options.signal.addEventListener("abort", abortHandler, { once: true });
}
// Track tool_use blocks for break-early decision at message_stop
let sawBuiltInOrCustomTool = false;
let firstLineReceived = false;
// Guard against buffered readline lines firing after rl.close()
let broken = false;
// Set up readline for line-by-line NDJSON parsing
const rl = createInterface({
input: proc.stdout!,
crlfDelay: Infinity,
terminal: false,
});
// Handle process error -- use endStreamWithError for guard
proc.on("error", (err: Error) => {
if (broken) return; // Break-early killed the process intentionally
const stderr = getStderr();
endStreamWithError(stderr || err.message);
});
// Handle subprocess close -- surface crashes with stderr and exit code
proc.on("close", (code: number | null, _signal: string | null) => {
clearTimeout(inactivityTimer);
debugLog(`subprocess closed: code=${code} signal=${_signal}`);
if (broken) return; // Break-early kill, expected
const stderr = getStderr().trim();
if (stderr) {
console.warn(`[droid-cli] Droid CLI stderr on close: ${stderr}`);
}
if (code !== 0 && code !== null) {
const message = stderr
? `Droid CLI exited with code ${code}: ${stderr}`
: `Droid CLI exited unexpectedly with code ${code}`;
endStreamWithError(message);
}
});
// Start inactivity timer after writing user message
resetInactivityTimer();
// Cold-start ceiling: only fires if firstLineReceived stays false. Cleared
// when the first line arrives, when proc closes, or on break-early. This
// distinguishes "droid never started" from "droid is taking a long time
// between thinking deltas" so the inactivity kill carries actionable info.
const firstLineTimer: ReturnType<typeof setTimeout> = setTimeout(() => {
if (firstLineReceived) return;
forceKillProcess(proc!);
endStreamWithError(
`Droid CLI produced no output within ${FIRST_LINE_TIMEOUT_MS / 1000}s — likely binary hang or auth failure (try \`droid --version\` and \`droid auth status\`)`,
);
}, FIRST_LINE_TIMEOUT_MS);
proc.on("close", () => clearTimeout(firstLineTimer));
// Process NDJSON lines from stdout using event-based callback
// NOTE: Using 'line' event instead of `for await` because the async
// iterator batches lines, breaking real-time streaming to pi.
rl.on("line", (line: string) => {
if (!firstLineReceived) {
firstLineReceived = true;
debugLog("first stdout line received from Droid CLI");
}
if (broken) return; // Guard: ignore buffered lines after break-early
// Reset inactivity timer on each line of output
resetInactivityTimer();
const msg = parseLine(line);
if (!msg) return;
if (msg.type === "stream_event") {
// Only forward top-level events to pi's event bridge.
// Sub-agent events (parent_tool_use_id !== null) are internal to the CLI.
const isTopLevel = !msg.parent_tool_use_id;
if (isTopLevel) {
bridge.handleEvent(msg.event);
}
// Track tool_use blocks for break-early decision (top-level only)
if (
isTopLevel &&
msg.event.type === "content_block_start" &&
msg.event.content_block?.type === "tool_use"
) {
const toolName = msg.event.content_block.name;
if (toolName) {
const piKnownTool = isPiKnownDroidTool(toolName);
debugLog(
`top-level tool_use seen: ${toolName} (piKnown=${piKnownTool ? "yes" : "no"})`,
);
if (piKnownTool) {
// Built-in tool (Read/Write/etc.) OR custom MCP tool (mcp__custom-tools__*)
// Internal Claude Code tools (ToolSearch, Task, etc.) are excluded
sawBuiltInOrCustomTool = true;
}
}
}
// Break-early at message_stop: kill subprocess before CLI auto-executes tools
// Only on top-level message_stop — sub-agent message_stop is internal
if (
isTopLevel &&
msg.event.type === "message_stop" &&
sawBuiltInOrCustomTool
) {
debugLog("break-early triggered at message_stop after pi-known tool_use");
broken = true; // Set guard BEFORE rl.close() to prevent buffered lines
clearTimeout(inactivityTimer);
clearTimeout(firstLineTimer);
// Pi will execute these tools. Kill subprocess to prevent CLI from executing them.
forceKillProcess(proc!);
rl.close();
return; // Don't process further -- done event already pushed by event bridge
}
} else if (msg.type === "control_request") {
debugLog(
`unexpected control_request received (stdin already closed): ${msg.request_id}`,
);
} else if (msg.type === "result") {
if (msg.subtype === "error") {
endStreamWithError(msg.error ?? "Unknown error from Droid CLI");
}
// For both success and error: clean up the subprocess
clearTimeout(inactivityTimer);
clearTimeout(firstLineTimer);
cleanupProcess(proc!);
rl.close();
}
});
// Wait for readline to close (result received or process ended).
// Also resolve on subprocess close: if SIGKILL races readline (e.g. after
// an external abort or watchdog kill), `rl` may never emit "close" because
// its input stream was destroyed mid-buffer. Forcing rl.close() from the
// proc close handler guarantees this await unblocks instead of hanging
// and triggering the engine's "executor did not unwind within 60s" path.
await new Promise<void>((resolve) => {
rl.on("close", resolve);
proc!.on("close", () => {
try { rl.close(); } catch { /* already closed */ }
resolve();
});
});
// Push done event after readline closes (async). Pushing synchronously
// inside handleMessageStop prevents pi from executing tools.
// Guard with streamEnded to avoid pushing done after an error was already pushed.
if (!streamEnded) {
const output = bridge.getOutput();
const contentEvents = output.content || [];
if (contentEvents.length === 0) {
console.warn(
`[droid-cli] Droid CLI closed without content events (model=${model.id}, sessionId=${options?.sessionId ?? "none"})`,
);
}
// If stopReason is toolUse but there are no pi-known tool calls in content,
// it means only user MCP tools were called (filtered by event bridge).
// Override to "stop" so pi doesn't try to execute non-existent tools.
const piToolCalls = (output.content || []).filter(
(c: TextContent | ThinkingContent | ToolCall) => c.type === "toolCall",
);
const effectiveReason =
output.stopReason === "toolUse" && piToolCalls.length === 0
? "stop"
: output.stopReason;
streamEnded = true;
stream.push({
type: "done",
reason:
effectiveReason === "toolUse"
? "toolUse"
: effectiveReason === "length"
? "length"
: "stop",
message: { ...output, stopReason: effectiveReason },
});
stream.end();
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
// Push a "done" event with a text error so pi gets a valid AssistantMessage.
// Pushing type:"error" would require an AssistantMessage in the error field,
// but we don't have a full AssistantMessage here.
stream.push({
type: "done",
reason: "stop",
message: {
role: "assistant" as const,
content: [{ type: "text" as const, text: `Error: ${errMsg}` }],
api: "droid-cli",
provider: model.provider,
model: model.id,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "stop" as const,
timestamp: Date.now(),
},
});
stream.end();
} finally {
// Clean up abort listener
if (options?.signal && abortHandler) {
options.signal.removeEventListener("abort", abortHandler);
}
cleanupSystemPromptFile();
}
})();
return stream;
}
export { streamViaCli } from "../../../plugins/fusion-plugin-droid-runtime/src/provider.js";

View File

@@ -1,37 +1 @@
import type { NdjsonMessage } from "./types";
/**
* Parse a single NDJSON line from Droid CLI stdout into a typed message.
*
* This function is deliberately resilient -- it never throws. Debug noise,
* empty lines, and malformed JSON all return null so the streaming pipeline
* can safely skip them and continue processing.
*/
export function parseLine(line: string): NdjsonMessage | null {
const trimmed = line.trim();
// Skip empty lines
if (!trimmed) {
return null;
}
// Skip non-JSON lines (debug output like "[SandboxDebug] ...")
if (!trimmed.startsWith("{")) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
console.error("Failed to parse NDJSON line:", trimmed);
return null;
}
// Validate that the parsed result is a non-null object (not array, not primitive)
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
return parsed as NdjsonMessage;
}
export * from "../../../plugins/fusion-plugin-droid-runtime/src/stream-parser.js";

View File

@@ -1,83 +1 @@
/**
* Thinking effort configuration for mapping pi's ThinkingLevel to Droid CLI --effort flags.
*
* Maps pi's reasoning levels (minimal/low/medium/high/xhigh) to the CLI's effort
* levels (low/medium/high/max). Opus models get an elevated mapping where medium
* becomes high and high becomes max, leveraging their superior reasoning capability.
*
* IMPORTANT: The CLI does NOT support --thinking-budget. Only --effort is supported.
*/
import type { ThinkingLevel, ThinkingBudgets } from "@mariozechner/pi-ai";
/** CLI effort levels accepted by the --effort flag */
export type CliEffortLevel = "low" | "medium" | "high" | "max";
/**
* Standard model mapping: pi ThinkingLevel -> CLI effort.
* Non-Opus models never receive "max" (would cause CLI error).
*/
const STANDARD_EFFORT_MAP: Record<ThinkingLevel, CliEffortLevel> = {
minimal: "low",
low: "low",
medium: "medium",
high: "high",
xhigh: "high", // non-Opus: silently downgrade (max not supported)
};
/**
* Opus model mapping: shifted up for elevated reasoning.
* Opus models get max capability at high/xhigh levels.
*/
const OPUS_EFFORT_MAP: Record<ThinkingLevel, CliEffortLevel> = {
minimal: "low",
low: "low",
medium: "high", // shifted: standard high
high: "max", // shifted: maximum capability
xhigh: "max", // Opus gets max
};
/**
* Detect whether a model ID refers to an Opus model.
* Uses includes('opus') for forward-compatibility with future Opus versions.
*
* @param modelId - The model identifier string
* @returns true if the model is an Opus variant
*/
export function isOpusModel(modelId: string): boolean {
return modelId.includes("opus");
}
/**
* Map pi's ThinkingLevel to a CLI effort string.
*
* When reasoning is undefined, returns undefined so the --effort flag is omitted
* entirely, letting the CLI use its default behavior. When thinkingBudgets are
* provided, a console.warn is logged because the CLI only supports effort levels,
* not token budgets.
*
* @param reasoning - Pi's thinking level (undefined = omit flag)
* @param modelId - Model ID for Opus detection
* @param thinkingBudgets - Custom budgets (logged as unsupported, not applied)
* @returns CLI effort level string, or undefined if flag should be omitted
*/
export function mapThinkingEffort(
reasoning?: ThinkingLevel,
modelId?: string,
thinkingBudgets?: ThinkingBudgets,
): CliEffortLevel | undefined {
if (reasoning === undefined) {
return undefined; // omit --effort flag entirely
}
if (thinkingBudgets && Object.keys(thinkingBudgets).length > 0) {
console.warn(
"[droid-cli] Custom thinkingBudgets are not supported with CLI subprocess. " +
"The CLI uses --effort levels instead of token budgets. Budgets will be ignored.",
);
}
const isOpus = modelId ? isOpusModel(modelId) : false;
const map = isOpus ? OPUS_EFFORT_MAP : STANDARD_EFFORT_MAP;
return map[reasoning];
}
export * from "../../../plugins/fusion-plugin-droid-runtime/src/thinking-config.js";

View File

@@ -1,147 +1 @@
/**
* Single-source-of-truth tool mapping table for bidirectional translation
* between Droid CLI tool names/arguments and pi tool names/arguments.
*
* All lookup tables are derived from the TOOL_MAPPINGS array.
* Unknown tools and arguments pass through unchanged.
*/
/**
* A mapping entry for a single tool.
* `args` maps Claude argument names to pi argument names (only renamed args).
*/
export interface ToolMapping {
claude: string;
pi: string;
args: Record<string, string>;
}
/**
* The canonical tool mapping table. All other lookup structures are derived from this.
*/
export const TOOL_MAPPINGS: ToolMapping[] = [
{ claude: "Read", pi: "read", args: { file_path: "path" } },
{ claude: "Write", pi: "write", args: { file_path: "path" } },
{
claude: "Edit",
pi: "edit",
args: { file_path: "path", old_string: "oldText", new_string: "newText" },
},
{ claude: "Bash", pi: "bash", args: {} },
{ claude: "Grep", pi: "grep", args: { head_limit: "limit" } },
{ claude: "Glob", pi: "find", args: {} },
];
/** Prefix for custom pi tools exposed via MCP. */
export const CUSTOM_TOOLS_MCP_PREFIX = "mcp__custom-tools__";
/** Set of built-in pi tool names derived from TOOL_MAPPINGS for O(1) lookup. */
const BUILT_IN_PI_NAMES = new Set(TOOL_MAPPINGS.map((m) => m.pi));
/**
* Check if a pi tool name is a custom tool (not one of the 6 built-in tools).
* Used by prompt builder to decide whether to add MCP prefix in history replay.
*/
export function isCustomToolName(piName: string): boolean {
return !BUILT_IN_PI_NAMES.has(piName);
}
/**
* Check if a Claude tool name maps to a pi-known tool.
* Returns true for built-in tools (Read, Write, etc.) and custom MCP tools (mcp__custom-tools__*).
* Returns false for internal Claude Code tools (ToolSearch, Task, Agent, etc.) that pi cannot execute.
* Used by event bridge to filter out internal tool calls.
*/
export function isPiKnownDroidTool(claudeName: string): boolean {
if (claudeName.startsWith(CUSTOM_TOOLS_MCP_PREFIX)) return true;
return claudeName.toLowerCase() in DROID_TO_PI_NAME;
}
// Derived lookup maps
/** Lowercase Claude name -> pi name */
const DROID_TO_PI_NAME: Record<string, string> = {};
/** Pi name -> PascalCase Claude name */
const PI_TO_DROID_NAME: Record<string, string> = {};
/** Lowercase Claude name -> { claudeArgName: piArgName } */
const DROID_TO_PI_ARGS: Record<string, Record<string, string>> = {};
/** Pi name -> { piArgName: claudeArgName } */
const PI_TO_DROID_ARGS: Record<string, Record<string, string>> = {};
for (const m of TOOL_MAPPINGS) {
DROID_TO_PI_NAME[m.claude.toLowerCase()] = m.pi;
PI_TO_DROID_NAME[m.pi] = m.claude;
DROID_TO_PI_ARGS[m.claude.toLowerCase()] = m.args;
// Build reverse arg map
const reverseArgs: Record<string, string> = {};
for (const [from, to] of Object.entries(m.args)) {
reverseArgs[to] = from;
}
PI_TO_DROID_ARGS[m.pi] = reverseArgs;
}
// Handle glob/find asymmetry: pi's "glob" also maps back to Claude's "Glob"
PI_TO_DROID_NAME["glob"] = "Glob";
/**
* Map a Claude tool name to the corresponding pi tool name.
* Strips the mcp__custom-tools__ prefix for custom tools first,
* then falls back to case-insensitive built-in lookup.
* Unknown tool names pass through unchanged.
*/
export function mapDroidToolNameToPi(claudeName: string): string {
// Strip custom-tools MCP prefix first (e.g., "mcp__custom-tools__deploy" -> "deploy")
if (claudeName.startsWith(CUSTOM_TOOLS_MCP_PREFIX)) {
return claudeName.slice(CUSTOM_TOOLS_MCP_PREFIX.length);
}
// Standard built-in tool mapping (case-insensitive)
return DROID_TO_PI_NAME[claudeName.toLowerCase()] ?? claudeName;
}
/**
* Map a pi tool name to the corresponding Claude tool name.
* Direct lookup. Unknown tool names pass through unchanged.
*/
export function mapPiToolNameToDroid(piName: string): string {
return PI_TO_DROID_NAME[piName] ?? piName;
}
/**
* Translate Claude tool arguments to pi format.
* Only known renamed arguments are translated; all others pass through unchanged.
* This prevents dropping unknown/extra arguments (Pitfall 5).
*/
export function translateDroidArgsToPi(
claudeToolName: string,
args: Record<string, unknown>,
): Record<string, unknown> {
const renames = DROID_TO_PI_ARGS[claudeToolName.toLowerCase()];
if (!renames || Object.keys(renames).length === 0) return args;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
const newKey = renames[key] ?? key;
result[newKey] = value;
}
return result;
}
/**
* Translate pi tool arguments to Claude format.
* Only known renamed arguments are translated; all others pass through unchanged.
*/
export function translatePiArgsToDroid(
piToolName: string,
args: Record<string, unknown>,
): Record<string, unknown> {
const renames = PI_TO_DROID_ARGS[piToolName];
if (!renames || Object.keys(renames).length === 0) return args;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
const newKey = renames[key] ?? key;
result[newKey] = value;
}
return result;
}
export * from "../../../plugins/fusion-plugin-droid-runtime/src/tool-mapping.js";

View File

@@ -1,87 +1 @@
// Wire protocol types for Droid CLI stream-json NDJSON communication
// NDJSON message types from Droid CLI stdout
export interface ClaudeStreamEventMessage {
type: "stream_event";
event: ClaudeApiEvent;
/** Present on sub-agent stream events; null/undefined for top-level events. */
parent_tool_use_id?: string | null;
}
export interface ClaudeResultMessage {
type: "result";
subtype: "success" | "error";
result?: string;
error?: string;
session_id?: string;
}
export interface ClaudeSystemMessage {
type: "system";
subtype: string;
session_id?: string;
tools?: unknown[];
}
export interface ClaudeControlRequest {
type: "control_request";
request_id: string;
request: {
subtype: "can_use_tool";
tool_name: string;
input: Record<string, unknown>;
};
}
export type NdjsonMessage =
| ClaudeStreamEventMessage
| ClaudeResultMessage
| ClaudeSystemMessage
| ClaudeControlRequest;
// Claude API event types (inside stream_event wrapper)
export interface ClaudeApiEvent {
type: string; // message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop
index?: number;
message?: {
id?: string;
type?: string;
role?: string;
content?: unknown[];
model?: string;
usage?: ClaudeUsage;
};
content_block?: {
type: string; // "text", "tool_use", "thinking"
text?: string;
id?: string;
name?: string;
input?: string;
};
delta?: {
type?: string; // "text_delta", "input_json_delta", "thinking_delta", "signature_delta"
text?: string;
partial_json?: string;
thinking?: string;
signature?: string;
stop_reason?: string;
};
usage?: ClaudeUsage;
}
export interface ClaudeUsage {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
}
// Content block tracking during stream processing
export interface TrackedContentBlock {
type: "text" | "thinking";
text: string;
index: number; // Claude's content_block index
}
export * from "../../../plugins/fusion-plugin-droid-runtime/src/types.js";