feat(FN-2978): add droid-cli package with provider registration and model d

This merge introduces a new `packages/droid-cli` package that bridges AI agent execution as a subprocess, including process management, event streaming, tool mapping, MCP configuration, and thinking-mode support. It also adds the `DroidCliProviderCard` dashboard UI component with onboarding flows an

Fusion-Task-Id: FN-2978
This commit is contained in:
Fusion
2026-05-01 03:45:26 -07:00
committed by gsxdsm
parent 1747d6fc43
commit 9f141800cd
36 changed files with 7589 additions and 6 deletions

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Runfusion
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,25 @@
# @fusion/droid-cli
First-party Fusion pi extension package that routes pi provider requests through the `droid` CLI subprocess using stream-json NDJSON.
## Provider
- Provider ID: `droid-cli`
- Binary: `droid` (must be installed and authenticated on PATH)
- Registration: package extension entrypoint in `index.ts`
## Capabilities
- Subprocess streaming bridge for text/thinking/tool events
- Model auto-discovery from Droid CLI at provider startup with in-process caching
- Session resume support (`--resume` / `--session-id`) to avoid replaying prior turns
- MCP schema bridge for exposing pi custom tools as schema-only definitions
- Tool mapping and break-early control so pi remains the tool executor
- Thinking effort mapping from pi reasoning options to Droid CLI flags
## Development
```bash
pnpm --filter @fusion/droid-cli test
pnpm --filter @fusion/droid-cli exec tsc --noEmit
```

127
packages/droid-cli/index.ts Normal file
View File

