453 lines
15 KiB
TypeScript
453 lines
15 KiB
TypeScript
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,
|
|
},
|
|
}
|
|
},
|
|
})
|
|
}
|
|
`
|