@trim21/personal-pi-extensions 0.0.160 → 0.0.161
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/package.json +1 -1
- package/src/gh-readonly.ts +1 -1
- package/src/spawn-agent-agents.ts +106 -0
- package/src/spawn-agent.ts +452 -0
package/package.json
CHANGED
package/src/gh-readonly.ts
CHANGED
|
@@ -177,7 +177,7 @@ export class GhError extends Error {
|
|
|
177
177
|
/** Run `gh` and return stdout. On non-zero exit, throws a `GhError` carrying the toolcall input and raw command. */
|
|
178
178
|
export async function ghExec(
|
|
179
179
|
args: string[],
|
|
180
|
-
ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
|
|
180
|
+
ctx: { cwd?: string; signal?: AbortSignal; input?: unknown; timeout?: number },
|
|
181
181
|
): Promise<string> {
|
|
182
182
|
const result = await runGh(args, ctx);
|
|
183
183
|
if (result.code !== 0) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent discovery for the `spawn_agent` tool.
|
|
3
|
+
*
|
|
4
|
+
* Subagents are defined as markdown files in `~/.pi/agent/agents/*.md`
|
|
5
|
+
* (user-level only; project-local agents are intentionally not supported).
|
|
6
|
+
* Each file carries YAML frontmatter plus a system-prompt body:
|
|
7
|
+
*
|
|
8
|
+
* ---
|
|
9
|
+
* name: scout
|
|
10
|
+
* description: Fast codebase recon
|
|
11
|
+
* tools:
|
|
12
|
+
* - read
|
|
13
|
+
* - grep
|
|
14
|
+
* - find
|
|
15
|
+
* - ls
|
|
16
|
+
* model: claude-haiku-4-5 # optional
|
|
17
|
+
* thinkingLevel: high # optional; applied as "model:high"
|
|
18
|
+
* ---
|
|
19
|
+
* System prompt for the agent goes here.
|
|
20
|
+
*
|
|
21
|
+
* Frontmatter is validated with a typebox schema; files that fail validation
|
|
22
|
+
* (missing name/description, wrong field types) are skipped. If `tools` is
|
|
23
|
+
* omitted, the subagent runs with the read-only default toolset from the
|
|
24
|
+
* spawn-agent config (read/grep/find/ls) unless overridden there.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
|
|
30
|
+
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { Type } from "typebox";
|
|
32
|
+
import { Value } from "typebox/value";
|
|
33
|
+
|
|
34
|
+
/** Valid thinking levels, mirroring pi's ThinkingLevel type. */
|
|
35
|
+
const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
36
|
+
|
|
37
|
+
const agentFrontmatterSchema = Type.Object({
|
|
38
|
+
name: Type.String({ minLength: 1 }),
|
|
39
|
+
description: Type.String({ minLength: 1 }),
|
|
40
|
+
tools: Type.Optional(Type.Array(Type.String())),
|
|
41
|
+
model: Type.Optional(Type.String()),
|
|
42
|
+
thinkingLevel: Type.Optional(Type.Union(THINKING_LEVELS.map((level) => Type.Literal(level)))),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function parseAgentFrontmatter(frontmatter: unknown) {
|
|
46
|
+
try {
|
|
47
|
+
return Value.Parse(agentFrontmatterSchema, frontmatter);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AgentConfig {
|
|
54
|
+
name: string;
|
|
55
|
+
description: string;
|
|
56
|
+
/** Toolset from the frontmatter; undefined means "use the config default". */
|
|
57
|
+
tools?: string[];
|
|
58
|
+
model?: string;
|
|
59
|
+
/** Thinking level, applied as a ":level" suffix on the model id. */
|
|
60
|
+
thinkingLevel?: (typeof THINKING_LEVELS)[number];
|
|
61
|
+
systemPrompt: string;
|
|
62
|
+
filePath: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function discoverAgents(dir = join(getAgentDir(), "agents")): AgentConfig[] {
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const agents: AgentConfig[] = [];
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
76
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
77
|
+
|
|
78
|
+
const filePath = join(dir, entry.name);
|
|
79
|
+
let content: string;
|
|
80
|
+
try {
|
|
81
|
+
content = readFileSync(filePath, "utf8");
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
87
|
+
const fm = parseAgentFrontmatter(frontmatter);
|
|
88
|
+
if (!fm) continue; // missing name/description or wrong field types → not an agent
|
|
89
|
+
|
|
90
|
+
agents.push({
|
|
91
|
+
name: fm.name,
|
|
92
|
+
description: fm.description,
|
|
93
|
+
tools: fm.tools,
|
|
94
|
+
model: fm.model,
|
|
95
|
+
thinkingLevel: fm.thinkingLevel,
|
|
96
|
+
systemPrompt: body,
|
|
97
|
+
filePath,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return agents;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function formatAgentList(agents: AgentConfig[]): string {
|
|
104
|
+
if (agents.length === 0) return "none";
|
|
105
|
+
return agents.map((a) => `${a.name}: ${a.description}`).join("; ");
|
|
106
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spawn_agent tool — delegate a task to a subagent running in a separate pi
|
|
3
|
+
* process with an isolated context window.
|
|
4
|
+
*
|
|
5
|
+
* The subagent definition comes from `~/.pi/agent/agents/*.md` (markdown with
|
|
6
|
+
* YAML frontmatter, see spawn-agent-agents.ts). The extension discovers the
|
|
7
|
+
* available subagent types once at startup and appends them to the system
|
|
8
|
+
* prompt on every agent start (same pattern as the bwrap extension), so the
|
|
9
|
+
* model always knows which `agent` names it can pass to the tool. Execution
|
|
10
|
+
* is blocking: the tool awaits the subagent process until it exits and
|
|
11
|
+
* returns its final output to the parent model. Progress is streamed through
|
|
12
|
+
* `onUpdate`, the same channel the built-in bash tool uses for live output.
|
|
13
|
+
*
|
|
14
|
+
* Security default: without an explicit `tools:` in the frontmatter, the
|
|
15
|
+
* subagent only gets read-only tools (read/grep/find/ls) — no bash/write/edit.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { basename, dirname, join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
25
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
26
|
+
import {
|
|
27
|
+
type ExtensionAPI,
|
|
28
|
+
getMarkdownTheme,
|
|
29
|
+
truncateTail,
|
|
30
|
+
withFileMutationQueue,
|
|
31
|
+
} from "@earendil-works/pi-coding-agent";
|
|
32
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
33
|
+
import { Type } from "typebox";
|
|
34
|
+
|
|
35
|
+
import { type AgentConfig, discoverAgents, formatAgentList } from "./spawn-agent-agents.js";
|
|
36
|
+
|
|
37
|
+
// ── constants ────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Subagent output returned to the parent model is capped at 50KB. */
|
|
40
|
+
const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
41
|
+
/** Read-only toolset used when an agent does not declare `tools`. */
|
|
42
|
+
const DEFAULT_TOOLS = ["read", "grep", "find", "ls"];
|
|
43
|
+
|
|
44
|
+
// ── schema ───────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const spawnAgentSchema = Type.Object({
|
|
47
|
+
agent: Type.String({
|
|
48
|
+
description:
|
|
49
|
+
"Name of the subagent type to invoke. Choose one of the available subagent types listed in your system prompt.",
|
|
50
|
+
}),
|
|
51
|
+
task: Type.String({ description: "Task to delegate to the subagent" }),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── result types ─────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
interface UsageStats {
|
|
57
|
+
input: number;
|
|
58
|
+
output: number;
|
|
59
|
+
cacheRead: number;
|
|
60
|
+
cacheWrite: number;
|
|
61
|
+
cost: number;
|
|
62
|
+
contextTokens: number;
|
|
63
|
+
turns: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface SubagentDetails {
|
|
67
|
+
agent: string;
|
|
68
|
+
task: string;
|
|
69
|
+
exitCode: number;
|
|
70
|
+
messages: Message[];
|
|
71
|
+
stderr: string;
|
|
72
|
+
usage: UsageStats;
|
|
73
|
+
model?: string;
|
|
74
|
+
stopReason?: string;
|
|
75
|
+
errorMessage?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function getFinalOutput(messages: Message[]): string {
|
|
81
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
82
|
+
const msg = messages[i];
|
|
83
|
+
if (msg.role === "assistant") {
|
|
84
|
+
for (const part of msg.content) {
|
|
85
|
+
if (part.type === "text") return part.text;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatTokens(count: number): string {
|
|
93
|
+
if (count < 1000) return count.toString();
|
|
94
|
+
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
|
|
95
|
+
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
96
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatUsageStats(usage: UsageStats, model?: string): string {
|
|
100
|
+
const parts: string[] = [];
|
|
101
|
+
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
102
|
+
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
103
|
+
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
104
|
+
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
105
|
+
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
106
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
107
|
+
if (usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
108
|
+
if (model) parts.push(model);
|
|
109
|
+
return parts.join(" ");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve how to spawn the subagent process. Running through the current
|
|
114
|
+
* entry script (when available) keeps model/tool/extension config identical
|
|
115
|
+
* to the parent; otherwise fall back to the `pi` binary on PATH.
|
|
116
|
+
*/
|
|
117
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
118
|
+
const currentScript = process.argv[1];
|
|
119
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
120
|
+
if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
|
|
121
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
122
|
+
}
|
|
123
|
+
const execName = basename(process.execPath).toLowerCase();
|
|
124
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
125
|
+
if (!isGenericRuntime) return { command: process.execPath, args };
|
|
126
|
+
return { command: "pi", args };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function writePromptToTempFile(agentName: string, prompt: string): Promise<string> {
|
|
130
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-spawn-agent-"));
|
|
131
|
+
const safeName = agentName.replaceAll(/[^\w.-]+/g, "_");
|
|
132
|
+
const filePath = join(dir, `prompt-${safeName}.md`);
|
|
133
|
+
await withFileMutationQueue(filePath, async () => {
|
|
134
|
+
await writeFile(filePath, prompt, { encoding: "utf8", mode: 0o600 });
|
|
135
|
+
});
|
|
136
|
+
return filePath;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function buildSubagentArgs(
|
|
140
|
+
agent: AgentConfig,
|
|
141
|
+
task: string,
|
|
142
|
+
systemPromptPath: string | undefined,
|
|
143
|
+
): string[] {
|
|
144
|
+
// --mode json: emit events as JSON lines; -p: single-shot answer;
|
|
145
|
+
// --no-session: ephemeral, do not persist. --no-extensions keeps the
|
|
146
|
+
// subagent clean (no recursive spawn_agent, no sandbox surprises).
|
|
147
|
+
const args: string[] = ["--mode", "json", "-p", "--no-session", "--no-extensions"];
|
|
148
|
+
// Thinking level rides on the model shorthand ("model:level"); it cannot be
|
|
149
|
+
// set without a model, so a level without a model is ignored.
|
|
150
|
+
const model =
|
|
151
|
+
agent.model !== undefined && agent.thinkingLevel !== undefined
|
|
152
|
+
? `${agent.model}:${agent.thinkingLevel}`
|
|
153
|
+
: agent.model;
|
|
154
|
+
if (model) args.push("--model", model);
|
|
155
|
+
// Read-only default unless the agent explicitly declares a toolset.
|
|
156
|
+
const tools = agent.tools ?? DEFAULT_TOOLS;
|
|
157
|
+
args.push("--tools", tools.join(","));
|
|
158
|
+
if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
|
|
159
|
+
args.push(`Task: ${task}`);
|
|
160
|
+
return args;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── subagent runner ──────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
|
|
166
|
+
|
|
167
|
+
export async function runAgent(
|
|
168
|
+
agent: AgentConfig,
|
|
169
|
+
task: string,
|
|
170
|
+
cwd: string,
|
|
171
|
+
signal: AbortSignal | undefined,
|
|
172
|
+
onUpdate: OnUpdateCallback | undefined,
|
|
173
|
+
): Promise<SubagentDetails> {
|
|
174
|
+
const result: SubagentDetails = {
|
|
175
|
+
agent: agent.name,
|
|
176
|
+
task,
|
|
177
|
+
exitCode: 0,
|
|
178
|
+
messages: [],
|
|
179
|
+
stderr: "",
|
|
180
|
+
usage: {
|
|
181
|
+
input: 0,
|
|
182
|
+
output: 0,
|
|
183
|
+
cacheRead: 0,
|
|
184
|
+
cacheWrite: 0,
|
|
185
|
+
cost: 0,
|
|
186
|
+
contextTokens: 0,
|
|
187
|
+
turns: 0,
|
|
188
|
+
},
|
|
189
|
+
model: agent.model,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
let tmpPromptPath: string | null = null;
|
|
193
|
+
try {
|
|
194
|
+
if (agent.systemPrompt.trim()) {
|
|
195
|
+
tmpPromptPath = await writePromptToTempFile(agent.name, agent.systemPrompt);
|
|
196
|
+
}
|
|
197
|
+
const args = buildSubagentArgs(agent, task, tmpPromptPath ?? undefined);
|
|
198
|
+
const invocation = getPiInvocation(args);
|
|
199
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
200
|
+
cwd,
|
|
201
|
+
shell: false,
|
|
202
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
203
|
+
// Mark the child as a subagent so extensions running inside it (e.g.
|
|
204
|
+
// bwrap's subagent policy) can recognize and treat it accordingly.
|
|
205
|
+
env: { ...process.env, PI_SUBAGENT_CHILD: "1" },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const emitUpdate = () => {
|
|
209
|
+
onUpdate?.({
|
|
210
|
+
content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
|
|
211
|
+
details: { ...result },
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
let buffer = "";
|
|
216
|
+
|
|
217
|
+
const processLine = (line: string) => {
|
|
218
|
+
if (!line.trim()) return;
|
|
219
|
+
let event: unknown;
|
|
220
|
+
try {
|
|
221
|
+
event = JSON.parse(line);
|
|
222
|
+
} catch {
|
|
223
|
+
return; // not a JSON event line
|
|
224
|
+
}
|
|
225
|
+
if (!isRecord(event)) return;
|
|
226
|
+
|
|
227
|
+
if (event.type === "message_end" && isRecord(event.message)) {
|
|
228
|
+
const msg = event.message as unknown as Message;
|
|
229
|
+
result.messages.push(msg);
|
|
230
|
+
if (msg.role === "assistant") {
|
|
231
|
+
result.usage.turns++;
|
|
232
|
+
const usage: Record<string, unknown> = isRecord(msg.usage) ? msg.usage : {};
|
|
233
|
+
result.usage.input += num(usage.input);
|
|
234
|
+
result.usage.output += num(usage.output);
|
|
235
|
+
result.usage.cacheRead += num(usage.cacheRead);
|
|
236
|
+
result.usage.cacheWrite += num(usage.cacheWrite);
|
|
237
|
+
result.usage.cost += num(isRecord(usage.cost) ? usage.cost.total : undefined);
|
|
238
|
+
result.usage.contextTokens = num(usage.totalTokens);
|
|
239
|
+
if (!result.model && typeof msg.model === "string") result.model = msg.model;
|
|
240
|
+
if (typeof msg.stopReason === "string") result.stopReason = msg.stopReason;
|
|
241
|
+
if (typeof msg.errorMessage === "string") result.errorMessage = msg.errorMessage;
|
|
242
|
+
}
|
|
243
|
+
emitUpdate();
|
|
244
|
+
} else if (event.type === "tool_result_end" && isRecord(event.message)) {
|
|
245
|
+
result.messages.push(event.message as unknown as Message);
|
|
246
|
+
emitUpdate();
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
proc.stdout.on("data", (data: Buffer) => {
|
|
251
|
+
buffer += data.toString();
|
|
252
|
+
const lines = buffer.split("\n");
|
|
253
|
+
buffer = lines.pop() ?? "";
|
|
254
|
+
for (const line of lines) processLine(line);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
proc.stderr.on("data", (data: Buffer) => {
|
|
258
|
+
result.stderr += data.toString();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
262
|
+
proc.on("close", (code) => {
|
|
263
|
+
if (buffer.trim()) processLine(buffer);
|
|
264
|
+
resolve(code ?? 0);
|
|
265
|
+
});
|
|
266
|
+
proc.on("error", () => resolve(1));
|
|
267
|
+
|
|
268
|
+
const kill = () => {
|
|
269
|
+
proc.kill("SIGTERM");
|
|
270
|
+
setTimeout(() => {
|
|
271
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
272
|
+
}, 5000);
|
|
273
|
+
};
|
|
274
|
+
if (signal) {
|
|
275
|
+
if (signal.aborted) kill();
|
|
276
|
+
else signal.addEventListener("abort", kill, { once: true });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
result.exitCode = exitCode;
|
|
281
|
+
return result;
|
|
282
|
+
} finally {
|
|
283
|
+
if (tmpPromptPath) {
|
|
284
|
+
try {
|
|
285
|
+
await rm(tmpPromptPath, { force: true });
|
|
286
|
+
await rm(dirname(tmpPromptPath), { recursive: true, force: true });
|
|
287
|
+
} catch {
|
|
288
|
+
// best-effort cleanup
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
295
|
+
return typeof v === "object" && v !== null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function num(v: unknown): number {
|
|
299
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Session entry customType used to mark the injected subagent list. */
|
|
303
|
+
export function formatAgentListSection(agents: AgentConfig[]): string {
|
|
304
|
+
const lines = agents.map((a) => `- \`${a.name}\`: ${a.description}`);
|
|
305
|
+
return [
|
|
306
|
+
"## Available subagents",
|
|
307
|
+
"",
|
|
308
|
+
"You can delegate tasks to the following subagent types by calling the `spawn_agent` tool with their name in the `agent` parameter:",
|
|
309
|
+
"",
|
|
310
|
+
...lines,
|
|
311
|
+
].join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── extension ────────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
export default function spawnAgent(pi: ExtensionAPI) {
|
|
317
|
+
// Discover the available subagent types once at extension startup. The
|
|
318
|
+
// extension owns this discovery: the model never has to guess agent names
|
|
319
|
+
// or read the agent directory itself. Editing ~/.pi/agent/agents/*.md
|
|
320
|
+
// requires /reload to take effect.
|
|
321
|
+
const agents = discoverAgents();
|
|
322
|
+
const agentListSection = agents.length > 0 ? formatAgentListSection(agents) : null;
|
|
323
|
+
|
|
324
|
+
if (agentListSection) {
|
|
325
|
+
// Same pattern as the bwrap extension: append the list to the system
|
|
326
|
+
// prompt on every agent start. The system prompt is rebuilt each turn
|
|
327
|
+
// anyway, so a persistent per-session injection would add no value.
|
|
328
|
+
pi.on("before_agent_start", (event) => {
|
|
329
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${agentListSection}` };
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
pi.registerTool<typeof spawnAgentSchema, SubagentDetails>({
|
|
334
|
+
name: "spawn_agent",
|
|
335
|
+
label: "spawn_agent",
|
|
336
|
+
description: [
|
|
337
|
+
"Delegate a task to a subagent running in a separate pi process with an isolated context window.",
|
|
338
|
+
"The `agent` parameter must be one of the available subagent types listed in the system prompt.",
|
|
339
|
+
`Subagents run read-only (${DEFAULT_TOOLS.join(", ")}) unless the agent declares an explicit toolset.`,
|
|
340
|
+
].join(" "),
|
|
341
|
+
parameters: spawnAgentSchema,
|
|
342
|
+
|
|
343
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
344
|
+
const agent = agents.find((a) => a.name === params.agent);
|
|
345
|
+
if (!agent) {
|
|
346
|
+
return {
|
|
347
|
+
content: [
|
|
348
|
+
{
|
|
349
|
+
type: "text",
|
|
350
|
+
text: `Unknown agent "${params.agent}". Available agents: ${formatAgentList(agents)}`,
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
details: {
|
|
354
|
+
agent: params.agent,
|
|
355
|
+
task: params.task,
|
|
356
|
+
exitCode: 1,
|
|
357
|
+
messages: [],
|
|
358
|
+
stderr: "",
|
|
359
|
+
usage: {
|
|
360
|
+
input: 0,
|
|
361
|
+
output: 0,
|
|
362
|
+
cacheRead: 0,
|
|
363
|
+
cacheWrite: 0,
|
|
364
|
+
cost: 0,
|
|
365
|
+
contextTokens: 0,
|
|
366
|
+
turns: 0,
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
isError: true,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const result = await runAgent(agent, params.task, ctx.cwd, signal, onUpdate);
|
|
374
|
+
|
|
375
|
+
const isError =
|
|
376
|
+
result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
377
|
+
if (isError) {
|
|
378
|
+
const reason =
|
|
379
|
+
result.stopReason ?? (result.exitCode === 0 ? "failed" : `exit ${result.exitCode}`);
|
|
380
|
+
const message =
|
|
381
|
+
result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
|
|
382
|
+
return {
|
|
383
|
+
content: [
|
|
384
|
+
{ type: "text", text: `Subagent "${result.agent}" failed (${reason}): ${message}` },
|
|
385
|
+
],
|
|
386
|
+
details: result,
|
|
387
|
+
isError: true,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const output = getFinalOutput(result.messages) || "(no output)";
|
|
392
|
+
const truncation = truncateTail(output, { maxBytes: MAX_OUTPUT_BYTES });
|
|
393
|
+
const text = truncation.truncated
|
|
394
|
+
? `${truncation.content}\n\n[Output truncated to ${formatTokens(truncation.content.length)} bytes. Full result preserved in tool details.]`
|
|
395
|
+
: output;
|
|
396
|
+
return { content: [{ type: "text", text }], details: result };
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
renderCall(args, theme) {
|
|
400
|
+
const preview = args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task;
|
|
401
|
+
let text = theme.fg("toolTitle", theme.bold("spawn_agent ")) + theme.fg("accent", args.agent);
|
|
402
|
+
text += `\n ${theme.fg("dim", preview)}`;
|
|
403
|
+
return new Text(text, 0, 0);
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
renderResult(result, { expanded }, theme) {
|
|
407
|
+
const details = result.details;
|
|
408
|
+
const isError =
|
|
409
|
+
details.exitCode !== 0 ||
|
|
410
|
+
details.stopReason === "error" ||
|
|
411
|
+
details.stopReason === "aborted";
|
|
412
|
+
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
413
|
+
const finalOutput = getFinalOutput(details.messages);
|
|
414
|
+
const usageStr = formatUsageStats(details.usage, details.model);
|
|
415
|
+
|
|
416
|
+
if (expanded) {
|
|
417
|
+
const container = new Container();
|
|
418
|
+
const header = `${icon} ${theme.fg("toolTitle", theme.bold(details.agent))}${
|
|
419
|
+
details.stopReason ? ` ${theme.fg("error", `[${details.stopReason}]`)}` : ""
|
|
420
|
+
}`;
|
|
421
|
+
container.addChild(new Text(header, 0, 0));
|
|
422
|
+
if (isError && details.errorMessage) {
|
|
423
|
+
container.addChild(new Text(theme.fg("error", `Error: ${details.errorMessage}`), 0, 0));
|
|
424
|
+
}
|
|
425
|
+
container.addChild(new Spacer(1));
|
|
426
|
+
container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
|
|
427
|
+
container.addChild(new Text(theme.fg("dim", details.task), 0, 0));
|
|
428
|
+
if (finalOutput) {
|
|
429
|
+
container.addChild(new Spacer(1));
|
|
430
|
+
container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
|
|
431
|
+
container.addChild(new Markdown(finalOutput.trim(), 0, 0, getMarkdownTheme()));
|
|
432
|
+
}
|
|
433
|
+
if (usageStr) {
|
|
434
|
+
container.addChild(new Spacer(1));
|
|
435
|
+
container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
|
|
436
|
+
}
|
|
437
|
+
return container;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let text = `${icon} ${theme.fg("toolTitle", theme.bold(details.agent))}`;
|
|
441
|
+
if (isError && details.errorMessage) {
|
|
442
|
+
text += `\n${theme.fg("error", `Error: ${details.errorMessage}`)}`;
|
|
443
|
+
} else if (finalOutput) {
|
|
444
|
+
text += `\n${theme.fg("toolOutput", finalOutput.split("\n").slice(0, 5).join("\n"))}`;
|
|
445
|
+
} else {
|
|
446
|
+
text += `\n${theme.fg("muted", "(no output)")}`;
|
|
447
|
+
}
|
|
448
|
+
if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
|
|
449
|
+
return new Text(text, 0, 0);
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
}
|