feat(pi): first-class support via pi-subagents + pi-ask-user (#651)
Some checks failed
CI / pr-title (push) Has been cancelled
CI / test (push) Has been cancelled
Release PR / release-pr (push) Has been cancelled
Release PR / publish-cli (push) Has been cancelled

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trevin Chow
2026-04-22 10:26:29 -07:00
committed by GitHub
parent cce95fb814
commit 7ddfbed33b
53 changed files with 371 additions and 636 deletions

View File

@@ -2,12 +2,11 @@ import { formatFrontmatter } from "../utils/frontmatter"
import { type ClaudeAgent, type ClaudeCommand, type ClaudeMcpServer, type ClaudePlugin, filterSkillsByPlatform } from "../types/claude"
import type {
PiBundle,
PiGeneratedSkill,
PiGeneratedAgent,
PiMcporterConfig,
PiMcporterServer,
} from "../types/pi"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
import { PI_COMPAT_EXTENSION_SOURCE } from "../templates/pi/compat-extension"
export type ClaudeToPiOptions = ClaudeToOpenCodeOptions
@@ -19,20 +18,17 @@ export function convertClaudeToPi(
): PiBundle {
const platformSkills = filterSkillsByPlatform(plugin.skills, "pi")
const promptNames = new Set<string>()
const usedSkillNames = new Set<string>(platformSkills.map((skill) => normalizeName(skill.name)))
// Pi agents and skills live in separate directories (.pi/agents/<name>.md vs
// .pi/skills/<name>/SKILL.md), so their names don't need to be deduplicated
// against each other — nicobailon/pi-subagents resolves agents by filename
// match and ignores skill dirs.
const usedAgentNames = new Set<string>()
const prompts = plugin.commands
.filter((command) => !command.disableModelInvocation)
.map((command) => convertPrompt(command, promptNames))
const generatedSkills = plugin.agents.map((agent) => convertAgent(agent, usedSkillNames))
const extensions = [
{
name: "compound-engineering-compat.ts",
content: PI_COMPAT_EXTENSION_SOURCE,
},
]
const agents = plugin.agents.map((agent) => convertAgent(agent, usedAgentNames))
return {
pluginName: plugin.manifest.name,
@@ -41,12 +37,38 @@ export function convertClaudeToPi(
name: skill.name,
sourceDir: skill.sourceDir,
})),
generatedSkills,
extensions,
generatedSkills: [],
agents,
extensions: [],
mcporterConfig: plugin.mcpServers ? convertMcpToMcporter(plugin.mcpServers) : undefined,
}
}
function convertMcpToMcporter(servers: Record<string, ClaudeMcpServer>): PiMcporterConfig {
const mcpServers: Record<string, PiMcporterServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
mcpServers[name] = {
command: server.command,
args: server.args,
env: server.env,
headers: server.headers,
}
continue
}
if (server.url) {
mcpServers[name] = {
baseUrl: server.url,
headers: server.headers,
}
}
}
return { mcpServers }
}
function convertPrompt(command: ClaudeCommand, usedNames: Set<string>) {
const name = uniqueName(normalizeName(command.name), usedNames)
const frontmatter: Record<string, unknown> = {
@@ -54,8 +76,7 @@ function convertPrompt(command: ClaudeCommand, usedNames: Set<string>) {
"argument-hint": command.argumentHint,
}
let body = transformContentForPi(command.body)
body = appendCompatibilityNoteIfNeeded(body)
const body = transformContentForPi(command.body)
return {
name,
@@ -63,7 +84,7 @@ function convertPrompt(command: ClaudeCommand, usedNames: Set<string>) {
}
}
function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): PiGeneratedSkill {
function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): PiGeneratedAgent {
const name = uniqueName(normalizeName(agent.name), usedNames)
const description = sanitizeDescription(
agent.description ?? `Converted from Claude agent ${agent.name}`,
@@ -107,8 +128,6 @@ export function transformContentForPi(body: string): string {
: `${prefix}Run subagent with agent=\"${skillName}\".`
})
// Claude-specific tool references
result = result.replace(/\bAskUserQuestion\b/g, "ask_user_question")
// Claude Code task-tracking primitives: current Task* API (TaskCreate/TaskUpdate/TaskList/TaskGet/TaskStop/TaskOutput)
// plus the deprecated legacy pair (TodoWrite/TodoRead). All map to the platform's task-tracking primitive.
result = result.replace(
@@ -141,46 +160,6 @@ export function transformContentForPi(body: string): string {
return result
}
function appendCompatibilityNoteIfNeeded(body: string): string {
if (!/\bmcp\b/i.test(body)) return body
const note = [
"",
"## Pi + MCPorter note",
"For MCP access in Pi, use MCPorter via the generated tools:",
"- `mcporter_list` to inspect available MCP tools",
"- `mcporter_call` to invoke a tool",
"",
].join("\n")
return body + note
}
function convertMcpToMcporter(servers: Record<string, ClaudeMcpServer>): PiMcporterConfig {
const mcpServers: Record<string, PiMcporterServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
mcpServers[name] = {
command: server.command,
args: server.args,
env: server.env,
headers: server.headers,
}
continue
}
if (server.url) {
mcpServers[name] = {
baseUrl: server.url,
headers: server.headers,
}
}
}
return { mcpServers }
}
function normalizeName(value: string): string {
const trimmed = value.trim()
if (!trimmed) return "item"

View File

@@ -25,12 +25,13 @@ const PI_AGENTS_BLOCK_BODY = `## Compound Engineering (Pi compatibility)
This block is managed by compound-plugin.
Compatibility notes:
- Claude Task(agent, args) maps to the subagent extension tool
- For parallel agent runs, batch multiple subagent calls with multi_tool_use.parallel
- AskUserQuestion maps to the ask_user_question extension tool
- MCP access uses MCPorter via mcporter_list and mcporter_call extension tools
- MCPorter config path: .pi/compound-engineering/mcporter.json (project) or ~/.pi/agent/compound-engineering/mcporter.json (global)
Pi extensions used by this plugin:
- Required: \`pi-subagents\` (by nicobailon) provides the \`subagent\` tool used by skills that dispatch parallel agents
- Recommended: \`pi-ask-user\` (by edlsh) provides the \`ask_user\` tool; skills fall back to numbered options in chat when it is missing
Install with:
pi install npm:pi-subagents
pi install npm:pi-ask-user
`
export type PiInstallManifest = {
@@ -39,6 +40,8 @@ export type PiInstallManifest = {
skills: string[]
prompts: string[]
extensions: string[]
// Added in v2.69+. Older manifests omit this; reads default to [].
agents: string[]
}
type PiPaths = {
@@ -46,6 +49,7 @@ type PiPaths = {
skillsDir: string
promptsDir: string
extensionsDir: string
agentsDir: string
mcporterConfigPath: string
agentsPath: string
}
@@ -61,15 +65,18 @@ export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promi
...bundle.skillDirs.map((skill) => sanitizePathName(skill.name)),
...bundle.generatedSkills.map((skill) => sanitizePathName(skill.name)),
]
const currentAgents = bundle.agents.map((agent) => `${sanitizePathName(agent.name)}.md`)
const currentExtensions = bundle.extensions.map((extension) => extension.name)
await ensureDir(paths.skillsDir)
await ensureDir(paths.promptsDir)
await ensureDir(paths.extensionsDir)
await ensureDir(paths.agentsDir)
await cleanupStaleAgents(paths.skillsDir, null)
await cleanupRemovedPrompts(paths.promptsDir, manifest, currentPrompts)
await cleanupRemovedSkills(paths.skillsDir, manifest, currentSkills)
await cleanupRemovedAgents(paths.agentsDir, manifest, currentAgents)
await cleanupRemovedExtensions(paths.extensionsDir, manifest, currentExtensions)
for (const prompt of bundle.prompts) {
@@ -90,6 +97,13 @@ export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promi
await writeText(path.join(targetDir, "SKILL.md"), skill.content + "\n")
}
for (const agent of bundle.agents) {
const agentFileName = `${sanitizePathName(agent.name)}.md`
const targetPath = path.join(paths.agentsDir, agentFileName)
await cleanupCurrentManagedAgentFile(targetPath, manifest, agentFileName)
await writeText(targetPath, agent.content + "\n")
}
for (const extension of bundle.extensions) {
await writeText(path.join(paths.extensionsDir, extension.name), extension.content + "\n")
}
@@ -111,6 +125,7 @@ export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promi
skills: currentSkills,
prompts: currentPrompts,
extensions: currentExtensions,
agents: currentAgents,
})
await archiveLegacyInstallManifestIfOwned(paths.managedDir, pluginName)
await cleanupKnownLegacyPiArtifacts(paths, bundle)
@@ -131,6 +146,7 @@ function resolvePiPaths(outputRoot: string, pluginName?: string): PiPaths {
skillsDir: path.join(outputRoot, "skills"),
promptsDir: path.join(outputRoot, "prompts"),
extensionsDir: path.join(outputRoot, "extensions"),
agentsDir: path.join(outputRoot, "agents"),
mcporterConfigPath: path.join(outputRoot, managedSegment, "mcporter.json"),
agentsPath: path.join(outputRoot, "AGENTS.md"),
}
@@ -142,6 +158,7 @@ function resolvePiPaths(outputRoot: string, pluginName?: string): PiPaths {
skillsDir: path.join(outputRoot, "skills"),
promptsDir: path.join(outputRoot, "prompts"),
extensionsDir: path.join(outputRoot, "extensions"),
agentsDir: path.join(outputRoot, "agents"),
mcporterConfigPath: path.join(outputRoot, managedSegment, "mcporter.json"),
agentsPath: path.join(outputRoot, "AGENTS.md"),
}
@@ -152,6 +169,7 @@ function resolvePiPaths(outputRoot: string, pluginName?: string): PiPaths {
skillsDir: path.join(outputRoot, ".pi", "skills"),
promptsDir: path.join(outputRoot, ".pi", "prompts"),
extensionsDir: path.join(outputRoot, ".pi", "extensions"),
agentsDir: path.join(outputRoot, ".pi", "agents"),
mcporterConfigPath: path.join(outputRoot, ".pi", managedSegment, "mcporter.json"),
agentsPath: path.join(outputRoot, "AGENTS.md"),
}
@@ -259,7 +277,7 @@ async function readInstallManifest(
Array.isArray(parsed.extensions)
) {
// Filter manifest entries at read time. Cleanup functions join these
// strings into `fs.rm` paths against the Pi skills/prompts/extensions
// strings into `fs.rm` paths against the Pi skills/prompts/extensions/agents
// directories, so a tampered or corrupted `install-manifest.json` with
// entries like `../../config.toml` or `/etc/passwd` would otherwise
// delete outside the Pi managed tree. Validate each group against the
@@ -270,12 +288,18 @@ async function readInstallManifest(
const skillsRoot = paths?.skillsDir ?? managedDir
const promptsRoot = paths?.promptsDir ?? managedDir
const extensionsRoot = paths?.extensionsDir ?? managedDir
const agentsRoot = paths?.agentsDir ?? managedDir
// `agents` was added in v2.69+; accept missing/omitted to stay
// backward-compatible with v2.x manifests that only tracked skills,
// prompts, and extensions. Drop non-array values defensively.
const rawAgents = Array.isArray(parsed.agents) ? parsed.agents : []
return {
version: 1,
pluginName,
skills: filterSafePiManifestEntries(parsed.skills, skillsRoot, manifestPath, "skills"),
prompts: filterSafePiManifestEntries(parsed.prompts, promptsRoot, manifestPath, "prompts"),
extensions: filterSafePiManifestEntries(parsed.extensions, extensionsRoot, manifestPath, "extensions"),
agents: filterSafePiManifestEntries(rawAgents, agentsRoot, manifestPath, "agents"),
}
}
} catch (err) {
@@ -354,6 +378,20 @@ async function cleanupRemovedExtensions(
}
}
async function cleanupRemovedAgents(
agentsDir: string,
manifest: PiInstallManifest | null,
currentAgents: string[],
): Promise<void> {
if (!manifest) return
const current = new Set(currentAgents)
for (const agentFile of manifest.agents) {
if (current.has(agentFile)) continue
if (!isSafeManagedPath(agentsDir, agentFile)) continue
await fs.rm(path.join(agentsDir, agentFile), { force: true })
}
}
async function cleanupCurrentManagedSkillDir(
targetDir: string,
manifest: PiInstallManifest | null,
@@ -363,6 +401,43 @@ async function cleanupCurrentManagedSkillDir(
await fs.rm(targetDir, { recursive: true, force: true })
}
async function cleanupCurrentManagedAgentFile(
targetPath: string,
manifest: PiInstallManifest | null,
agentFileName: string,
): Promise<void> {
if (!manifest?.agents.includes(agentFileName)) return
await fs.rm(targetPath, { force: true })
}
// Explicit legacy Pi extension names this plugin has historically shipped and
// no longer does. The manifest-diff cleanup in cleanupRemovedExtensions handles
// post-manifest installs automatically, but pre-manifest installs return null
// from readInstallManifestWithLegacyFallback and would otherwise leak the file
// on upgrade. This list is the safety net for that case.
const LEGACY_PI_EXTENSIONS_BY_PLUGIN: Record<string, string[]> = {
"compound-engineering": ["compound-engineering-compat.ts"],
}
// Plugins that historically shipped an mcporter.json (via the now-removed
// compat extension) but no longer do when `bundle.mcporterConfig` is absent.
// The per-plugin guard keeps us from touching mcporter configs owned by
// plugins that still legitimately emit one.
const LEGACY_PI_MCPORTER_PLUGINS = new Set<string>(["compound-engineering"])
type LegacyArtifactKind = "skills" | "prompts" | "extensions" | "mcporter"
// Display label used in the "Moved legacy Pi <label> artifact ..." log line.
// Most kinds are a simple plural→singular trim, but "mcporter" isn't a plural,
// so we special-case it instead of slicing off a character and logging
// "mcporte".
const LEGACY_ARTIFACT_LABELS: Record<LegacyArtifactKind, string> = {
skills: "skill",
prompts: "prompt",
extensions: "extension",
mcporter: "mcporter config",
}
async function cleanupKnownLegacyPiArtifacts(paths: PiPaths, bundle: PiBundle): Promise<void> {
const pluginName = bundle.pluginName
if (!pluginName) return
@@ -377,11 +452,29 @@ async function cleanupKnownLegacyPiArtifacts(paths: PiPaths, bundle: PiBundle):
const legacyPromptPath = path.join(paths.promptsDir, promptFile)
await moveLegacyArtifactToBackup(paths.managedDir, "prompts", legacyPromptPath)
}
// Only sweep legacy extensions the current bundle is not actively writing.
// A caller that explicitly ships an extension (e.g., tests or a future
// bundle that reintroduces one) must not have its write undone.
const currentExtensionNames = new Set(bundle.extensions.map((extension) => extension.name))
for (const extensionFile of LEGACY_PI_EXTENSIONS_BY_PLUGIN[pluginName] ?? []) {
if (currentExtensionNames.has(extensionFile)) continue
const legacyExtensionPath = path.join(paths.extensionsDir, extensionFile)
await moveLegacyArtifactToBackup(paths.managedDir, "extensions", legacyExtensionPath)
}
// Sweep the stale mcporter.json left behind by the removed compat extension.
// Only runs when the current bundle is NOT writing a fresh mcporter config —
// if it IS (e.g. a plugin with `mcpServers`), the existing write path backs
// up and overwrites the file and this sweep would undo that write.
if (!bundle.mcporterConfig && LEGACY_PI_MCPORTER_PLUGINS.has(pluginName)) {
await moveLegacyArtifactToBackup(paths.managedDir, "mcporter", paths.mcporterConfigPath)
}
}
async function moveLegacyArtifactToBackup(
managedDir: string,
kind: "skills" | "prompts",
kind: LegacyArtifactKind,
artifactPath: string,
): Promise<void> {
if (!(await pathExists(artifactPath))) return
@@ -390,11 +483,12 @@ async function moveLegacyArtifactToBackup(
const backupPath = path.join(backupDir, path.basename(artifactPath))
await ensureDir(backupDir)
await fs.rename(artifactPath, backupPath)
console.warn(`Moved legacy Pi ${kind.slice(0, -1)} artifact to ${backupPath}`)
console.warn(`Moved legacy Pi ${LEGACY_ARTIFACT_LABELS[kind]} artifact to ${backupPath}`)
}
export {
cleanupRemovedSkills as cleanupRemovedPiSkills,
cleanupRemovedPrompts as cleanupRemovedPiPrompts,
cleanupRemovedExtensions as cleanupRemovedPiExtensions,
cleanupRemovedAgents as cleanupRemovedPiAgents,
}

View File

@@ -1,452 +0,0 @@
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,
},
}
},
})
}
`

View File

@@ -13,6 +13,11 @@ export type PiGeneratedSkill = {
content: string
}
export type PiGeneratedAgent = {
name: string
content: string
}
export type PiExtensionFile = {
name: string
content: string
@@ -36,6 +41,7 @@ export type PiBundle = {
prompts: PiPrompt[]
skillDirs: PiSkillDir[]
generatedSkills: PiGeneratedSkill[]
agents: PiGeneratedAgent[]
extensions: PiExtensionFile[]
mcporterConfig?: PiMcporterConfig
}