Skip to content

Commit 993150a

Browse files
committed
fix(pi): avoid subagent tool conflicts
1 parent 4ea5ca7 commit 993150a

10 files changed

Lines changed: 236 additions & 121 deletions

File tree

src/converters/claude-to-pi.ts

Lines changed: 5 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { formatFrontmatter } from "../utils/frontmatter"
2-
import { normalizePiSkillName, uniquePiSkillName } from "../utils/pi-skills"
2+
import { normalizePiSkillName, transformPiBodyContent, uniquePiSkillName } from "../utils/pi-skills"
33
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
44
import type {
55
PiBundle,
@@ -49,14 +49,13 @@ export function convertClaudeToPi(
4949
}
5050

5151
function convertPrompt(command: ClaudeCommand, usedNames: Set<string>) {
52-
const name = uniqueName(normalizeName(command.name), usedNames)
52+
const name = uniquePiSkillName(normalizePiSkillName(command.name), usedNames)
5353
const frontmatter: Record<string, unknown> = {
5454
description: command.description,
5555
"argument-hint": command.argumentHint,
5656
}
5757

58-
let body = transformContentForPi(command.body)
59-
body = appendCompatibilityNoteIfNeeded(body)
58+
const body = transformPiBodyContent(command.body)
6059

6160
return {
6261
name,
@@ -80,74 +79,19 @@ function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): PiGeneratedSk
8079
sections.push(`## Capabilities\n${agent.capabilities.map((capability) => `- ${capability}`).join("\n")}`)
8180
}
8281

83-
const body = [
82+
const body = transformPiBodyContent([
8483
...sections,
8584
agent.body.trim().length > 0
8685
? agent.body.trim()
8786
: `Instructions converted from the ${agent.name} agent.`,
88-
].join("\n\n")
87+
].join("\n\n"))
8988

9089
return {
9190
name,
9291
content: formatFrontmatter(frontmatter, body),
9392
}
9493
}
9594

96-
function transformContentForPi(body: string): string {
97-
let result = body
98-
99-
// Task repo-research-analyst(feature_description)
100-
// -> Run subagent with agent="repo-research-analyst" and task="feature_description"
101-
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
102-
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
103-
const skillName = normalizeName(agentName)
104-
const trimmedArgs = args.trim().replace(/\s+/g, " ")
105-
return `${prefix}Run subagent with agent=\"${skillName}\" and task=\"${trimmedArgs}\".`
106-
})
107-
108-
// Claude-specific tool references
109-
result = result.replace(/\bAskUserQuestion\b/g, "ask_user_question")
110-
result = result.replace(/\bTodoWrite\b/g, "file-based todos (todos/ + /skill:file-todos)")
111-
result = result.replace(/\bTodoRead\b/g, "file-based todos (todos/ + /skill:file-todos)")
112-
113-
// /command-name or /workflows:command-name -> /workflows-command-name
114-
const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
115-
result = result.replace(slashCommandPattern, (match, commandName: string) => {
116-
if (commandName.includes("/")) return match
117-
if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) {
118-
return match
119-
}
120-
121-
if (commandName.startsWith("skill:")) {
122-
const skillName = commandName.slice("skill:".length)
123-
return `/skill:${normalizePiSkillName(skillName)}`
124-
}
125-
126-
const withoutPrefix = commandName.startsWith("prompts:")
127-
? commandName.slice("prompts:".length)
128-
: commandName
129-
130-
return `/${normalizeName(withoutPrefix)}`
131-
})
132-
133-
return result
134-
}
135-
136-
function appendCompatibilityNoteIfNeeded(body: string): string {
137-
if (!/\bmcp\b/i.test(body)) return body
138-
139-
const note = [
140-
"",
141-
"## Pi + MCPorter note",
142-
"For MCP access in Pi, use MCPorter via the generated tools:",
143-
"- `mcporter_list` to inspect available MCP tools",
144-
"- `mcporter_call` to invoke a tool",
145-
"",
146-
].join("\n")
147-
148-
return body + note
149-
}
150-
15195
function convertMcpToMcporter(servers: Record<string, ClaudeMcpServer>): PiMcporterConfig {
15296
const mcpServers: Record<string, PiMcporterServer> = {}
15397

@@ -173,36 +117,10 @@ function convertMcpToMcporter(servers: Record<string, ClaudeMcpServer>): PiMcpor
173117
return { mcpServers }
174118
}
175119

