feat(FN-3332): add droid runtime plugin with event bridge and process manag

The merge delivers a new `fusion-plugin-droid-runtime` plugin providing a full MCP server and runtime adapter for droid-based agents, with event bridging, process management, tool mapping, and a prompt builder. It also adds agent delegation and org hierarchy tools to the pi extension, while fixing a

Fusion-Task-Id: FN-3332
This commit is contained in:
Fusion
2026-05-04 03:04:33 -07:00
committed by gsxdsm
parent 79c4970739
commit 5be5da1938
36 changed files with 2947 additions and 214 deletions

View File

@@ -0,0 +1,5 @@
# Changelog
## 0.1.0
- Initial Droid runtime plugin package.

View File

@@ -0,0 +1,9 @@
# Droid Runtime Plugin
Provides the Droid CLI runtime/provider integration for Fusion.
- Runtime ID: `droid`
- Provider ID: `droid-cli`
- Exports probe helpers and runtime adapter for dashboard + engine integration.
`@fusion/droid-cli` now acts as a compatibility shim and delegates to this plugin-owned implementation.

View File

@@ -0,0 +1,13 @@
{
"id": "fusion-plugin-droid-runtime",
"name": "Droid Runtime Plugin",
"version": "0.1.0",
"description": "Droid runtime plugin for Fusion",
"author": "Fusion Team",
"runtime": {
"runtimeId": "droid",
"name": "Droid Runtime",
"description": "Drives the Droid CLI for Fusion agents",
"version": "0.1.0"
}
}

View File

