Merge pull request #183 from richardanaya/master

Adding support for pi-local
This commit is contained in:
Dotta
2026-03-07 09:31:35 -06:00
committed by GitHub
28 changed files with 2061 additions and 12 deletions

View File

@@ -0,0 +1,5 @@
---
"@paperclipai/shared": minor
---
Add support for Pi local adapter in constants and onboarding UI.

View File

@@ -38,6 +38,7 @@
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/db": "workspace:*",

View File

@@ -3,6 +3,7 @@ import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
import { processCLIAdapter } from "./process/index.js";
import { httpCLIAdapter } from "./http/index.js";
@@ -22,6 +23,11 @@ const openCodeLocalCLIAdapter: CLIAdapterModule = {
formatStdoutEvent: printOpenCodeStreamEvent,
};
const piLocalCLIAdapter: CLIAdapterModule = {
type: "pi_local",
formatStdoutEvent: printPiStreamEvent,
};
const cursorLocalCLIAdapter: CLIAdapterModule = {
type: "cursor",
formatStdoutEvent: printCursorStreamEvent,
@@ -33,7 +39,7 @@ const openclawCLIAdapter: CLIAdapterModule = {
};
const adaptersByType = new Map<string, CLIAdapterModule>(
[claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
[claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
);
export function getCLIAdapter(type: string): CLIAdapterModule {

View File

@@ -0,0 +1,50 @@
{
"name": "@paperclipai/adapter-pi-local",
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,107 @@
import pc from "picocolors";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!)
.join("");
}
export function printPiStreamEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
if (!line) return;
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
console.log(line);
return;
}
const type = asString(parsed.type);
if (type === "agent_start") {
console.log(pc.blue("Pi agent started"));
return;
}
if (type === "agent_end") {
console.log(pc.blue("Pi agent finished"));
return;
}
if (type === "turn_start") {
console.log(pc.blue("Turn started"));
return;
}
if (type === "turn_end") {
const message = asRecord(parsed.message);
if (message) {
const content = message.content as string | Array<{ type: string; text?: string }>;
const text = extractTextContent(content);
if (text) {
console.log(pc.green(`assistant: ${text}`));
}
}
return;
}
if (type === "message_update") {
const assistantEvent = asRecord(parsed.assistantMessageEvent);
if (assistantEvent) {
const msgType = asString(assistantEvent.type);
if (msgType === "text_delta") {
const delta = asString(assistantEvent.delta);
if (delta) {
console.log(pc.green(delta));
}
}
}
return;
}
if (type === "tool_execution_start") {
const toolName = asString(parsed.toolName);
const args = parsed.args;
console.log(pc.yellow(`tool_start: ${toolName}`));
if (args !== undefined) {
try {
console.log(pc.gray(JSON.stringify(args, null, 2)));
} catch {
console.log(pc.gray(String(args)));
}
}
return;
}
if (type === "tool_execution_end") {
const result = parsed.result;
const isError = parsed.isError === true;
const output = typeof result === "string" ? result : JSON.stringify(result);
if (output) {
console.log((isError ? pc.red : pc.gray)(output));
}
return;
}
console.log(line);
}

View File

@@ -0,0 +1 @@
export { printPiStreamEvent } from "./format-event.js";

View File

@@ -0,0 +1,40 @@
export const type = "pi_local";
export const label = "Pi (local)";
export const models: Array<{ id: string; label: string }> = [];
export const agentConfigurationDoc = `# pi_local agent configuration
Adapter: pi_local
Use when:
- You want Paperclip to run Pi (the AI coding agent) locally as the agent runtime
- You want provider/model routing in Pi format (--provider <name> --model <id>)
- You want Pi session resume across heartbeats via --session
- You need Pi's tool set (read, bash, edit, write, grep, find, ls)
Don't use when:
- You need webhook-style external invocation (use openclaw or http)
- You only need one-shot shell commands (use process)
- Pi CLI is not installed on the machine
Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file appended to system prompt via --append-system-prompt
- promptTemplate (string, optional): user prompt template passed via -p flag
- model (string, required): Pi model id in provider/model format (for example xai/grok-4)
- thinking (string, optional): thinking level (off, minimal, low, medium, high, xhigh)
- command (string, optional): defaults to "pi"
- env (object, optional): KEY=VALUE environment variables
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- Pi supports multiple providers and models. Use \`pi --list-models\` to list available options.
- Paperclip requires an explicit \`model\` value for \`pi_local\` agents.
- Sessions are stored in ~/.pi/paperclips/ and resumed with --session.
- All tools (read, bash, edit, write, grep, find, ls) are enabled by default.
- Agent instructions are appended to Pi's system prompt via --append-system-prompt, while the user task is sent via -p.
`;

View File

@@ -0,0 +1,478 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
asString,
asNumber,
asStringArray,
parseObject,
buildPaperclipEnv,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js";
import { ensurePiModelConfiguredAndAvailable } from "./models.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips");
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function parseModelProvider(model: string | null): string | null {
if (!model) return null;
const trimmed = model.trim();
if (!trimmed.includes("/")) return null;
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
}
function parseModelId(model: string | null): string | null {
if (!model) return null;
const trimmed = model.trim();
if (!trimmed.includes("/")) return trimmed || null;
return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null;
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) return;
const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills");
await fs.mkdir(piSkillsHome, { recursive: true });
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
const target = path.join(piSkillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
await onLog(
"stderr",
`[paperclip] Injected Pi skill "${entry.name}" into ${piSkillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to inject Pi skill "${entry.name}" into ${piSkillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
async function ensureSessionsDir(): Promise<string> {
await fs.mkdir(PAPERCLIP_SESSIONS_DIR, { recursive: true });
return PAPERCLIP_SESSIONS_DIR;
}
function buildSessionPath(agentId: string, timestamp: string): string {
const safeTimestamp = timestamp.replace(/[:.]/g, "-");
return path.join(PAPERCLIP_SESSIONS_DIR, `${safeTimestamp}-${agentId}.jsonl`);
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
const promptTemplate = asString(
config.promptTemplate,
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
);
const command = asString(config.command, "pi");
const model = asString(config.model, "").trim();
const thinking = asString(config.thinking, "").trim();
// Parse model into provider and model id
const provider = parseModelProvider(model);
const modelId = parseModelId(model);
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
// Ensure sessions directory exists
await ensureSessionsDir();
// Inject skills
await ensurePiSkillsInjected(onLog);
// Build environment
const envConfig = parseObject(config.env);
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
null;
const wakeReason =
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
? context.wakeReason.trim()
: null;
const wakeCommentId =
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
null;
const approvalId =
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
? context.approvalId.trim()
: null;
const approvalStatus =
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
? context.approvalStatus.trim()
: null;
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (workspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
await ensureCommandResolvable(command, cwd, runtimeEnv);
// Validate model is available before execution
await ensurePiModelConfiguredAndAvailable({
model,
command,
cwd,
env: runtimeEnv,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
// Handle session
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionPath = canResumeSession ? runtimeSessionId : buildSessionPath(agent.id, new Date().toISOString());
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stderr",
`[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
// Ensure session file exists (Pi requires this on first run)
if (!canResumeSession) {
try {
await fs.writeFile(sessionPath, "", { flag: "wx" });
} catch (err) {
// File may already exist, that's ok
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
throw err;
}
}
}
// Handle instructions file and build system prompt extension
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const resolvedInstructionsFilePath = instructionsFilePath
? path.resolve(cwd, instructionsFilePath)
: "";
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let systemPromptExtension = "";
let instructionsReadFailed = false;
if (resolvedInstructionsFilePath) {
try {
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
systemPromptExtension =
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
`You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`;
await onLog(
"stderr",
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
);
} catch (err) {
instructionsReadFailed = true;
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stderr",
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
);
// Fall back to base prompt template
systemPromptExtension = promptTemplate;
}
} else {
systemPromptExtension = promptTemplate;
}
const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
});
// User prompt is simple - just the rendered prompt template without instructions
const userPrompt = renderTemplate(promptTemplate, {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
});
const commandNotes = (() => {
if (!resolvedInstructionsFilePath) return [] as string[];
if (instructionsReadFailed) {
return [
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
];
}
return [
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
`Appended instructions + path directive to system prompt (relative references from ${instructionsFileDir}).`,
];
})();
const buildArgs = (sessionFile: string): string[] => {
const args: string[] = [];
// Use RPC mode for proper lifecycle management (waits for agent completion)
args.push("--mode", "rpc");
// Use --append-system-prompt to extend Pi's default system prompt
args.push("--append-system-prompt", renderedSystemPromptExtension);
if (provider) args.push("--provider", provider);
if (modelId) args.push("--model", modelId);
if (thinking) args.push("--thinking", thinking);
args.push("--tools", "read,bash,edit,write,grep,find,ls");
args.push("--session", sessionFile);
if (extraArgs.length > 0) args.push(...extraArgs);
return args;
};
const buildRpcStdin = (): string => {
// Send the prompt as an RPC command
const promptCommand = {
type: "prompt",
message: userPrompt,
};
return JSON.stringify(promptCommand) + "\n";
};
const runAttempt = async (sessionFile: string) => {
const args = buildArgs(sessionFile);
if (onMeta) {
await onMeta({
adapterType: "pi_local",
command,
cwd,
commandNotes,
commandArgs: args,
env: redactEnvForLogs(env),
prompt: userPrompt,
context,
});
}
// Buffer stdout by lines to handle partial JSON chunks
let stdoutBuffer = "";
const bufferedOnLog = async (stream: "stdout" | "stderr", chunk: string) => {
if (stream === "stderr") {
// Pass stderr through immediately (not JSONL)
await onLog(stream, chunk);
return;
}
// Buffer stdout and emit only complete lines
stdoutBuffer += chunk;
const lines = stdoutBuffer.split("\n");
// Keep the last (potentially incomplete) line in the buffer
stdoutBuffer = lines.pop() || "";
// Emit complete lines
for (const line of lines) {
if (line) {
await onLog(stream, line + "\n");
}
}
};
const proc = await runChildProcess(runId, command, args, {
cwd,
env: runtimeEnv,
timeoutSec,
graceSec,
onLog: bufferedOnLog,
stdin: buildRpcStdin(),
});
// Flush any remaining buffer content
if (stdoutBuffer) {
await onLog("stdout", stdoutBuffer);
}
return {
proc,
rawStderr: proc.stderr,
parsed: parsePiJsonl(proc.stdout),
};
};
const toResult = (
attempt: {
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
rawStderr: string;
parsed: ReturnType<typeof parsePiJsonl>;
},
clearSessionOnMissingSession = false,
): AdapterExecutionResult => {
if (attempt.proc.timedOut) {
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
clearSession: clearSessionOnMissingSession,
};
}
const resolvedSessionId = clearSessionOnMissingSession ? null : sessionPath;
const resolvedSessionParams = resolvedSessionId
? { sessionId: resolvedSessionId, cwd }
: null;
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const rawExitCode = attempt.proc.exitCode;
const fallbackErrorMessage = stderrLine || `Pi exited with code ${rawExitCode ?? -1}`;
return {
exitCode: rawExitCode,
signal: attempt.proc.signal,
timedOut: false,
errorMessage: (rawExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
usage: {
inputTokens: attempt.parsed.usage.inputTokens,
outputTokens: attempt.parsed.usage.outputTokens,
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
},
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: provider,
model: model,
billingType: "unknown",
costUsd: attempt.parsed.usage.costUsd,
resultJson: {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.finalMessage ?? attempt.parsed.messages.join("\n\n").trim(),
clearSession: Boolean(clearSessionOnMissingSession),
};
};
const initial = await runAttempt(sessionPath);
const initialFailed =
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || initial.parsed.errors.length > 0);
if (
canResumeSession &&
initialFailed &&
isPiUnknownSessionError(initial.proc.stdout, initial.rawStderr)
) {
await onLog(
"stderr",
`[paperclip] Pi session "${runtimeSessionId}" is unavailable; retrying with a fresh session.\n`,
);
const newSessionPath = buildSessionPath(agent.id, new Date().toISOString());
try {
await fs.writeFile(newSessionPath, "", { flag: "wx" });
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
throw err;
}
}
const retry = await runAttempt(newSessionPath);
return toResult(retry, true);
}
return toResult(initial);
}

