feat: add first-class pi target with mcporter/subagent compatibility

This commit is contained in:
Geet Khosla
2026-02-12 23:07:34 +01:00
parent 87e98b24d3
commit e84fef7a56
14 changed files with 1358 additions and 18 deletions

View File

@@ -0,0 +1,452 @@
export const PI_COMPAT_EXTENSION_SOURCE = `import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
import { Type } from "@sinclair/typebox"
const MAX_BYTES = 50 * 1024
const DEFAULT_SUBAGENT_TIMEOUT_MS = 10 * 60 * 1000
const MAX_PARALLEL_SUBAGENTS = 8
type SubagentTask = {
agent: string
task: string
cwd?: string
}
type SubagentResult = {
agent: string
task: string
cwd: string
exitCode: number
output: string
stderr: string
}
function truncate(value: string): string {
const input = value ?? ""
if (Buffer.byteLength(input, "utf8") <= MAX_BYTES) return input
const head = input.slice(0, MAX_BYTES)
return head + "\\n\\n[Output truncated to 50KB]"
}
function shellEscape(value: string): string {
return "'" + value.replace(/'/g, "'\\"'\\"'") + "'"
}
function normalizeName(value: string): string {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
}
function resolveBundledMcporterConfigPath(): string | undefined {
try {
const extensionDir = path.dirname(fileURLToPath(import.meta.url))
const candidates = [
path.join(extensionDir, "..", "pi-resources", "compound-engineering", "mcporter.json"),
path.join(extensionDir, "..", "compound-engineering", "mcporter.json"),
]
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate
}
} catch {
// noop: bundled path is best-effort fallback
}
return undefined
}
function resolveMcporterConfigPath(cwd: string, explicit?: string): string | undefined {
if (explicit && explicit.trim()) {
return path.resolve(explicit)
}
const projectPath = path.join(cwd, ".pi", "compound-engineering", "mcporter.json")
if (fs.existsSync(projectPath)) return projectPath
const globalPath = path.join(os.homedir(), ".pi", "agent", "compound-engineering", "mcporter.json")
if (fs.existsSync(globalPath)) return globalPath
return resolveBundledMcporterConfigPath()
}
function resolveTaskCwd(baseCwd: string, taskCwd?: string): string {
if (!taskCwd || !taskCwd.trim()) return baseCwd
const expanded = taskCwd === "~"
? os.homedir()
: taskCwd.startsWith("~" + path.sep)
? path.join(os.homedir(), taskCwd.slice(2))
: taskCwd
return path.resolve(baseCwd, expanded)
}
async function runSingleSubagent(
pi: ExtensionAPI,
baseCwd: string,
task: SubagentTask,
signal?: AbortSignal,
timeoutMs = DEFAULT_SUBAGENT_TIMEOUT_MS,
): Promise<SubagentResult> {
const agent = normalizeName(task.agent)
if (!agent) {
throw new Error("Subagent task is missing a valid agent name")
}
const taskText = String(task.task ?? "").trim()
if (!taskText) {
throw new Error("Subagent task for " + agent + " is empty")
}
const cwd = resolveTaskCwd(baseCwd, task.cwd)
const prompt = "/skill:" + agent + " " + taskText
const script = "cd " + shellEscape(cwd) + " && pi --no-session -p " + shellEscape(prompt)
const result = await pi.exec("bash", ["-lc", script], { signal, timeout: timeoutMs })
return {
agent,
task: taskText,
cwd,
exitCode: result.code,
output: truncate(result.stdout || ""),
stderr: truncate(result.stderr || ""),
}
}
async function runParallelSubagents(
pi: ExtensionAPI,
baseCwd: string,
tasks: SubagentTask[],
signal?: AbortSignal,
timeoutMs = DEFAULT_SUBAGENT_TIMEOUT_MS,
maxConcurrency = 4,
onProgress?: (completed: number, total: number) => void,
): Promise<SubagentResult[]> {
const safeConcurrency = Math.max(1, Math.min(maxConcurrency, MAX_PARALLEL_SUBAGENTS, tasks.length))
const results: SubagentResult[] = new Array(tasks.length)
let nextIndex = 0
let completed = 0
const workers = Array.from({ length: safeConcurrency }, async () => {
while (true) {
const current = nextIndex
nextIndex += 1
if (current >= tasks.length) return
results[current] = await runSingleSubagent(pi, baseCwd, tasks[current], signal, timeoutMs)
completed += 1
onProgress?.(completed, tasks.length)
}
})
await Promise.all(workers)
return results
}
function formatSubagentSummary(results: SubagentResult[]): string {
if (results.length === 0) return "No subagent work was executed."
const success = results.filter((result) => result.exitCode === 0).length
const failed = results.length - success
const header = failed === 0
? "Subagent run completed: " + success + "/" + results.length + " succeeded."
: "Subagent run completed: " + success + "/" + results.length + " succeeded, " + failed + " failed."
const lines = results.map((result) => {
const status = result.exitCode === 0 ? "ok" : "error"
const body = result.output || result.stderr || "(no output)"
const preview = body.split("\\n").slice(0, 6).join("\\n")
return "\\n[" + status + "] " + result.agent + "\\n" + preview
})
return header + lines.join("\\n")
}
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "ask_user_question",
label: "Ask User Question",
description: "Ask the user a question with optional choices.",
parameters: Type.Object({
question: Type.String({ description: "Question shown to the user" }),
options: Type.Optional(Type.Array(Type.String(), { description: "Selectable options" })),
allowCustom: Type.Optional(Type.Boolean({ default: true })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
if (!ctx.hasUI) {
return {
isError: true,
content: [{ type: "text", text: "UI is unavailable in this mode." }],
details: {},
}
}
const options = params.options ?? []
const allowCustom = params.allowCustom ?? true
if (options.length === 0) {
const answer = await ctx.ui.input(params.question)
if (!answer) {
return {
content: [{ type: "text", text: "User cancelled." }],
details: { answer: null },
}
}
return {
content: [{ type: "text", text: "User answered: " + answer }],
details: { answer, mode: "input" },
}
}
const customLabel = "Other (type custom answer)"
const selectable = allowCustom ? [...options, customLabel] : options
const selected = await ctx.ui.select(params.question, selectable)
if (!selected) {
return {
content: [{ type: "text", text: "User cancelled." }],
details: { answer: null },
}
}
if (selected === customLabel) {
const custom = await ctx.ui.input("Your answer")
if (!custom) {
return {
content: [{ type: "text", text: "User cancelled." }],
details: { answer: null },
}
}
return {
content: [{ type: "text", text: "User answered: " + custom }],
details: { answer: custom, mode: "custom" },
}
}
return {
content: [{ type: "text", text: "User selected: " + selected }],
details: { answer: selected, mode: "select" },
}
},
})
const subagentTaskSchema = Type.Object({
agent: Type.String({ description: "Skill/agent name to invoke" }),
task: Type.String({ description: "Task instructions for that skill" }),
cwd: Type.Optional(Type.String({ description: "Optional working directory for this task" })),
})
pi.registerTool({
name: "subagent",
label: "Subagent",
description: "Run one or more skill-based subagent tasks. Supports single, parallel, and chained execution.",
parameters: Type.Object({
agent: Type.Optional(Type.String({ description: "Single subagent name" })),
task: Type.Optional(Type.String({ description: "Single subagent task" })),
cwd: Type.Optional(Type.String({ description: "Working directory for single mode" })),
tasks: Type.Optional(Type.Array(subagentTaskSchema, { description: "Parallel subagent tasks" })),
chain: Type.Optional(Type.Array(subagentTaskSchema, { description: "Sequential tasks; supports {previous} placeholder" })),
maxConcurrency: Type.Optional(Type.Number({ default: 4 })),
timeoutMs: Type.Optional(Type.Number({ default: DEFAULT_SUBAGENT_TIMEOUT_MS })),
}),
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const hasSingle = Boolean(params.agent && params.task)
const hasTasks = Boolean(params.tasks && params.tasks.length > 0)
const hasChain = Boolean(params.chain && params.chain.length > 0)
const modeCount = Number(hasSingle) + Number(hasTasks) + Number(hasChain)
if (modeCount !== 1) {
return {
isError: true,
content: [{ type: "text", text: "Provide exactly one mode: single (agent+task), tasks, or chain." }],
details: {},
}
}
const timeoutMs = Number(params.timeoutMs || DEFAULT_SUBAGENT_TIMEOUT_MS)
try {
if (hasSingle) {
const result = await runSingleSubagent(
pi,
ctx.cwd,
{ agent: params.agent!, task: params.task!, cwd: params.cwd },
signal,
timeoutMs,
)
const body = formatSubagentSummary([result])
return {
isError: result.exitCode !== 0,
content: [{ type: "text", text: body }],
details: { mode: "single", results: [result] },
}
}
if (hasTasks) {
const tasks = params.tasks as SubagentTask[]
const maxConcurrency = Number(params.maxConcurrency || 4)
const results = await runParallelSubagents(
pi,
ctx.cwd,
tasks,
signal,
timeoutMs,
maxConcurrency,
(completed, total) => {
onUpdate?.({
content: [{ type: "text", text: "Subagent progress: " + completed + "/" + total }],
details: { mode: "parallel", completed, total },
})
},
)
const body = formatSubagentSummary(results)
const hasFailure = results.some((result) => result.exitCode !== 0)
return {
isError: hasFailure,
content: [{ type: "text", text: body }],
details: { mode: "parallel", results },
}
}
const chain = params.chain as SubagentTask[]
const results: SubagentResult[] = []
let previous = ""
for (const step of chain) {
const resolvedTask = step.task.replace(/\\{previous\\}/g, previous)
const result = await runSingleSubagent(
pi,
ctx.cwd,
{ agent: step.agent, task: resolvedTask, cwd: step.cwd },
signal,
timeoutMs,
)
results.push(result)
previous = result.output || result.stderr
onUpdate?.({
content: [{ type: "text", text: "Subagent chain progress: " + results.length + "/" + chain.length }],
details: { mode: "chain", completed: results.length, total: chain.length },
})
if (result.exitCode !== 0) break
}
const body = formatSubagentSummary(results)
const hasFailure = results.some((result) => result.exitCode !== 0)
return {
isError: hasFailure,
content: [{ type: "text", text: body }],
details: { mode: "chain", results },
}
} catch (error) {
return {
isError: true,
content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
details: {},
}
}
},
})
pi.registerTool({
name: "mcporter_list",
label: "MCPorter List",
description: "List tools on an MCP server through MCPorter.",
parameters: Type.Object({
server: Type.String({ description: "Configured MCP server name" }),
allParameters: Type.Optional(Type.Boolean({ default: false })),
json: Type.Optional(Type.Boolean({ default: true })),
configPath: Type.Optional(Type.String({ description: "Optional mcporter config path" })),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const args = ["list", params.server]
if (params.allParameters) args.push("--all-parameters")
if (params.json ?? true) args.push("--json")
const configPath = resolveMcporterConfigPath(ctx.cwd, params.configPath)
if (configPath) {
args.push("--config", configPath)
}
const result = await pi.exec("mcporter", args, { signal })
const output = truncate(result.stdout || result.stderr || "")
return {
isError: result.code !== 0,
content: [{ type: "text", text: output || "(no output)" }],
details: {
exitCode: result.code,
command: "mcporter " + args.join(" "),
configPath,
},
}
},
})
pi.registerTool({
name: "mcporter_call",
label: "MCPorter Call",
description: "Call a specific MCP tool through MCPorter.",
parameters: Type.Object({
call: Type.Optional(Type.String({ description: "Function-style call, e.g. linear.list_issues(limit: 5)" })),
server: Type.Optional(Type.String({ description: "Server name (if call is omitted)" })),
tool: Type.Optional(Type.String({ description: "Tool name (if call is omitted)" })),
args: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "JSON arguments object" })),
configPath: Type.Optional(Type.String({ description: "Optional mcporter config path" })),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const args = ["call"]
if (params.call && params.call.trim()) {
args.push(params.call.trim())
} else {
if (!params.server || !params.tool) {
return {
isError: true,
content: [{ type: "text", text: "Provide either call, or server + tool." }],
details: {},
}
}
args.push(params.server + "." + params.tool)
if (params.args) {
args.push("--args", JSON.stringify(params.args))
}
}
args.push("--output", "json")
const configPath = resolveMcporterConfigPath(ctx.cwd, params.configPath)
if (configPath) {
args.push("--config", configPath)
}
const result = await pi.exec("mcporter", args, { signal })
const output = truncate(result.stdout || result.stderr || "")
return {
isError: result.code !== 0,
content: [{ type: "text", text: output || "(no output)" }],
details: {
exitCode: result.code,
command: "mcporter " + args.join(" "),
configPath,
},
}
},
})
}
`