176-
function normalizeName(value: string): string {
177-
const trimmed = value.trim()
178-
if (!trimmed) return "item"
179-
const normalized = trimmed
180-
.toLowerCase()
181-
.replace(/[\\/]+/g, "-")
182-
.replace(/[:\s]+/g, "-")
183-
.replace(/[^a-z0-9_-]+/g, "-")
184-
.replace(/-+/g, "-")
185-
.replace(/^-+|-+$/g, "")
186-
return normalized || "item"
187-
}
188-
189120
function sanitizeDescription(value: string, maxLength = PI_DESCRIPTION_MAX_LENGTH): string {
190121
const normalized = value.replace(/\s+/g, " ").trim()
191122
if (normalized.length <= maxLength) return normalized
192123
const ellipsis = "..."
193124
return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis
194125
}
195126

196-
function uniqueName(base: string, used: Set<string>): string {
197-
if (!used.has(base)) {
198-
used.add(base)
199-
return base
200-
}
201-
let index = 2
202-
while (used.has(`${base}-${index}`)) {
203-
index += 1
204-
}
205-
const name = `${base}-${index}`
206-
used.add(name)
207-
return name
208-
}

src/sync/pi-skills.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import path from "path"
2+
import type { ClaudeSkill } from "../types/claude"
3+
import { ensureDir } from "../utils/files"
4+
import { copySkillDirForPi, normalizePiSkillName, skillFileMatchesPiTarget, uniquePiSkillName } from "../utils/pi-skills"
5+
import { forceSymlink, isValidSkillName } from "../utils/symlink"
6+
7+
export async function syncPiSkills(
8+
skills: ClaudeSkill[],
9+
skillsDir: string,
10+
): Promise<void> {
11+
await ensureDir(skillsDir)
12+
13+
const usedNames = new Set<string>()
14+
15+
for (const skill of skills) {
16+
if (!isValidSkillName(skill.name)) {
17+
console.warn(`Skipping skill with unsafe name: ${skill.name}`)
18+
continue
19+
}
20+
21+
const targetName = uniquePiSkillName(normalizePiSkillName(skill.name), usedNames)
22+
const target = path.join(skillsDir, targetName)
23+
const alreadyPiCompatible = await skillFileMatchesPiTarget(skill.skillPath, targetName)
24+
25+
if (skill.name === targetName && alreadyPiCompatible) {
26+
await forceSymlink(skill.sourceDir, target)
27+
continue
28+
}
29+
30+
await copySkillDirForPi(skill.sourceDir, target, targetName)
31+
}
32+
}

src/sync/pi.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { ClaudeMcpServer } from "../types/claude"
44
import { ensureDir } from "../utils/files"
55
import { syncPiCommands } from "./commands"
66
import { mergeJsonConfigAtKey } from "./json-config"
7-
import { syncSkills } from "./skills"
7+
import { syncPiSkills } from "./pi-skills"
88