View File

@@ -0,0 +1,60 @@
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId =
readNonEmptyString(record.sessionId) ??
readNonEmptyString(record.session_id) ??
readNonEmptyString(record.session);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
return {
sessionId,
...(cwd ? { cwd } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId =
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.session);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
return {
sessionId,
...(cwd ? { cwd } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return (
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.session)
);
},
};
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export {
listPiModels,
discoverPiModels,
discoverPiModelsCached,
ensurePiModelConfiguredAndAvailable,
resetPiModelsCacheForTests,
} from "./models.js";
export { parsePiJsonl, isPiUnknownSessionError } from "./parse.js";

View File

@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it } from "vitest";
import {
ensurePiModelConfiguredAndAvailable,
listPiModels,
resetPiModelsCacheForTests,
} from "./models.js";
describe("pi models", () => {
afterEach(() => {
delete process.env.PAPERCLIP_PI_COMMAND;
resetPiModelsCacheForTests();
});
it("returns an empty list when discovery command is unavailable", async () => {
process.env.PAPERCLIP_PI_COMMAND = "__paperclip_missing_pi_command__";
await expect(listPiModels()).resolves.toEqual([]);
});
it("rejects when model is missing", async () => {
await expect(
ensurePiModelConfiguredAndAvailable({ model: "" }),
).rejects.toThrow("Pi requires `adapterConfig.model`");
});
it("rejects when discovery cannot run for configured model", async () => {
process.env.PAPERCLIP_PI_COMMAND = "__paperclip_missing_pi_command__";
await expect(
ensurePiModelConfiguredAndAvailable({
model: "xai/grok-4",
}),
).rejects.toThrow();
});
});

