feat: add first-class pi target with mcporter/subagent compatibility
This commit is contained in:
452
src/templates/pi/compat-extension.ts
Normal file
452
src/templates/pi/compat-extension.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
`
|
||||
Reference in New Issue
Block a user