@@ -0,0 +1,127 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { streamViaCli } from "./src/provider.js";
import {
validateCliPresenceAsync,
validateCliAuthAsync,
killAllProcesses,
discoverDroidModels,
} from "./src/process-manager.js";
import { createHash } from "node:crypto";
import {
getCustomToolDefs,
toolsFromContext,
writeMcpConfig,
type McpToolDef,
} from "./src/mcp-config.js";
process.on("exit", killAllProcesses);
const PROVIDER_ID = "droid-cli";
let cliValidationPromise: Promise<void> | undefined;
type DiscoveredModel = { id: string; name: string; reasoning: boolean; input: Array<"text" | "image">; cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow: number; maxTokens: number };
let discoveredModelsPromise: Promise<DiscoveredModel[]> | undefined;
function runCliValidationOnce(): Promise<void> {
if (cliValidationPromise) return cliValidationPromise;
cliValidationPromise = (async () => {
const presence = await validateCliPresenceAsync();
if (!presence.ok) {
console.warn(`[droid-cli] ${presence.error.message}`);
return;
}
await validateCliAuthAsync();
})();
return cliValidationPromise;
}
async function getDiscoveredModels() {
if (!discoveredModelsPromise) {
discoveredModelsPromise = (async () => {
try {
const ids = Array.from(new Set(await discoverDroidModels()));
if (ids.length === 0) return [];
return ids.map((id) => ({
id,
name: id,
reasoning: true,
input: ["text", "image"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
}));
} catch (error) {
console.warn("[droid-cli] model auto-discovery failed; registering provider with empty model list", error);
return [];
}
})();
}
return discoveredModelsPromise;
}
let cachedMcpConfig: { hash: string; configPath: string } | undefined;
function ensureMcpConfig(
pi: ExtensionAPI,
contextTools?: ReadonlyArray<{
name: string;
description: string;
parameters: Record<string, unknown>;
}>,
): string | undefined {
try {
let toolDefs: McpToolDef[] = toolsFromContext(contextTools);
if (toolDefs.length === 0) {
const allTools = pi.getAllTools();
if (!Array.isArray(allTools)) return cachedMcpConfig?.configPath;
toolDefs = getCustomToolDefs(pi);
}
if (toolDefs.length === 0) {
cachedMcpConfig = undefined;
return undefined;
}
const hash = createHash("sha1").update(JSON.stringify(toolDefs)).digest("hex").slice(0, 12);
if (cachedMcpConfig?.hash === hash) return cachedMcpConfig.configPath;
const configPath = writeMcpConfig(toolDefs, hash);
cachedMcpConfig = { hash, configPath };
return configPath;
} catch {
return cachedMcpConfig?.configPath;
}
}
export default function (pi: ExtensionAPI) {
void runCliValidationOnce();
pi.on("session_start", async () => {
const allTools = pi.getAllTools();
if (Array.isArray(allTools)) {
pi.setActiveTools(allTools.map((t: { name: string }) => t.name));
}
});
void (async () => {
const models = await getDiscoveredModels();
try {
pi.registerProvider(PROVIDER_ID, {
baseUrl: "droid-cli",
apiKey: "unused",
api: "droid-cli",
models,
streamSimple: (model, context, options) => {
const configPath = ensureMcpConfig(
pi,
(context as { tools?: ReadonlyArray<{ name: string; description: string; parameters: Record<string, unknown> }> }).tools,
);
return streamViaCli(model, context, { ...options, mcpConfigPath: configPath });
},
});
} catch (err) {
console.error("[droid-cli] Failed to register provider:", err);
}
})();
}

View File

@@ -0,0 +1,37 @@
{
"name": "@fusion/droid-cli",
"version": "0.11.0",
"description": "First-party Fusion pi extension that routes LLM calls through the Droid CLI subprocess.",
"license": "MIT",
"private": true,
"type": "module",
"main": "index.ts",
"keywords": [
"pi-package",
"fusion",
"droid"
],
"pi": {
"extensions": [
"index.ts"
]
},
"repository": {
"type": "git",
"url": "https://github.com/Runfusion/Fusion",
"directory": "packages/droid-cli"
},
"peerDependencies": {
"@mariozechner/pi-ai": "*",
"@mariozechner/pi-coding-agent": "*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"scripts": {
"test": "vitest run --reporter=dot",
"typecheck": "tsc --noEmit"
}
}

View File

@@ -0,0 +1,164 @@
import { describe, it, expect, vi } from "vitest";
import type { ClaudeControlRequest } from "../types";
import {
handleControlRequest,
TOOL_EXECUTION_DENIED_MESSAGE,
MCP_PREFIX,
} from "../control-handler";
function makeControlRequest(
toolName: string,
requestId = "req-test-001",
input: Record<string, unknown> = {},
): ClaudeControlRequest {
return {
type: "control_request",
request_id: requestId,
request: {
subtype: "can_use_tool",
tool_name: toolName,
input,
},
};
}
describe("control-handler", () => {
describe("exported constants", () => {
it("exports TOOL_EXECUTION_DENIED_MESSAGE", () => {
expect(TOOL_EXECUTION_DENIED_MESSAGE).toBe(
"Tool execution is unavailable in this environment.",
);
});
it("exports MCP_PREFIX", () => {
expect(MCP_PREFIX).toBe("mcp__");
});
});
describe("denies custom MCP tools (mcp__custom-tools__*)", () => {
it("denies mcp__custom-tools__weather", () => {
const msg = makeControlRequest("mcp__custom-tools__weather");
const result = handleControlRequest(msg);
expect(result.allowed).toBe(false);
expect(result.response.response.response.behavior).toBe("deny");
expect(result.response.response.response.message).toBe(
TOOL_EXECUTION_DENIED_MESSAGE,
);
});
it("denies mcp__custom-tools__deploy", () => {
const msg = makeControlRequest("mcp__custom-tools__deploy");
const result = handleControlRequest(msg);
expect(result.allowed).toBe(false);
expect(result.response.response.response.behavior).toBe("deny");
});
});
describe("allows user MCP tools and other tools", () => {
it("allows user MCP tool mcp__database__query", () => {
const msg = makeControlRequest("mcp__database__query");
const result = handleControlRequest(msg);
expect(result.allowed).toBe(true);
expect(result.response.response.response.behavior).toBe("allow");
});
it("allows built-in tool Read", () => {
const msg = makeControlRequest("Read");
const result = handleControlRequest(msg);
expect(result.allowed).toBe(true);
expect(result.response.response.response.behavior).toBe("allow");
});
it("allows internal tools like ToolSearch", () => {
const msg = makeControlRequest("ToolSearch");
const result = handleControlRequest(msg);
expect(result.allowed).toBe(true);
expect(result.response.response.response.behavior).toBe("allow");
});
it("allows unknown tools", () => {
const msg = makeControlRequest("SomeUnknownTool");
const result = handleControlRequest(msg);
expect(result.allowed).toBe(true);
expect(result.response.response.response.behavior).toBe("allow");
});
});
describe("response format", () => {
it("includes matching request_id", () => {
const msg = makeControlRequest("Read", "custom-req-id-42");
const result = handleControlRequest(msg);
expect(result.response.request_id).toBe("custom-req-id-42");
});
it("returns a JSON-serializable response object", () => {
const msg = makeControlRequest("Read");
const result = handleControlRequest(msg);
const serialized = JSON.stringify(result.response);
expect(() => JSON.parse(serialized)).not.toThrow();
});
it("deny response includes message field", () => {
const msg = makeControlRequest("mcp__custom-tools__foo");
const result = handleControlRequest(msg);
expect(result.response.response.response.message).toBe(
TOOL_EXECUTION_DENIED_MESSAGE,
);
});
it("allow response does not include a message field", () => {
const msg = makeControlRequest("mcp__database__query");
const result = handleControlRequest(msg);
expect(result.response.response.response.message).toBeUndefined();
});
});
describe("malformed input", () => {
it("returns denied decision object for missing request_id", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const msg = {
type: "control_request",
} as unknown as ClaudeControlRequest;
const result = handleControlRequest(msg);
expect(result.allowed).toBe(false);
expect(result.response.response.response.behavior).toBe("deny");
spy.mockRestore();
});
it("returns denied decision object for missing request object", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const msg = {
type: "control_request",
request_id: "req-001",
} as unknown as ClaudeControlRequest;
const result = handleControlRequest(msg);
expect(result.allowed).toBe(false);
expect(result.response.response.response.behavior).toBe("deny");
spy.mockRestore();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,310 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Hoist mock references so they survive vi.mock hoisting
const mocks = vi.hoisted(() => ({
writeFileSync: vi.fn(),
tmpdir: vi.fn(() => "/tmp"),
}));
// Mock node:fs writeFileSync to avoid disk I/O
vi.mock("node:fs", () => ({
writeFileSync: mocks.writeFileSync,
}));
// Mock node:os tmpdir
vi.mock("node:os", () => ({
tmpdir: mocks.tmpdir,
}));
import { getCustomToolDefs, writeMcpConfig, toolsFromContext } from "../mcp-config";
import type { McpToolDef } from "../mcp-config";
describe("getCustomToolDefs", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("filters out all 6 built-in tools and returns only custom tools", () => {
const mockPi = {
getAllTools: vi.fn(() => [
{
name: "read",
description: "Read file",
parameters: { type: "object" },
},
{
name: "write",
description: "Write file",
parameters: { type: "object" },
},
{
name: "edit",
description: "Edit file",
parameters: { type: "object" },
},
{
name: "bash",
description: "Run bash",
parameters: { type: "object" },
},
{ name: "grep", description: "Search", parameters: { type: "object" } },
{
name: "find",
description: "Find files",
parameters: { type: "object" },
},
{
name: "search",
description: "Custom search tool",
parameters: {
type: "object",
properties: { query: { type: "string" } },
},
},
{
name: "deploy",
description: "Deploy app",
parameters: {
type: "object",
properties: { target: { type: "string" } },
},
},
]),
};
const result = getCustomToolDefs(mockPi);
expect(result).toHaveLength(2);
expect(result[0].name).toBe("search");
expect(result[1].name).toBe("deploy");
});
it("returns empty array when all tools are built-in", () => {
const mockPi = {
getAllTools: vi.fn(() => [
{
name: "read",
description: "Read file",
parameters: { type: "object" },
},
{
name: "write",
description: "Write file",
parameters: { type: "object" },
},
{
name: "edit",
description: "Edit file",
parameters: { type: "object" },
},
{
name: "bash",
description: "Run bash",
parameters: { type: "object" },
},
{ name: "grep", description: "Search", parameters: { type: "object" } },
{
name: "find",
description: "Find files",
parameters: { type: "object" },
},
]),
};
const result = getCustomToolDefs(mockPi);
expect(result).toEqual([]);
});
it("includes custom tool with correct name, description, inputSchema from parameters", () => {
const customParams = {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
limit: { type: "number" },
},
required: ["query"],
};
const mockPi = {
getAllTools: vi.fn(() => [
{
name: "custom_search",
description: "Search the codebase",
parameters: customParams,
},
]),
};
const result = getCustomToolDefs(mockPi);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("custom_search");
expect(result[0].description).toBe("Search the codebase");
expect(result[0].inputSchema).toBe(customParams);
});
it("handles pi.getAllTools() returning empty array", () => {
const mockPi = {
getAllTools: vi.fn(() => []),
};
const result = getCustomToolDefs(mockPi);
expect(result).toEqual([]);
});
it("returns empty array when pi.getAllTools() returns undefined", () => {
const mockPi = {
getAllTools: vi.fn(() => undefined),
};
const result = getCustomToolDefs(mockPi);
expect(result).toEqual([]);
});
it("returns empty array when pi.getAllTools() returns null", () => {
const mockPi = {
getAllTools: vi.fn(() => null),
};
const result = getCustomToolDefs(mockPi);
expect(result).toEqual([]);
});
});
describe("toolsFromContext", () => {
it("keeps ls as a custom tool (not filtered as built-in)", () => {
const defs = toolsFromContext([
{ name: "read", description: "builtin", parameters: { type: "object" } },
{ name: "ls", description: "list files", parameters: { type: "object" } },
{
name: "fn_task_list",
description: "list tasks",
parameters: { type: "object", properties: {} },
},
]);
expect(defs.map((d) => d.name)).toEqual(["ls", "fn_task_list"]);
});
});
describe("writeMcpConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.tmpdir.mockReturnValue("/tmp");
});
it("writes schema file to tmpdir with correct content (JSON array of tool defs)", () => {
const toolDefs: McpToolDef[] = [
{
name: "search",
description: "Search",
inputSchema: { type: "object" },
},
];
writeMcpConfig(toolDefs);
// First writeFileSync call is the schema file
const schemaCall = mocks.writeFileSync.mock.calls[0];
expect(schemaCall[0]).toMatch(/droid-cli-mcp-schemas/);
expect(JSON.parse(schemaCall[1])).toEqual(toolDefs);
});
it("writes config file to tmpdir with mcpServers.custom-tools entry", () => {
const toolDefs: McpToolDef[] = [
{
name: "search",
description: "Search",
inputSchema: { type: "object" },
},
];
writeMcpConfig(toolDefs);
// Second writeFileSync call is the config file
const configCall = mocks.writeFileSync.mock.calls[1];
const config = JSON.parse(configCall[1]);
expect(config).toHaveProperty("mcpServers");
expect(config.mcpServers).toHaveProperty("custom-tools");
});
it("config uses 'command': 'node' format (not 'type': 'http')", () => {
const toolDefs: McpToolDef[] = [
{
name: "search",
description: "Search",
inputSchema: { type: "object" },
},
];
writeMcpConfig(toolDefs);
const configCall = mocks.writeFileSync.mock.calls[1];
const config = JSON.parse(configCall[1]);
const server = config.mcpServers["custom-tools"];
expect(server.command).toBe("node");
expect(server).not.toHaveProperty("type");
});
it("config args include path to mcp-schema-server.cjs and schema file path", () => {
const toolDefs: McpToolDef[] = [
{
name: "search",
description: "Search",
inputSchema: { type: "object" },
},
];
writeMcpConfig(toolDefs);
const configCall = mocks.writeFileSync.mock.calls[1];
const config = JSON.parse(configCall[1]);
const server = config.mcpServers["custom-tools"];
expect(server.args).toHaveLength(2);
// First arg should be the server .cjs path (normalize separators for Windows)
expect(server.args[0].replace(/\\/g, "/")).toContain(
"mcp-schema-server.cjs",
);
// Second arg should be the schema file path
expect(server.args[1]).toMatch(/droid-cli-mcp-schemas/);
});
it("returns the config file path", () => {
const toolDefs: McpToolDef[] = [
{
name: "search",
description: "Search",
inputSchema: { type: "object" },
},
];
const result = writeMcpConfig(toolDefs);
expect(result).toMatch(/droid-cli-mcp-config/);
expect(result).toMatch(/\.json$/);
});
it("includes cacheKey in filenames when provided so distinct tool sets do not collide", () => {
const toolDefs: McpToolDef[] = [
{
name: "search",
description: "Search",
inputSchema: { type: "object" },
},
];
const pathA = writeMcpConfig(toolDefs, "aaaaaaaaaaaa");
const pathB = writeMcpConfig(toolDefs, "bbbbbbbbbbbb");
expect(pathA).toContain("aaaaaaaaaaaa");
expect(pathB).toContain("bbbbbbbbbbbb");
expect(pathA).not.toBe(pathB);
const schemaPathA = mocks.writeFileSync.mock.calls[0][0];
const schemaPathB = mocks.writeFileSync.mock.calls[2][0];
expect(schemaPathA).toContain("aaaaaaaaaaaa");
expect(schemaPathB).toContain("bbbbbbbbbbbb");
});
});