View File

@@ -0,0 +1,208 @@
import { createHash } from "node:crypto";
import type { AdapterModel } from "@paperclipai/adapter-utils";
import { asString, runChildProcess } from "@paperclipai/adapter-utils/server-utils";
const MODELS_CACHE_TTL_MS = 60_000;
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function parseModelsOutput(stdout: string): AdapterModel[] {
const parsed: AdapterModel[] = [];
const lines = stdout.split(/\r?\n/);
// Skip header line if present
let startIndex = 0;
if (lines.length > 0 && (lines[0].includes("provider") || lines[0].includes("model"))) {
startIndex = 1;
}
for (let i = startIndex; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Parse format: "provider model context max-out thinking images"
// Split by 2+ spaces to handle the columnar format
const parts = line.split(/\s{2,}/);
if (parts.length < 2) continue;
const provider = parts[0].trim();
const model = parts[1].trim();
if (!provider || !model) continue;
if (provider === "provider" && model === "model") continue; // Skip header
const id = `${provider}/${model}`;
parsed.push({ id, label: id });
}
return parsed;
}
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>();
const deduped: AdapterModel[] = [];
for (const model of models) {
const id = model.id.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push({ id, label: model.label.trim() || id });
}
return deduped;
}
function sortModels(models: AdapterModel[]): AdapterModel[] {
return [...models].sort((a, b) =>
a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }),
);
}
function resolvePiCommand(input: unknown): string {
const envOverride =
typeof process.env.PAPERCLIP_PI_COMMAND === "string" &&
process.env.PAPERCLIP_PI_COMMAND.trim().length > 0
? process.env.PAPERCLIP_PI_COMMAND.trim()
: "pi";
return asString(input, envOverride);
}
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const;
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]);
function isVolatileEnvKey(key: string): boolean {
if (VOLATILE_ENV_KEY_EXACT.has(key)) return true;
return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
}
function hashValue(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
const envKey = Object.entries(env)
.filter(([key]) => !isVolatileEnvKey(key))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${hashValue(value)}`)
.join("\n");
return `${command}\n${cwd}\n${envKey}`;
}
function pruneExpiredDiscoveryCache(now: number) {
for (const [key, value] of discoveryCache.entries()) {
if (value.expiresAt <= now) discoveryCache.delete(key);
}
}
export async function discoverPiModels(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = resolvePiCommand(input.command);
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
const runtimeEnv = normalizeEnv({ ...process.env, ...env });
const result = await runChildProcess(
`pi-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
["--list-models"],
{
cwd,
env: runtimeEnv,
timeoutSec: 20,
graceSec: 3,
onLog: async () => {},
},
);
if (result.timedOut) {
throw new Error("`pi --list-models` timed out.");
}
if ((result.exitCode ?? 1) !== 0) {
const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout);
throw new Error(detail ? `\`pi --list-models\` failed: ${detail}` : "`pi --list-models` failed.");
}
return sortModels(dedupeModels(parseModelsOutput(result.stdout)));
}
function normalizeEnv(input: unknown): Record<string, string> {
const envInput = typeof input === "object" && input !== null && !Array.isArray(input)
? (input as Record<string, unknown>)
: {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envInput)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
export async function discoverPiModelsCached(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = resolvePiCommand(input.command);
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
const key = discoveryCacheKey(command, cwd, env);
const now = Date.now();
pruneExpiredDiscoveryCache(now);
const cached = discoveryCache.get(key);
if (cached && cached.expiresAt > now) return cached.models;
const models = await discoverPiModels({ command, cwd, env });
discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models });
return models;
}
export async function ensurePiModelConfiguredAndAvailable(input: {
model?: unknown;
command?: unknown;
cwd?: unknown;
env?: unknown;
}): Promise<AdapterModel[]> {
const model = asString(input.model, "").trim();
if (!model) {
throw new Error("Pi requires `adapterConfig.model` in provider/model format.");
}
const models = await discoverPiModelsCached({
command: input.command,
cwd: input.cwd,
env: input.env,
});
if (models.length === 0) {
throw new Error("Pi returned no models. Run `pi --list-models` and verify provider auth.");
}
if (!models.some((entry) => entry.id === model)) {
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
throw new Error(
`Configured Pi model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
);
}
return models;
}
export async function listPiModels(): Promise<AdapterModel[]> {
try {
return await discoverPiModelsCached();
} catch {
return [];
}
}
export function resetPiModelsCacheForTests() {
discoveryCache.clear();
}

View File

@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import { parsePiJsonl, isPiUnknownSessionError } from "./parse.js";
describe("parsePiJsonl", () => {
it("parses agent lifecycle and messages", () => {
const stdout = [
JSON.stringify({ type: "agent_start" }),
JSON.stringify({
type: "turn_end",
message: {
role: "assistant",
content: [{ type: "text", text: "Hello from Pi" }],
},
}),
JSON.stringify({ type: "agent_end", messages: [] }),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.messages).toContain("Hello from Pi");
expect(parsed.finalMessage).toBe("Hello from Pi");
});
it("parses streaming text deltas", () => {
const stdout = [
JSON.stringify({
type: "message_update",
assistantMessageEvent: { type: "text_delta", delta: "Hello " },
}),
JSON.stringify({
type: "message_update",
assistantMessageEvent: { type: "text_delta", delta: "World" },
}),
JSON.stringify({
type: "turn_end",
message: {
role: "assistant",
content: "Hello World",
},
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.messages).toContain("Hello World");
});
it("parses tool execution", () => {
const stdout = [
JSON.stringify({
type: "tool_execution_start",
toolCallId: "tool_1",
toolName: "read",
args: { path: "/tmp/test.txt" },
}),
JSON.stringify({
type: "tool_execution_end",
toolCallId: "tool_1",
toolName: "read",
result: "file contents",
isError: false,
}),
JSON.stringify({
type: "turn_end",
message: { role: "assistant", content: "Done" },
toolResults: [
{
toolCallId: "tool_1",
content: "file contents",
isError: false,
},
],
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.toolCalls).toHaveLength(1);
expect(parsed.toolCalls[0].toolName).toBe("read");
expect(parsed.toolCalls[0].result).toBe("file contents");
expect(parsed.toolCalls[0].isError).toBe(false);
});
it("handles errors in tool execution", () => {
const stdout = [
JSON.stringify({
type: "tool_execution_start",
toolCallId: "tool_1",
toolName: "read",
args: { path: "/missing.txt" },
}),
JSON.stringify({
type: "tool_execution_end",
toolCallId: "tool_1",
toolName: "read",
result: "File not found",
isError: true,
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.toolCalls).toHaveLength(1);
expect(parsed.toolCalls[0].isError).toBe(true);
expect(parsed.toolCalls[0].result).toBe("File not found");
});
it("extracts usage and cost from turn_end events", () => {
const stdout = [
JSON.stringify({
type: "turn_end",
message: {
role: "assistant",
content: "Response with usage",
usage: {
input: 100,
output: 50,
cacheRead: 20,
totalTokens: 170,
cost: {
input: 0.001,
output: 0.0015,
cacheRead: 0.0001,
cacheWrite: 0,
total: 0.0026,
},
},
},
toolResults: [],
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.usage.inputTokens).toBe(100);
expect(parsed.usage.outputTokens).toBe(50);
expect(parsed.usage.cachedInputTokens).toBe(20);
expect(parsed.usage.costUsd).toBeCloseTo(0.0026, 4);
});
it("accumulates usage from multiple turns", () => {
const stdout = [
JSON.stringify({
type: "turn_end",
message: {
role: "assistant",
content: "First response",
usage: {
input: 50,
output: 25,
cacheRead: 0,
cost: { total: 0.001 },
},
},
}),
JSON.stringify({
type: "turn_end",
message: {
role: "assistant",
content: "Second response",
usage: {
input: 30,
output: 20,
cacheRead: 10,
cost: { total: 0.0015 },
},
},
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.usage.inputTokens).toBe(80);
expect(parsed.usage.outputTokens).toBe(45);
expect(parsed.usage.cachedInputTokens).toBe(10);
expect(parsed.usage.costUsd).toBeCloseTo(0.0025, 4);
});
it("handles standalone usage events with Pi format", () => {
const stdout = [
JSON.stringify({
type: "usage",
usage: {
input: 200,
output: 100,
cacheRead: 50,
cost: { total: 0.005 },
},
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.usage.inputTokens).toBe(200);
expect(parsed.usage.outputTokens).toBe(100);
expect(parsed.usage.cachedInputTokens).toBe(50);
expect(parsed.usage.costUsd).toBe(0.005);
});
it("handles standalone usage events with generic format", () => {
const stdout = [
JSON.stringify({
type: "usage",
usage: {
inputTokens: 150,
outputTokens: 75,
cachedInputTokens: 25,
costUsd: 0.003,
},
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.usage.inputTokens).toBe(150);
expect(parsed.usage.outputTokens).toBe(75);
expect(parsed.usage.cachedInputTokens).toBe(25);
expect(parsed.usage.costUsd).toBe(0.003);
});
});
describe("isPiUnknownSessionError", () => {
it("detects unknown session errors", () => {
expect(isPiUnknownSessionError("session not found: s_123", "")).toBe(true);
expect(isPiUnknownSessionError("", "unknown session id")).toBe(true);
expect(isPiUnknownSessionError("", "no session available")).toBe(true);
expect(isPiUnknownSessionError("all good", "")).toBe(false);
expect(isPiUnknownSessionError("working fine", "no errors")).toBe(false);
});
});

View File

@@ -0,0 +1,211 @@
import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
interface ParsedPiOutput {
sessionId: string | null;
messages: string[];
errors: string[];
usage: {
inputTokens: number;
outputTokens: number;
cachedInputTokens: number;
costUsd: number;
};
finalMessage: string | null;
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; result: string | null; isError: boolean }>;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!)
.join("");
}
export function parsePiJsonl(stdout: string): ParsedPiOutput {
const result: ParsedPiOutput = {
sessionId: null,
messages: [],
errors: [],
usage: {
inputTokens: 0,
outputTokens: 0,
cachedInputTokens: 0,
costUsd: 0,
},
finalMessage: null,
toolCalls: [],
};
let currentToolCall: { toolCallId: string; toolName: string; args: unknown } | null = null;
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const event = parseJson(line);
if (!event) continue;
const eventType = asString(event.type, "");
// RPC protocol messages - skip these (internal implementation detail)
if (eventType === "response" || eventType === "extension_ui_request" || eventType === "extension_ui_response" || eventType === "extension_error") {
continue;
}
// Agent lifecycle
if (eventType === "agent_start") {
continue;
}
if (eventType === "agent_end") {
const messages = event.messages as Array<Record<string, unknown>> | undefined;
if (messages && messages.length > 0) {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === "assistant") {
const content = lastMessage.content as string | Array<{ type: string; text?: string }>;
result.finalMessage = extractTextContent(content);
}
}
continue;
}
// Turn lifecycle
if (eventType === "turn_start") {
continue;
}
if (eventType === "turn_end") {
const message = asRecord(event.message);
if (message) {
const content = message.content as string | Array<{ type: string; text?: string }>;
const text = extractTextContent(content);
if (text) {
result.finalMessage = text;
result.messages.push(text);
}
// Extract usage and cost from assistant message
const usage = asRecord(message.usage);
if (usage) {
result.usage.inputTokens += asNumber(usage.input, 0);
result.usage.outputTokens += asNumber(usage.output, 0);
result.usage.cachedInputTokens += asNumber(usage.cacheRead, 0);
// Pi stores cost in usage.cost.total (and broken down in usage.cost.input, etc.)
const cost = asRecord(usage.cost);
if (cost) {
result.usage.costUsd += asNumber(cost.total, 0);
}
}
}
// Tool results are in toolResults array
const toolResults = event.toolResults as Array<Record<string, unknown>> | undefined;
if (toolResults) {
for (const tr of toolResults) {
const toolCallId = asString(tr.toolCallId, "");
const content = tr.content;
const isError = tr.isError === true;
// Find matching tool call by toolCallId
const existingCall = result.toolCalls.find((tc) => tc.toolCallId === toolCallId);
if (existingCall) {
existingCall.result = typeof content === "string" ? content : JSON.stringify(content);
existingCall.isError = isError;
}
}
}
continue;
}
// Message updates (streaming)
if (eventType === "message_update") {
const assistantEvent = asRecord(event.assistantMessageEvent);
if (assistantEvent) {
const msgType = asString(assistantEvent.type, "");
if (msgType === "text_delta") {
const delta = asString(assistantEvent.delta, "");
if (delta) {
// Append to last message or create new
if (result.messages.length === 0) {
result.messages.push(delta);
} else {
result.messages[result.messages.length - 1] += delta;
}
}
}
}
continue;
}
// Tool execution
if (eventType === "tool_execution_start") {
const toolCallId = asString(event.toolCallId, "");
const toolName = asString(event.toolName, "");
const args = event.args;
currentToolCall = { toolCallId, toolName, args };
result.toolCalls.push({
toolCallId,
toolName,
args,
result: null,
isError: false,
});
continue;
}
if (eventType === "tool_execution_end") {
const toolCallId = asString(event.toolCallId, "");
const toolName = asString(event.toolName, "");
const toolResult = event.result;
const isError = event.isError === true;
// Find the tool call by toolCallId (not toolName, to handle multiple calls to same tool)
const existingCall = result.toolCalls.find((tc) => tc.toolCallId === toolCallId);
if (existingCall) {
existingCall.result = typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult);
existingCall.isError = isError;
}
currentToolCall = null;
continue;
}
// Usage tracking if available in the event (fallback for standalone usage events)
if (eventType === "usage" || event.usage) {
const usage = asRecord(event.usage);
if (usage) {
// Support both Pi format (input/output/cacheRead) and generic format (inputTokens/outputTokens/cachedInputTokens)
result.usage.inputTokens += asNumber(usage.inputTokens ?? usage.input, 0);
result.usage.outputTokens += asNumber(usage.outputTokens ?? usage.output, 0);
result.usage.cachedInputTokens += asNumber(usage.cachedInputTokens ?? usage.cacheRead, 0);
// Cost may be in usage.costUsd (direct) or usage.cost.total (Pi format)
const cost = asRecord(usage.cost);
if (cost) {
result.usage.costUsd += asNumber(cost.total ?? usage.costUsd, 0);
} else {
result.usage.costUsd += asNumber(usage.costUsd, 0);
}
}
}
}
return result;
}
export function isPiUnknownSessionError(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
return /unknown\s+session|session\s+not\s+found|session\s+.*\s+not\s+found|no\s+session/i.test(haystack);
}

View File

@@ -0,0 +1,276 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import {
asString,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
asStringArray,
} from "@paperclipai/adapter-utils/server-utils";
import { discoverPiModelsCached } from "./models.js";
import { parsePiJsonl } from "./parse.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
const clean = raw.replace(/\s+/g, " ").trim();
const max = 240;
return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean;
}
function normalizeEnv(input: unknown): Record<string, string> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
const PI_AUTH_REQUIRED_RE =
/(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|free\s+usage\s+exceeded)/i;
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "pi");
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
checks.push({
code: "pi_cwd_valid",
level: "info",
message: `Working directory is valid: ${cwd}`,
});
} catch (err) {
checks.push({
code: "pi_cwd_invalid",
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
});
}
const envConfig = parseObject(config.env);
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
const cwdInvalid = checks.some((check) => check.code === "pi_cwd_invalid");
if (cwdInvalid) {
checks.push({
code: "pi_command_skipped",
level: "warn",
message: "Skipped command check because working directory validation failed.",
detail: command,
});
} else {
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "pi_command_resolvable",
level: "info",
message: `Command is executable: ${command}`,
});
} catch (err) {
checks.push({
code: "pi_command_unresolvable",
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
});
}
}
const canRunProbe =
checks.every((check) => check.code !== "pi_cwd_invalid" && check.code !== "pi_command_unresolvable");
if (canRunProbe) {
try {
const discovered = await discoverPiModelsCached({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
checks.push({
code: "pi_models_discovered",
level: "info",
message: `Discovered ${discovered.length} model(s) from Pi.`,
});
} else {
checks.push({
code: "pi_models_empty",
level: "warn",
message: "Pi returned no models.",
hint: "Run `pi --list-models` and verify provider authentication.",
});
}
} catch (err) {
checks.push({
code: "pi_models_discovery_failed",
level: "warn",
message: err instanceof Error ? err.message : "Pi model discovery failed.",
hint: "Run `pi --list-models` manually to verify provider auth and config.",
});
}
}
const configuredModel = asString(config.model, "").trim();
if (!configuredModel) {
checks.push({
code: "pi_model_required",
level: "error",
message: "Pi requires a configured model in provider/model format.",
hint: "Set adapterConfig.model using an ID from `pi --list-models`.",
});
} else if (canRunProbe) {
// Verify model is in the list
try {
const discovered = await discoverPiModelsCached({ command, cwd, env: runtimeEnv });
const modelExists = discovered.some((m: { id: string }) => m.id === configuredModel);
if (modelExists) {
checks.push({
code: "pi_model_configured",
level: "info",
message: `Configured model: ${configuredModel}`,
});
} else {
checks.push({
code: "pi_model_not_found",
level: "warn",
message: `Configured model "${configuredModel}" not found in available models.`,
hint: "Run `pi --list-models` and choose a currently available provider/model ID.",
});
}
} catch {
// If we can't verify, just note it
checks.push({
code: "pi_model_configured",
level: "info",
message: `Configured model: ${configuredModel}`,
});
}
}
if (canRunProbe && configuredModel) {
// Parse model for probe
const provider = configuredModel.includes("/")
? configuredModel.slice(0, configuredModel.indexOf("/"))
: "";
const modelId = configuredModel.includes("/")
? configuredModel.slice(configuredModel.indexOf("/") + 1)
: configuredModel;
const thinking = asString(config.thinking, "").trim();
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const args = ["-p", "Respond with hello.", "--mode", "json"];
if (provider) args.push("--provider", provider);
if (modelId) args.push("--model", modelId);
if (thinking) args.push("--thinking", thinking);
args.push("--tools", "read");
if (extraArgs.length > 0) args.push(...extraArgs);
try {
const probe = await runChildProcess(
`pi-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{
cwd,
env: runtimeEnv,
timeoutSec: 60,
graceSec: 5,
onLog: async () => {},
},
);
const parsed = parsePiJsonl(probe.stdout);
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errors[0] ?? null);
const authEvidence = `${parsed.errors.join("\n")}\n${probe.stdout}\n${probe.stderr}`.trim();
if (probe.timedOut) {
checks.push({
code: "pi_hello_probe_timed_out",
level: "warn",
message: "Pi hello probe timed out.",
hint: "Retry the probe. If this persists, run Pi manually in this working directory.",
});
} else if ((probe.exitCode ?? 1) === 0 && parsed.errors.length === 0) {
const summary = (parsed.finalMessage || parsed.messages.join(" ")).trim();
const hasHello = /\bhello\b/i.test(summary);
checks.push({
code: hasHello ? "pi_hello_probe_passed" : "pi_hello_probe_unexpected_output",
level: hasHello ? "info" : "warn",
message: hasHello
? "Pi hello probe succeeded."
: "Pi probe ran but did not return `hello` as expected.",
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
...(hasHello
? {}
: {
hint: "Run `pi --mode json` manually and prompt `Respond with hello` to inspect output.",
}),
});
} else if (PI_AUTH_REQUIRED_RE.test(authEvidence)) {
checks.push({
code: "pi_hello_probe_auth_required",
level: "warn",
message: "Pi is installed, but provider authentication is not ready.",
...(detail ? { detail } : {}),
hint: "Set provider API key environment variable (e.g., ANTHROPIC_API_KEY, XAI_API_KEY) and retry.",
});
} else {
checks.push({
code: "pi_hello_probe_failed",
level: "error",
message: "Pi hello probe failed.",
...(detail ? { detail } : {}),
hint: "Run `pi --mode json` manually in this working directory to debug.",
});
}
} catch (err) {
checks.push({
code: "pi_hello_probe_failed",
level: "error",
message: "Pi hello probe failed.",
detail: err instanceof Error ? err.message : String(err),
hint: "Run `pi --mode json` manually in this working directory to debug.",
});
}
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View File

