import { Type, type TextContent } from "@earendil-works/pi-ai";
import {
getMarkdownTheme,
keyHint,
type AgentToolResult,
type ExtensionAPI,
type ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { Box, Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
import { resolveModel, type UsageTotals } from "../pi-common";
import { discoverAgents, type AgentDiagnostic, type AgentProfile } from "./agents";
import { inChildSessionContext } from "./child-context";
import { loadConfig } from "./config";
import { formatDuration, formatUsage } from "./format";
import { SubagentManager, type SpawnStopReason } from "./manager";
import { createSubagentWidget } from "./widget";
const WIDGET_KEY = "pi-subagent";
function reportDiagnostics(ctx: ExtensionContext, diagnostics: AgentDiagnostic[]): void {
if (diagnostics.length === 0) return;
const message = [
`Skipped ${diagnostics.length} invalid subagent definition${diagnostics.length > 1 ? "s" : ""}:`,
...diagnostics.map((diagnostic) => `- ${diagnostic.path}: ${diagnostic.message}`),
].join("\n");
if (ctx.hasUI) {
ctx.ui.notify(message, "warning");
} else {
process.stderr.write(`[pi-subagent] ${message}\n`);
}
}
function escapeXml(text: string): string {
return text.replace(/&/g, "&").replace(//g, ">");
}
function formatAvailableAgents(agents: AgentProfile[]): string {
const entries = agents
.map(
(agent) =>
` \n ${escapeXml(agent.name)}\n ${escapeXml(agent.description)}\n `,
)
.join("\n");
return `\n${entries}\n`;
}
interface AgentToolDetails {
name: string;
title: string;
model: string;
turns: number;
durationMs: number;
usage: UsageTotals;
stopReason: SpawnStopReason;
}
export default function (pi: ExtensionAPI) {
if (inChildSessionContext()) return;
const config = loadConfig();
if (!config.enabled) return;
let agents: AgentProfile[] = [];
let manager: SubagentManager | undefined;
pi.registerTool({
name: "agent",
label: "Agent",
description: [
"Delegate a focused task to a specialized subagent that runs in an isolated context.",
"The subagent does not see this conversation, so include every piece of context it needs in `task`.",
].join("\n"),
promptSnippet: "Delegate a focused task to a specialized subagent that runs in an isolated context",
promptGuidelines: [
"Use agent to delegate self-contained tasks to a specialized subagent; the subagent does not see the current conversation, so put every needed detail into `task`.",
"Use agent when a task matches one of the listed subagents and benefits from an isolated context (e.g. broad research, parallelizable subtasks) — avoid it for trivial lookups you can handle directly.",
],
parameters: Type.Object({
name: Type.String({
minLength: 1,
description: `Which subagent to run.`,
}),
title: Type.String({
description: "A short title shown in the running subagent widget; it is not sent to the subagent",
minLength: 1,
maxLength: 60,
pattern: "^(?=.*\\S)[^\\r\\n]+$",
}),
task: Type.String({ description: "The full task for the subagent, including all needed context" }),
}),
renderShell: "self",
async execute(toolCallId, params, signal, _onUpdate, ctx): Promise> {
if (!manager) {
throw new Error("Agent manager not initialized");
}
const profile = agents.find((agent) => agent.name === params.name);
if (!profile) {
throw new Error(`Unknown subagent "${params.name}"`);
}
const resolved = resolveModel(ctx, {
model: profile.model,
thinkingLevel: profile.effort,
fallbackModel: ctx.model,
fallbackThinkingLevel: ctx.thinkingLevel,
});
if (!resolved) {
throw new Error(`No model available for subagent "${profile.name}"`);
}
const result = await manager.spawn(
{
id: toolCallId,
profile,
title: params.title,
task: params.task,
model: resolved.model,
thinkingLevel: resolved.thinkingLevel,
},
signal,
);
return {
content: [{ type: "text", text: result.text }],
details: {
name: profile.name,
title: params.title,
model: result.model,
turns: result.turns,
durationMs: result.durationMs,
usage: result.usage,
stopReason: result.stopReason,
},
};
},
renderCall(args, theme) {
return new Container(); // call 过程中不渲染,展示在 widget 中
},
renderResult(result, { expanded, isPartial }, theme, context) {
if (isPartial) return new Container();
// 折叠态输出最大行数
const COLLAPSED_LINE_COUNT = 5;
const details = result.details as Partial | undefined;
const isError = context.isError || details?.stopReason === "aborted" || details?.stopReason === "max_turns";
const box = new Box(1, 1, (text) => theme.bg(isError ? "toolErrorBg" : "toolSuccessBg", text));
const content = new Container();
// 标题行:agent explore — 探索项目结构
const name = details?.name ? theme.fg("accent", details.name) : "";
const title = details?.title ? ` — ${theme.fg("muted", details.title)}` : "";
content.addChild(new Text(`${theme.fg("toolTitle", theme.bold("agent"))} ${name}${title}`, 0, 0));
const output = result.content
.filter((part): part is TextContent => part.type === "text")
.map((part) => part.text)
.join("\n");
if (expanded) {
// 展开态:完整 markdown 渲染
if (output) content.addChild(new Markdown(output, 0, 0, getMarkdownTheme()));
} else if (output) {
// 折叠态:前 5 行用普通文本,超出附展开提示
const outputLines = output.split("\n");
content.addChild(new Text(theme.fg("toolOutput", outputLines.slice(0, COLLAPSED_LINE_COUNT).join("\n")), 0, 0));
if (outputLines.length > COLLAPSED_LINE_COUNT) {
// 把 `)` 写到外侧 theme.fg("muted", "...)") 有 bug 导致最后一个符号是正常颜色,因此把 `)` 写到 keyHint description 里
content.addChild(new Text(theme.fg("muted", `(${keyHint("app.tools.expand", "to expand)")}`), 0, 0));
}
}
if (details) {
content.addChild(new Spacer(1));
const parts = [formatDuration(details.durationMs ?? 0), `${details.turns ?? 0} turns`];
if (details.model) parts.push(details.model);
const usage = details.usage ? formatUsage(details.usage) : "";
if (usage) parts.push(usage);
const statusLine = parts.join(" · ");
content.addChild(new Text(theme.fg("dim", statusLine), 0, 0));
}
box.addChild(content);
return box;
},
});
pi.on("session_start", (_event, ctx) => {
const catalog = discoverAgents(ctx.cwd, ctx.isProjectTrusted());
reportDiagnostics(ctx, catalog.diagnostics);
if (catalog.agents.length === 0) return;
agents = catalog.agents;
manager = new SubagentManager(ctx.cwd, config.maxConcurrent);
ctx.ui.setWidget(WIDGET_KEY, createSubagentWidget(manager), { placement: "aboveEditor" });
});
pi.on("before_agent_start", async (event) => {
if (agents.length === 0) return;
return { systemPrompt: `${event.systemPrompt}\n\n${formatAvailableAgents(agents)}` };
});
pi.on("session_shutdown", async (_event, ctx) => {
ctx.ui.setWidget(WIDGET_KEY, undefined);
await manager?.shutdown();
manager = undefined;
});
}