View File

@@ -0,0 +1,818 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { ChildProcess } from "node:child_process";
// Mock child_process.spawn before importing process-manager
vi.mock("node:child_process", () => ({
spawn: vi.fn(() => {
const EventEmitter = require("node:events");
const proc = new EventEmitter();
proc.stdin = { write: vi.fn(), end: vi.fn() };
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.killed = false;
proc.kill = vi.fn(() => {
proc.killed = true;
});
proc.pid = 12345;
return proc;
}),
execSync: vi.fn(),
}));
const mocks = vi.hoisted(() => ({
writeFileSync: vi.fn(),
unlinkSync: vi.fn(),
existsSync: vi.fn(),
readFileSync: vi.fn(),
tmpdir: vi.fn(() => "/mock-tmp"),
}));
vi.mock("node:fs", () => ({
writeFileSync: mocks.writeFileSync,
unlinkSync: mocks.unlinkSync,
existsSync: mocks.existsSync,
readFileSync: mocks.readFileSync,
}));
vi.mock("node:os", () => ({
tmpdir: mocks.tmpdir,
}));
import { spawn, execSync } from "node:child_process";
import {
spawnDroid,
buildDroidSpawnArgs,
writeUserMessage,
cleanupProcess,
captureStderr,
validateCliPresence,
validateCliAuth,
validateCliPresenceAsync,
validateCliAuthAsync,
forceKillProcess,
registerProcess,
killAllProcesses,
cleanupSystemPromptFile,
discoverDroidModels,
} from "../process-manager";
describe("buildDroidSpawnArgs", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.writeFileSync.mockReset();
mocks.tmpdir.mockReset();
mocks.tmpdir.mockReturnValue("/mock-tmp");
});
it("builds args including model and optional session/mcp flags", () => {
const args = buildDroidSpawnArgs("claude-sonnet-4-6", undefined, {
resumeSessionId: "sess-1",
effort: "high",
mcpConfigPath: "/tmp/mcp.json",
});
expect(args).toContain("--model");
expect(args).toContain("claude-sonnet-4-6");
expect(args).toContain("--resume");
expect(args).toContain("sess-1");
expect(args).toContain("--effort");
expect(args).toContain("high");
expect(args).toContain("--mcp-config");
expect(args).toContain("/tmp/mcp.json");
});
});
describe("spawnDroid", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.writeFileSync.mockReset();
mocks.existsSync.mockReset();
mocks.readFileSync.mockReset();
mocks.tmpdir.mockReset();
mocks.tmpdir.mockReturnValue("/mock-tmp");
});
it("spawns claude with all required CLI flags", () => {
spawnDroid("claude-sonnet-4-5-20250929");
expect(spawn).toHaveBeenCalledTimes(1);
const [cmd, args] = (spawn as any).mock.calls[0];
expect(cmd).toBe("droid");
expect(args).toContain("-p");
expect(args).toContain("--input-format");
expect(args).toContain("stream-json");
expect(args).toContain("--output-format");
expect(args).toContain("--verbose");
expect(args).toContain("--include-partial-messages");
expect(args).not.toContain("--no-session-persistence");
expect(args).toContain("--model");
expect(args).toContain("claude-sonnet-4-5-20250929");
expect(args).not.toContain("--permission-prompt-tool");
expect(args).not.toContain("stdio");
});
it("passes stream-json for both input-format and output-format", () => {
spawnDroid("claude-sonnet-4-5-20250929");
const args = (spawn as any).mock.calls[0][1] as string[];
const inputFormatIdx = args.indexOf("--input-format");
expect(args[inputFormatIdx + 1]).toBe("stream-json");
const outputFormatIdx = args.indexOf("--output-format");
expect(args[outputFormatIdx + 1]).toBe("stream-json");
});
it("sets stdio to pipe for stdin, stdout, and stderr", () => {
spawnDroid("claude-sonnet-4-5-20250929");
const options = (spawn as any).mock.calls[0][2];
expect(options.stdio).toEqual(["pipe", "pipe", "pipe"]);
});
it("passes cwd from options when provided", () => {
spawnDroid("claude-sonnet-4-5-20250929", undefined, {
cwd: "/custom/path",
});
const options = (spawn as any).mock.calls[0][2];
expect(options.cwd).toBe("/custom/path");
});
it("writes system prompt to temp file and passes path via --append-system-prompt", () => {
spawnDroid("claude-sonnet-4-5-20250929", "You are a helpful assistant.");
const args = (spawn as any).mock.calls[0][1] as string[];
const expectedTmpFile = `/mock-tmp/droid-cli-sysprompt-${process.pid}.txt`;
expect(mocks.writeFileSync).toHaveBeenCalledWith(
expectedTmpFile,
"You are a helpful assistant.",
"utf-8",
);
expect(args).toContain("--append-system-prompt");
const idx = args.indexOf("--append-system-prompt");
expect(args[idx + 1]).toContain("droid-cli-sysprompt-");
expect(args[idx + 1]).toBe(expectedTmpFile);
});
it("temp file contains the system prompt text", () => {
spawnDroid("claude-sonnet-4-5-20250929", "You are a helpful assistant.");
expect(mocks.writeFileSync).toHaveBeenCalledWith(
`/mock-tmp/droid-cli-sysprompt-${process.pid}.txt`,
"You are a helpful assistant.",
"utf-8",
);
});
it("does not include --append-system-prompt when no system prompt", () => {
spawnDroid("claude-sonnet-4-5-20250929");
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).not.toContain("--append-system-prompt");
});
it("returns the spawned ChildProcess", () => {
const proc = spawnDroid("claude-sonnet-4-5-20250929");
expect(proc).toBeDefined();
expect(proc.pid).toBe(12345);
});
});
describe("effort flag", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("includes --effort and high in args when effort is high", () => {
spawnDroid("claude-sonnet-4-5-20250929", undefined, { effort: "high" });
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toContain("--effort");
const idx = args.indexOf("--effort");
expect(args[idx + 1]).toBe("high");
});
it("includes --effort and max in args when effort is max", () => {
spawnDroid("claude-opus-4-6-20260301", undefined, { effort: "max" });
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toContain("--effort");
const idx = args.indexOf("--effort");
expect(args[idx + 1]).toBe("max");
});
it("includes --effort and low in args when effort is low", () => {
spawnDroid("claude-sonnet-4-5-20250929", undefined, { effort: "low" });
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toContain("--effort");
const idx = args.indexOf("--effort");
expect(args[idx + 1]).toBe("low");
});
it("does NOT include --effort when effort is undefined", () => {
spawnDroid("claude-sonnet-4-5-20250929", undefined, { cwd: "/some/path" });
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).not.toContain("--effort");
});
it("does NOT include --effort when options is undefined", () => {
spawnDroid("claude-sonnet-4-5-20250929");
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).not.toContain("--effort");
});
it("is backward compatible - existing calls without effort still work", () => {
spawnDroid("claude-sonnet-4-5-20250929", "system prompt", {
cwd: "/path",
});
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toContain("--append-system-prompt");
expect(args).not.toContain("--effort");
});
});
describe("writeUserMessage", () => {
it("writes correct NDJSON user message to stdin", () => {
const mockStdin = { write: vi.fn(), end: vi.fn() };
const proc = { stdin: mockStdin } as unknown as ChildProcess;
writeUserMessage(proc, "Hello Claude");
expect(mockStdin.write).toHaveBeenCalledTimes(1);
const written = mockStdin.write.mock.calls[0][0] as string;
const parsed = JSON.parse(written.trim());
expect(parsed.type).toBe("user");
expect(parsed.message.role).toBe("user");
expect(parsed.message.content).toBe("Hello Claude");
});
it("appends newline to the JSON", () => {
const mockStdin = { write: vi.fn(), end: vi.fn() };
const proc = { stdin: mockStdin } as unknown as ChildProcess;
writeUserMessage(proc, "test");
const written = mockStdin.write.mock.calls[0][0] as string;
expect(written.endsWith("\n")).toBe(true);
});
it("calls stdin.end() after writing user message", () => {
const mockStdin = { write: vi.fn(), end: vi.fn() };
const proc = { stdin: mockStdin } as unknown as ChildProcess;
writeUserMessage(proc, "test");
expect(mockStdin.end).toHaveBeenCalledTimes(1);
});
it("sends string content in NDJSON when given string", () => {
const mockStdin = { write: vi.fn(), end: vi.fn() };
const proc = { stdin: mockStdin } as unknown as ChildProcess;
writeUserMessage(proc, "hello");
const written = mockStdin.write.mock.calls[0][0] as string;
const parsed = JSON.parse(written.trim());
expect(typeof parsed.message.content).toBe("string");
expect(parsed.message.content).toBe("hello");
});
it("sends array content in NDJSON when given ContentBlock[]", () => {
const mockStdin = { write: vi.fn(), end: vi.fn() };
const proc = { stdin: mockStdin } as unknown as ChildProcess;
const blocks = [
{ type: "text", text: "hello" },
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "abc" },
},
];
writeUserMessage(proc, blocks as any);
const written = mockStdin.write.mock.calls[0][0] as string;
const parsed = JSON.parse(written.trim());
expect(Array.isArray(parsed.message.content)).toBe(true);
expect(parsed.message.content).toEqual(blocks);
});
});
describe("cleanupProcess", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("kills the process with SIGKILL after 500ms grace period", () => {
const mockProc: any = {
killed: false,
exitCode: null,
kill: vi.fn(() => {
mockProc.killed = true;
}),
};
cleanupProcess(mockProc as ChildProcess);
// Not killed immediately
expect(mockProc.kill).not.toHaveBeenCalled();
// Not killed at 400ms
vi.advanceTimersByTime(400);
expect(mockProc.kill).not.toHaveBeenCalled();
// Killed after 500ms grace period
vi.advanceTimersByTime(100);
expect(mockProc.kill).toHaveBeenCalledWith("SIGKILL");
});
it("does not kill if process is already killed", () => {
const proc = {
killed: true,
exitCode: null,
kill: vi.fn(),
} as unknown as ChildProcess;
cleanupProcess(proc);
vi.advanceTimersByTime(500);
expect(proc.kill).not.toHaveBeenCalled();
});
});
describe("captureStderr", () => {
it("returns a function that accumulates stderr data", () => {
const EventEmitter = require("node:events");
const stderr = new EventEmitter();
const proc = { stderr } as unknown as ChildProcess;
const getStderr = captureStderr(proc);
stderr.emit("data", Buffer.from("error line 1\n"));
stderr.emit("data", Buffer.from("error line 2\n"));
expect(getStderr()).toBe("error line 1\nerror line 2\n");
});
it("returns empty string when no stderr data", () => {
const EventEmitter = require("node:events");
const stderr = new EventEmitter();
const proc = { stderr } as unknown as ChildProcess;
const getStderr = captureStderr(proc);
expect(getStderr()).toBe("");
});
});
describe("validateCliPresence", () => {
it("does not throw when droid --version succeeds", () => {
(execSync as any).mockReturnValue(Buffer.from("1.0.0"));
expect(() => validateCliPresence()).not.toThrow();
});
it("throws with install instructions when droid --version fails", () => {
(execSync as any).mockImplementation(() => {
throw new Error("command not found");
});
expect(() => validateCliPresence()).toThrow();
try {
validateCliPresence();
} catch (e: any) {
expect(e.message).toContain("Droid CLI not found");
expect(e.message).toContain("Install Droid CLI");
}
});
});
describe("validateCliAuth", () => {
it("returns true when droid auth status succeeds", () => {
(execSync as any).mockReturnValue(Buffer.from("Logged in"));
expect(validateCliAuth()).toBe(true);
});
it("returns false and warns when droid auth status fails", () => {
(execSync as any).mockImplementation(() => {
throw new Error("not authenticated");
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(validateCliAuth()).toBe(false);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("not authenticated"),
);
warnSpy.mockRestore();
});
});
describe("validateCliPresenceAsync", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("resolves ok=true when droid --version exits 0", async () => {
const EventEmitter = require("node:events");
(spawn as any).mockImplementationOnce(() => {
const proc = new EventEmitter();
proc.kill = vi.fn();
setImmediate(() => proc.emit("exit", 0));
return proc;
});
const result = await validateCliPresenceAsync();
expect(result).toEqual({ ok: true });
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toEqual(["--version"]);
});
it("resolves ok=false with install message when spawn errors", async () => {
const EventEmitter = require("node:events");
(spawn as any).mockImplementationOnce(() => {
const proc = new EventEmitter();
proc.kill = vi.fn();
setImmediate(() => proc.emit("error", new Error("ENOENT")));
return proc;
});
const result = await validateCliPresenceAsync();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("Droid CLI not found");
expect(result.error.message).toContain("Install Droid CLI");
}
});
it("resolves ok=false when droid --version exits non-zero", async () => {
const EventEmitter = require("node:events");
(spawn as any).mockImplementationOnce(() => {
const proc = new EventEmitter();
proc.kill = vi.fn();
setImmediate(() => proc.emit("exit", 1));
return proc;
});
const result = await validateCliPresenceAsync();
expect(result.ok).toBe(false);
});
});
describe("validateCliAuthAsync", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("resolves true when droid auth status exits 0", async () => {
const EventEmitter = require("node:events");
(spawn as any).mockImplementationOnce(() => {
const proc = new EventEmitter();
proc.kill = vi.fn();
setImmediate(() => proc.emit("exit", 0));
return proc;
});
expect(await validateCliAuthAsync()).toBe(true);
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toEqual(["auth", "status"]);
});
it("resolves false and warns when droid auth status fails", async () => {
const EventEmitter = require("node:events");
(spawn as any).mockImplementationOnce(() => {
const proc = new EventEmitter();
proc.kill = vi.fn();
setImmediate(() => proc.emit("exit", 1));
return proc;
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await validateCliAuthAsync()).toBe(false);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("not authenticated"),
);
warnSpy.mockRestore();
});
});
describe("CLI flags", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("spawnDroid does NOT include --permission-mode or dontAsk in args", () => {
spawnDroid("claude-sonnet-4-5-20250929");
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).not.toContain("--permission-mode");
expect(args).not.toContain("dontAsk");
});
it("spawnDroid does NOT include --permission-prompt-tool in args", () => {
spawnDroid("claude-sonnet-4-5-20250929");
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).not.toContain("--permission-prompt-tool");
});
});
describe("mcp-config flag", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("spawnDroid with mcpConfigPath includes --mcp-config followed by the path", () => {
spawnDroid("claude-sonnet-4-5-20250929", undefined, {
mcpConfigPath: "/tmp/mcp-config.json",
});
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toContain("--mcp-config");
const idx = args.indexOf("--mcp-config");
expect(args[idx + 1]).toBe("/tmp/mcp-config.json");
});
it("spawnDroid without mcpConfigPath does NOT include --mcp-config in args", () => {
spawnDroid("claude-sonnet-4-5-20250929");
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).not.toContain("--mcp-config");
});
it("spawnDroid NEVER includes --strict-mcp-config in args", () => {
spawnDroid("claude-sonnet-4-5-20250929", undefined, {
mcpConfigPath: "/tmp/mcp-config.json",
});
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).not.toContain("--strict-mcp-config");
});
it("backward compatibility - existing calls with only effort/cwd still work", () => {
spawnDroid("claude-sonnet-4-5-20250929", "system prompt", {
cwd: "/path",
effort: "high",
});
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toContain("--append-system-prompt");
expect(args).toContain("--effort");
expect(args).not.toContain("--mcp-config");
expect(args).not.toContain("--permission-prompt-tool");
});
});
describe("forceKillProcess", () => {
it("calls proc.kill('SIGKILL') on live process", () => {
const proc = {
killed: false,
exitCode: null,
kill: vi.fn(),
} as unknown as ChildProcess;
forceKillProcess(proc);
expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
});
it("no-ops when proc.killed is true", () => {
const proc = {
killed: true,
exitCode: null,
kill: vi.fn(),
} as unknown as ChildProcess;
forceKillProcess(proc);
expect(proc.kill).not.toHaveBeenCalled();
});
it("no-ops when proc.exitCode is not null", () => {
const proc = {
killed: false,
exitCode: 0,
kill: vi.fn(),
} as unknown as ChildProcess;
forceKillProcess(proc);
expect(proc.kill).not.toHaveBeenCalled();
});
});
describe("process registry", () => {
beforeEach(() => {
// Clear registry between tests
killAllProcesses();
vi.clearAllMocks();
});
it("registerProcess adds proc and killAllProcesses kills it", () => {
const EventEmitter = require("node:events");
const proc = new EventEmitter();
proc.killed = false;
proc.exitCode = null;
proc.kill = vi.fn(() => {
proc.killed = true;
});
registerProcess(proc as unknown as ChildProcess);
killAllProcesses();
expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
});
it("proc exit event removes from registry", () => {
const EventEmitter = require("node:events");
const proc = new EventEmitter();
proc.killed = false;
proc.exitCode = null;
proc.kill = vi.fn(() => {
proc.killed = true;
});
registerProcess(proc as unknown as ChildProcess);
// Simulate natural exit
proc.exitCode = 0;
proc.emit("exit", 0, null);
// Clear mock to check killAllProcesses doesn't call kill again
proc.kill.mockClear();
proc.killed = false;
proc.exitCode = null;
killAllProcesses();
// Should NOT have been killed since it was removed on exit
expect(proc.kill).not.toHaveBeenCalled();
});
it("killAllProcesses clears set and handles already-dead processes", () => {
const EventEmitter = require("node:events");
const proc1 = new EventEmitter();
proc1.killed = true; // already dead
proc1.exitCode = null;
proc1.kill = vi.fn();
const proc2 = new EventEmitter();
proc2.killed = false;
proc2.exitCode = 1; // already exited
proc2.kill = vi.fn();
const proc3 = new EventEmitter();
proc3.killed = false;
proc3.exitCode = null; // alive
proc3.kill = vi.fn(() => {
proc3.killed = true;
});
registerProcess(proc1 as unknown as ChildProcess);
registerProcess(proc2 as unknown as ChildProcess);
registerProcess(proc3 as unknown as ChildProcess);
killAllProcesses();
// Already dead -- forceKillProcess should no-op
expect(proc1.kill).not.toHaveBeenCalled();
expect(proc2.kill).not.toHaveBeenCalled();
// Live process should be killed
expect(proc3.kill).toHaveBeenCalledWith("SIGKILL");
// Calling again should not kill anything (set was cleared)
proc3.kill.mockClear();
proc3.killed = false;
proc3.exitCode = null;
killAllProcesses();
expect(proc3.kill).not.toHaveBeenCalled();
});
});
describe("resume session flag", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("includes --resume followed by session ID when resumeSessionId is provided", () => {
spawnDroid("claude-sonnet-4-5-20250929", undefined, {
resumeSessionId: "session-abc-123",
});
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toContain("--resume");
const idx = args.indexOf("--resume");
expect(args[idx + 1]).toBe("session-abc-123");
});
it("does NOT include --resume when resumeSessionId is undefined", () => {
spawnDroid("claude-sonnet-4-5-20250929");
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).not.toContain("--resume");
});
it("includes both --resume and --effort when both are provided", () => {
spawnDroid("claude-sonnet-4-5-20250929", undefined, {
resumeSessionId: "session-abc",
effort: "high",
});
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toContain("--resume");
expect(args).toContain("--effort");
});
it("includes both --resume and --mcp-config when both are provided", () => {
spawnDroid("claude-sonnet-4-5-20250929", undefined, {
resumeSessionId: "session-abc",
mcpConfigPath: "/tmp/mcp.json",
});
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toContain("--resume");
expect(args).toContain("--mcp-config");
});
});
describe("cleanupSystemPromptFile", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.unlinkSync.mockReset();
mocks.tmpdir.mockReset();
mocks.tmpdir.mockReturnValue("/mock-tmp");
});
it("deletes the temp file when it exists", () => {
cleanupSystemPromptFile();
expect(mocks.unlinkSync).toHaveBeenCalledWith(
`/mock-tmp/droid-cli-sysprompt-${process.pid}.txt`,
);
});
it("does not throw when file does not exist", () => {
mocks.unlinkSync.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(() => cleanupSystemPromptFile()).not.toThrow();
});
});
describe("discoverDroidModels", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("parses model ids from JSON output", async () => {
(spawn as any).mockImplementationOnce(() => {
const EventEmitter = require("node:events");
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
setTimeout(() => {
proc.stdout.emit("data", Buffer.from('[{"id":"droid-pro"},{"name":"droid-max"}]'));
proc.emit("exit", 0);
}, 0);
return proc;
});
await expect(discoverDroidModels()).resolves.toEqual(["droid-pro", "droid-max"]);
});
it("falls back across attempts and parses newline output", async () => {
(spawn as any)
.mockImplementationOnce(() => {
const EventEmitter = require("node:events");
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
setTimeout(() => proc.emit("exit", 1), 0);
return proc;
})
.mockImplementationOnce(() => {
const EventEmitter = require("node:events");
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
setTimeout(() => proc.emit("exit", 1), 0);
return proc;
})
.mockImplementationOnce(() => {
const EventEmitter = require("node:events");
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
setTimeout(() => {
proc.stdout.emit("data", Buffer.from("droid-lite\ndroid-lite\ndroid-pro\n"));
proc.emit("exit", 0);
}, 0);
return proc;
});
await expect(discoverDroidModels()).resolves.toEqual(["droid-lite", "droid-pro"]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const registerProvider = vi.fn();
const on = vi.fn();
const getAllTools = vi.fn(() => [{ name: "read" }, { name: "bash" }, { name: "custom_tool", description: "c", parameters: {} }]);
const setActiveTools = vi.fn();
const streamViaCli = vi.fn(() => ({ mocked: true }));
const writeMcpConfig = vi.fn(() => "/tmp/droid-mcp.json");
const toolsFromContext = vi.fn(() => [{ name: "custom_tool", description: "c", inputSchema: {} }]);
vi.mock("../../src/provider.js", () => ({ streamViaCli }));
vi.mock("../../src/mcp-config.js", () => ({
getCustomToolDefs: vi.fn(() => [{ name: "custom_tool", description: "c", inputSchema: {} }]),
toolsFromContext,
writeMcpConfig,
}));
vi.mock("../../src/process-manager.js", () => ({
validateCliPresenceAsync: vi.fn(async () => ({ ok: true })),
validateCliAuthAsync: vi.fn(async () => true),
killAllProcesses: vi.fn(),
discoverDroidModels: vi.fn(async () => ["droid-pro", "droid-max", "droid-pro"]),
}));
describe("droid-cli extension", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("registers provider id droid-cli with deduped discovered models", async () => {
const mod = await import("../../index.js");
mod.default({ registerProvider, on, getAllTools, setActiveTools } as never);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(registerProvider).toHaveBeenCalledTimes(1);
const [providerId, config] = registerProvider.mock.calls[0];
expect(providerId).toBe("droid-cli");
expect(config.models.map((m: { id: string }) => m.id)).toEqual(["droid-pro", "droid-max"]);
});
it("wires MCP config into streamSimple and reuses cache for same tool set", async () => {
const mod = await import("../../index.js");
mod.default({ registerProvider, on, getAllTools, setActiveTools } as never);
await new Promise((resolve) => setTimeout(resolve, 0));
const config = registerProvider.mock.calls[0][1];
const model = { provider: "droid-cli", id: "droid-pro" };
const context = { tools: [{ name: "custom_tool", description: "c", parameters: {} }] };
config.streamSimple(model, context, { sessionId: "s1" });
config.streamSimple(model, context, { sessionId: "s2" });
expect(streamViaCli).toHaveBeenCalledTimes(2);
const firstCall = (streamViaCli as unknown as { mock: { calls: Array<unknown[]> } }).mock.calls[0] ?? [];
const firstCallOptions = firstCall[2] as { mcpConfigPath?: string } | undefined;
expect(firstCallOptions?.mcpConfigPath).toBe("/tmp/droid-mcp.json");
expect(writeMcpConfig).toHaveBeenCalledTimes(1);
});
it("activates all tools on session_start", async () => {
const mod = await import("../../index.js");
mod.default({ registerProvider, on, getAllTools, setActiveTools } as never);
const sessionStart = on.mock.calls.find((c) => c[0] === "session_start")?.[1];
await sessionStart();
expect(setActiveTools).toHaveBeenCalledWith(["read", "bash", "custom_tool"]);
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
const TEMP_HOME_PREFIX = "fn-test-home-";
describe("test isolation setup", () => {
it("overrides process.env.HOME to a temp directory", () => {
const home = process.env.HOME;
const userProfile = process.env.USERPROFILE;
expect(home).toBeDefined();
expect(home).toContain(tmpdir());
expect(home).toContain(TEMP_HOME_PREFIX);
expect(userProfile).toBe(home);
});
it("resolves homedir() to the temp HOME", () => {
const home = homedir();
expect(home).toContain(tmpdir());
expect(home).toContain(TEMP_HOME_PREFIX);
});
it("resolves ~/.pi/agent/AGENTS.md under the temp HOME", () => {
const agentsPath = join(homedir(), ".pi", "agent", "AGENTS.md");
expect(agentsPath).toContain(tmpdir());
expect(agentsPath).toContain(TEMP_HOME_PREFIX);
expect(agentsPath).toMatch(/fn-test-home-.*[\\/]\.pi[\\/]agent[\\/]AGENTS\.md$/);
});
});

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,188 @@
import { describe, it, expect, vi } from "vitest";
import { parseLine } from "../stream-parser";
import type {
ClaudeStreamEventMessage,
ClaudeResultMessage,
ClaudeSystemMessage,
} from "../types";
describe("parseLine", () => {
describe("valid JSON parsing", () => {
it("parses a valid stream_event message", () => {
const line = JSON.stringify({
type: "stream_event",
event: {
type: "message_start",
message: { usage: { input_tokens: 10 } },
},
});
const result = parseLine(line);
expect(result).not.toBeNull();
expect(result!.type).toBe("stream_event");
expect((result as ClaudeStreamEventMessage).event.type).toBe(
"message_start",
);
});
it("parses a valid result message", () => {
const line = JSON.stringify({
type: "result",
subtype: "success",
result: "Hello world",
});
const result = parseLine(line);
expect(result).not.toBeNull();
expect(result!.type).toBe("result");
expect((result as ClaudeResultMessage).subtype).toBe("success");
expect((result as ClaudeResultMessage).result).toBe("Hello world");
});
it("parses a valid system message", () => {
const line = JSON.stringify({
type: "system",
subtype: "init",
session_id: "test-session",
});
const result = parseLine(line);
expect(result).not.toBeNull();
expect(result!.type).toBe("system");
expect((result as ClaudeSystemMessage).subtype).toBe("init");
});
it("parses a valid control_request message", () => {
const line = JSON.stringify({
type: "control_request",
request_id: "req-001",
request: {
subtype: "can_use_tool",
tool_name: "Read",
input: { file_path: "/test" },
},
});
const result = parseLine(line);
expect(result).not.toBeNull();
expect(result!.type).toBe("control_request");
});
});
describe("empty and whitespace lines", () => {
it("returns null for empty string", () => {
expect(parseLine("")).toBeNull();
});
it("returns null for whitespace-only line", () => {
expect(parseLine(" ")).toBeNull();
});
it("returns null for tab-only line", () => {
expect(parseLine("\t\t")).toBeNull();
});
it("returns null for newline-only line", () => {
expect(parseLine("\n")).toBeNull();
});
});
describe("non-JSON lines (debug noise)", () => {
it("returns null for SandboxDebug output", () => {
expect(parseLine("[SandboxDebug] loading config...")).toBeNull();
});
it("returns null for plain text", () => {
expect(parseLine("Some debug message")).toBeNull();
});
it("returns null for lines starting with [", () => {
expect(parseLine("[INFO] starting up")).toBeNull();
});
it("returns null for lines starting with #", () => {
expect(parseLine("# comment")).toBeNull();
});
});
describe("malformed JSON", () => {
it("returns null for truncated JSON without throwing", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(parseLine('{"type":"stream_event","event":')).toBeNull();
spy.mockRestore();
});
it("returns null for invalid JSON syntax without throwing", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(parseLine("{not valid json}")).toBeNull();
spy.mockRestore();
});
it("returns null for JSON with trailing comma without throwing", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(parseLine('{"type":"test",}')).toBeNull();
spy.mockRestore();
});
});
describe("non-object JSON", () => {
it("returns null for JSON array", () => {
expect(parseLine("[1, 2, 3]")).toBeNull();
});
it("returns null for JSON string", () => {
expect(parseLine('"hello"')).toBeNull();
});
it("returns null for JSON number", () => {
expect(parseLine("42")).toBeNull();
});
it("returns null for JSON null", () => {
expect(parseLine("null")).toBeNull();
});
it("returns null for JSON boolean", () => {
expect(parseLine("true")).toBeNull();
});
});
describe("whitespace handling", () => {
it("trims leading whitespace before parsing", () => {
const line = ` ${JSON.stringify({ type: "system", subtype: "init" })}`;
const result = parseLine(line);
expect(result).not.toBeNull();
expect(result!.type).toBe("system");
});
it("trims trailing whitespace before parsing", () => {
const line = `${JSON.stringify({ type: "system", subtype: "init" })} `;
const result = parseLine(line);
expect(result).not.toBeNull();
expect(result!.type).toBe("system");
});
it("trims both leading and trailing whitespace", () => {
const line = ` ${JSON.stringify({ type: "result", subtype: "success" })} `;
const result = parseLine(line);
expect(result).not.toBeNull();
expect(result!.type).toBe("result");
});
});
describe("resilience", () => {
it("never throws regardless of input", () => {
const inputs = [
"",
" ",
"garbage",
"{bad",
"null",
"undefined",
"[1,2]",
'{"valid": true}',
"[SandboxDebug] test",
'{"type":"stream_event","event":{"type":"message_start"}}',
];
for (const input of inputs) {
expect(() => parseLine(input)).not.toThrow();
}
});
});
});

View File

@@ -0,0 +1,141 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { mapThinkingEffort, isOpusModel } from "../thinking-config";
import type { ThinkingBudgets } from "@mariozechner/pi-ai";
describe("isOpusModel", () => {
it("returns true for claude-opus-4-6-20260301", () => {
expect(isOpusModel("claude-opus-4-6-20260301")).toBe(true);
});
it("returns false for claude-sonnet-4-5-20250929", () => {
expect(isOpusModel("claude-sonnet-4-5-20250929")).toBe(false);
});
it("returns true for future Opus models (forward-compatible)", () => {
expect(isOpusModel("claude-opus-5-20270101")).toBe(true);
});
it("returns false for non-Opus model strings", () => {
expect(isOpusModel("claude-haiku-3-5-20240307")).toBe(false);
});
});
describe("mapThinkingEffort", () => {
describe("undefined reasoning", () => {
it("returns undefined when reasoning is undefined", () => {
expect(
mapThinkingEffort(undefined, "claude-sonnet-4-5", undefined),
).toBeUndefined();
});
it("returns undefined regardless of model", () => {
expect(
mapThinkingEffort(undefined, "claude-opus-4-6-20260301", undefined),
).toBeUndefined();
});
});
describe("standard (non-Opus) model mapping", () => {
const model = "claude-sonnet-4-5";
it("maps minimal to low", () => {
expect(mapThinkingEffort("minimal", model, undefined)).toBe("low");
});
it("maps low to low", () => {
expect(mapThinkingEffort("low", model, undefined)).toBe("low");
});
it("maps medium to medium", () => {
expect(mapThinkingEffort("medium", model, undefined)).toBe("medium");
});
it("maps high to high", () => {
expect(mapThinkingEffort("high", model, undefined)).toBe("high");
});
it("maps xhigh to high (downgrade for non-Opus)", () => {
expect(mapThinkingEffort("xhigh", model, undefined)).toBe("high");
});
});
describe("Opus model mapping (elevated)", () => {
const model = "claude-opus-4-6-20260301";
it("maps minimal to low", () => {
expect(mapThinkingEffort("minimal", model, undefined)).toBe("low");
});
it("maps low to low", () => {
expect(mapThinkingEffort("low", model, undefined)).toBe("low");
});
it("maps medium to high (shifted up)", () => {
expect(mapThinkingEffort("medium", model, undefined)).toBe("high");
});
it("maps high to max (shifted up)", () => {
expect(mapThinkingEffort("high", model, undefined)).toBe("max");
});
it("maps xhigh to max", () => {
expect(mapThinkingEffort("xhigh", model, undefined)).toBe("max");
});
});
describe("thinkingBudgets warning", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("logs console.warn when thinkingBudgets is provided with entries", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const budgets: ThinkingBudgets = { high: 50000 };
mapThinkingEffort("high", "claude-sonnet-4-5", budgets);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("thinkingBudgets are not supported"),
);
});
it("does not warn when thinkingBudgets is undefined", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mapThinkingEffort("high", "claude-sonnet-4-5", undefined);
expect(warnSpy).not.toHaveBeenCalled();
});
it("does not warn when thinkingBudgets is empty object", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mapThinkingEffort("high", "claude-sonnet-4-5", {} as ThinkingBudgets);
expect(warnSpy).not.toHaveBeenCalled();
});
it("still returns correct effort level when budgets trigger warning", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const budgets: ThinkingBudgets = { high: 50000 };
const result = mapThinkingEffort(
"high",
"claude-opus-4-6-20260301",
budgets,
);
expect(result).toBe("max");
});
});
describe("no modelId defaults to non-Opus behavior", () => {
it("uses standard mapping when modelId is undefined", () => {
expect(mapThinkingEffort("medium", undefined, undefined)).toBe("medium");
});
it("does not return max for xhigh when modelId is undefined", () => {
expect(mapThinkingEffort("xhigh", undefined, undefined)).toBe("high");
});
});
});