99
type McporterServer = {
1010
baseUrl?: string
@@ -24,7 +24,7 @@ export async function syncToPi(
2424
): Promise<void> {
2525
const mcporterPath = path.join(outputRoot, "compound-engineering", "mcporter.json")
2626

27-
await syncSkills(config.skills, path.join(outputRoot, "skills"))
27+
await syncPiSkills(config.skills, path.join(outputRoot, "skills"))
2828
await syncPiCommands(config, outputRoot)
2929

3030
if (Object.keys(config.mcpServers).length > 0) {

src/sync/skills.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import path from "path"
22
import type { ClaudeSkill } from "../types/claude"
3-
import { copySkillDirForPi, normalizePiSkillName, skillFrontmatterMatchesPiName, uniquePiSkillName } from "../utils/pi-skills"
43
import { ensureDir } from "../utils/files"
54
import { forceSymlink, isValidSkillName } from "../utils/symlink"
65

@@ -10,23 +9,13 @@ export async function syncSkills(
109
): Promise<void> {
1110
await ensureDir(skillsDir)
1211

13-
const usedNames = new Set<string>()
14-
1512
for (const skill of skills) {
1613
if (!isValidSkillName(skill.name)) {
17-
console.warn(`Skipping skill with unsafe name: ${skill.name}`)
18-
continue
19-
}
20-
21-
const targetName = uniquePiSkillName(normalizePiSkillName(skill.name), usedNames)
22-
const target = path.join(skillsDir, targetName)
23-
const frontmatterMatches = await skillFrontmatterMatchesPiName(skill.skillPath, targetName)
24-
25-
if (skill.name === targetName && frontmatterMatches) {
26-
await forceSymlink(skill.sourceDir, target)
14+
console.warn(`Skipping skill with invalid name: ${skill.name}`)
2715
continue
2816
}
2917

30-
await copySkillDirForPi(skill.sourceDir, target, targetName)
18+
const target = path.join(skillsDir, skill.name)
19+
await forceSymlink(skill.sourceDir, target)
3120
}
3221
}

src/targets/pi.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ const PI_AGENTS_BLOCK_BODY = `## Compound Engineering (Pi compatibility)
1818
This block is managed by compound-plugin.
1919
2020
Compatibility notes:
21-
- Claude Task(agent, args) maps to the subagent extension tool
22-
- For parallel agent runs, batch multiple subagent calls with multi_tool_use.parallel
21+
- Claude Task(agent, args) maps to the ce_subagent extension tool
22+
- Use ce_subagent for Compound Engineering workflows even when another extension also provides a generic subagent tool
2323
- AskUserQuestion maps to the ask_user_question extension tool
2424
- MCP access uses MCPorter via mcporter_list and mcporter_call extension tools
2525
- MCPorter config path: .pi/compound-engineering/mcporter.json (project) or ~/.pi/agent/compound-engineering/mcporter.json (global)

src/templates/pi/compat-extension.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,9 @@ export default function (pi: ExtensionAPI) {
245245
})
246246
247247
pi.registerTool({
248-
name: "subagent",
249-
label: "Subagent",
250-
description: "Run one or more skill-based subagent tasks. Supports single, parallel, and chained execution.",
248+
name: "ce_subagent",
249+
label: "Compound Engineering Subagent",
250+
description: "Run one or more Compound Engineering skill-based subagent tasks. Supports single, parallel, and chained execution.",
251251
parameters: Type.Object({
252252
agent: Type.Optional(Type.String({ description: "Single subagent name" })),
253253
task: Type.Optional(Type.String({ description: "Single subagent task" })),

src/utils/pi-skills.ts

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { promises as fs } from "fs"
12
import path from "path"
23
import { copyDir, pathExists, readText, writeText } from "./files"
34
import { formatFrontmatter, parseFrontmatter } from "./frontmatter"
45

6+
export const PI_CE_SUBAGENT_TOOL = "ce_subagent"
7+
58
export function normalizePiSkillName(value: string): string {
69
const trimmed = value.trim()
710
if (!trimmed) return "item"
@@ -37,22 +40,80 @@ export function uniquePiSkillName(base: string, used: Set<string>): string {
3740
return name
3841
}
3942

40-
export async function skillFrontmatterMatchesPiName(skillPath: string, targetName: string): Promise<boolean> {
43+
export function transformPiBodyContent(body: string): string {
44+
let result = body
45+
46+
const taskPattern = /^(\s*(?:(?:[-*])\s+|\d+\.\s+)?)Task\s+([a-z][a-z0-9:_-]*)\(([^)]+)\)/gm
47+
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
48+
const normalizedAgent = normalizePiTaskAgentName(agentName)
49+
const trimmedArgs = args.trim().replace(/\s+/g, " ")
50+
return `${prefix}Run ${PI_CE_SUBAGENT_TOOL} with agent="${normalizedAgent}" and task="${trimmedArgs}".`
51+
})
52+
53+
result = result.replace(/\bRun subagent with\b/g, `Run ${PI_CE_SUBAGENT_TOOL} with`)
54+
result = result.replace(/\bAskUserQuestion\b/g, "ask_user_question")
55+
result = result.replace(/\bTodoWrite\b/g, "file-based todos (todos/ + /skill:file-todos)")
56+
result = result.replace(/\bTodoRead\b/g, "file-based todos (todos/ + /skill:file-todos)")
57+
58+
const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
59+
result = result.replace(slashCommandPattern, (match, commandName: string) => {
60+
if (commandName.includes("/")) return match
61+
if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) {
62+
return match
63+
}
64+
65+
if (commandName.startsWith("skill:")) {
66+
const skillName = commandName.slice("skill:".length)
67+
return `/skill:${normalizePiSkillName(skillName)}`
68+
}
69+
70+
const withoutPrefix = commandName.startsWith("prompts:")
71+
? commandName.slice("prompts:".length)
72+
: commandName
73+
74+
return `/${normalizePiSkillName(withoutPrefix)}`
75+
})
76+
77+
return appendCompatibilityNoteIfNeeded(result)
78+
}
79+
80+
export async function skillFileMatchesPiTarget(skillPath: string, targetName: string): Promise<boolean> {
4181
if (!(await pathExists(skillPath))) {
4282
return false
4383
}
4484

4585
const raw = await readText(skillPath)
4686
const parsed = parseFrontmatter(raw)
47-
return parsed.data.name === targetName
87+
if (Object.keys(parsed.data).length === 0 && parsed.body === raw) {
88+
return false
89+
}
90+
91+
if (parsed.data.name !== targetName) {
92+
return false
93+
}
94+
95+
return transformPiBodyContent(parsed.body) === parsed.body
4896
}
4997

50-
export async function copySkillDirForPi(sourceDir: string, targetDir: string, targetName: string): Promise<void> {
98+
export async function copySkillDirForPi(
99+
sourceDir: string,
100+
targetDir: string,
101+
targetName: string,
102+
): Promise<void> {
103+
if (await pathExists(targetDir)) {
104+
const stats = await fs.lstat(targetDir)
105+
if (stats.isSymbolicLink()) {
106+
await fs.unlink(targetDir)
107+
} else {
108+
await fs.rm(targetDir, { recursive: true, force: true })
109+
}
110+
}
111+
51112
await copyDir(sourceDir, targetDir)
52-
await rewriteSkillFrontmatterForPi(path.join(targetDir, "SKILL.md"), targetName)
113+
await rewriteSkillFileForPi(path.join(targetDir, "SKILL.md"), targetName)
53114
}
54115

55-
export async function rewriteSkillFrontmatterForPi(skillPath: string, targetName: string): Promise<void> {
116+
export async function rewriteSkillFileForPi(skillPath: string, targetName: string): Promise<void> {
56117
if (!(await pathExists(skillPath))) {
57118
return
58119
}
@@ -63,8 +124,32 @@ export async function rewriteSkillFrontmatterForPi(skillPath: string, targetName
63124
return
64125
}
65126

66-
const updated = formatFrontmatter({ ...parsed.data, name: targetName }, parsed.body)
127+
const updated = formatFrontmatter(
128+
{ ...parsed.data, name: targetName },
129+
transformPiBodyContent(parsed.body),
130+
)
131+
67132
if (updated !== raw) {
68133
await writeText(skillPath, updated)
69134
}
70135
}
136+
137+
function normalizePiTaskAgentName(value: string): string {
138+
const leafName = value.split(":").filter(Boolean).pop() ?? value
139+
return normalizePiSkillName(leafName)
140+
}
141+
142+
function appendCompatibilityNoteIfNeeded(body: string): string {
143+
if (!/\bmcp\b/i.test(body)) return body
144+
145+
const note = [
146+
"",
147+
"## Pi + MCPorter note",
148+
"For MCP access in Pi, use MCPorter via the generated tools:",
149+
"- `mcporter_list` to inspect available MCP tools",
150+
"- `mcporter_call` to invoke a tool",
151+
"",
152+
].join("\n")
153+
154+
return body + note
155+
}

0 commit comments

Comments
 (0)