feat: add "Anthropic — via Claude CLI" as a first-class provider

Replaces the stray useClaudeCli settings checkbox + onboarding question
with a proper provider-card UX. The card lives next to OAuth + API-key
cards in onboarding and settings, with Enable/Disable + Test actions.

Backend:
 - Vendors rchern/pi-claude-cli@0.3.1 as packages/pi-claude-cli
   (MIT, attribution in UPSTREAM.md). Lets us bump peer-dep on
   pi-coding-agent in lockstep with Fusion (upstream pinned ^0.52.0
   vs ours ^0.62.0) and fix bugs without waiting on upstream.
 - Adds @fusion/pi-claude-cli as a workspace dep of @runfusion/fusion
   so users don't have to `npm install -g pi-claude-cli` manually.
 - serve/daemon/dashboard conditionally load the extension via
   discoverAndLoadExtensions() when GlobalSettings.useClaudeCli is on;
   no side-effects on user ~/.fusion/agent/settings.json.
 - New GET /api/providers/claude-cli/status: claude --version probe
   + toggle state + cached extension resolution.
 - New POST /api/auth/claude-cli: flips useClaudeCli, refuses if the
   claude binary is missing, fires the existing skill-backfill hook.
 - /api/auth/status now injects a synthetic {id:"claude-cli", type:"cli"}
   provider entry so onboarding + settings see a consistent list.

Frontend:
 - New ClaudeCliProviderCard component shared between ModelOnboardingModal
   and SettingsModal's Authentication section.
 - New AuthProvider.type = "cli" variant.
 - Removed the old "Route AI calls through the Claude CLI" checkbox from
   Global Models settings and the opt-in step from the onboarding wizard.
 - ProviderIcon gets a composite Anthropic-mark-plus-terminal glyph for
   the claude-cli provider id.

Tests:
 - 8 unit tests for extension resolution (@fusion/pi-claude-cli is
   workspace-linked so these run in-tree).
 - 2 unit tests for the binary probe.
 - Existing /auth/status tests filter out the new synthetic entry so
   they keep asserting structural OAuth/API-key behavior in isolation.
 - The vendored package's own 296 tests still pass unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-23 16:48:57 -07:00