View File

@@ -0,0 +1,253 @@
import { describe, it, expect } from "vitest";
import {
TOOL_MAPPINGS,
CUSTOM_TOOLS_MCP_PREFIX,
mapDroidToolNameToPi,
mapPiToolNameToDroid,
translateDroidArgsToPi,
translatePiArgsToDroid,
isCustomToolName,
} from "../tool-mapping";
describe("tool-mapping", () => {
describe("TOOL_MAPPINGS", () => {
it("exports 6 tool mappings", () => {
expect(TOOL_MAPPINGS).toHaveLength(6);
});
});
describe("mapDroidToolNameToPi", () => {
it("maps Read to read", () => {
expect(mapDroidToolNameToPi("Read")).toBe("read");
});
it("maps Write to write", () => {
expect(mapDroidToolNameToPi("Write")).toBe("write");
});
it("maps Edit to edit", () => {
expect(mapDroidToolNameToPi("Edit")).toBe("edit");
});
it("maps Bash to bash", () => {
expect(mapDroidToolNameToPi("Bash")).toBe("bash");
});
it("maps Grep to grep", () => {
expect(mapDroidToolNameToPi("Grep")).toBe("grep");
});
it("maps Glob to find", () => {
expect(mapDroidToolNameToPi("Glob")).toBe("find");
});
it("passes through unknown tool names unchanged", () => {
expect(mapDroidToolNameToPi("UnknownTool")).toBe("UnknownTool");
});
it("is case-insensitive for Claude tool names", () => {
expect(mapDroidToolNameToPi("read")).toBe("read");
expect(mapDroidToolNameToPi("READ")).toBe("read");
});
});
describe("mapPiToolNameToDroid", () => {
it("maps read to Read", () => {
expect(mapPiToolNameToDroid("read")).toBe("Read");
});
it("maps write to Write", () => {
expect(mapPiToolNameToDroid("write")).toBe("Write");
});
it("maps edit to Edit", () => {
expect(mapPiToolNameToDroid("edit")).toBe("Edit");
});
it("maps bash to Bash", () => {
expect(mapPiToolNameToDroid("bash")).toBe("Bash");
});
it("maps grep to Grep", () => {
expect(mapPiToolNameToDroid("grep")).toBe("Grep");
});
it("maps find to Glob", () => {
expect(mapPiToolNameToDroid("find")).toBe("Glob");
});
it("maps glob to Glob (asymmetry: both find and glob map to Glob)", () => {
expect(mapPiToolNameToDroid("glob")).toBe("Glob");
});
it("passes through unknown tool names unchanged", () => {
expect(mapPiToolNameToDroid("unknownTool")).toBe("unknownTool");
});
});
describe("translateDroidArgsToPi", () => {
it("renames file_path to path for Read", () => {
const result = translateDroidArgsToPi("Read", {
file_path: "/foo",
offset: 10,
});
expect(result).toEqual({ path: "/foo", offset: 10 });
});
it("renames file_path to path for Write", () => {
const result = translateDroidArgsToPi("Write", {
file_path: "/bar",
content: "hello",
});
expect(result).toEqual({ path: "/bar", content: "hello" });
});
it("renames file_path, old_string, new_string for Edit", () => {
const result = translateDroidArgsToPi("Edit", {
file_path: "/f",
old_string: "a",
new_string: "b",
});
expect(result).toEqual({ path: "/f", oldText: "a", newText: "b" });
});
it("passes through Bash args unchanged (no renames)", () => {
const result = translateDroidArgsToPi("Bash", { command: "ls" });
expect(result).toEqual({ command: "ls" });
});
it("renames head_limit to limit for Grep", () => {
const result = translateDroidArgsToPi("Grep", {
pattern: "x",
head_limit: 5,
});
expect(result).toEqual({ pattern: "x", limit: 5 });
});
it("passes through Glob args unchanged (no renames)", () => {
const result = translateDroidArgsToPi("Glob", { pattern: "*.ts" });
expect(result).toEqual({ pattern: "*.ts" });
});
it("passes through args for unknown tools unchanged", () => {
const result = translateDroidArgsToPi("UnknownTool", {
foo: 1,
bar: "baz",
});
expect(result).toEqual({ foo: 1, bar: "baz" });
});
it("preserves unknown args alongside renamed args", () => {
const result = translateDroidArgsToPi("Read", {
file_path: "/foo",
offset: 10,
limit: 50,
extra_arg: true,
});
expect(result).toEqual({
path: "/foo",
offset: 10,
limit: 50,
extra_arg: true,
});
});
});
describe("translatePiArgsToDroid", () => {
it("renames path to file_path for read", () => {
const result = translatePiArgsToDroid("read", { path: "/foo" });
expect(result).toEqual({ file_path: "/foo" });
});
it("renames path, oldText, newText for edit", () => {
const result = translatePiArgsToDroid("edit", {
path: "/f",
oldText: "a",
newText: "b",
});
expect(result).toEqual({
file_path: "/f",
old_string: "a",
new_string: "b",
});
});
it("renames limit to head_limit for grep", () => {
const result = translatePiArgsToDroid("grep", {
pattern: "x",
limit: 5,
});
expect(result).toEqual({ pattern: "x", head_limit: 5 });
});
it("passes through unknown args alongside renamed args", () => {
const result = translatePiArgsToDroid("read", {
path: "/foo",
offset: 10,
extra: "val",
});
expect(result).toEqual({ file_path: "/foo", offset: 10, extra: "val" });
});
it("passes through args for unknown tools unchanged", () => {
const result = translatePiArgsToDroid("unknownTool", { foo: 1 });
expect(result).toEqual({ foo: 1 });
});
});
describe("MCP prefix stripping", () => {
it("strips mcp__custom-tools__ prefix from myTool", () => {
expect(mapDroidToolNameToPi("mcp__custom-tools__myTool")).toBe("myTool");
});
it("strips mcp__custom-tools__ prefix from deploy", () => {
expect(mapDroidToolNameToPi("mcp__custom-tools__deploy")).toBe("deploy");
});
it("handles empty name after prefix", () => {
expect(mapDroidToolNameToPi("mcp__custom-tools__")).toBe("");
});
it("does NOT strip other MCP server prefixes", () => {
expect(mapDroidToolNameToPi("mcp__other-server__foo")).toBe(
"mcp__other-server__foo",
);
});
it("built-in mappings still work alongside MCP prefix stripping", () => {
expect(mapDroidToolNameToPi("Read")).toBe("read");
expect(mapDroidToolNameToPi("Glob")).toBe("find");
});
it("CUSTOM_TOOLS_MCP_PREFIX is the correct string", () => {
expect(CUSTOM_TOOLS_MCP_PREFIX).toBe("mcp__custom-tools__");
});
});
describe("isCustomToolName", () => {
it("returns true for custom tool names", () => {
expect(isCustomToolName("myTool")).toBe(true);
expect(isCustomToolName("deploy")).toBe(true);
expect(isCustomToolName("ls")).toBe(true);
});
it("returns false for all 6 built-in tool names", () => {
expect(isCustomToolName("read")).toBe(false);
expect(isCustomToolName("write")).toBe(false);
expect(isCustomToolName("edit")).toBe(false);
expect(isCustomToolName("bash")).toBe(false);
expect(isCustomToolName("grep")).toBe(false);
expect(isCustomToolName("find")).toBe(false);
});
});
describe("translateDroidArgsToPi with MCP prefix", () => {
it("MCP-prefixed custom tool args pass through unchanged", () => {
const result = translateDroidArgsToPi("mcp__custom-tools__myTool", {
foo: 1,
bar: "baz",
});
expect(result).toEqual({ foo: 1, bar: "baz" });
});
});
});

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";
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";
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,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,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: 5000 });
} 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: 5000 });
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 = 5000): 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,638 @@
/**
* 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.
* We only need to send the new content since the last turn: the last assistant
* response's tool results (if any) followed by the latest user message.
*
* For tool_use flows: pi sends [user, assistant(toolCall), toolResult, ...]
* We need to include tool results so the resumed session sees them, plus the
* final user message.
*
* Falls back to full prompt if the message structure is unexpected.
*/
export function buildResumePrompt(context: PiContext): string | AnthropicContentBlock[] {
const messages = context.messages;
if (messages.length === 0) return "";
// Find the last user message
const finalUserIndex = findFinalUserMessageIndex(messages);
if (finalUserIndex < 0) return "";
// Collect new messages: everything from the last assistant turn onwards
// (tool results from the last assistant + the new user message)
const newMessages: PiMessage[] = [];
// Walk backwards from finalUserIndex to find where new content starts.
// Include trailing toolResult messages that follow the last assistant turn.
let startIdx = finalUserIndex;
for (let i = finalUserIndex - 1; i >= 0; i--) {
if (messages[i].role === "toolResult") {
startIdx = i;
} else {
break;
}
}
for (let i = startIdx; i < messages.length; i++) {
newMessages.push(messages[i]);
}
// If there are only tool results + one user message, build a combined prompt
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") {
// Check for images in the final user message
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,414 @@
/**
* 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;
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();
// 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);
// 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);
cleanupProcess(proc!);
rl.close();
}
});
// Wait for readline to close (result received or process ended)
await new Promise<void>((resolve) => {
rl.on("close", 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,37 @@
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;
}

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,87 @@
// 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
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "index.ts"]
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
import { resolve } from "node:path";
export default defineConfig({
test: {
globals: true,
setupFiles: [
"./src/__tests__/setup-test-isolation.ts",
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
],
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
coverage: {
provider: "v8",
reporter: ["text", "json-summary"],
include: ["src/**/*.ts", "index.ts"],
exclude: ["src/mcp-schema-server.cjs"],
thresholds: {
lines: 92,
functions: 92,
branches: 88,
statements: 92,
},
},
},
});