@@ -0,0 +1,34 @@
{
"name": "@fusion-plugin-examples/droid-runtime",
"version": "0.1.0",
"type": "module",
"description": "Droid runtime plugin for Fusion",
"keywords": ["fusion-plugin", "droid", "runtime"],
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.js"
},
"./probe": {
"types": "./src/probe.ts",
"import": "./dist/probe.js"
}
},
"private": true,
"scripts": {
"build": "tsc",
"test": "vitest run --silent=passed-only --reporter=dot"
},
"dependencies": {
"@fusion/plugin-sdk": "workspace:*"
},
"peerDependencies": {
"@mariozechner/pi-ai": "*",
"@mariozechner/pi-coding-agent": "*"
},
"devDependencies": {
"@types/node": "^25.5.2",
"typescript": "^5.7.0",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { validatePluginManifest } from "@fusion/plugin-sdk";
import plugin, { droidRuntimeMetadata, DROID_RUNTIME_ID } from "../index.js";
describe("droid runtime plugin index", () => {
it("exports expected manifest/runtime metadata", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-droid-runtime");
expect(droidRuntimeMetadata.runtimeId).toBe("droid");
expect(DROID_RUNTIME_ID).toBe("droid");
});
it("registers required ui slots", () => {
const slots = plugin.uiSlots?.map((s) => s.slotId) ?? [];
expect(slots).toEqual(expect.arrayContaining([
"settings-provider-card",
"onboarding-provider-card",
"onboarding-setup-help",
"post-onboarding-recommendation",
]));
});
it("has a valid manifest", () => {
expect(() => validatePluginManifest(plugin.manifest)).not.toThrow();
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
vi.mock("node:child_process", () => ({
spawn: vi.fn(() => {
const proc = new EventEmitter() as any;
proc.stdout = new PassThrough();
proc.stderr = new PassThrough();
queueMicrotask(() => proc.emit("error", Object.assign(new Error("not found"), { code: "ENOENT" })));
return proc;
}),
}));
import { probeDroidBinary } from "../probe.js";
describe("probeDroidBinary", () => {
it("returns unavailable when binary is missing", async () => {
const result = await probeDroidBinary({ timeoutMs: 10 });
expect(result.available).toBe(false);
});
});

View File

@@ -0,0 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import * as processManager from "../process-manager.js";
describe("provider dependencies", () => {
it("deduplicates discovered model ids", async () => {
vi.spyOn(processManager, "discoverDroidModels").mockResolvedValue(["a", "b", "a"]);
const ids = Array.from(new Set(await processManager.discoverDroidModels()));
expect(ids).toEqual(["a", "b"]);
});
});

View File

@@ -0,0 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { DroidRuntimeAdapter } from "../runtime-adapter.js";
describe("DroidRuntimeAdapter", () => {
it("creates session and describes model", async () => {
const adapter = new DroidRuntimeAdapter({ droidModel: "droid-pro" });
const result = await adapter.createSession({ cwd: process.cwd(), systemPrompt: "sys", onText: vi.fn() });
expect(result.session).toBeDefined();
expect(adapter.describeModel(result.session)).toContain("droid");
});
});

View File

@@ -0,0 +1,14 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
if (process.platform === "win32") {
const match = tempHome.match(/^([A-Za-z]:)(.*)$/);
if (match) {
process.env.HOMEDRIVE = match[1];
process.env.HOMEPATH = match[2] || "\\";
}
}

View File

@@ -0,0 +1,13 @@
export interface DroidCliSettings {
binaryPath: string;
model?: string;
}
export function resolveCliSettings(settings?: Record<string, unknown>): DroidCliSettings {
const binaryPath =
typeof settings?.droidBinaryPath === "string" && settings.droidBinaryPath.trim().length > 0
? settings.droidBinaryPath.trim()
: "droid";
const model = typeof settings?.droidModel === "string" ? settings.droidModel : undefined;
return { binaryPath, model };
}

View File

@@ -0,0 +1,82 @@
/**
* 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.js";
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 };
}

View File

@@ -0,0 +1,397 @@
import type { ClaudeApiEvent, TrackedContentBlock } from "./types.js";
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,
};
}

View File

@@ -0,0 +1,58 @@
import { definePlugin } from "@fusion/plugin-sdk";
import type { FusionPlugin, PluginRuntimeFactory, PluginRuntimeManifestMetadata } from "@fusion/plugin-sdk";
import { resolveCliSettings } from "./cli-spawn.js";
import { DroidRuntimeAdapter } from "./runtime-adapter.js";
export const DROID_RUNTIME_ID = "droid";
const DROID_RUNTIME_VERSION = "0.1.0";
export const droidRuntimeMetadata: PluginRuntimeManifestMetadata = {
runtimeId: DROID_RUNTIME_ID,
name: "Droid Runtime",
description: "Drives the Droid CLI for Fusion agents",
version: DROID_RUNTIME_VERSION,
};
export const droidRuntimeFactory: PluginRuntimeFactory = async (ctx) =>
new DroidRuntimeAdapter(ctx.settings as Record<string, unknown> | undefined);
const plugin: FusionPlugin = definePlugin({
manifest: {
id: "fusion-plugin-droid-runtime",
name: "Droid Runtime Plugin",
version: DROID_RUNTIME_VERSION,
description: "Drives the Droid CLI for Fusion agents",
runtime: droidRuntimeMetadata,
},
state: "installed",
hooks: {
onLoad: (ctx) => {
const settings = resolveCliSettings(ctx.settings as Record<string, unknown>);
ctx.logger.info(`Droid Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"}`);
},
},
uiSlots: [
{ slotId: "settings-provider-card", label: "Droid CLI Provider", componentPath: "./components/settings-provider-card.js" },
{ slotId: "onboarding-provider-card", label: "Droid CLI Provider", componentPath: "./components/onboarding-provider-card.js" },
{ slotId: "onboarding-setup-help", label: "Droid CLI Setup Help", componentPath: "./components/onboarding-setup-help.js" },
{ slotId: "post-onboarding-recommendation", label: "Droid CLI Recommendation", componentPath: "./components/post-onboarding-recommendation.js" }
],
runtime: {
metadata: droidRuntimeMetadata,
factory: droidRuntimeFactory,
},
});
export default plugin;
export { DroidRuntimeAdapter };
export { probeDroidBinary } from "./probe.js";
export type { DroidBinaryStatus } from "./probe.js";
export { streamViaCli } from "./provider.js";
export {
discoverDroidModels,
validateCliPresenceAsync,
validateCliAuthAsync,
killAllProcesses,
} from "./process-manager.js";
export { getCustomToolDefs, toolsFromContext, writeMcpConfig } from "./mcp-config.js";
export type { McpToolDef } from "./mcp-config.js";

View File

@@ -0,0 +1,144 @@
/**
* 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;
}

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
// Schema-only MCP server. Reads tool schemas from a JSON file.
// Only implements initialize + tools/list. tools/call is never reached
// because the parent process kills the Claude subprocess at message_stop
// before tool execution (break-early pattern).
"use strict";
const fs = require("fs");
const readline = require("readline");
const schemaPath = process.argv[2];
if (!schemaPath) {
process.exit(1);
}
let tools = [];
try {
tools = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
} catch {
process.exit(1);
}
const rl = readline.createInterface({ input: process.stdin });
rl.on("line", (line) => {
let msg;
try {
msg = JSON.parse(line);
} catch {
return;
}
if (msg.method === "initialize") {
const resp = {
jsonrpc: "2.0",
id: msg.id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "custom-tools", version: "1.0.0" },
},
};
process.stdout.write(JSON.stringify(resp) + "\n");
} else if (msg.method === "tools/list") {
const resp = { jsonrpc: "2.0", id: msg.id, result: { tools } };
process.stdout.write(JSON.stringify(resp) + "\n");
}
// notifications/initialized: no response needed (notification)
// tools/call: never reached (break-early kills subprocess first)
});

View File

@@ -0,0 +1,57 @@
import { spawn } from "node:child_process";
export interface DroidBinaryStatus {
available: boolean;
authenticated?: boolean;
binaryPath?: string;
version?: string;
reason?: string;
probeDurationMs: number;
}
async function run(binary: string, args: string[], timeoutMs = 2000): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
try { child.kill("SIGKILL"); } catch {
// ignore kill errors
}
resolve({ code: 124, stdout, stderr });
}, timeoutMs);
child.stdout?.on("data", (c: Buffer) => { stdout += c.toString("utf-8"); });
child.stderr?.on("data", (c: Buffer) => { stderr += c.toString("utf-8"); });
child.on("error", () => {
clearTimeout(timer);
resolve({ code: 127, stdout, stderr });
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ code, stdout, stderr });
});
});
}
export async function probeDroidBinary(options?: { binaryPath?: string; timeoutMs?: number }): Promise<DroidBinaryStatus> {
const startedAt = Date.now();
const binaryPath = options?.binaryPath?.trim() || "droid";
const timeoutMs = options?.timeoutMs ?? 2000;
const versionRun = await run(binaryPath, ["--version"], timeoutMs);
if (versionRun.code !== 0) {
return {
available: false,
binaryPath,
reason: versionRun.code === 124 ? `Probe timed out after ${timeoutMs}ms` : "`droid` not found on PATH",
probeDurationMs: Date.now() - startedAt,
};
}
return {
available: true,
binaryPath,
version: versionRun.stdout.trim() || undefined,
probeDurationMs: Date.now() - startedAt,
};
}

View File

@@ -0,0 +1,358 @@
/**
* 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 [];
}

View File

@@ -0,0 +1,629 @@
/**
* 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;
}

View File

@@ -0,0 +1,447 @@
/**
* 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;
}

View File

@@ -0,0 +1,59 @@
import { streamViaCli } from "./provider.js";
import { resolveCliSettings } from "./cli-spawn.js";
import type { AgentRuntime, AgentRuntimeOptions, AgentSession, AgentSessionResult, DroidSession } from "./types.js";
export class DroidRuntimeAdapter implements AgentRuntime {
readonly id = "droid";
readonly name = "Droid Runtime";
private readonly settings: ReturnType<typeof resolveCliSettings>;
constructor(settings?: Record<string, unknown>) {
this.settings = resolveCliSettings(settings);
}
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
const model = this.settings.model ?? options.defaultModelId ?? "droid";
const session: DroidSession = {
model,
systemPrompt: options.systemPrompt,
messages: [],
apiKey: undefined,
thinkingLevel: options.defaultThinkingLevel,
sessionId: "",
lastModelDescription: `droid/${model}`,
callbacks: {
onText: options.onText,
onThinking: options.onThinking,
onToolStart: options.onToolStart,
onToolEnd: options.onToolEnd,
},
dispose: () => undefined,
};
return { session, sessionFile: undefined };
}
async promptWithFallback(session: AgentSession, prompt: string, _options?: unknown): Promise<void> {
const model = {
id: String(session.model ?? this.settings.model ?? "droid"),
provider: "droid-cli",
api: "droid-cli",
} as any;
const stream = streamViaCli(model, {
messages: [{ role: "user", content: prompt }],
systemPrompt: session.systemPrompt,
} as any, { sessionId: session.sessionId } as any);
await new Promise<void>((resolve) => {
const streamAny = stream as any;
streamAny.on?.("text_delta", (event: any) => session.callbacks.onText?.(event.text ?? ""));
streamAny.on?.("thinking_delta", (event: any) => session.callbacks.onThinking?.(event.text ?? ""));
streamAny.on?.("done", () => resolve());
streamAny.on?.("error", () => resolve());
});
}
describeModel(session: AgentSession): string {
return session.lastModelDescription || "droid";
}
}

View File

@@ -0,0 +1,37 @@
import type { NdjsonMessage } from "./types.js";
/**
* 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;
}

View File

@@ -0,0 +1,83 @@
/**
* 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];
}

View File

@@ -0,0 +1,147 @@
/**
* 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;
}

View File

@@ -0,0 +1,135 @@
// 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 interface DroidCallbacks {
onText?: (text: string) => void;
onThinking?: (text: string) => void;
onToolStart?: (toolName: string, args?: unknown) => void;
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
}
export interface DroidSession {
model: unknown;
systemPrompt: string;
messages: unknown[];
apiKey: string | undefined;
thinkingLevel: string | undefined;
sessionId: string;
lastModelDescription: string;
callbacks: DroidCallbacks;
dispose(): void;
}
export type AgentSession = DroidSession;
export interface AgentRuntimeOptions {
cwd: string;
systemPrompt: string;
tools?: "coding" | "readonly";
onText?: (text: string) => void;
onThinking?: (text: string) => void;
onToolStart?: (toolName: string, args?: unknown) => void;
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
defaultProvider?: string;
defaultModelId?: string;
defaultThinkingLevel?: string;
}
export interface AgentSessionResult {
session: AgentSession;
sessionFile?: string;
}
export interface AgentRuntime {
id: string;
name: string;
createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>;
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
describeModel(session: AgentSession): string;
dispose?(session: AgentSession): Promise<void>;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers";
const maxWorkers = computeMaxWorkers();
export default defineConfig({
resolve: {
alias: {
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
},
},
test: {
include: ["src/**/*.test.ts"],
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
pool: "threads",
maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
},
});