@@ -0,0 +1,71 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
export function buildPiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.thinking = v.thinkingEffort;
// Pi sessions can run until the CLI exits naturally; keep timeout disabled (0)
ac.timeoutSec = 0;
ac.graceSec = 20;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
if (Object.keys(env).length > 0) ac.env = env;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = v.extraArgs;
if (v.args) ac.args = v.args;
return ac;
}

View File

@@ -0,0 +1,2 @@
export { parsePiStdoutLine } from "./parse-stdout.js";
export { buildPiLocalConfig } from "./build-config.js";

View File

@@ -0,0 +1,147 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!)
.join("");
}
export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
return [{ kind: "stdout", ts, text: line }];
}
const type = asString(parsed.type);
// RPC protocol messages - filter these out (internal implementation detail)
if (type === "response" || type === "extension_ui_request" || type === "extension_ui_response" || type === "extension_error") {
return [];
}
// Agent lifecycle
if (type === "agent_start") {
return [{ kind: "system", ts, text: "Pi agent started" }];
}
if (type === "agent_end") {
return [{ kind: "system", ts, text: "Pi agent finished" }];
}
// Turn lifecycle
if (type === "turn_start") {
return [{ kind: "system", ts, text: "Turn started" }];
}
if (type === "turn_end") {
const message = asRecord(parsed.message);
const toolResults = parsed.toolResults as Array<Record<string, unknown>> | undefined;
const entries: TranscriptEntry[] = [];
if (message) {
const content = message.content as string | Array<{ type: string; text?: string }>;
const text = extractTextContent(content);
if (text) {
entries.push({ kind: "assistant", ts, text });
}
}
// Process tool results
if (toolResults) {
for (const tr of toolResults) {
const content = tr.content;
const isError = tr.isError === true;
const contentStr = typeof content === "string" ? content : JSON.stringify(content);
entries.push({
kind: "tool_result",
ts,
toolUseId: asString(tr.toolCallId, "unknown"),
content: contentStr,
isError,
});
}
}
return entries.length > 0 ? entries : [{ kind: "system", ts, text: "Turn ended" }];
}
// Message streaming
if (type === "message_start") {
return [];
}
if (type === "message_update") {
const assistantEvent = asRecord(parsed.assistantMessageEvent);
if (assistantEvent) {
const msgType = asString(assistantEvent.type);
if (msgType === "text_delta") {
const delta = asString(assistantEvent.delta);
if (delta) {
return [{ kind: "assistant", ts, text: delta, delta: true }];
}
}
}
return [];
}
if (type === "message_end") {
return [];
}
// Tool execution
if (type === "tool_execution_start") {
const toolName = asString(parsed.toolName);
const args = parsed.args;
if (toolName) {
return [{
kind: "tool_call",
ts,
name: toolName,
input: args,
}];
}
return [{ kind: "system", ts, text: `Tool started` }];
}
if (type === "tool_execution_update") {
return [];
}
if (type === "tool_execution_end") {
const toolCallId = asString(parsed.toolCallId);
const result = parsed.result;
const isError = parsed.isError === true;
const contentStr = typeof result === "string" ? result : JSON.stringify(result);
return [{
kind: "tool_result",
ts,
toolUseId: toolCallId || "unknown",
content: contentStr,
isError,
}];
}
return [{ kind: "stdout", ts, text: line }];
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});

