@ferris1225/pi-subagents 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agents.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Agent discovery.
3
+ *
4
+ * Agents are Markdown files (YAML frontmatter + body-as-system-prompt) loaded from
5
+ * three scopes with override priority builtin < user < project (same `name` wins
6
+ * at the higher scope). Discovery is re-run on every invocation so editing a file or
7
+ * dropping a new one takes effect mid-session without a reload.
8
+ *
9
+ * builtin : <package>/agents (shipped with this extension)
10
+ * user : <agentDir>/agents (~/.pi/agent/agents)
11
+ * project : <cwd...>/.pi/agents (nearest, walking up)
12
+ */
13
+
14
+ import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
18
+ import type { AgentScope } from "./config.ts";
19
+
20
+ export type AgentSource = "builtin" | "user" | "project";
21
+
22
+ export interface AgentConfig {
23
+ name: string;
24
+ description: string;
25
+ tools?: string[];
26
+ model?: string;
27
+ systemPrompt: string;
28
+ source: AgentSource;
29
+ filePath: string;
30
+ }
31
+
32
+ export interface AgentDiscoveryResult {
33
+ agents: AgentConfig[];
34
+ projectAgentsDir: string | null;
35
+ }
36
+
37
+ const here = dirname(fileURLToPath(import.meta.url));
38
+ /** <package>/agents — the agents shipped with this extension. */
39
+ export const BUILTIN_AGENTS_DIR = join(here, "..", "agents");
40
+
41
+ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
42
+ const agents: AgentConfig[] = [];
43
+ if (!existsSync(dir)) return agents;
44
+
45
+ let entries: Dirent[];
46
+ try {
47
+ entries = readdirSync(dir, { withFileTypes: true });
48
+ } catch {
49
+ return agents;
50
+ }
51
+
52
+ for (const entry of entries) {
53
+ if (!entry.name.endsWith(".md")) continue;
54
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
55
+
56
+ const filePath = join(dir, entry.name);
57
+ let content: string;
58
+ try {
59
+ content = readFileSync(filePath, "utf-8");
60
+ } catch {
61
+ continue;
62
+ }
63
+
64
+ const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
65
+ // name + description are required; skip malformed files silently.
66
+ if (!frontmatter.name || !frontmatter.description) continue;
67
+
68
+ const tools = frontmatter.tools
69
+ ?.split(",")
70
+ .map((t) => t.trim())
71
+ .filter(Boolean);
72
+
73
+ agents.push({
74
+ name: frontmatter.name,
75
+ description: frontmatter.description,
76
+ tools: tools && tools.length > 0 ? tools : undefined,
77
+ model: frontmatter.model,
78
+ systemPrompt: body,
79
+ source,
80
+ filePath,
81
+ });
82
+ }
83
+
84
+ return agents;
85
+ }
86
+
87
+ function isDirectory(p: string): boolean {
88
+ try {
89
+ return statSync(p).isDirectory();
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ function findNearestProjectAgentsDir(cwd: string): string | null {
96
+ let currentDir = cwd;
97
+ while (true) {
98
+ const candidate = join(currentDir, CONFIG_DIR_NAME, "agents");
99
+ if (isDirectory(candidate)) return candidate;
100
+ const parentDir = dirname(currentDir);
101
+ if (parentDir === currentDir) return null;
102
+ currentDir = parentDir;
103
+ }
104
+ }
105
+
106
+ export interface DiscoverOptions {
107
+ /** Which directories to read from. Default: "user". */
108
+ scope?: AgentScope;
109
+ /** If provided and non-empty, only agents whose name is listed are returned. */
110
+ enabledNames?: readonly string[];
111
+ /** Per-agent model override ("provider/model-id"), keyed by agent name. */
112
+ modelOverrides?: Record<string, string>;
113
+ /** Override the built-in agents directory (used by tests). */
114
+ builtinDir?: string;
115
+ }
116
+
117
+ /**
118
+ * Discover agents across scopes, apply enable-filter and model overrides.
119
+ * Override priority for the same name: project > user > builtin.
120
+ */
121
+ export function discoverAgents(cwd: string, options: DiscoverOptions = {}): AgentDiscoveryResult {
122
+ const scope = options.scope ?? "user";
123
+ const builtinDir = options.builtinDir ?? BUILTIN_AGENTS_DIR;
124
+ const projectAgentsDir = findNearestProjectAgentsDir(cwd);
125
+
126
+ const builtin = loadAgentsFromDir(builtinDir, "builtin");
127
+ const user = scope === "project" ? [] : loadAgentsFromDir(join(getAgentDir(), "agents"), "user");
128
+ const project =
129
+ scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
130
+
131
+ // Merge with override priority builtin < user < project.
132
+ const byName = new Map<string, AgentConfig>();
133
+ for (const agent of builtin) byName.set(agent.name, agent);
134
+ for (const agent of user) byName.set(agent.name, agent);
135
+ for (const agent of project) byName.set(agent.name, agent);
136
+
137
+ let agents = Array.from(byName.values());
138
+
139
+ if (options.enabledNames && options.enabledNames.length > 0) {
140
+ const enabled = new Set(options.enabledNames);
141
+ agents = agents.filter((agent) => enabled.has(agent.name));
142
+ }
143
+
144
+ if (options.modelOverrides) {
145
+ agents = agents.map((agent) => {
146
+ const override = options.modelOverrides?.[agent.name];
147
+ return override ? { ...agent, model: override } : agent;
148
+ });
149
+ }
150
+
151
+ return { agents, projectAgentsDir };
152
+ }
153
+
154
+ /** One-line catalog entry for system-prompt injection and error messages. */
155
+ export function formatCatalogEntry(agent: AgentConfig): string {
156
+ return `- ${agent.name}: ${agent.description}`;
157
+ }
package/src/config.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Configuration load/save for pi-subagents.
3
+ *
4
+ * Config lives at <agentDir>/pi-subagents.json (agentDir defaults to ~/.pi/agent
5
+ * and honors PI_CODING_AGENT_DIR). Parsing is defensive: invalid fields fall back
6
+ * to defaults instead of throwing, so a hand-edited or partially-written file can
7
+ * never break the extension at runtime.
8
+ */
9
+
10
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
11
+ import { dirname, join } from "node:path";
12
+ import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
13
+
14
+ /** Full catalog of agents shipped with the package (selectable in /subagents-setup). */
15
+ export const BUILTIN_AGENT_NAMES = ["explore", "plan", "worker", "reviewer"] as const;
16
+ export type BuiltinAgentName = (typeof BUILTIN_AGENT_NAMES)[number];
17
+
18
+ /** Agents enabled out of the box. `plan` ships but is opt-in: a worker plans internally. */
19
+ export const DEFAULT_ENABLED_AGENTS: readonly string[] = ["explore", "worker", "reviewer"];
20
+
21
+ export const AGENT_SCOPE_VALUES = ["user", "project", "both"] as const;
22
+ export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
23
+
24
+ export const CONFIG_FILE_NAME = "pi-subagents.json";
25
+
26
+ export interface SubagentsConfig {
27
+ /** Agent names that are discoverable and injected. Default: explore, worker, reviewer. */
28
+ enabledAgents: string[];
29
+ /** Per-agent model override, keyed by agent name, as "provider/model-id". */
30
+ agentModels: Record<string, string>;
31
+ /** Whether to inject the delegation directive into the parent system prompt. Default: true. */
32
+ proactiveInjection: boolean;
33
+ /** Which agent directories to discover from. Default: "user". */
34
+ agentScope: AgentScope;
35
+ }
36
+
37
+ export const DEFAULT_CONFIG: SubagentsConfig = {
38
+ enabledAgents: [...DEFAULT_ENABLED_AGENTS],
39
+ agentModels: {},
40
+ proactiveInjection: true,
41
+ agentScope: "user",
42
+ };
43
+
44
+ export function getConfigPath(agentDir: string = getAgentDir()): string {
45
+ return join(agentDir, CONFIG_FILE_NAME);
46
+ }
47
+
48
+ function isRecord(value: unknown): value is Record<string, unknown> {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value);
50
+ }
51
+
52
+ function isAgentScope(value: unknown): value is AgentScope {
53
+ return typeof value === "string" && (AGENT_SCOPE_VALUES as readonly string[]).includes(value);
54
+ }
55
+
56
+ function isModelReference(value: unknown): value is string {
57
+ if (typeof value !== "string") return false;
58
+ const normalized = value.trim();
59
+ const slash = normalized.indexOf("/");
60
+ return slash > 0 && slash < normalized.length - 1 && !/\s/u.test(normalized);
61
+ }
62
+
63
+ /**
64
+ * Merge a raw parsed JSON value over the defaults, dropping invalid fields.
65
+ * Exported for tests.
66
+ */
67
+ export function normalizeConfig(raw: unknown): SubagentsConfig {
68
+ if (!isRecord(raw)) return { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_CONFIG.enabledAgents] };
69
+
70
+ const config: SubagentsConfig = {
71
+ enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
72
+ agentModels: {},
73
+ proactiveInjection: DEFAULT_CONFIG.proactiveInjection,
74
+ agentScope: DEFAULT_CONFIG.agentScope,
75
+ };
76
+
77
+ if (Array.isArray(raw.enabledAgents)) {
78
+ const names = raw.enabledAgents.filter(
79
+ (name): name is string => typeof name === "string" && name.trim().length > 0,
80
+ );
81
+ // An explicitly empty array is honored (disables all agents); otherwise keep valid names.
82
+ config.enabledAgents = [...new Set(names.map((name) => name.trim()))];
83
+ }
84
+
85
+ if (isRecord(raw.agentModels)) {
86
+ for (const [key, value] of Object.entries(raw.agentModels)) {
87
+ if (isModelReference(value)) config.agentModels[key.trim()] = value.trim();
88
+ }
89
+ }
90
+
91
+ if (typeof raw.proactiveInjection === "boolean") {
92
+ config.proactiveInjection = raw.proactiveInjection;
93
+ }
94
+
95
+ if (isAgentScope(raw.agentScope)) {
96
+ config.agentScope = raw.agentScope;
97
+ }
98
+
99
+ return config;
100
+ }
101
+
102
+ /**
103
+ * Load config. A missing file is a normal state and yields the defaults (not an error).
104
+ * A corrupt file also falls back to defaults rather than throwing, so startup never breaks.
105
+ */
106
+ export async function loadConfig(configPath: string = getConfigPath()): Promise<SubagentsConfig> {
107
+ let text: string;
108
+ try {
109
+ text = await readFile(configPath, "utf8");
110
+ } catch (error) {
111
+ if (isNodeError(error) && error.code === "ENOENT") {
112
+ return { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_CONFIG.enabledAgents] };
113
+ }
114
+ // Unreadable for another reason: fall back to defaults but do not crash startup.
115
+ return { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_CONFIG.enabledAgents] };
116
+ }
117
+
118
+ let parsed: unknown;
119
+ try {
120
+ parsed = JSON.parse(text);
121
+ } catch {
122
+ return { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_CONFIG.enabledAgents] };
123
+ }
124
+
125
+ return normalizeConfig(parsed);
126
+ }
127
+
128
+ /**
129
+ * Save config atomically (temp file + rename) serialized through pi's per-file
130
+ * mutation queue so concurrent writers cannot interleave.
131
+ */
132
+ export async function saveConfig(
133
+ config: SubagentsConfig,
134
+ configPath: string = getConfigPath(),
135
+ ): Promise<void> {
136
+ const normalized = normalizeConfig(config);
137
+ await mkdir(dirname(configPath), { recursive: true });
138
+ await withFileMutationQueue(configPath, async () => {
139
+ const temporaryPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
140
+ try {
141
+ await writeFile(temporaryPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
142
+ await rename(temporaryPath, configPath);
143
+ } finally {
144
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
145
+ }
146
+ });
147
+ }
148
+
149
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
150
+ return error instanceof Error;
151
+ }
152
+
153
+ export function errorMessage(error: unknown): string {
154
+ return error instanceof Error ? error.message : String(error);
155
+ }
package/src/index.ts ADDED
@@ -0,0 +1,307 @@
1
+ /**
2
+ * pi-subagents — focused sub-agent delegation for pi.
3
+ *
4
+ * Registers:
5
+ * - a `subagent` tool that runs explore/plan/worker/reviewer agents as isolated
6
+ * `pi` child processes (single or parallel),
7
+ * - a `/subagents-setup` command for selection-only configuration,
8
+ * - a `before_agent_start` hook that injects a delegation directive into the
9
+ * parent system prompt so the main model uses the tool proactively.
10
+ *
11
+ * The tool is NOT registered inside nested sub-agent processes beyond
12
+ * MAX_SUBAGENT_DEPTH, which both prevents runaway recursion and keeps child
13
+ * context windows clean.
14
+ */
15
+
16
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
17
+ import { StringEnum } from "@earendil-works/pi-ai";
18
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { Text } from "@earendil-works/pi-tui";
20
+ import { Type } from "typebox";
21
+ import { discoverAgents, type AgentConfig } from "./agents.ts";
22
+ import { getConfigPath, loadConfig } from "./config.ts";
23
+ import { buildDelegationDirective } from "./prompt.ts";
24
+ import { runSetup } from "./setup.ts";
25
+ import {
26
+ MAX_CONCURRENCY,
27
+ MAX_PARALLEL_TASKS,
28
+ MAX_SUBAGENT_DEPTH,
29
+ currentSubagentDepth,
30
+ getFinalOutput,
31
+ getResultOutput,
32
+ isFailedResult,
33
+ mapWithConcurrencyLimit,
34
+ runSingleAgent,
35
+ truncateParallelOutput,
36
+ type OnUpdateCallback,
37
+ type SingleResult,
38
+ type SubagentDetails,
39
+ type UsageStats,
40
+ } from "./spawn.ts";
41
+
42
+ const TaskItem = Type.Object({
43
+ agent: Type.String({ description: "Name of the agent to invoke" }),
44
+ task: Type.String({ description: "Self-contained task to delegate (the agent has no memory of this conversation)" }),
45
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
46
+ });
47
+
48
+ const SubagentParams = Type.Object({
49
+ agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
50
+ task: Type.Optional(Type.String({ description: "Self-contained task to delegate (single mode)" })),
51
+ tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
52
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
53
+ });
54
+
55
+ function emptyUsage(): UsageStats {
56
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
57
+ }
58
+
59
+ function aggregateUsage(results: SingleResult[]): UsageStats {
60
+ const total = emptyUsage();
61
+ for (const r of results) {
62
+ total.input += r.usage.input;
63
+ total.output += r.usage.output;
64
+ total.cacheRead += r.usage.cacheRead;
65
+ total.cacheWrite += r.usage.cacheWrite;
66
+ total.cost += r.usage.cost;
67
+ total.turns += r.usage.turns;
68
+ }
69
+ return total;
70
+ }
71
+
72
+ function formatTokens(count: number): string {
73
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
74
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
75
+ return String(count);
76
+ }
77
+
78
+ function formatUsage(usage: UsageStats): string {
79
+ const parts: string[] = [];
80
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
81
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
82
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
83
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
84
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
85
+ return parts.join(" ");
86
+ }
87
+
88
+ export default function (pi: ExtensionAPI): void {
89
+ const configPath = getConfigPath(getAgentDir());
90
+
91
+ // Recursion guard: do not register the tool deep inside nested sub-agents.
92
+ if (currentSubagentDepth() >= MAX_SUBAGENT_DEPTH) {
93
+ pi.registerCommand("subagents-setup", {
94
+ description: "Configure pi-subagents (disabled in nested sub-agent processes)",
95
+ handler: async (_args, ctx) => {
96
+ ctx.ui.notify("pi-subagents setup is unavailable inside a nested sub-agent.", "warning");
97
+ },
98
+ });
99
+ return;
100
+ }
101
+
102
+ pi.registerTool({
103
+ name: "subagent",
104
+ label: "Subagent",
105
+ description: [
106
+ "Delegate a discrete, self-contained task to a specialized sub-agent running in an ISOLATED context window.",
107
+ "Agents: explore (read-only codebase recon), plan (implementation plan, opt-in), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
108
+ "Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
109
+ "Use it to keep the main conversation clean: delegate the work, then orchestrate and verify the results yourself.",
110
+ "Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output).",
111
+ ].join(" "),
112
+ promptSnippet:
113
+ "Delegate discrete tasks to isolated sub-agents: explore (read-only search), worker (implement), reviewer (adversarial pre-commit review); plan is opt-in.",
114
+ promptGuidelines: [
115
+ "Use subagent to delegate discrete, self-contained tasks so the main context stays clean; do orchestration and verification yourself.",
116
+ "Use subagent with agent 'explore' for broad or open-ended code search before large changes.",
117
+ "Use subagent with agent 'worker' to implement a well-scoped task; it plans internally.",
118
+ "Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
119
+ "Run independent tasks in parallel by passing a tasks array to subagent; keep dependent work sequential.",
120
+ ],
121
+ parameters: SubagentParams,
122
+
123
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
124
+ const config = await loadConfig(configPath);
125
+ const discovery = discoverAgents(ctx.cwd, {
126
+ scope: config.agentScope,
127
+ enabledNames: config.enabledAgents,
128
+ });
129
+
130
+ // Effective model precedence: setup override > current session model > frontmatter default.
131
+ const sessionRef = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
132
+ const agents: AgentConfig[] = discovery.agents.map((agent) => ({
133
+ ...agent,
134
+ model: config.agentModels[agent.name] ?? sessionRef ?? agent.model,
135
+ }));
136
+
137
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
138
+ const hasSingle = Boolean(params.agent && params.task);
139
+
140
+ const makeDetails =
141
+ (mode: "single" | "parallel") =>
142
+ (results: SingleResult[]): SubagentDetails => ({ mode, results });
143
+
144
+ const catalog = agents.map((a) => a.name).join(", ") || "none";
145
+
146
+ if (Number(hasTasks) + Number(hasSingle) !== 1) {
147
+ return {
148
+ content: [
149
+ {
150
+ type: "text",
151
+ text: `Invalid parameters. Provide exactly one mode: single {agent, task} or parallel {tasks: [...]}. Enabled agents: ${catalog}.`,
152
+ },
153
+ ],
154
+ details: makeDetails("single")([]),
155
+ };
156
+ }
157
+
158
+ // ---- Parallel mode ----
159
+ if (params.tasks && params.tasks.length > 0) {
160
+ if (params.tasks.length > MAX_PARALLEL_TASKS) {
161
+ return {
162
+ content: [
163
+ { type: "text", text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.` },
164
+ ],
165
+ details: makeDetails("parallel")([]),
166
+ };
167
+ }
168
+
169
+ const allResults: SingleResult[] = params.tasks.map((t) => ({
170
+ agent: t.agent,
171
+ agentSource: "unknown",
172
+ task: t.task,
173
+ exitCode: -1,
174
+ messages: [],
175
+ stderr: "",
176
+ usage: emptyUsage(),
177
+ }));
178
+
179
+ const emitParallelUpdate = (): void => {
180
+ if (!onUpdate) return;
181
+ const done = allResults.filter((r) => r.exitCode !== -1).length;
182
+ onUpdate({
183
+ content: [{ type: "text", text: `Parallel: ${done}/${allResults.length} done...` }],
184
+ details: makeDetails("parallel")([...allResults]),
185
+ });
186
+ };
187
+
188
+ const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
189
+ const perTaskUpdate: OnUpdateCallback | undefined = onUpdate
190
+ ? (partial) => {
191
+ const current = partial.details?.results[0];
192
+ if (current) {
193
+ allResults[index] = current;
194
+ emitParallelUpdate();
195
+ }
196
+ }
197
+ : undefined;
198
+ const result = await runSingleAgent({
199
+ defaultCwd: ctx.cwd,
200
+ agent: agents.find((a) => a.name === t.agent),
201
+ agentName: t.agent,
202
+ task: t.task,
203
+ cwd: t.cwd,
204
+ signal,
205
+ onUpdate: perTaskUpdate,
206
+ makeDetails: makeDetails("parallel"),
207
+ });
208
+ allResults[index] = result;
209
+ emitParallelUpdate();
210
+ return result;
211
+ });
212
+
213
+ const successCount = results.filter((r) => !isFailedResult(r)).length;
214
+ const summaries = results.map((r) => {
215
+ const output = truncateParallelOutput(getResultOutput(r));
216
+ const status = isFailedResult(r) ? "failed" : "completed";
217
+ const usage = formatUsage(r.usage);
218
+ return `### [${r.agent}] ${status}${usage ? ` (${usage})` : ""}\n\n${output}`;
219
+ });
220
+ return {
221
+ content: [
222
+ {
223
+ type: "text",
224
+ text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
225
+ },
226
+ ],
227
+ details: makeDetails("parallel")(results),
228
+ };
229
+ }
230
+
231
+ // ---- Single mode ----
232
+ const result = await runSingleAgent({
233
+ defaultCwd: ctx.cwd,
234
+ agent: agents.find((a) => a.name === params.agent),
235
+ agentName: params.agent as string,
236
+ task: params.task as string,
237
+ cwd: params.cwd,
238
+ signal,
239
+ onUpdate,
240
+ makeDetails: makeDetails("single"),
241
+ });
242
+
243
+ if (isFailedResult(result)) {
244
+ return {
245
+ content: [{ type: "text", text: `Agent ${result.agent} ${result.stopReason || "failed"}: ${getResultOutput(result)}` }],
246
+ details: makeDetails("single")([result]),
247
+ isError: true,
248
+ };
249
+ }
250
+ return {
251
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
252
+ details: makeDetails("single")([result]),
253
+ };
254
+ },
255
+
256
+ renderCall(args, theme) {
257
+ if (args.tasks && args.tasks.length > 0) {
258
+ let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
259
+ for (const t of args.tasks.slice(0, 4)) {
260
+ const preview = t.task.length > 48 ? `${t.task.slice(0, 48)}…` : t.task;
261
+ text += `\n ${theme.fg("accent", t.agent)} ${theme.fg("dim", preview)}`;
262
+ }
263
+ if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
264
+ return new Text(text, 0, 0);
265
+ }
266
+ const task: string = args.task ?? "";
267
+ const preview = task.length > 60 ? `${task.slice(0, 60)}…` : task;
268
+ return new Text(
269
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")} ${theme.fg("dim", preview)}`,
270
+ 0,
271
+ 0,
272
+ );
273
+ },
274
+
275
+ renderResult(result, _options, theme) {
276
+ const details = result.details as SubagentDetails | undefined;
277
+ if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
278
+ const usage = formatUsage(aggregateUsage(details.results));
279
+ const header =
280
+ details.mode === "parallel"
281
+ ? `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`
282
+ : `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", details.results[0].agent)}`;
283
+ const suffix = usage ? ` ${theme.fg("dim", usage)}` : "";
284
+ return new Text(header + suffix, 0, 0);
285
+ },
286
+ });
287
+
288
+ pi.registerCommand("subagents-setup", {
289
+ description: "Configure pi-subagents: enable agents, pick per-agent models, toggle proactive injection",
290
+ handler: async (_args, ctx) => {
291
+ await runSetup(ctx, configPath);
292
+ },
293
+ });
294
+
295
+ // Proactive dispatch: inject the delegation directive into the parent system prompt.
296
+ pi.on("before_agent_start", async (event, ctx) => {
297
+ const config = await loadConfig(configPath);
298
+ if (!config.proactiveInjection) return undefined;
299
+ const { agents } = discoverAgents(ctx.cwd, {
300
+ scope: config.agentScope,
301
+ enabledNames: config.enabledAgents,
302
+ });
303
+ const directive = buildDelegationDirective(agents);
304
+ if (!directive) return undefined;
305
+ return { systemPrompt: `${event.systemPrompt}\n${directive}` };
306
+ });
307
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Builds the delegation directive injected into the parent model's system prompt
3
+ * via the `before_agent_start` hook. This is the lever that makes the main model
4
+ * actually USE the subagent tool proactively (pi never shows it the per-agent
5
+ * descriptions otherwise).
6
+ *
7
+ * The directive is a self-contained replacement for the "Sub-agent Dispatch" and
8
+ * "Review, Verification & Commit" sections users otherwise keep in a global
9
+ * AGENTS.md — so installing this extension lets them delete those sections without
10
+ * losing the behavior. (Other AGENTS.md sections — Behavior, Git & Security,
11
+ * platform/language rules — are unrelated and stay put.)
12
+ */
13
+
14
+ import type { AgentConfig } from "./agents.ts";
15
+ import { formatCatalogEntry } from "./agents.ts";
16
+
17
+ /** Compact role routing hints, emitted only for roles that are enabled. */
18
+ const ROLE_ROUTING: Record<string, string> = {
19
+ explore: "explore — broad/open-ended code search, \"where is X\", multi-file lookups (read-only, cheap).",
20
+ plan: "plan — a separate, human-reviewable implementation plan before any code (read-only).",
21
+ worker: "worker — implement/fix/refactor/test a well-scoped task (full tools; plans internally).",
22
+ reviewer: "reviewer — adversarial pre-commit review of a diff (read-only; independent context).",
23
+ };
24
+
25
+ export function buildDelegationDirective(agents: AgentConfig[]): string {
26
+ if (agents.length === 0) return "";
27
+
28
+ const catalog = agents.map(formatCatalogEntry).join("\n");
29
+ const routing = agents
30
+ .map((a) => ROLE_ROUTING[a.name])
31
+ .filter((line): line is string => Boolean(line))
32
+ .map((line) => `- ${line}`)
33
+ .join("\n");
34
+ const hasReviewer = agents.some((a) => a.name === "reviewer");
35
+ const hasMultiple = agents.length > 1;
36
+
37
+ return `
38
+ ## Sub-agent delegation (pi-subagents)
39
+
40
+ You have a \`subagent\` tool that runs specialized agents in ISOLATED context windows.
41
+ Delegate discrete, self-contained tasks to it instead of doing everything inline, so the
42
+ main window stays focused on orchestration, synthesis, and verification.
43
+
44
+ Available agents:
45
+ ${catalog}
46
+
47
+ ${routing ? `Routing:\n${routing}\n` : ""}Dispatch discipline:
48
+ - Default to delegating every discrete task to a sub-agent; do the orchestration and verification yourself in the main window.
49
+ - Only handle inline: pure Q&A, a single trivial edit/lookup, or when the user explicitly says to do it directly. When in doubt, delegate.
50
+ - For an already-known or trivial target, use a direct search/read tool (e.g. grep/find/read) — do not over-delegate a one-line lookup.
51
+ ${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `tasks` array, and track them with your todo list. Keep dependent work sequential (e.g. explore, then worker, then reviewer).\n" : ""}- Brief each sub-agent as self-contained: goal, exact paths, constraints, expected output. It has NO memory of this conversation.
52
+ - Trust but verify: a sub-agent's summary describes intent, not outcome. Check the actual changes/results before reporting work done.
53
+
54
+ Review & verification:
55
+ - Never report an unrun check as passed; report it as unavailable or as a pre-existing failure.
56
+ ${hasReviewer ? "- For non-trivial diffs, run one fresh read-only `reviewer` sub-agent before reporting done. Fix only concrete blockers and re-review at most once.\n- Use multi-model cross-review only when explicitly requested or for genuinely high-risk changes (security, unsafe/FFI, persistence-migration, concurrency). Reviewers are read-only; only the main agent edits.\n" : ""}- Commit or push only when explicitly requested, applicable checks pass, and no accepted blockers remain.`;
57
+ }