From 264d40e6ca8e749a6f2b5dd88d93260f140eef0a Mon Sep 17 00:00:00 2001 From: MumuTW Date: Thu, 5 Mar 2026 17:03:37 +0000 Subject: [PATCH 1/9] fix: use root option in sendFile to avoid dotfile 500 on SPA refresh When served via npx, the absolute path to index.html traverses .npm, triggering Express 5 / send's dotfile guard. Using the root option makes send only check the relative filename for dotfiles. Fixes #48 --- server/src/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/app.ts b/server/src/app.ts index 1faab285..ab6dcfad 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -133,7 +133,7 @@ export async function createApp( if (uiDist) { app.use(express.static(uiDist)); app.get(/.*/, (_req, res) => { - res.sendFile(path.join(uiDist, "index.html")); + res.sendFile("index.html", { root: uiDist }); }); } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); From 59a07324ecdeb7db7c248429951008b55f12fff3 Mon Sep 17 00:00:00 2001 From: Ikko Ashimine Date: Sat, 7 Mar 2026 02:57:28 +0900 Subject: [PATCH 2/9] Add License --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..a63594a5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Paperclip AI + +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. From eb7f690ceb32c39da3592c61a4934d471610879a Mon Sep 17 00:00:00 2001 From: Richard Anaya Date: Fri, 6 Mar 2026 18:29:38 -0800 Subject: [PATCH 3/9] Adding support for pi-local --- cli/package.json | 1 + cli/src/adapters/registry.ts | 8 +- packages/adapters/pi-local/package.json | 50 ++ .../adapters/pi-local/src/cli/format-event.ts | 107 +++++ packages/adapters/pi-local/src/cli/index.ts | 1 + packages/adapters/pi-local/src/index.ts | 40 ++ .../adapters/pi-local/src/server/execute.ts | 435 ++++++++++++++++++ .../adapters/pi-local/src/server/index.ts | 60 +++ .../pi-local/src/server/models.test.ts | 33 ++ .../adapters/pi-local/src/server/models.ts | 208 +++++++++ .../pi-local/src/server/parse.test.ts | 113 +++++ .../adapters/pi-local/src/server/parse.ts | 182 ++++++++ packages/adapters/pi-local/src/server/test.ts | 276 +++++++++++ .../adapters/pi-local/src/ui/build-config.ts | 71 +++ packages/adapters/pi-local/src/ui/index.ts | 2 + .../adapters/pi-local/src/ui/parse-stdout.ts | 142 ++++++ packages/adapters/pi-local/tsconfig.json | 8 + packages/adapters/pi-local/vitest.config.ts | 7 + pnpm-lock.yaml | 43 +- server/package.json | 1 + server/src/adapters/registry.ts | 22 +- ui/package.json | 1 + ui/src/adapters/pi-local/config-fields.tsx | 47 ++ ui/src/adapters/pi-local/index.ts | 12 + ui/src/adapters/registry.ts | 3 +- 25 files changed, 1861 insertions(+), 12 deletions(-) create mode 100644 packages/adapters/pi-local/package.json create mode 100644 packages/adapters/pi-local/src/cli/format-event.ts create mode 100644 packages/adapters/pi-local/src/cli/index.ts create mode 100644 packages/adapters/pi-local/src/index.ts create mode 100644 packages/adapters/pi-local/src/server/execute.ts create mode 100644 packages/adapters/pi-local/src/server/index.ts create mode 100644 packages/adapters/pi-local/src/server/models.test.ts create mode 100644 packages/adapters/pi-local/src/server/models.ts create mode 100644 packages/adapters/pi-local/src/server/parse.test.ts create mode 100644 packages/adapters/pi-local/src/server/parse.ts create mode 100644 packages/adapters/pi-local/src/server/test.ts create mode 100644 packages/adapters/pi-local/src/ui/build-config.ts create mode 100644 packages/adapters/pi-local/src/ui/index.ts create mode 100644 packages/adapters/pi-local/src/ui/parse-stdout.ts create mode 100644 packages/adapters/pi-local/tsconfig.json create mode 100644 packages/adapters/pi-local/vitest.config.ts create mode 100644 ui/src/adapters/pi-local/config-fields.tsx create mode 100644 ui/src/adapters/pi-local/index.ts diff --git a/cli/package.json b/cli/package.json index 4126d93b..84edcb58 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 818bc6e6..66829b2c 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -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( - [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 { diff --git a/packages/adapters/pi-local/package.json b/packages/adapters/pi-local/package.json new file mode 100644 index 00000000..1184c1ca --- /dev/null +++ b/packages/adapters/pi-local/package.json @@ -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" + } +} diff --git a/packages/adapters/pi-local/src/cli/format-event.ts b/packages/adapters/pi-local/src/cli/format-event.ts new file mode 100644 index 00000000..e93319a6 --- /dev/null +++ b/packages/adapters/pi-local/src/cli/format-event.ts @@ -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 | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +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); +} diff --git a/packages/adapters/pi-local/src/cli/index.ts b/packages/adapters/pi-local/src/cli/index.ts new file mode 100644 index 00000000..94d5961a --- /dev/null +++ b/packages/adapters/pi-local/src/cli/index.ts @@ -0,0 +1 @@ +export { printPiStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/pi-local/src/index.ts b/packages/adapters/pi-local/src/index.ts new file mode 100644 index 00000000..3794426f --- /dev/null +++ b/packages/adapters/pi-local/src/index.ts @@ -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 --model ) +- 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. +`; diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts new file mode 100644 index 00000000..3ea848f5 --- /dev/null +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -0,0 +1,435 @@ +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 { + 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 { + 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 { + 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 => 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 = { ...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 = ""; + 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) { + 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 (systemPromptExtension.length > 0) { + return [ + `Loaded agent instructions from ${resolvedInstructionsFilePath}`, + `Appended instructions + path directive to system prompt (relative references from ${instructionsFileDir}).`, + ]; + } + return [ + `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ]; + })(); + + const buildArgs = (sessionFile: string): string[] => { + const args: string[] = ["-p", userPrompt]; + + // 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("--mode", "json"); + args.push("--tools", "read,bash,edit,write,grep,find,ls"); + args.push("--session", sessionFile); + + if (extraArgs.length > 0) args.push(...extraArgs); + + return args; + }; + + 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, + }); + } + + const proc = await runChildProcess(runId, command, args, { + cwd, + env: runtimeEnv, + timeoutSec, + graceSec, + onLog, + }); + 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; + }, + 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); +} diff --git a/packages/adapters/pi-local/src/server/index.ts b/packages/adapters/pi-local/src/server/index.ts new file mode 100644 index 00000000..a18d5264 --- /dev/null +++ b/packages/adapters/pi-local/src/server/index.ts @@ -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; + 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 | 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 | 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"; diff --git a/packages/adapters/pi-local/src/server/models.test.ts b/packages/adapters/pi-local/src/server/models.test.ts new file mode 100644 index 00000000..df777544 --- /dev/null +++ b/packages/adapters/pi-local/src/server/models.test.ts @@ -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(); + }); +}); diff --git a/packages/adapters/pi-local/src/server/models.ts b/packages/adapters/pi-local/src/server/models.ts new file mode 100644 index 00000000..4a85a457 --- /dev/null +++ b/packages/adapters/pi-local/src/server/models.ts @@ -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(); + 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(); +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) { + 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 { + 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 { + const envInput = typeof input === "object" && input !== null && !Array.isArray(input) + ? (input as Record) + : {}; + const env: Record = {}; + 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 { + 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 { + 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 { + try { + return await discoverPiModelsCached(); + } catch { + return []; + } +} + +export function resetPiModelsCacheForTests() { + discoveryCache.clear(); +} diff --git a/packages/adapters/pi-local/src/server/parse.test.ts b/packages/adapters/pi-local/src/server/parse.test.ts new file mode 100644 index 00000000..d1d695b3 --- /dev/null +++ b/packages/adapters/pi-local/src/server/parse.test.ts @@ -0,0 +1,113 @@ +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"); + }); +}); + +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); + }); +}); diff --git a/packages/adapters/pi-local/src/server/parse.ts b/packages/adapters/pi-local/src/server/parse.ts new file mode 100644 index 00000000..095e814e --- /dev/null +++ b/packages/adapters/pi-local/src/server/parse.ts @@ -0,0 +1,182 @@ +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<{ toolName: string; args: unknown; result: string | null; isError: boolean }>; +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +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: { 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, ""); + + // Agent lifecycle + if (eventType === "agent_start") { + continue; + } + + if (eventType === "agent_end") { + const messages = event.messages as Array> | 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); + } + } + + // Tool results are in toolResults array + const toolResults = event.toolResults as Array> | 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 + const existingCall = result.toolCalls.find((tc) => tc.toolName === 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 toolName = asString(event.toolName, ""); + const args = event.args; + currentToolCall = { toolName, args }; + result.toolCalls.push({ + 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 + const existingCall = result.toolCalls.find((tc) => tc.toolName === toolName); + if (existingCall) { + existingCall.result = typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult); + existingCall.isError = isError; + } + currentToolCall = null; + continue; + } + + // Usage tracking if available in the event + if (eventType === "usage" || event.usage) { + const usage = asRecord(event.usage); + if (usage) { + result.usage.inputTokens += asNumber(usage.inputTokens, 0); + result.usage.outputTokens += asNumber(usage.outputTokens, 0); + result.usage.cachedInputTokens += asNumber(usage.cachedInputTokens, 0); + 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); +} diff --git a/packages/adapters/pi-local/src/server/test.ts b/packages/adapters/pi-local/src/server/test.ts new file mode 100644 index 00000000..cf8fa80a --- /dev/null +++ b/packages/adapters/pi-local/src/server/test.ts @@ -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 { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const env: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + 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 { + 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 = {}; + 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(), + }; +} diff --git a/packages/adapters/pi-local/src/ui/build-config.ts b/packages/adapters/pi-local/src/ui/build-config.ts new file mode 100644 index 00000000..f871019d --- /dev/null +++ b/packages/adapters/pi-local/src/ui/build-config.ts @@ -0,0 +1,71 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +function parseEnvVars(text: string): Record { + const env: Record = {}; + 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 { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + 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; + 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 { + const ac: Record = {}; + 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; +} diff --git a/packages/adapters/pi-local/src/ui/index.ts b/packages/adapters/pi-local/src/ui/index.ts new file mode 100644 index 00000000..89b781a7 --- /dev/null +++ b/packages/adapters/pi-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parsePiStdoutLine } from "./parse-stdout.js"; +export { buildPiLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/pi-local/src/ui/parse-stdout.ts b/packages/adapters/pi-local/src/ui/parse-stdout.ts new file mode 100644 index 00000000..08e13290 --- /dev/null +++ b/packages/adapters/pi-local/src/ui/parse-stdout.ts @@ -0,0 +1,142 @@ +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 | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +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); + + // 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> | 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 }]; +} diff --git a/packages/adapters/pi-local/tsconfig.json b/packages/adapters/pi-local/tsconfig.json new file mode 100644 index 00000000..2f355cfe --- /dev/null +++ b/packages/adapters/pi-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/adapters/pi-local/vitest.config.ts b/packages/adapters/pi-local/vitest.config.ts new file mode 100644 index 00000000..f624398e --- /dev/null +++ b/packages/adapters/pi-local/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 492cd35a..cb2d1d55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': {} diff --git a/server/package.json b/server/package.json index 2e470111..ae540789 100644 --- a/server/package.json +++ b/server/package.json @@ -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:*", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index cc8c040c..3d1b98d8 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -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( - [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 { diff --git a/ui/package.json b/ui/package.json index ccd40dd7..d6cb1113 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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:*", diff --git a/ui/src/adapters/pi-local/config-fields.tsx b/ui/src/adapters/pi-local/config-fields.tsx new file mode 100644 index 00000000..e6afacb3 --- /dev/null +++ b/ui/src/adapters/pi-local/config-fields.tsx @@ -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 ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ ); +} diff --git a/ui/src/adapters/pi-local/index.ts b/ui/src/adapters/pi-local/index.ts new file mode 100644 index 00000000..cfebf669 --- /dev/null +++ b/ui/src/adapters/pi-local/index.ts @@ -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, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 2ce643f0..f9e0080c 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -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( - [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 { From 6077ae6064dc6a554cc548385cc0a707daf727fb Mon Sep 17 00:00:00 2001 From: Richard Anaya Date: Fri, 6 Mar 2026 18:47:44 -0800 Subject: [PATCH 4/9] feat: add Pi adapter support to constants and onboarding UI --- .changeset/add-pi-adapter-support.md | 5 +++++ packages/shared/src/constants.ts | 1 + ui/src/components/OnboardingWizard.tsx | 8 ++++++++ 3 files changed, 14 insertions(+) create mode 100644 .changeset/add-pi-adapter-support.md diff --git a/.changeset/add-pi-adapter-support.md b/.changeset/add-pi-adapter-support.md new file mode 100644 index 00000000..97005a39 --- /dev/null +++ b/.changeset/add-pi-adapter-support.md @@ -0,0 +1,5 @@ +--- +"@paperclipai/shared": minor +--- + +Add support for Pi local adapter in constants and onboarding UI. \ No newline at end of file diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 4f6b75b9..53a6400b 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -27,6 +27,7 @@ export const AGENT_ADAPTER_TYPES = [ "claude_local", "codex_local", "opencode_local", + "pi_local", "cursor", "openclaw", ] as const; diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 77fb4db8..7338d3d2 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -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") && (
From 36e4e67025a3b55c5169a1c70f28dae279d854cf Mon Sep 17 00:00:00 2001 From: hougangdev Date: Sat, 7 Mar 2026 17:38:07 +0800 Subject: [PATCH 5/9] fix(sidebar-badges): include approvals in inbox badge count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a company has "require board approval for new agents" enabled, hiring an agent creates a pending approval that requires the user (as a board member) to approve before the agent can start working. However, the sidebar inbox badge did not include pending approvals in its count, so there was no visual indicator that action was needed. Users had no way of knowing an approval was waiting unless they happened to open the Inbox page manually. The root cause: the sidebar-badges service correctly included approvals in the inbox total, but the route handler overwrites badges.inbox to add alertsCount and staleIssueCount — and in doing so dropped badges.approvals from the sum. Add badges.approvals to the inbox count recalculation so that pending and revision-requested approvals surface in the sidebar notification badge alongside failed runs, alerts, stale work, and join requests. Affected files: - server/src/routes/sidebar-badges.ts --- server/src/routes/sidebar-badges.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index edf34709..0cd302e5 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -45,7 +45,7 @@ export function sidebarBadgeRoutes(db: Db) { const alertsCount = (summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) + (summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0); - badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount; + badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals; res.json(badges); }); From a6b5f12daf8f92ae7cdc996b234226834a7b5655 Mon Sep 17 00:00:00 2001 From: Richard Anaya Date: Sat, 7 Mar 2026 07:23:44 -0800 Subject: [PATCH 6/9] feat(pi-local): fix bugs, add RPC mode, improve cost tracking and output handling Major improvements to the Pi local adapter: Bug Fixes (Greptile-identified): - Fix string interpolation in models.ts error message (was showing literal ${detail}) - Fix tool matching in parse.ts to use toolCallId instead of toolName for correct multi-call handling and result assignment - Fix dead code in execute.ts by tracking instructionsReadFailed flag Feature Improvements: - Switch from print mode (-p) to RPC mode (--mode rpc) to prevent agent from exiting prematurely and ensure proper lifecycle completion - Add stdin command sending via JSON-RPC format for prompt delivery - Add line buffering in execute.ts to handle partial JSON chunks correctly - Filter RPC protocol messages (response, extension_ui_request, etc.) from transcript Cost Tracking: - Extract cost and usage data from turn_end assistant messages - Support both Pi format (input/output/cacheRead/cost.total) and generic format - Add tests for cost extraction and accumulation across multiple turns All tests pass (12/12), typecheck clean, server builds successfully. --- .../adapters/pi-local/src/server/execute.ts | 57 +++++++-- .../adapters/pi-local/src/server/models.ts | 2 +- .../pi-local/src/server/parse.test.ts | 109 ++++++++++++++++++ .../adapters/pi-local/src/server/parse.ts | 53 +++++++-- .../adapters/pi-local/src/ui/parse-stdout.ts | 5 + 5 files changed, 206 insertions(+), 20 deletions(-) diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 3ea848f5..23cad28b 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -246,6 +246,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { if (!resolvedInstructionsFilePath) return [] as string[]; - if (systemPromptExtension.length > 0) { + if (instructionsReadFailed) { return [ - `Loaded agent instructions from ${resolvedInstructionsFilePath}`, - `Appended instructions + path directive to system prompt (relative references from ${instructionsFileDir}).`, + `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, ]; } return [ - `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, + `Loaded agent instructions from ${resolvedInstructionsFilePath}`, + `Appended instructions + path directive to system prompt (relative references from ${instructionsFileDir}).`, ]; })(); const buildArgs = (sessionFile: string): string[] => { - const args: string[] = ["-p", userPrompt]; + 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); @@ -315,7 +320,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + // 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) { @@ -339,13 +352,43 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + 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, + onLog: bufferedOnLog, + stdin: buildRpcStdin(), }); + + // Flush any remaining buffer content + if (stdoutBuffer) { + await onLog("stdout", stdoutBuffer); + } + return { proc, rawStderr: proc.stderr, diff --git a/packages/adapters/pi-local/src/server/models.ts b/packages/adapters/pi-local/src/server/models.ts index 4a85a457..3212312a 100644 --- a/packages/adapters/pi-local/src/server/models.ts +++ b/packages/adapters/pi-local/src/server/models.ts @@ -128,7 +128,7 @@ export async function discoverPiModels(input: { } 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."); + throw new Error(detail ? `\`pi --list-models\` failed: ${detail}` : "`pi --list-models` failed."); } return sortModels(dedupeModels(parseModelsOutput(result.stdout))); diff --git a/packages/adapters/pi-local/src/server/parse.test.ts b/packages/adapters/pi-local/src/server/parse.test.ts index d1d695b3..6a3eef4d 100644 --- a/packages/adapters/pi-local/src/server/parse.test.ts +++ b/packages/adapters/pi-local/src/server/parse.test.ts @@ -100,6 +100,115 @@ describe("parsePiJsonl", () => { 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", () => { diff --git a/packages/adapters/pi-local/src/server/parse.ts b/packages/adapters/pi-local/src/server/parse.ts index 095e814e..3ba50d8b 100644 --- a/packages/adapters/pi-local/src/server/parse.ts +++ b/packages/adapters/pi-local/src/server/parse.ts @@ -11,7 +11,7 @@ interface ParsedPiOutput { costUsd: number; }; finalMessage: string | null; - toolCalls: Array<{ toolName: string; args: unknown; result: string | null; isError: boolean }>; + toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; result: string | null; isError: boolean }>; } function asRecord(value: unknown): Record | null { @@ -43,7 +43,7 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { toolCalls: [], }; - let currentToolCall: { toolName: string; args: unknown } | null = null; + let currentToolCall: { toolCallId: string; toolName: string; args: unknown } | null = null; for (const rawLine of stdout.split(/\r?\n/)) { const line = rawLine.trim(); @@ -54,6 +54,11 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { 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; @@ -85,6 +90,20 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { 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 @@ -95,8 +114,8 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { const content = tr.content; const isError = tr.isError === true; - // Find matching tool call - const existingCall = result.toolCalls.find((tc) => tc.toolName === toolCallId); + // 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; @@ -128,10 +147,12 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { // Tool execution if (eventType === "tool_execution_start") { + const toolCallId = asString(event.toolCallId, ""); const toolName = asString(event.toolName, ""); const args = event.args; - currentToolCall = { toolName, args }; + currentToolCall = { toolCallId, toolName, args }; result.toolCalls.push({ + toolCallId, toolName, args, result: null, @@ -146,8 +167,8 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { const toolResult = event.result; const isError = event.isError === true; - // Find the tool call - const existingCall = result.toolCalls.find((tc) => tc.toolName === toolName); + // 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; @@ -156,14 +177,22 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { continue; } - // Usage tracking if available in the event + // Usage tracking if available in the event (fallback for standalone usage events) if (eventType === "usage" || event.usage) { const usage = asRecord(event.usage); if (usage) { - result.usage.inputTokens += asNumber(usage.inputTokens, 0); - result.usage.outputTokens += asNumber(usage.outputTokens, 0); - result.usage.cachedInputTokens += asNumber(usage.cachedInputTokens, 0); - result.usage.costUsd += asNumber(usage.costUsd, 0); + // 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); + } } } } diff --git a/packages/adapters/pi-local/src/ui/parse-stdout.ts b/packages/adapters/pi-local/src/ui/parse-stdout.ts index 08e13290..b80fe5f1 100644 --- a/packages/adapters/pi-local/src/ui/parse-stdout.ts +++ b/packages/adapters/pi-local/src/ui/parse-stdout.ts @@ -34,6 +34,11 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { 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" }]; From 049f768bc79fd0254323267086cbe117529fc263 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 7 Mar 2026 15:32:27 +0000 Subject: [PATCH 7/9] Add Contributing guide --- CONTRIBUTING.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..ab420a24 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing Guide + +Thanks for wanting to contribute! + +We really appreciate both small fixes and thoughtful larger changes. + +## Two Paths to Get Your Pull Request Accepted + +### Path 1: Small, Focused Changes (Fastest way to get merged) +- Pick **one** clear thing to fix/improve +- Touch the **smallest possible number of files** +- Make sure the change is very targeted and easy to review +- All automated checks pass (including Greptile comments) +- No new lint/test failures + +These almost always get merged quickly when they're clean. + +### Path 2: Bigger or Impactful Changes +- **First** talk about it in Discord → #dev channel + → Describe what you're trying to solve + → Share rough ideas / approach +- Once there's rough agreement, build it +- In your PR include: + - Before / After screenshots (or short video if UI/behavior change) + - Clear description of what & why + - Proof it works (manual testing notes) + - All tests passing + - All Greptile + other PR comments addressed + +PRs that follow this path are **much** more likely to be accepted, even when they're large. + +## General Rules (both paths) +- Write clear commit messages +- Keep PR title + description meaningful +- One PR = one logical change (unless it's a small related group) +- Run tests locally first +- Be kind in discussions 😄 + +Questions? Just ask in #dev — we're happy to help. + +Happy hacking! From ddb7101fa5a95fe52f8b39150f5cb40366509fe0 Mon Sep 17 00:00:00 2001 From: Richard Anaya Date: Sat, 7 Mar 2026 07:44:46 -0800 Subject: [PATCH 8/9] fixing overhanging recommended text in onboarding --- ui/src/components/OnboardingWizard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 7338d3d2..12c7695e 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -730,7 +730,7 @@ export function OnboardingWizard() { }} > {opt.recommended && ( - + Recommended )} From 638f2303bb3ead29753b8a5a0e7e9d0a10e89b05 Mon Sep 17 00:00:00 2001 From: Richard Anaya Date: Sat, 7 Mar 2026 08:12:37 -0800 Subject: [PATCH 9/9] Rename Invoke button to Run Heartbeat for clarity --- ui/src/pages/AgentDetail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 2888d8c9..06c3a2f4 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -468,7 +468,7 @@ export function AgentDetail() { disabled={agentAction.isPending || isPendingApproval} > - Invoke + Run Heartbeat {agent.status === "paused" ? (