View File

@@ -27,6 +27,7 @@ export const AGENT_ADAPTER_TYPES = [
"claude_local",
"codex_local",
"opencode_local",
"pi_local",
"cursor",
"openclaw",
] as const;

43
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
'@paperclipai/adapter-opencode-local':
specifier: workspace:*
version: link:../packages/adapters/opencode-local
'@paperclipai/adapter-pi-local':
specifier: workspace:*
version: link:../packages/adapters/pi-local
'@paperclipai/adapter-utils':
specifier: workspace:*
version: link:../packages/adapter-utils
@@ -156,8 +159,24 @@ importers:
version: 1.1.1
devDependencies:
'@types/node':
specifier: ^22.12.0
version: 22.19.11
specifier: ^24.6.0
version: 24.12.0
typescript:
specifier: ^5.7.3
version: 5.9.3
packages/adapters/pi-local:
dependencies:
'@paperclipai/adapter-utils':
specifier: workspace:*
version: link:../../adapter-utils
picocolors:
specifier: ^1.1.1
version: 1.1.1
devDependencies:
'@types/node':
specifier: ^24.6.0
version: 24.12.0
typescript:
specifier: ^5.7.3
version: 5.9.3
@@ -220,6 +239,9 @@ importers:
'@paperclipai/adapter-opencode-local':
specifier: workspace:*
version: link:../packages/adapters/opencode-local
'@paperclipai/adapter-pi-local':
specifier: workspace:*
version: link:../packages/adapters/pi-local
'@paperclipai/adapter-utils':
specifier: workspace:*
version: link:../packages/adapter-utils
@@ -332,6 +354,9 @@ importers:
'@paperclipai/adapter-opencode-local':
specifier: workspace:*
version: link:../packages/adapters/opencode-local
'@paperclipai/adapter-pi-local':
specifier: workspace:*
version: link:../packages/adapters/pi-local
'@paperclipai/adapter-utils':
specifier: workspace:*
version: link:../packages/adapter-utils
@@ -8162,7 +8187,7 @@ snapshots:
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 24.12.0
'@types/node': 25.2.3
'@types/chai@5.2.3':
dependencies:
@@ -8171,7 +8196,7 @@ snapshots:
'@types/connect@3.4.38':
dependencies:
'@types/node': 24.12.0
'@types/node': 25.2.3
'@types/cookiejar@2.1.5': {}
@@ -8189,7 +8214,7 @@ snapshots:
'@types/express-serve-static-core@5.1.1':
dependencies:
'@types/node': 24.12.0
'@types/node': 25.2.3
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -8246,18 +8271,18 @@ snapshots:
'@types/send@1.2.1':
dependencies:
'@types/node': 24.12.0
'@types/node': 25.2.3
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 24.12.0
'@types/node': 25.2.3
'@types/superagent@8.1.9':
dependencies:
'@types/cookiejar': 2.1.5
'@types/methods': 1.1.4
'@types/node': 24.12.0
'@types/node': 25.2.3
form-data: 4.0.5
'@types/supertest@6.0.3':
@@ -8271,7 +8296,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.12.0
'@types/node': 25.2.3
'@ungap/structured-clone@1.3.0': {}