parent 82b4c696d8
commit f4e0850f9d
44 changed files with 9390 additions and 76 deletions

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Rebecca Chernoff
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,40 @@
# pi-claude-cli
A [pi](https://github.com/mariozechner/pi-coding-agent) extension that routes LLM calls through the [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) as a subprocess. Use your Claude Pro/Max subscription as the LLM backend — no API key, no separate billing.
## How it works
The extension registers as a custom pi provider exposing all Claude models. Each request spawns a `claude -p` subprocess using the stream-json wire protocol, with `--resume` on follow-up turns to reuse the CLI's session state instead of replaying full history. Claude proposes tool calls, pi executes them natively. Custom pi tools are exposed to Claude via a schema-only MCP server.
## Requirements
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated (`claude` on PATH)
- A Claude Pro or Max subscription
- [pi](https://github.com/mariozechner/pi-coding-agent) or [GSD](https://github.com/gsd-build/gsd-2)
## Installation
Add to `~/.gsd/agent/settings.json`:
```json
{
"packages": ["npm:pi-claude-cli"]
}
```
Then select a Claude model via `/model` in the interactive UI. All Claude models appear under the `pi-claude-cli` provider.
## Features
- Streams text, thinking, and tool call tokens in real-time
- Maps tool names and arguments bidirectionally between Claude and pi
- Exposes custom pi tools to Claude via MCP (schema-only, no execution)
- Break-early pattern prevents Claude CLI from auto-executing tools
- Session resume via `--resume` eliminates history replay on follow-up turns
- Configurable thinking effort with elevated budgets for Opus models
- Cross-platform subprocess management (Windows, macOS, Linux)
- Inactivity timeout and process registry for cleanup
## License
MIT

View File

@@ -0,0 +1,38 @@
# Upstream provenance
This package is a vendored fork of:
- **Upstream**: https://github.com/rchern/pi-claude-cli
- **Forked at version**: `0.3.1` (see `package.json`)
- **Forked on**: 2026-04-23
The original project is MIT-licensed; the full license text is preserved in
`LICENSE` alongside this file. All copyrights noted in the original source
are retained.
## Why a fork
`pi-claude-cli` is a load-bearing runtime extension for Fusion's "Route AI
through Claude CLI" feature. Vendoring lets us:
1. Bump its peer dependency on `@mariozechner/pi-coding-agent` in lockstep
with Fusion's own version (upstream tracked `^0.52.0`; Fusion needs
`^0.62.0`).
2. Fix bugs without waiting for upstream release cadence.
3. Wire the extension into the monorepo's build, typecheck, and test
pipeline.
4. Ship it bundled with `@runfusion/fusion` so users don't have to
`npm install -g pi-claude-cli` manually.
## Syncing from upstream
This is a soft fork — we do not track upstream automatically. When upstream
releases a change worth adopting:
1. `git diff` the upstream ref against this directory.
2. Hand-apply the relevant parts (most of our modifications are localized to
`package.json` — peer-dep pins, package name scoping).
3. Update "Forked at version" above.
There is no `git subtree` relationship — upstream's git history is not
preserved here. Attribution is via this file plus the retained `LICENSE`.

View File

@@ -0,0 +1,109 @@
/**
* Pi extension entry point for pi-claude-cli.
*
* Registers a custom provider that routes LLM calls through the Claude Code CLI
* subprocess using stream-json NDJSON protocol.
*/
import { getModels } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { streamViaCli } from "./src/provider.js";
import {
validateCliPresence,
validateCliAuth,
killAllProcesses,
} from "./src/process-manager.js";
import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js";
// Kill all active Claude subprocesses on process exit to prevent orphans
process.on("exit", killAllProcesses);
const PROVIDER_ID = "pi-claude-cli";
let mcpConfigPath: string | undefined;
let mcpConfigResolved = false;
/**
* Lazily generate MCP config on first request (not at load time).
* pi.getAllTools() fails during extension loading; this defers it
* until the pi runtime is fully initialized.
*
* Only locks (sets mcpConfigResolved) when getAllTools() returns a
* real array — if it returns undefined/null (registry not ready),
* we retry on the next request. Once the registry is ready we
* commit to the result even if there are zero custom tools.
*
* Uses warn-don't-block: failure logs a warning but does not
* prevent the provider from functioning (built-ins still work).
*/
function ensureMcpConfig(pi: ExtensionAPI): string | undefined {
if (mcpConfigResolved) return mcpConfigPath;
try {
const allTools = pi.getAllTools();
// Registry not ready yet — don't lock, retry on next call
if (!Array.isArray(allTools)) {
return mcpConfigPath;
}
// Registry is ready — lock regardless of whether custom tools exist
mcpConfigResolved = true;
const toolDefs = getCustomToolDefs(pi);
if (toolDefs.length > 0) {
mcpConfigPath = writeMcpConfig(toolDefs);
console.error(
`[pi-claude-cli] MCP config generated with ${toolDefs.length} custom tool(s)`,
);
}
} catch (err) {
console.warn(
"[pi-claude-cli] MCP config generation failed, custom tools unavailable:",
err,
);
}
return mcpConfigPath;
}
export default function (pi: ExtensionAPI) {
try {
// Startup validation
validateCliPresence(); // throws if CLI not on PATH
validateCliAuth(); // warns if not authenticated
const models = getModels("anthropic").map((model) => ({
id: model.id,
name: model.name,
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
}));
// Ensure all registered tools are active so pi can execute them.
// Some tools (find, grep, ls) are registered but not activated by default.
pi.on("session_start", async () => {
const allTools = pi.getAllTools();
if (Array.isArray(allTools)) {
pi.setActiveTools(allTools.map((t: any) => t.name));
}
});
pi.registerProvider(PROVIDER_ID, {
baseUrl: "pi-claude-cli",
apiKey: "unused",
api: "pi-claude-cli",
models,
streamSimple: (model, context, options) => {
const configPath = ensureMcpConfig(pi);
return streamViaCli(model, context, {
...options,
mcpConfigPath: configPath,
});
},
});
} catch (err) {
console.error(`[pi-claude-cli] Failed to register provider:`, err);
}
}

View File

@@ -0,0 +1,39 @@
{
"name": "@fusion/pi-claude-cli",
"version": "0.3.1",
"description": "Fusion vendored fork: pi coding-agent extension that routes LLM calls through the Claude Code CLI. Forked from rchern/pi-claude-cli (MIT). See UPSTREAM.md.",
"license": "MIT",
"private": true,
"type": "module",
"main": "index.ts",
"keywords": [
"pi-package"
],
"pi": {
"extensions": [
"index.ts"
]
},
"repository": {
"type": "git",
"url": "https://github.com/Runfusion/Fusion",
"directory": "packages/pi-claude-cli"
},
"dependencies": {
"cross-spawn": "^7.0.6"
},
"peerDependencies": {
"@mariozechner/pi-ai": "*",
"@mariozechner/pi-coding-agent": "*"
},
"devDependencies": {
"@types/cross-spawn": "^6.0.6",
"@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,68 @@
/**
* Control protocol handler for Claude CLI stream-json communication.
*
* Processes control_request messages from Claude CLI stdout and writes
* control_response messages to stdin.
*
* - 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 Claude CLI.
*
* Denies custom MCP tools (mcp__custom-tools__*) so pi can execute them.
* Allows everything else (user MCP tools, internal Claude tools).
*
* @returns true if the tool was allowed, false if denied
*/
export function handleControlRequest(
msg: ClaudeControlRequest,
stdin: NodeJS.WritableStream,
): boolean {
if (!msg.request_id || !msg.request) {
console.error(
"[pi-claude-cli] Malformed control_request: missing request_id or request object",
msg,
);
return false;
}
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" },
},
};
stdin.write(JSON.stringify(response) + "\n");
return !isCustomTool;
}

View File

@@ -0,0 +1,385 @@
import type { ClaudeApiEvent, TrackedContentBlock } from "./types";
import { calculateCost } from "@mariozechner/pi-ai";
import type {
AssistantMessage,
AssistantMessageEventStream,
Model,
TextContent,
ThinkingContent,
ToolCall,
} from "@mariozechner/pi-ai";
import {
mapClaudeToolNameToPi,
translateClaudeArgsToPi,
isPiKnownClaudeTool,
} 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<any>,
): 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: "pi-claude-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 (!isPiKnownClaudeTool(claudeName)) {
return;
}
const piName = mapClaudeToolNameToPi(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 any).arguments = block.arguments;
} 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 any).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
let finalArgs: Record<string, unknown> | string;
try {
const parsed = JSON.parse(block.partialJson);
finalArgs = translateClaudeArgsToPi(block.claudeName, parsed);
} catch {
finalArgs = block.partialJson;
}
// Update output.content with final arguments
const contentBlock = output.content[idx] as ToolCall;
(contentBlock as any).arguments = finalArgs;
// 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.
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,93 @@
/**
* 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";
/** 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: any): McpToolDef[] {
const allTools = pi.getAllTools();
if (!Array.isArray(allTools)) {
return [];
}
return allTools
.filter((tool: any) => !BUILT_IN_TOOL_NAMES.has(tool.name))
.map((tool: any) => ({
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
* @returns Path to the MCP config file
*/
export function writeMcpConfig(toolDefs: McpToolDef[]): string {
// Write tool schemas to temp file
const schemaFilePath = join(
tmpdir(),
`pi-claude-mcp-schemas-${process.pid}.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(),
`pi-claude-mcp-config-${process.pid}.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,218 @@
/**
* Process manager for spawning and managing Claude 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 spawn from "cross-spawn";
import { execSync } from "node:child_process";
import { writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { ChildProcess } from "node:child_process";
/**
* Spawn a Claude 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 spawnClaude(
modelId: string,
systemPrompt?: string,
options?: {
cwd?: string;
signal?: AbortSignal;
effort?: string;
mcpConfigPath?: string;
resumeSessionId?: string;
newSessionId?: string;
},
): ChildProcess {
const args = [
"-p",
"--input-format",
"stream-json",
"--output-format",
"stream-json",
"--verbose",
"--include-partial-messages",
"--model",
modelId,
"--permission-prompt-tool",
"stdio",
];
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.
// Claude CLI's --append-system-prompt accepts a file path or literal text.
const tmpFile = join(
tmpdir(),
`pi-claude-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);
}
const proc = spawn("claude", args, {
stdio: ["pipe", "pipe", "pipe"],
cwd: options?.cwd ?? process.cwd(),
});
return proc as ChildProcess;
}
/**
* Clean up the temp system prompt file created by spawnClaude.
* Safe to call multiple times or when no file exists.
*/
export function cleanupSystemPromptFile(): void {
try {
unlinkSync(join(tmpdir(), `pi-claude-cli-sysprompt-${process.pid}.txt`));
} catch {
// File doesn't exist or already deleted — ignore
}
}
/**
* Write a user message to the subprocess stdin as NDJSON.
* Does NOT call stdin.end() -- stdin stays open for control_response in Phase 2.
*
* 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 | any[],
): void {
const message = {
type: "user",
message: {
role: "user",
content: prompt,
},
};
proc.stdin!.write(JSON.stringify(message) + "\n");
}
/**
* 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 Claude 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 Claude CLI is installed and on PATH.
* Throws with install instructions if not found.
*/
export function validateCliPresence(): void {
try {
execSync("claude --version", { stdio: "pipe", timeout: 5000 });
} catch {
throw new Error(
"Claude Code CLI not found. Install it: npm install -g @anthropic-ai/claude-code\n" +
"Then authenticate: claude auth login",
);
}
}
/**
* Validate that the Claude CLI is authenticated.
* Returns false and warns if not authenticated.
*
* @returns true if authenticated, false otherwise
*/
export function validateCliAuth(): boolean {
try {
execSync("claude auth status", { stdio: "pipe", timeout: 5000 });
return true;
} catch {
console.warn(
"[pi-claude-cli] Claude CLI is not authenticated. " +
"Run 'claude auth login' to authenticate.",
);
return false;
}
}

View File

@@ -0,0 +1,514 @@
/**
* 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";
import {
mapPiToolNameToClaude,
translatePiArgsToClaude,
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 };
};
// We use `any` for Context to avoid requiring @mariozechner/pi-ai at dev time.
// At runtime, pi provides the real Context type.
/**
* Flattens a pi conversation context's messages array into a labeled text prompt
* suitable for sending to the Claude CLI subprocess.
*
* Each message is labeled with its role:
* - USER: for user messages
* - ASSISTANT: for assistant messages
* - TOOL RESULT (historical {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: any): AnthropicContentBlock | null {
if (piBlock.data && piBlock.mimeType) {
return {
type: "image",
source: {
type: "base64",
media_type: piBlock.mimeType,
data: piBlock.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 | any[],
): AnthropicContentBlock[] {
if (typeof content === "string") {
return [{ type: "text", text: content }];
}
if (!Array.isArray(content)) {
return [{ type: "text", text: "" }];
}
const blocks: AnthropicContentBlock[] = [];
for (const block of content) {
if (block.type === "text") {
blocks.push({ type: "text", text: 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 | any[]): boolean {
if (typeof content === "string" || !Array.isArray(content)) return false;
return content.some((block) => block.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: any[]): 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--) {
if (messages[i].role === "user") {
userMessage = userContentToText(messages[i].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: {
messages: any[];
}): 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: any[] = [];
// 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
? mapPiToolNameToClaude(msg.toolName)
: "unknown";
parts.push(`TOOL RESULT (historical ${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: {
messages: any[];
}): 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(
`[pi-claude-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 finalUserHasImages =
finalUserIndex >= 0 &&
contentHasImages(context.messages[finalUserIndex].content);
const anyToolResultHasImages = context.messages.some(
(m: any) => 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
? mapPiToolNameToClaude(message.toolName)
: "unknown";
historyParts.push(`TOOL RESULT (historical ${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 block of message.content) {
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 =
finalUserIndex >= 0
? buildFinalUserContent(context.messages[finalUserIndex].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(
`[pi-claude-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
? mapPiToolNameToClaude(message.toolName)
: "unknown";
parts.push(`TOOL RESULT (historical ${claudeToolName}):`);
}
parts.push(toolResultContentToText(message.content));
}
}
if (placeholderImageCount > 0) {
console.warn(
`[pi-claude-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: any[]): 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: { systemPrompt?: string; messages: any[] },
cwd: string,
): string {
const parts: string[] = [];
if (context.systemPrompt) {
parts.push(context.systemPrompt);
}
// 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: any) => 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.",
);
}
return parts.join("\n\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 | any[]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const texts: string[] = [];
for (const block of content) {
if (block.type === "text") {
texts.push(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 | any[]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((block) => {
if (block.type === "text") return block.text ?? "";
if (block.type === "thinking") return ""; // Skip thinking — internal reasoning, not conversation
if (block.type === "toolCall") {
const isCustom = isCustomToolName(block.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 = block.arguments
? JSON.stringify(block.arguments)
: "{}";
return `[Used ${block.name} tool with args: ${argsStr}]`;
}
const claudeName = mapPiToolNameToClaude(block.name);
const claudeArgs =
block.arguments && typeof block.arguments === "object"
? translatePiArgsToClaude(
block.name,
block.arguments as Record<string, unknown>,
)
: block.arguments;
const argsStr = claudeArgs ? JSON.stringify(claudeArgs) : "{}";
return `Historical tool call (non-executable): ${claudeName} args=${argsStr}`;
}
// Unknown block types are represented as a placeholder
return `[${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 | any[]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const texts: string[] = [];
for (const block of content) {
if (block.type === "text") {
texts.push(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 | any[]): boolean {
if (typeof content === "string" || !Array.isArray(content)) return false;
return content.some((block) => block.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 globalPath = join(homedir(), ".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,336 @@
/**
* Provider orchestration for bridging pi requests to the Claude CLI subprocess.
*
* streamViaCli is the core function that:
* 1. Builds the prompt from conversation context
* 2. Spawns a Claude 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 Model,
type SimpleStreamOptions,
} from "@mariozechner/pi-ai";
import {
buildPrompt,
buildSystemPrompt,
buildResumePrompt,
} from "./prompt-builder.js";
import {
spawnClaude,
writeUserMessage,
cleanupProcess,
captureStderr,
forceKillProcess,
registerProcess,
cleanupSystemPromptFile,
} from "./process-manager.js";
import { parseLine } from "./stream-parser.js";
import { createEventBridge } from "./event-bridge.js";
import { handleControlRequest } from "./control-handler.js";
import { mapThinkingEffort } from "./thinking-config.js";
import { isPiKnownClaudeTool } from "./tool-mapping.js";
/** Inactivity timeout: kill subprocess if no stdout for 180 seconds (3 minutes). */
const INACTIVITY_TIMEOUT_MS = 180_000;
/** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
type StreamViaCLiOptions = SimpleStreamOptions & {
cwd?: string;
mcpConfigPath?: string;
};
/**
* Stream a response from Claude 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 Claude 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<any>,
context: { messages: any[]; systemPrompt?: string },
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 spawnClaude> | 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,
);
// Spawn subprocess
proc = spawnClaude(model.id, systemPrompt || undefined, {
cwd,
signal: options?.signal,
effort,
mcpConfigPath: options?.mcpConfigPath,
resumeSessionId,
newSessionId: !resumeSessionId ? options?.sessionId : undefined,
});
const getStderr = captureStderr(proc);
// Register in global process registry for teardown cleanup
registerProcess(proc);
// Write user message to subprocess stdin
writeUserMessage(proc, prompt);
// 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,
} as any);
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(
`Claude 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;
// 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);
if (broken) return; // Break-early kill, expected
if (code !== 0 && code !== null) {
const stderr = getStderr();
const message = stderr
? `Claude CLI exited with code ${code}: ${stderr.trim()}`
: `Claude 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 (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 as any).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 && isPiKnownClaudeTool(toolName)) {
// 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
) {
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") {
handleControlRequest(msg, proc!.stdin!);
} else if (msg.type === "result") {
if (msg.subtype === "error") {
endStreamWithError(msg.error ?? "Unknown error from Claude 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();
// 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: any) => 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: any) {
stream.push({
type: "error",
reason: "error",
error: err.message ?? "Unexpected error in streamViaCli",
} as any);
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 Claude 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 Claude 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(
"[pi-claude-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 Claude 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 isPiKnownClaudeTool(claudeName: string): boolean {
if (claudeName.startsWith(CUSTOM_TOOLS_MCP_PREFIX)) return true;
return claudeName.toLowerCase() in CLAUDE_TO_PI_NAME;
}
// Derived lookup maps
/** Lowercase Claude name -> pi name */
const CLAUDE_TO_PI_NAME: Record<string, string> = {};
/** Pi name -> PascalCase Claude name */
const PI_TO_CLAUDE_NAME: Record<string, string> = {};
/** Lowercase Claude name -> { claudeArgName: piArgName } */
const CLAUDE_TO_PI_ARGS: Record<string, Record<string, string>> = {};
/** Pi name -> { piArgName: claudeArgName } */
const PI_TO_CLAUDE_ARGS: Record<string, Record<string, string>> = {};
for (const m of TOOL_MAPPINGS) {
CLAUDE_TO_PI_NAME[m.claude.toLowerCase()] = m.pi;
PI_TO_CLAUDE_NAME[m.pi] = m.claude;
CLAUDE_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_CLAUDE_ARGS[m.pi] = reverseArgs;
}
// Handle glob/find asymmetry: pi's "glob" also maps back to Claude's "Glob"
PI_TO_CLAUDE_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 mapClaudeToolNameToPi(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 CLAUDE_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 mapPiToolNameToClaude(piName: string): string {
return PI_TO_CLAUDE_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 translateClaudeArgsToPi(
claudeToolName: string,
args: Record<string, unknown>,
): Record<string, unknown> {
const renames = CLAUDE_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 translatePiArgsToClaude(
piToolName: string,
args: Record<string, unknown>,
): Record<string, unknown> {
const renames = PI_TO_CLAUDE_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,85 @@
// Wire protocol types for Claude CLI stream-json NDJSON communication
// NDJSON message types from Claude CLI stdout
export interface ClaudeStreamEventMessage {
type: "stream_event";
event: ClaudeApiEvent;
}
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,191 @@
import { describe, it, expect, vi } from "vitest";
import { PassThrough } from "node:stream";
import type { ClaudeControlRequest } from "../src/types";
import {
handleControlRequest,
TOOL_EXECUTION_DENIED_MESSAGE,
MCP_PREFIX,
} from "../src/control-handler";
function createMockStdin() {
const stream = new PassThrough();
const chunks: string[] = [];
stream.on("data", (data: Buffer) => chunks.push(data.toString()));
return { stream, chunks };
}
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 and returns false", () => {
const { stream, chunks } = createMockStdin();
const msg = makeControlRequest("mcp__custom-tools__weather");
const result = handleControlRequest(msg, stream);
expect(result).toBe(false);
const response = JSON.parse(chunks[0].trim());
expect(response.response.response.behavior).toBe("deny");
expect(response.response.response.message).toBe(
TOOL_EXECUTION_DENIED_MESSAGE,
);
});
it("denies mcp__custom-tools__deploy", () => {
const { stream, chunks } = createMockStdin();
const msg = makeControlRequest("mcp__custom-tools__deploy");
const result = handleControlRequest(msg, stream);
expect(result).toBe(false);
const response = JSON.parse(chunks[0].trim());
expect(response.response.response.behavior).toBe("deny");
});
});
describe("allows user MCP tools and other tools", () => {
it("allows user MCP tool mcp__database__query and returns true", () => {
const { stream, chunks } = createMockStdin();
const msg = makeControlRequest("mcp__database__query");
const result = handleControlRequest(msg, stream);
expect(result).toBe(true);
const response = JSON.parse(chunks[0].trim());
expect(response.response.response.behavior).toBe("allow");
});
it("allows built-in tool Read", () => {
const { stream, chunks } = createMockStdin();
const msg = makeControlRequest("Read");
const result = handleControlRequest(msg, stream);
expect(result).toBe(true);
const response = JSON.parse(chunks[0].trim());
expect(response.response.response.behavior).toBe("allow");
});
it("allows internal tools like ToolSearch", () => {
const { stream, chunks } = createMockStdin();
const msg = makeControlRequest("ToolSearch");
const result = handleControlRequest(msg, stream);
expect(result).toBe(true);
const response = JSON.parse(chunks[0].trim());
expect(response.response.response.behavior).toBe("allow");
});
it("allows unknown tools", () => {
const { stream, chunks } = createMockStdin();
const msg = makeControlRequest("SomeUnknownTool");
const result = handleControlRequest(msg, stream);
expect(result).toBe(true);
const response = JSON.parse(chunks[0].trim());
expect(response.response.response.behavior).toBe("allow");
});
});
describe("response format", () => {
it("includes matching request_id", () => {
const { stream, chunks } = createMockStdin();
const msg = makeControlRequest("Read", "custom-req-id-42");
handleControlRequest(msg, stream);
const response = JSON.parse(chunks[0].trim());
expect(response.request_id).toBe("custom-req-id-42");
});
it("writes response as NDJSON (JSON + newline)", () => {
const { stream, chunks } = createMockStdin();
const msg = makeControlRequest("Read");
handleControlRequest(msg, stream);
expect(chunks[0].endsWith("\n")).toBe(true);
expect(() => JSON.parse(chunks[0].trim())).not.toThrow();
});
it("deny response includes message field", () => {
const { stream, chunks } = createMockStdin();
const msg = makeControlRequest("mcp__custom-tools__foo");
handleControlRequest(msg, stream);
const response = JSON.parse(chunks[0].trim());
expect(response.response.response.message).toBe(
TOOL_EXECUTION_DENIED_MESSAGE,
);
});
it("allow response does not include a message field", () => {
const { stream, chunks } = createMockStdin();
const msg = makeControlRequest("mcp__database__query");
handleControlRequest(msg, stream);
const response = JSON.parse(chunks[0].trim());
expect(response.response.response.message).toBeUndefined();
});
});
describe("malformed input", () => {
it("returns false for missing request_id", () => {
const { stream } = createMockStdin();
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const msg = {
type: "control_request",
} as unknown as ClaudeControlRequest;
const result = handleControlRequest(msg, stream);
expect(result).toBe(false);
spy.mockRestore();
});
it("returns false for missing request object", () => {
const { stream } = createMockStdin();
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const msg = {
type: "control_request",
request_id: "req-001",
} as unknown as ClaudeControlRequest;
const result = handleControlRequest(msg, stream);
expect(result).toBe(false);
spy.mockRestore();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,272 @@
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 } from "../src/mcp-config";
import type { McpToolDef } from "../src/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("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(/pi-claude-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(/pi-claude-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(/pi-claude-mcp-config/);
expect(result).toMatch(/\.json$/);
});
});

View File

@@ -0,0 +1,619 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { ChildProcess } from "node:child_process";
// Mock cross-spawn before importing process-manager
vi.mock("cross-spawn", () => ({
default: 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;
}),
}));
// Mock child_process.execSync for validation tests
vi.mock("node:child_process", () => ({
execSync: vi.fn(),
}));
import spawn from "cross-spawn";
import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
spawnClaude,
writeUserMessage,
cleanupProcess,
captureStderr,
validateCliPresence,
validateCliAuth,
forceKillProcess,
registerProcess,
killAllProcesses,
cleanupSystemPromptFile,
} from "../src/process-manager";
describe("spawnClaude", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("spawns claude with all required CLI flags", () => {
spawnClaude("claude-sonnet-4-5-20250929");
expect(spawn).toHaveBeenCalledTimes(1);
const [cmd, args] = (spawn as any).mock.calls[0];
expect(cmd).toBe("claude");
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).toContain("--permission-prompt-tool");
expect(args).toContain("stdio");
});
it("passes stream-json for both input-format and output-format", () => {
spawnClaude("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", () => {
spawnClaude("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", () => {
spawnClaude("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", () => {
spawnClaude("claude-sonnet-4-5-20250929", "You are a helpful assistant.");
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toContain("--append-system-prompt");
const idx = args.indexOf("--append-system-prompt");
expect(args[idx + 1]).toContain("pi-claude-cli-sysprompt-");
});
it("temp file contains the system prompt text", () => {
spawnClaude("claude-sonnet-4-5-20250929", "You are a helpful assistant.");
const tmpFile = join(
tmpdir(),
`pi-claude-cli-sysprompt-${process.pid}.txt`,
);
expect(existsSync(tmpFile)).toBe(true);
expect(readFileSync(tmpFile, "utf-8")).toBe("You are a helpful assistant.");
});
it("does not include --append-system-prompt when no system prompt", () => {
spawnClaude("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 = spawnClaude("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", () => {
spawnClaude("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", () => {
spawnClaude("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", () => {
spawnClaude("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", () => {
spawnClaude("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", () => {
spawnClaude("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", () => {
spawnClaude("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("does NOT call stdin.end()", () => {
const mockStdin = { write: vi.fn(), end: vi.fn() };
const proc = { stdin: mockStdin } as unknown as ChildProcess;
writeUserMessage(proc, "test");
expect(mockStdin.end).not.toHaveBeenCalled();
});
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 claude --version succeeds", () => {
(execSync as any).mockReturnValue(Buffer.from("1.0.0"));
expect(() => validateCliPresence()).not.toThrow();
});
it("throws with install instructions when claude --version fails", () => {
(execSync as any).mockImplementation(() => {
throw new Error("command not found");
});
expect(() => validateCliPresence()).toThrow();
try {
validateCliPresence();
} catch (e: any) {
expect(e.message).toContain("Claude Code CLI not found");
expect(e.message).toContain("npm install");
}
});
});
describe("validateCliAuth", () => {
it("returns true when claude auth status succeeds", () => {
(execSync as any).mockReturnValue(Buffer.from("Logged in"));
expect(validateCliAuth()).toBe(true);
});
it("returns false and warns when claude 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("CLI flags", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("spawnClaude does NOT include --permission-mode or dontAsk in args", () => {
spawnClaude("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("spawnClaude includes --permission-prompt-tool followed by stdio in args", () => {
spawnClaude("claude-sonnet-4-5-20250929");
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toContain("--permission-prompt-tool");
const idx = args.indexOf("--permission-prompt-tool");
expect(args[idx + 1]).toBe("stdio");
});
});
describe("mcp-config flag", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("spawnClaude with mcpConfigPath includes --mcp-config followed by the path", () => {
spawnClaude("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("spawnClaude without mcpConfigPath does NOT include --mcp-config in args", () => {
spawnClaude("claude-sonnet-4-5-20250929");
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).not.toContain("--mcp-config");
});
it("spawnClaude NEVER includes --strict-mcp-config in args", () => {
spawnClaude("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", () => {
spawnClaude("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).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", () => {
spawnClaude("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", () => {
spawnClaude("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", () => {
spawnClaude("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", () => {
spawnClaude("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", () => {
const tmpFile = join(tmpdir(), `pi-claude-cli-sysprompt-${process.pid}.txt`);
it("deletes the temp file when it exists", () => {
// Create the file by spawning with a system prompt
spawnClaude("claude-sonnet-4-5-20250929", "test prompt");
expect(existsSync(tmpFile)).toBe(true);
cleanupSystemPromptFile();
expect(existsSync(tmpFile)).toBe(false);
});
it("does not throw when file does not exist", () => {
// Ensure file doesn't exist
cleanupSystemPromptFile();
// Call again — should not throw
expect(() => cleanupSystemPromptFile()).not.toThrow();
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,188 @@
import { describe, it, expect, vi } from "vitest";
import { parseLine } from "../src/stream-parser";
import type {
ClaudeStreamEventMessage,
ClaudeResultMessage,
ClaudeSystemMessage,
} from "../src/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 "../src/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,252 @@
import { describe, it, expect } from "vitest";
import {
TOOL_MAPPINGS,
CUSTOM_TOOLS_MCP_PREFIX,
mapClaudeToolNameToPi,
mapPiToolNameToClaude,
translateClaudeArgsToPi,
translatePiArgsToClaude,
isCustomToolName,
} from "../src/tool-mapping";
describe("tool-mapping", () => {
describe("TOOL_MAPPINGS", () => {
it("exports 6 tool mappings", () => {
expect(TOOL_MAPPINGS).toHaveLength(6);
});
});
describe("mapClaudeToolNameToPi", () => {
it("maps Read to read", () => {
expect(mapClaudeToolNameToPi("Read")).toBe("read");
});
it("maps Write to write", () => {
expect(mapClaudeToolNameToPi("Write")).toBe("write");
});
it("maps Edit to edit", () => {
expect(mapClaudeToolNameToPi("Edit")).toBe("edit");
});
it("maps Bash to bash", () => {
expect(mapClaudeToolNameToPi("Bash")).toBe("bash");
});
it("maps Grep to grep", () => {
expect(mapClaudeToolNameToPi("Grep")).toBe("grep");
});
it("maps Glob to find", () => {
expect(mapClaudeToolNameToPi("Glob")).toBe("find");
});
it("passes through unknown tool names unchanged", () => {
expect(mapClaudeToolNameToPi("UnknownTool")).toBe("UnknownTool");
});
it("is case-insensitive for Claude tool names", () => {
expect(mapClaudeToolNameToPi("read")).toBe("read");
expect(mapClaudeToolNameToPi("READ")).toBe("read");
});
});
describe("mapPiToolNameToClaude", () => {
it("maps read to Read", () => {
expect(mapPiToolNameToClaude("read")).toBe("Read");
});
it("maps write to Write", () => {
expect(mapPiToolNameToClaude("write")).toBe("Write");
});
it("maps edit to Edit", () => {
expect(mapPiToolNameToClaude("edit")).toBe("Edit");
});
it("maps bash to Bash", () => {
expect(mapPiToolNameToClaude("bash")).toBe("Bash");
});
it("maps grep to Grep", () => {
expect(mapPiToolNameToClaude("grep")).toBe("Grep");
});
it("maps find to Glob", () => {
expect(mapPiToolNameToClaude("find")).toBe("Glob");
});
it("maps glob to Glob (asymmetry: both find and glob map to Glob)", () => {
expect(mapPiToolNameToClaude("glob")).toBe("Glob");
});
it("passes through unknown tool names unchanged", () => {
expect(mapPiToolNameToClaude("unknownTool")).toBe("unknownTool");
});
});
describe("translateClaudeArgsToPi", () => {
it("renames file_path to path for Read", () => {
const result = translateClaudeArgsToPi("Read", {
file_path: "/foo",
offset: 10,
});
expect(result).toEqual({ path: "/foo", offset: 10 });
});
it("renames file_path to path for Write", () => {
const result = translateClaudeArgsToPi("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 = translateClaudeArgsToPi("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 = translateClaudeArgsToPi("Bash", { command: "ls" });
expect(result).toEqual({ command: "ls" });
});
it("renames head_limit to limit for Grep", () => {
const result = translateClaudeArgsToPi("Grep", {
pattern: "x",
head_limit: 5,
});
expect(result).toEqual({ pattern: "x", limit: 5 });
});
it("passes through Glob args unchanged (no renames)", () => {
const result = translateClaudeArgsToPi("Glob", { pattern: "*.ts" });
expect(result).toEqual({ pattern: "*.ts" });
});
it("passes through args for unknown tools unchanged", () => {
const result = translateClaudeArgsToPi("UnknownTool", {
foo: 1,
bar: "baz",
});
expect(result).toEqual({ foo: 1, bar: "baz" });
});
it("preserves unknown args alongside renamed args", () => {
const result = translateClaudeArgsToPi("Read", {
file_path: "/foo",
offset: 10,
limit: 50,
extra_arg: true,
});
expect(result).toEqual({
path: "/foo",
offset: 10,
limit: 50,
extra_arg: true,
});
});
});
describe("translatePiArgsToClaude", () => {
it("renames path to file_path for read", () => {
const result = translatePiArgsToClaude("read", { path: "/foo" });
expect(result).toEqual({ file_path: "/foo" });
});
it("renames path, oldText, newText for edit", () => {
const result = translatePiArgsToClaude("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 = translatePiArgsToClaude("grep", {
pattern: "x",
limit: 5,
});
expect(result).toEqual({ pattern: "x", head_limit: 5 });
});
it("passes through unknown args alongside renamed args", () => {
const result = translatePiArgsToClaude("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 = translatePiArgsToClaude("unknownTool", { foo: 1 });
expect(result).toEqual({ foo: 1 });
});
});
describe("MCP prefix stripping", () => {
it("strips mcp__custom-tools__ prefix from myTool", () => {
expect(mapClaudeToolNameToPi("mcp__custom-tools__myTool")).toBe("myTool");
});
it("strips mcp__custom-tools__ prefix from deploy", () => {
expect(mapClaudeToolNameToPi("mcp__custom-tools__deploy")).toBe("deploy");
});
it("handles empty name after prefix", () => {
expect(mapClaudeToolNameToPi("mcp__custom-tools__")).toBe("");
});
it("does NOT strip other MCP server prefixes", () => {
expect(mapClaudeToolNameToPi("mcp__other-server__foo")).toBe(
"mcp__other-server__foo",
);
});
it("built-in mappings still work alongside MCP prefix stripping", () => {
expect(mapClaudeToolNameToPi("Read")).toBe("read");
expect(mapClaudeToolNameToPi("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);
});
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("translateClaudeArgsToPi with MCP prefix", () => {
it("MCP-prefixed custom tool args pass through unchanged", () => {
const result = translateClaudeArgsToPi("mcp__custom-tools__myTool", {
foo: 1,
bar: "baz",
});
expect(result).toEqual({ foo: 1, bar: "baz" });
});
});
});

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", "index.ts", "tests/**/*.ts"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
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,
},
},
},
});