feat(pi): first-class support via pi-subagents + pi-ask-user (#651)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
`
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user