@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/setup.ts ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Interactive configuration wizard for /subagents-setup.
3
+ *
4
+ * Everything is selection-driven (no free-text answers): a multi-select for which
5
+ * agents to enable, a per-agent single-select for model overrides (fuzzy filter +
6
+ * paging), and simple menus for the injection toggle and agent scope. Config is
7
+ * written to <agentDir>/pi-subagents.json.
8
+ */
9
+
10
+ import { stat } from "node:fs/promises";
11
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ AGENT_SCOPE_VALUES,
14
+ BUILTIN_AGENT_NAMES,
15
+ DEFAULT_CONFIG,
16
+ DEFAULT_ENABLED_AGENTS,
17
+ type AgentScope,
18
+ type SubagentsConfig,
19
+ errorMessage,
20
+ getConfigPath,
21
+ loadConfig,
22
+ saveConfig,
23
+ } from "./config.ts";
24
+ import { promptSelectMany, promptSelectOne } from "./ui.ts";
25
+
26
+ const INHERIT = "__inherit__";
27
+
28
+ /** Short, selection-friendly descriptions for the built-in agents. */
29
+ const MODULE_HINTS: Record<string, string> = {
30
+ explore: "read-only codebase recon (fast model)",
31
+ plan: "implementation plan before code (opt-in)",
32
+ worker: "implement / fix / refactor / test (full tools)",
33
+ reviewer: "adversarial pre-commit review (read-only)",
34
+ };
35
+
36
+ function moduleLabel(name: string): string {
37
+ const hint = MODULE_HINTS[name];
38
+ return hint ? `${name} — ${hint}` : name;
39
+ }
40
+
41
+ async function configExists(configPath: string): Promise<boolean> {
42
+ try {
43
+ await stat(configPath);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /** Ordered "provider/model-id" references, with the active model first. */
51
+ function availableModelRefs(ctx: ExtensionCommandContext): string[] {
52
+ const refs = new Set<string>();
53
+ if (ctx.model) refs.add(`${ctx.model.provider}/${ctx.model.id}`);
54
+ const models =
55
+ ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : ctx.modelRegistry.getAvailable();
56
+ for (const model of models) refs.add(`${model.provider}/${model.id}`);
57
+ return [...refs];
58
+ }
59
+
60
+ async function pickEnabledAgents(
61
+ ctx: ExtensionCommandContext,
62
+ current: readonly string[],
63
+ ): Promise<string[] | undefined> {
64
+ const items = BUILTIN_AGENT_NAMES.map((name) => ({ value: name, label: moduleLabel(name) }));
65
+ return promptSelectMany(
66
+ ctx,
67
+ "Enable which sub-agents?",
68
+ "Space toggles • Enter confirms • Esc cancels",
69
+ items,
70
+ current,
71
+ );
72
+ }
73
+
74
+ async function pickAgentModels(
75
+ ctx: ExtensionCommandContext,
76
+ enabledAgents: readonly string[],
77
+ current: Record<string, string>,
78
+ ): Promise<Record<string, string> | undefined> {
79
+ const refs = availableModelRefs(ctx);
80
+ if (refs.length === 0) {
81
+ ctx.ui.notify("No Pi models are currently available; model overrides left unchanged.", "warning");
82
+ return { ...current };
83
+ }
84
+
85
+ const result: Record<string, string> = {};
86
+ for (const name of enabledAgents) {
87
+ const currentRef = current[name];
88
+ const items = [
89
+ {
90
+ value: INHERIT,
91
+ label: currentRef
92
+ ? `(use main session's model — drop override "${currentRef}")`
93
+ : "(use main session's current model — no override)",
94
+ },
95
+ ...refs.map((ref) => ({ value: ref, label: ref === currentRef ? `${ref} (current)` : ref })),
96
+ ];
97
+ const choice = await promptSelectOne(
98
+ ctx,
99
+ `Model for "${name}"`,
100
+ "Type to filter • ↑/↓ • PgUp/PgDn • Enter selects • Esc cancels setup",
101
+ items,
102
+ );
103
+ if (choice === undefined) return undefined; // Esc aborts the whole wizard
104
+ if (choice !== INHERIT) result[name] = choice;
105
+ }
106
+ return result;
107
+ }
108
+
109
+ async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Promise<boolean | undefined> {
110
+ const on = "On — inject the delegation directive into the system prompt (recommended)";
111
+ const off = "Off — do not inject (rely on the tool description alone)";
112
+ const choice = await ctx.ui.select("Proactive dispatch injection?", [current ? `${on} (current)` : on, current ? off : `${off} (current)`]);
113
+ if (choice === undefined) return undefined;
114
+ return choice.startsWith("On");
115
+ }
116
+
117
+ async function pickScope(ctx: ExtensionCommandContext, current: AgentScope): Promise<AgentScope | undefined> {
118
+ const labels: Record<AgentScope, string> = {
119
+ user: "user — built-in + ~/.pi/agent/agents (default)",
120
+ project: "project — built-in + nearest .pi/agents only",
121
+ both: "both — user agents, overridden by project agents",
122
+ };
123
+ const options = AGENT_SCOPE_VALUES.map((scope) =>
124
+ scope === current ? `${labels[scope]} (current)` : labels[scope],
125
+ );
126
+ const choice = await ctx.ui.select("Which agent directories to discover from?", options);
127
+ if (choice === undefined) return undefined;
128
+ const scope = AGENT_SCOPE_VALUES.find((s) => choice.startsWith(s));
129
+ return scope;
130
+ }
131
+
132
+ /** Validate that every configured model override still resolves; drop stale ones. */
133
+ function pruneStaleModels(ctx: ExtensionCommandContext, agentModels: Record<string, string>): Record<string, string> {
134
+ const clean: Record<string, string> = {};
135
+ for (const [name, ref] of Object.entries(agentModels)) {
136
+ const slash = ref.indexOf("/");
137
+ if (slash <= 0) continue;
138
+ const model = ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
139
+ if (model) clean[name] = ref;
140
+ else ctx.ui.notify(`Dropped stale model override for "${name}": ${ref}`, "warning");
141
+ }
142
+ return clean;
143
+ }
144
+
145
+ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, base: SubagentsConfig): Promise<void> {
146
+ const enabled = await pickEnabledAgents(ctx, base.enabledAgents);
147
+ if (enabled === undefined) return notifyCancelled(ctx);
148
+
149
+ const models = await pickAgentModels(ctx, enabled, base.agentModels);
150
+ if (models === undefined) return notifyCancelled(ctx);
151
+
152
+ const injection = await pickInjection(ctx, base.proactiveInjection);
153
+ if (injection === undefined) return notifyCancelled(ctx);
154
+
155
+ const scope = await pickScope(ctx, base.agentScope);
156
+ if (scope === undefined) return notifyCancelled(ctx);
157
+
158
+ const next: SubagentsConfig = {
159
+ enabledAgents: enabled,
160
+ agentModels: pruneStaleModels(ctx, models),
161
+ proactiveInjection: injection,
162
+ agentScope: scope,
163
+ };
164
+ await saveConfig(next, configPath);
165
+ ctx.ui.notify(`pi-subagents configured. Saved to ${configPath}`, "info");
166
+ }
167
+
168
+ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config: SubagentsConfig): Promise<void> {
169
+ const choice = await ctx.ui.select("pi-subagents is already configured. What would you like to change?", [
170
+ "Enable/disable agents",
171
+ "Change agent models",
172
+ "Toggle proactive injection",
173
+ "Change agent scope",
174
+ "Full re-setup",
175
+ ]);
176
+ if (choice === undefined) return notifyCancelled(ctx);
177
+
178
+ if (choice.startsWith("Full")) return runFullSetup(ctx, configPath, config);
179
+
180
+ let next: SubagentsConfig = { ...config, agentModels: { ...config.agentModels } };
181
+
182
+ if (choice.startsWith("Enable")) {
183
+ const enabled = await pickEnabledAgents(ctx, config.enabledAgents);
184
+ if (enabled === undefined) return notifyCancelled(ctx);
185
+ next.enabledAgents = enabled;
186
+ } else if (choice.startsWith("Change agent models")) {
187
+ const models = await pickAgentModels(ctx, config.enabledAgents, config.agentModels);
188
+ if (models === undefined) return notifyCancelled(ctx);
189
+ next.agentModels = pruneStaleModels(ctx, models);
190
+ } else if (choice.startsWith("Toggle")) {
191
+ const injection = await pickInjection(ctx, config.proactiveInjection);
192
+ if (injection === undefined) return notifyCancelled(ctx);
193
+ next.proactiveInjection = injection;
194
+ } else if (choice.startsWith("Change agent scope")) {
195
+ const scope = await pickScope(ctx, config.agentScope);
196
+ if (scope === undefined) return notifyCancelled(ctx);
197
+ next.agentScope = scope;
198
+ }
199
+
200
+ await saveConfig(next, configPath);
201
+ ctx.ui.notify(`pi-subagents updated. Saved to ${configPath}`, "info");
202
+ }
203
+
204
+ function notifyCancelled(ctx: ExtensionCommandContext): void {
205
+ ctx.ui.notify("pi-subagents setup cancelled.", "info");
206
+ }
207
+
208
+ /** Entry point for the /subagents-setup command. */
209
+ export async function runSetup(ctx: ExtensionCommandContext, configPath: string = getConfigPath()): Promise<void> {
210
+ if (ctx.mode !== "tui") {
211
+ ctx.ui.notify("/subagents-setup requires Pi's interactive TUI.", "error");
212
+ return;
213
+ }
214
+ try {
215
+ const exists = await configExists(configPath);
216
+ const config = await loadConfig(configPath);
217
+ if (exists) await runMenu(ctx, configPath, config);
218
+ else await runFullSetup(ctx, configPath, { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_ENABLED_AGENTS] });
219
+ } catch (error) {
220
+ ctx.ui.notify(`pi-subagents setup failed: ${errorMessage(error)}`, "error");
221
+ }
222
+ }
package/src/spawn.ts ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Sub-agent dispatch: each agent runs as an isolated `pi` child process
3
+ * (`--mode json -p --no-session`). The agent's system prompt (the .md body) is
4
+ * written to a temp file and passed via `--append-system-prompt` (which accepts a
5
+ * file path). Child stdout is a JSON-lines event stream; we accumulate assistant
6
+ * messages from `message_end` events and stream partial output back via onUpdate.
7
+ *
8
+ * Adapted from the official pi example `examples/extensions/subagent`.
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
13
+ import { existsSync, unlinkSync, rmdirSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { basename, join } from "node:path";
16
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
17
+ import type { Message } from "@earendil-works/pi-ai";
18
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
19
+ import type { AgentConfig, AgentSource } from "./agents.ts";
20
+
21
+ export const MAX_PARALLEL_TASKS = 8;
22
+ export const MAX_CONCURRENCY = 4;
23
+ export const PER_TASK_OUTPUT_CAP = 50 * 1024;
24
+ /** Max nesting depth for sub-agent -> sub-agent spawning (recursion guard). */
25
+ export const MAX_SUBAGENT_DEPTH = 2;
26
+ export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
27
+
28
+ export interface UsageStats {
29
+ input: number;
30
+ output: number;
31
+ cacheRead: number;
32
+ cacheWrite: number;
33
+ cost: number;
34
+ contextTokens: number;
35
+ turns: number;
36
+ }
37
+
38
+ export interface SingleResult {
39
+ agent: string;
40
+ agentSource: AgentSource | "unknown";
41
+ task: string;
42
+ exitCode: number; // -1 = still running
43
+ messages: Message[];
44
+ stderr: string;
45
+ usage: UsageStats;
46
+ model?: string;
47
+ stopReason?: string;
48
+ errorMessage?: string;
49
+ }
50
+
51
+ export interface SubagentDetails {
52
+ mode: "single" | "parallel";
53
+ results: SingleResult[];
54
+ }
55
+
56
+ export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
57
+
58
+ function emptyUsage(): UsageStats {
59
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
60
+ }
61
+
62
+ export function getFinalOutput(messages: Message[]): string {
63
+ for (let i = messages.length - 1; i >= 0; i--) {
64
+ const msg = messages[i];
65
+ if (msg.role === "assistant") {
66
+ for (const part of msg.content) {
67
+ if (part.type === "text") return part.text;
68
+ }
69
+ }
70
+ }
71
+ return "";
72
+ }
73
+
74
+ export function isFailedResult(result: SingleResult): boolean {
75
+ return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
76
+ }
77
+
78
+ export function getResultOutput(result: SingleResult): string {
79
+ if (isFailedResult(result)) {
80
+ return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
81
+ }
82
+ return getFinalOutput(result.messages) || "(no output)";
83
+ }
84
+
85
+ export function truncateParallelOutput(output: string): string {
86
+ const byteLength = Buffer.byteLength(output, "utf8");
87
+ if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
88
+ let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
89
+ while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) truncated = truncated.slice(0, -1);
90
+ const omitted = byteLength - Buffer.byteLength(truncated, "utf8");
91
+ return `${truncated}\n\n[Output truncated: ${omitted} bytes omitted. Full output preserved in tool details.]`;
92
+ }
93
+
94
+ export async function mapWithConcurrencyLimit<TIn, TOut>(
95
+ items: TIn[],
96
+ concurrency: number,
97
+ fn: (item: TIn, index: number) => Promise<TOut>,
98
+ ): Promise<TOut[]> {
99
+ if (items.length === 0) return [];
100
+ const limit = Math.max(1, Math.min(concurrency, items.length));
101
+ const results: TOut[] = new Array(items.length);
102
+ let nextIndex = 0;
103
+ const workers = new Array(limit).fill(null).map(async () => {
104
+ while (true) {
105
+ const current = nextIndex++;
106
+ if (current >= items.length) return;
107
+ results[current] = await fn(items[current], current);
108
+ }
109
+ });
110
+ await Promise.all(workers);
111
+ return results;
112
+ }
113
+
114
+ async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
115
+ const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
116
+ const safeName = agentName.replace(/[^\w.-]+/g, "_");
117
+ const filePath = join(dir, `prompt-${safeName}.md`);
118
+ await withFileMutationQueue(filePath, async () => {
119
+ await writeFile(filePath, prompt, "utf8");
120
+ });
121
+ return { dir, filePath };
122
+ }
123
+
124
+ /** Resolve how to invoke the SAME pi build as the current process. */
125
+ export function getPiInvocation(args: string[]): { command: string; args: string[] } {
126
+ const currentScript = process.argv[1];
127
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
128
+ if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
129
+ return { command: process.execPath, args: [currentScript, ...args] };
130
+ }
131
+ const execName = basename(process.execPath).toLowerCase();
132
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
133
+ if (!isGenericRuntime) return { command: process.execPath, args };
134
+ return { command: "pi", args };
135
+ }
136
+
137
+ export function currentSubagentDepth(env: NodeJS.ProcessEnv = process.env): number {
138
+ const raw = env[DEPTH_ENV_VAR];
139
+ const parsed = raw === undefined ? 0 : Number.parseInt(raw, 10);
140
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
141
+ }
142
+
143
+ export interface RunSingleOptions {
144
+ defaultCwd: string;
145
+ agent: AgentConfig | undefined;
146
+ agentName: string;
147
+ task: string;
148
+ cwd?: string;
149
+ signal?: AbortSignal;
150
+ onUpdate?: OnUpdateCallback;
151
+ makeDetails: (results: SingleResult[]) => SubagentDetails;
152
+ env?: NodeJS.ProcessEnv;
153
+ }
154
+
155
+ /** Spawn one agent as an isolated pi child process and collect its output. */
156
+ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleResult> {
157
+ const { agent, agentName, task, cwd, signal, onUpdate, makeDetails } = options;
158
+
159
+ if (!agent) {
160
+ return {
161
+ agent: agentName,
162
+ agentSource: "unknown",
163
+ task,
164
+ exitCode: 1,
165
+ messages: [],
166
+ stderr: `Unknown agent: "${agentName}".`,
167
+ usage: emptyUsage(),
168
+ };
169
+ }
170
+
171
+ const args: string[] = ["--mode", "json", "-p", "--no-session"];
172
+ if (agent.model) args.push("--model", agent.model);
173
+ if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
174
+
175
+ let tmpPromptDir: string | null = null;
176
+ let tmpPromptPath: string | null = null;
177
+
178
+ const currentResult: SingleResult = {
179
+ agent: agentName,
180
+ agentSource: agent.source,
181
+ task,
182
+ exitCode: 0,
183
+ messages: [],
184
+ stderr: "",
185
+ usage: emptyUsage(),
186
+ model: agent.model,
187
+ };
188
+
189
+ const emitUpdate = (): void => {
190
+ onUpdate?.({
191
+ content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
192
+ details: makeDetails([currentResult]),
193
+ });
194
+ };
195
+
196
+ try {
197
+ if (agent.systemPrompt.trim()) {
198
+ const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
199
+ tmpPromptDir = tmp.dir;
200
+ tmpPromptPath = tmp.filePath;
201
+ args.push("--append-system-prompt", tmpPromptPath);
202
+ }
203
+
204
+ args.push(`Task: ${task}`);
205
+ let wasAborted = false;
206
+
207
+ // Increment depth so nested sub-agents can be guarded against runaway recursion.
208
+ const childDepth = currentSubagentDepth(options.env) + 1;
209
+ const childEnv: NodeJS.ProcessEnv = {
210
+ ...(options.env ?? process.env),
211
+ [DEPTH_ENV_VAR]: String(childDepth),
212
+ };
213
+
214
+ const exitCode = await new Promise<number>((resolve) => {
215
+ const invocation = getPiInvocation(args);
216
+ const proc = spawn(invocation.command, invocation.args, {
217
+ cwd: cwd ?? options.defaultCwd,
218
+ shell: false,
219
+ stdio: ["ignore", "pipe", "pipe"],
220
+ env: childEnv,
221
+ });
222
+ let buffer = "";
223
+
224
+ const processLine = (line: string): void => {
225
+ if (!line.trim()) return;
226
+ let event: any;
227
+ try {
228
+ event = JSON.parse(line);
229
+ } catch {
230
+ return;
231
+ }
232
+
233
+ if (event.type === "message_end" && event.message) {
234
+ const msg = event.message as Message;
235
+ currentResult.messages.push(msg);
236
+ if (msg.role === "assistant") {
237
+ currentResult.usage.turns++;
238
+ const usage = (msg as any).usage;
239
+ if (usage) {
240
+ currentResult.usage.input += usage.input || 0;
241
+ currentResult.usage.output += usage.output || 0;
242
+ currentResult.usage.cacheRead += usage.cacheRead || 0;
243
+ currentResult.usage.cacheWrite += usage.cacheWrite || 0;
244
+ currentResult.usage.cost += usage.cost?.total || 0;
245
+ currentResult.usage.contextTokens = usage.totalTokens || 0;
246
+ }
247
+ if (!currentResult.model && (msg as any).model) currentResult.model = (msg as any).model;
248
+ if ((msg as any).stopReason) currentResult.stopReason = (msg as any).stopReason;
249
+ if ((msg as any).errorMessage) currentResult.errorMessage = (msg as any).errorMessage;
250
+ }
251
+ emitUpdate();
252
+ }
253
+
254
+ if (event.type === "tool_result_end" && event.message) {
255
+ currentResult.messages.push(event.message as Message);
256
+ emitUpdate();
257
+ }
258
+ };
259
+
260
+ proc.stdout.on("data", (data) => {
261
+ buffer += data.toString();
262
+ const lines = buffer.split("\n");
263
+ buffer = lines.pop() || "";
264
+ for (const line of lines) processLine(line);
265
+ });
266
+
267
+ proc.stderr.on("data", (data) => {
268
+ currentResult.stderr += data.toString();
269
+ });
270
+
271
+ proc.on("close", (code) => {
272
+ if (buffer.trim()) processLine(buffer);
273
+ resolve(code ?? 0);
274
+ });
275
+
276
+ proc.on("error", () => resolve(1));
277
+
278
+ if (signal) {
279
+ const killProc = (): void => {
280
+ wasAborted = true;
281
+ proc.kill("SIGTERM");
282
+ setTimeout(() => {
283
+ if (!proc.killed) proc.kill("SIGKILL");
284
+ }, 5000);
285
+ };
286
+ if (signal.aborted) killProc();
287
+ else signal.addEventListener("abort", killProc, { once: true });
288
+ }
289
+ });
290
+
291
+ currentResult.exitCode = exitCode;
292
+ if (wasAborted) throw new Error("Subagent was aborted");
293
+ return currentResult;
294
+ } finally {
295
+ if (tmpPromptPath)
296
+ try {
297
+ unlinkSync(tmpPromptPath);
298
+ } catch {
299
+ /* ignore */
300
+ }
301
+ if (tmpPromptDir)
302
+ try {
303
+ rmdirSync(tmpPromptDir);
304
+ } catch {
305
+ /* ignore */
306
+ }
307
+ }
308
+ }