View File

@@ -35,6 +35,7 @@
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/db": "workspace:*",

View File

@@ -37,6 +37,15 @@ import {
} from "@paperclipai/adapter-openclaw";
import { listCodexModels } from "./codex-models.js";
import { listCursorModels } from "./cursor-models.js";
import {
execute as piExecute,
testEnvironment as piTestEnvironment,
sessionCodec as piSessionCodec,
listPiModels,
} from "@paperclipai/adapter-pi-local/server";
import {
agentConfigurationDoc as piAgentConfigurationDoc,
} from "@paperclipai/adapter-pi-local";
import { processAdapter } from "./process/index.js";
import { httpAdapter } from "./http/index.js";
@@ -93,8 +102,19 @@ const openCodeLocalAdapter: ServerAdapterModule = {
agentConfigurationDoc: openCodeAgentConfigurationDoc,
};
const piLocalAdapter: ServerAdapterModule = {
type: "pi_local",
execute: piExecute,
testEnvironment: piTestEnvironment,
sessionCodec: piSessionCodec,
models: [],
listModels: listPiModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: piAgentConfigurationDoc,
};
const adaptersByType = new Map<string, ServerAdapterModule>(
[claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
[claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, piLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
);
export function getServerAdapter(type: string): ServerAdapterModule {

View File

@@ -18,6 +18,7 @@
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*",

View File

@@ -0,0 +1,47 @@
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
DraftInput,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
export function PiLocalConfigFields({
isCreate,
values,
set,
config,
eff,
mark,
}: AdapterConfigFieldsProps) {
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
);
}

View File

@@ -0,0 +1,12 @@
import type { UIAdapterModule } from "../types";
import { parsePiStdoutLine } from "@paperclipai/adapter-pi-local/ui";
import { PiLocalConfigFields } from "./config-fields";
import { buildPiLocalConfig } from "@paperclipai/adapter-pi-local/ui";
export const piLocalUIAdapter: UIAdapterModule = {
type: "pi_local",
label: "Pi (local)",
parseStdoutLine: parsePiStdoutLine,
ConfigFields: PiLocalConfigFields,
buildAdapterConfig: buildPiLocalConfig,
};

View File

@@ -3,12 +3,13 @@ import { claudeLocalUIAdapter } from "./claude-local";
import { codexLocalUIAdapter } from "./codex-local";
import { cursorLocalUIAdapter } from "./cursor";
import { openCodeLocalUIAdapter } from "./opencode-local";
import { piLocalUIAdapter } from "./pi-local";
import { openClawUIAdapter } from "./openclaw";
import { processUIAdapter } from "./process";
import { httpUIAdapter } from "./http";
const adaptersByType = new Map<string, UIAdapterModule>(
[claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, cursorLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
[claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
);
export function getUIAdapter(type: string): UIAdapterModule {

View File

@@ -53,6 +53,7 @@ type AdapterType =
| "claude_local"
| "codex_local"
| "opencode_local"
| "pi_local"
| "cursor"
| "process"
| "http"
@@ -665,6 +666,12 @@ export function OnboardingWizard() {
icon: OpenCodeLogoIcon,
desc: "Local multi-provider agent"
},
{
value: "pi_local" as const,
label: "Pi",
icon: Terminal,
desc: "Local Pi agent"
},
{
value: "openclaw" as const,
label: "OpenClaw",
@@ -741,6 +748,7 @@ export function OnboardingWizard() {
{(adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor") && (
<div className="space-y-3">
<div>