@psnext/s-subagents 0.1.20260522-1
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/.slingshot/agentHooks.json +24 -0
- package/.slingshot/code-tagging/51adc022-context.json +15 -0
- package/.slingshot/code-tagging/51adc022-lock.json +101 -0
- package/.slingshot/code-tagging/5a95fab8-lock.json +87 -0
- package/.slingshot/prompts/sample.prompt.md +118 -0
- package/.slingshot/prompts_cache.json +1 -0
- package/.slingshot/skills_cache.json +1 -0
- package/.vscode/settings.json +3 -0
- package/README.md +164 -0
- package/agents/researcher.md +47 -0
- package/agents/scout.md +36 -0
- package/agents/worker.md +71 -0
- package/index.ts +932 -0
- package/package.json +10 -0
- package/tools/safe-bash.ts +60 -0
- package/tsconfig.json +13 -0
package/index.ts
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal subagents extension.
|
|
3
|
+
*
|
|
4
|
+
* Registers a single `subagent` tool with three agents: scout, researcher, worker.
|
|
5
|
+
* Supports single and parallel execution. Output is verbal only (no file handoff).
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { getMarkdownTheme, parseFrontmatter, truncateHead, withFileMutationQueue, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { Container, Markdown, Spacer, Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
14
|
+
import { Type } from "@sinclair/typebox";
|
|
15
|
+
|
|
16
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface AgentConfig {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
tools: string[];
|
|
22
|
+
model: string;
|
|
23
|
+
thinking: string;
|
|
24
|
+
systemPrompt: string;
|
|
25
|
+
filePath: string;
|
|
26
|
+
/**
|
|
27
|
+
* If this agent has the `subagent` tool, restrict which agents it may spawn.
|
|
28
|
+
* Passed to the child pi process via `PI_SUBAGENT_ALLOWED` so the child's
|
|
29
|
+
* subagents extension filters its own registry before exposing it to the LLM.
|
|
30
|
+
* `undefined` means no restriction (child sees every registered agent).
|
|
31
|
+
*/
|
|
32
|
+
subagentAgents?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ToolEvent {
|
|
36
|
+
tool: string;
|
|
37
|
+
args: string;
|
|
38
|
+
/** Matches the producing tool_execution_start/update/end event. */
|
|
39
|
+
toolCallId?: string;
|
|
40
|
+
/**
|
|
41
|
+
* "running" while between tool_execution_start and tool_execution_end; flipped
|
|
42
|
+
* to "done" on end. We store every in-flight call in recentTools (keyed by
|
|
43
|
+
* toolCallId) rather than a single current-tool slot, because pi-agent-core
|
|
44
|
+
* dispatches a turn's tool calls in parallel via Promise.all — a single slot
|
|
45
|
+
* would let the second start overwrite the first.
|
|
46
|
+
*/
|
|
47
|
+
status: "running" | "done";
|
|
48
|
+
/**
|
|
49
|
+
* Live progress of subagents spawned by this tool call. Populated only for
|
|
50
|
+
* `subagent` tool calls, from the `partialResult.details.results` payload of
|
|
51
|
+
* `tool_execution_update` events (and refreshed once more from the end
|
|
52
|
+
* event's final results). Recursive: each child's own progress may carry
|
|
53
|
+
* further children via its `recentTools[i].children`.
|
|
54
|
+
*/
|
|
55
|
+
children?: AgentResult[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface AgentProgress {
|
|
59
|
+
agent: string;
|
|
60
|
+
status: "pending" | "running" | "completed" | "failed";
|
|
61
|
+
task: string;
|
|
62
|
+
/**
|
|
63
|
+
* Chronological log of tool calls — running and done interleaved. The
|
|
64
|
+
* renderer prefixes running entries with `▸` and done ones with ` `.
|
|
65
|
+
*/
|
|
66
|
+
recentTools: ToolEvent[];
|
|
67
|
+
toolCount: number;
|
|
68
|
+
tokens: number;
|
|
69
|
+
durationMs: number;
|
|
70
|
+
lastMessage: string;
|
|
71
|
+
error?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface AgentResult {
|
|
75
|
+
agent: string;
|
|
76
|
+
task: string;
|
|
77
|
+
output: string;
|
|
78
|
+
exitCode: number;
|
|
79
|
+
progress: AgentProgress;
|
|
80
|
+
model?: string;
|
|
81
|
+
contextWindow?: number;
|
|
82
|
+
usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; turns: number };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface Details {
|
|
86
|
+
results: AgentResult[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Config ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
interface ExtensionConfig {
|
|
92
|
+
maxConcurrency?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const EXT_DIR = path.dirname(new URL(import.meta.url).pathname);
|
|
96
|
+
const AGENTS_DIR = path.join(EXT_DIR, "agents");
|
|
97
|
+
const TOOLS_DIR = path.join(EXT_DIR, "tools");
|
|
98
|
+
const CONFIG_PATH = path.join(EXT_DIR, "config.json");
|
|
99
|
+
const DEFAULT_MAX_CONCURRENCY = 4;
|
|
100
|
+
|
|
101
|
+
function loadConfig(): ExtensionConfig {
|
|
102
|
+
try {
|
|
103
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
104
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as ExtensionConfig;
|
|
105
|
+
}
|
|
106
|
+
} catch {}
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Built-in tools that pi provides natively (no extension needed)
|
|
111
|
+
const BUILTIN_TOOLS = new Set(["read", "write", "edit", "bash", "grep", "find", "ls"]);
|
|
112
|
+
|
|
113
|
+
// Custom tools that require loading an extension into the subagent process
|
|
114
|
+
const EXT_BASE = path.join(process.env.HOME || "~", ".sling", "agent", "extensions");
|
|
115
|
+
const CUSTOM_TOOL_EXTENSIONS: Record<string, string> = {
|
|
116
|
+
web_search: path.join(EXT_BASE, "web-search", "index.ts"),
|
|
117
|
+
web_fetch: path.join(EXT_BASE, "web-fetch", "index.ts"),
|
|
118
|
+
safe_bash: path.join(TOOLS_DIR, "safe-bash.ts"),
|
|
119
|
+
video_extract: path.join(EXT_BASE, "video-extract", "index.ts"),
|
|
120
|
+
youtube_search: path.join(EXT_BASE, "youtube-search", "index.ts"),
|
|
121
|
+
google_image_search: path.join(EXT_BASE, "google-image-search", "index.ts"),
|
|
122
|
+
// `subagent` is the tool this very extension registers. Listing it here lets
|
|
123
|
+
// a parent agent grant it to a child agent — the child pi process loads this
|
|
124
|
+
// same index.ts via `--extension`, sees its own subagent tool, and (if
|
|
125
|
+
// PI_SUBAGENT_ALLOWED is set) only registers the allowlisted agents.
|
|
126
|
+
subagent: path.join(EXT_DIR, "index.ts"),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// ── Agent Discovery & Registration ────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
let agents: AgentConfig[] = [];
|
|
132
|
+
|
|
133
|
+
// Read once at module load. If we're a child subagent process whose parent
|
|
134
|
+
// pinned an allowlist, we silently ignore any agent (built-in OR registered
|
|
135
|
+
// later by a third-party extension) that isn't in the list.
|
|
136
|
+
const SUBAGENT_ALLOWLIST: string[] | undefined = (() => {
|
|
137
|
+
const raw = process.env.PI_SUBAGENT_ALLOWED;
|
|
138
|
+
if (!raw) return undefined;
|
|
139
|
+
const list = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
140
|
+
return list.length > 0 ? list : undefined;
|
|
141
|
+
})();
|
|
142
|
+
|
|
143
|
+
export function registerAgent(config: AgentConfig): void {
|
|
144
|
+
if (SUBAGENT_ALLOWLIST && !SUBAGENT_ALLOWLIST.includes(config.name)) return;
|
|
145
|
+
if (agents.find((a) => a.name === config.name)) {
|
|
146
|
+
throw new Error(`Agent already registered: ${config.name}`);
|
|
147
|
+
}
|
|
148
|
+
agents.push(config);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function unregisterAgent(name: string): void {
|
|
152
|
+
agents = agents.filter((a) => a.name !== name);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Expose registration functions globally so other extensions loaded via jiti
|
|
156
|
+
// (which creates separate module instances) can access the shared agents array.
|
|
157
|
+
(globalThis as any).__pi_subagents = { registerAgent, unregisterAgent };
|
|
158
|
+
|
|
159
|
+
function loadAgents(): AgentConfig[] {
|
|
160
|
+
const agents: AgentConfig[] = [];
|
|
161
|
+
if (!fs.existsSync(AGENTS_DIR)) return agents;
|
|
162
|
+
for (const entry of fs.readdirSync(AGENTS_DIR)) {
|
|
163
|
+
if (!entry.endsWith(".md")) continue;
|
|
164
|
+
const filePath = path.join(AGENTS_DIR, entry);
|
|
165
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
166
|
+
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
|
|
167
|
+
if (!frontmatter.name) continue;
|
|
168
|
+
const tools = (frontmatter.tools || "")
|
|
169
|
+
.split(",")
|
|
170
|
+
.map((t) => t.trim())
|
|
171
|
+
.filter(Boolean);
|
|
172
|
+
const rawSubagentAgents = (frontmatter as Record<string, string>).subagent_agents;
|
|
173
|
+
const subagentAgents = rawSubagentAgents
|
|
174
|
+
? rawSubagentAgents.split(",").map((t) => t.trim()).filter(Boolean)
|
|
175
|
+
: undefined;
|
|
176
|
+
agents.push({
|
|
177
|
+
name: frontmatter.name,
|
|
178
|
+
description: frontmatter.description || "",
|
|
179
|
+
tools,
|
|
180
|
+
model: frontmatter.model || "claude-sonnet-4-5@20250929",
|
|
181
|
+
thinking: frontmatter.thinking || "medium",
|
|
182
|
+
systemPrompt: body,
|
|
183
|
+
filePath,
|
|
184
|
+
subagentAgents,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return agents;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Pi Binary Resolution ──────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
function resolvePiBinary(): { command: string; baseArgs: string[] } {
|
|
193
|
+
// Resolve the pi entry point from process.argv[1]
|
|
194
|
+
const entry = process.argv[1];
|
|
195
|
+
if (entry) {
|
|
196
|
+
try {
|
|
197
|
+
const realEntry = fs.realpathSync(entry);
|
|
198
|
+
if (/\.(?:mjs|cjs|js)$/i.test(realEntry)) {
|
|
199
|
+
return { command: process.execPath, baseArgs: [realEntry] };
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
return { command: "sling", baseArgs: [] };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Formatting Utilities ──────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
function formatTokens(n: number): string {
|
|
209
|
+
return n < 1000 ? String(n) : n < 10000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function formatDuration(ms: number): string {
|
|
213
|
+
if (ms < 1000) return `${ms}ms`;
|
|
214
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
215
|
+
return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function formatContextUsage(tokens: number, contextWindow: number | undefined): string {
|
|
219
|
+
if (!contextWindow) return `${formatTokens(tokens)} ctx`;
|
|
220
|
+
const pct = (tokens / contextWindow) * 100;
|
|
221
|
+
const maxStr = contextWindow >= 1_000_000 ? `${(contextWindow / 1_000_000).toFixed(1)}M` : `${Math.round(contextWindow / 1000)}k`;
|
|
222
|
+
return `${pct.toFixed(1)}%/${maxStr}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function formatToolPreview(name: string, args: Record<string, unknown>): string {
|
|
226
|
+
switch (name) {
|
|
227
|
+
case "bash":
|
|
228
|
+
case "safe_bash":
|
|
229
|
+
return `$ ${((args.command as string) || "").slice(0, 80)}`;
|
|
230
|
+
case "read":
|
|
231
|
+
return `read ${(args.path as string) || ""}`;
|
|
232
|
+
case "write":
|
|
233
|
+
return `write ${(args.path as string) || ""}`;
|
|
234
|
+
case "edit":
|
|
235
|
+
return `edit ${(args.path as string) || ""}`;
|
|
236
|
+
case "grep":
|
|
237
|
+
return `grep ${(args.pattern as string) || ""}`;
|
|
238
|
+
case "find":
|
|
239
|
+
return `find ${(args.pattern as string) || ""}`;
|
|
240
|
+
case "ls":
|
|
241
|
+
return `ls ${(args.path as string) || "."}`;
|
|
242
|
+
case "web_search":
|
|
243
|
+
return `search "${(args.query as string) || ""}"`;
|
|
244
|
+
case "web_fetch":
|
|
245
|
+
return `fetch ${(args.url as string) || ""}`;
|
|
246
|
+
default: {
|
|
247
|
+
const s = JSON.stringify(args);
|
|
248
|
+
return `${name} ${s.slice(0, 60)}`;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function truncLine(text: string, maxWidth: number): string {
|
|
254
|
+
// Collapse embedded newlines first so we render exactly one visible line.
|
|
255
|
+
// We can't strip them inside `text` directly (would also touch ANSI escapes
|
|
256
|
+
// like "\x1b[0m"), so we only target literal \r and \n outside of escapes.
|
|
257
|
+
if (text.includes("\n") || text.includes("\r")) {
|
|
258
|
+
text = text.replace(/\r?\n/g, "↵ ");
|
|
259
|
+
}
|
|
260
|
+
if (visibleWidth(text) <= maxWidth) return text;
|
|
261
|
+
// Simple truncation - strip to fit
|
|
262
|
+
let result = "";
|
|
263
|
+
let width = 0;
|
|
264
|
+
for (let i = 0; i < text.length; i++) {
|
|
265
|
+
const ch = text[i];
|
|
266
|
+
// Skip ANSI escape sequences
|
|
267
|
+
if (ch === "\x1b") {
|
|
268
|
+
const match = text.slice(i).match(/^\x1b\[[0-9;]*m/);
|
|
269
|
+
if (match) {
|
|
270
|
+
result += match[0];
|
|
271
|
+
i += match[0].length - 1;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (width >= maxWidth - 1) {
|
|
276
|
+
return result + "…";
|
|
277
|
+
}
|
|
278
|
+
result += ch;
|
|
279
|
+
width++;
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Subagent Execution ────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
async function buildPiArgs(
|
|
287
|
+
agent: AgentConfig,
|
|
288
|
+
task: string,
|
|
289
|
+
cwd: string,
|
|
290
|
+
): Promise<{ args: string[]; tempDir: string; childEnv: NodeJS.ProcessEnv | undefined }> {
|
|
291
|
+
const piBin = resolvePiBinary();
|
|
292
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-sub-"));
|
|
293
|
+
|
|
294
|
+
// Write system prompt to temp file
|
|
295
|
+
const promptPath = path.join(tempDir, `${agent.name}.md`);
|
|
296
|
+
await withFileMutationQueue(promptPath, async () => {
|
|
297
|
+
await fs.promises.writeFile(promptPath, agent.systemPrompt, { encoding: "utf-8", mode: 0o600 });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const args = [...piBin.baseArgs, "--mode", "json", "-p", "--no-session", "--no-skills"];
|
|
301
|
+
|
|
302
|
+
// Separate builtin tools from custom tools. Both kinds share the same
|
|
303
|
+
// --tools allowlist in pi; --no-tools would disable extension tools too.
|
|
304
|
+
const allowlist: string[] = [];
|
|
305
|
+
const extensionPaths = new Set<string>();
|
|
306
|
+
|
|
307
|
+
for (const tool of agent.tools) {
|
|
308
|
+
if (BUILTIN_TOOLS.has(tool)) {
|
|
309
|
+
allowlist.push(tool);
|
|
310
|
+
} else if (CUSTOM_TOOL_EXTENSIONS[tool]) {
|
|
311
|
+
allowlist.push(tool);
|
|
312
|
+
extensionPaths.add(CUSTOM_TOOL_EXTENSIONS[tool]);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Use --no-extensions then add only what we need
|
|
317
|
+
args.push("--no-extensions");
|
|
318
|
+
|
|
319
|
+
if (allowlist.length > 0) {
|
|
320
|
+
// --tools is a unified allowlist that applies to built-in, extension, and custom tools.
|
|
321
|
+
args.push("--tools", allowlist.join(","));
|
|
322
|
+
} else {
|
|
323
|
+
// Agent declared no tools — disable everything.
|
|
324
|
+
args.push("--no-tools");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
for (const extPath of extensionPaths) {
|
|
328
|
+
args.push("--extension", extPath);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
args.push("--models", agent.model);
|
|
332
|
+
args.push("--thinking", agent.thinking);
|
|
333
|
+
args.push("--append-system-prompt", promptPath);
|
|
334
|
+
|
|
335
|
+
// Handle long tasks by writing to file
|
|
336
|
+
const TASK_LIMIT = 8000;
|
|
337
|
+
if (task.length > TASK_LIMIT) {
|
|
338
|
+
const taskPath = path.join(tempDir, "task.md");
|
|
339
|
+
await withFileMutationQueue(taskPath, async () => {
|
|
340
|
+
await fs.promises.writeFile(taskPath, `Task: ${task}`, { encoding: "utf-8", mode: 0o600 });
|
|
341
|
+
});
|
|
342
|
+
args.push(`@${taskPath}`);
|
|
343
|
+
} else {
|
|
344
|
+
args.push(`Task: ${task}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// If this agent is allowed to spawn subagents AND we want to restrict which
|
|
348
|
+
// ones, pass the allowlist down via env. The child pi process loads this
|
|
349
|
+
// extension and filters its agent registry before exposing tool descriptions
|
|
350
|
+
// to the LLM — so the child literally cannot request an agent outside the
|
|
351
|
+
// allowlist (the name isn't in its prompt).
|
|
352
|
+
let childEnv: NodeJS.ProcessEnv | undefined;
|
|
353
|
+
if (agent.tools.includes("subagent") && agent.subagentAgents && agent.subagentAgents.length > 0) {
|
|
354
|
+
childEnv = { ...process.env, PI_SUBAGENT_ALLOWED: agent.subagentAgents.join(",") };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return { args: [piBin.command, ...args], tempDir, childEnv };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function extractTextFromContent(content: unknown): string {
|
|
361
|
+
if (!content) return "";
|
|
362
|
+
if (typeof content === "string") return content;
|
|
363
|
+
if (Array.isArray(content)) {
|
|
364
|
+
return content
|
|
365
|
+
.filter((c: any) => c.type === "text")
|
|
366
|
+
.map((c: any) => c.text)
|
|
367
|
+
.join("\n");
|
|
368
|
+
}
|
|
369
|
+
return "";
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Collapse any whitespace run (incl. newlines) into a single space. Used to
|
|
373
|
+
* keep tool-arg previews to one renderable line in collapsed view. */
|
|
374
|
+
function flatten(s: string): string {
|
|
375
|
+
return s.replace(/\s+/g, " ").trim();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Per-event hard cap on stored arg previews. Even in expanded view we don't
|
|
379
|
+
// want a 50KB bash heredoc sitting in memory per tool call across last-20
|
|
380
|
+
// `recentTools` slots per agent across N agents. A few KB covers any realistic
|
|
381
|
+
// command; anything longer is almost certainly a generated payload the user
|
|
382
|
+
// doesn't need to read inline anyway.
|
|
383
|
+
const MAX_ARG_PREVIEW = 4000;
|
|
384
|
+
|
|
385
|
+
function extractToolArgsPreview(args: Record<string, unknown>): string {
|
|
386
|
+
const cap = (s: string) => (s.length > MAX_ARG_PREVIEW ? s.slice(0, MAX_ARG_PREVIEW) + "…" : s);
|
|
387
|
+
if (args.command) return cap(flatten(String(args.command)));
|
|
388
|
+
if (args.path) return cap(flatten(String(args.path)));
|
|
389
|
+
if (args.query) return `"${cap(flatten(String(args.query)))}"`;
|
|
390
|
+
if (args.url) return cap(flatten(String(args.url)));
|
|
391
|
+
if (args.pattern) return cap(flatten(String(args.pattern)));
|
|
392
|
+
// `subagent` tool args: show which agent(s) it's calling, not the full task body.
|
|
393
|
+
if (args.agent) return flatten(String(args.agent));
|
|
394
|
+
if (Array.isArray(args.tasks)) {
|
|
395
|
+
const names = (args.tasks as Array<{ agent?: string }>)
|
|
396
|
+
.map((t) => t?.agent || "?")
|
|
397
|
+
.join(", ");
|
|
398
|
+
return `parallel(${names})`;
|
|
399
|
+
}
|
|
400
|
+
return cap(flatten(JSON.stringify(args)));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function runSubagent(
|
|
404
|
+
agent: AgentConfig,
|
|
405
|
+
task: string,
|
|
406
|
+
cwd: string,
|
|
407
|
+
signal: AbortSignal | undefined,
|
|
408
|
+
onUpdate?: (progress: AgentProgress, usage: AgentResult["usage"]) => void,
|
|
409
|
+
): Promise<AgentResult> {
|
|
410
|
+
const { args, tempDir, childEnv } = await buildPiArgs(agent, task, cwd);
|
|
411
|
+
const command = args[0];
|
|
412
|
+
const spawnArgs = args.slice(1);
|
|
413
|
+
|
|
414
|
+
const result: AgentResult = {
|
|
415
|
+
agent: agent.name,
|
|
416
|
+
task,
|
|
417
|
+
output: "",
|
|
418
|
+
exitCode: 0,
|
|
419
|
+
model: agent.model,
|
|
420
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
|
|
421
|
+
progress: {
|
|
422
|
+
agent: agent.name,
|
|
423
|
+
status: "running",
|
|
424
|
+
task,
|
|
425
|
+
recentTools: [],
|
|
426
|
+
toolCount: 0,
|
|
427
|
+
tokens: 0,
|
|
428
|
+
durationMs: 0,
|
|
429
|
+
lastMessage: "",
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const startTime = Date.now();
|
|
434
|
+
const progress = result.progress;
|
|
435
|
+
|
|
436
|
+
const fireUpdate = throttle(() => {
|
|
437
|
+
progress.durationMs = Date.now() - startTime;
|
|
438
|
+
onUpdate?.(progress, result.usage);
|
|
439
|
+
}, 150);
|
|
440
|
+
|
|
441
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
442
|
+
const proc = spawn(command, spawnArgs, {
|
|
443
|
+
cwd,
|
|
444
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
445
|
+
...(childEnv ? { env: childEnv } : {}),
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
let buf = "";
|
|
449
|
+
let stderrBuf = "";
|
|
450
|
+
|
|
451
|
+
const processLine = (line: string) => {
|
|
452
|
+
if (!line.trim()) return;
|
|
453
|
+
try {
|
|
454
|
+
const evt = JSON.parse(line) as any;
|
|
455
|
+
progress.durationMs = Date.now() - startTime;
|
|
456
|
+
|
|
457
|
+
if (evt.type === "tool_execution_start") {
|
|
458
|
+
progress.toolCount++;
|
|
459
|
+
progress.recentTools.push({
|
|
460
|
+
tool: evt.toolName,
|
|
461
|
+
args: extractToolArgsPreview((evt.args || {}) as Record<string, unknown>),
|
|
462
|
+
toolCallId: evt.toolCallId,
|
|
463
|
+
status: "running",
|
|
464
|
+
});
|
|
465
|
+
fireUpdate();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Subagents emit `tool_execution_update` while their own subagent tool
|
|
469
|
+
// runs — the partial result carries the live nested AgentResult[]. We
|
|
470
|
+
// surface that as `children` on the in-flight ToolEvent so the renderer
|
|
471
|
+
// can inline grandchild activity beneath the parent's tool row.
|
|
472
|
+
if (evt.type === "tool_execution_update") {
|
|
473
|
+
const partial = evt.partialResult as { details?: { results?: unknown } } | undefined;
|
|
474
|
+
const nested = partial?.details?.results;
|
|
475
|
+
if (evt.toolName === "subagent" && Array.isArray(nested) && evt.toolCallId) {
|
|
476
|
+
const hit = progress.recentTools.find((t) => t.toolCallId === evt.toolCallId);
|
|
477
|
+
if (hit) {
|
|
478
|
+
hit.children = nested as AgentResult[];
|
|
479
|
+
fireUpdate();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (evt.type === "tool_execution_end") {
|
|
485
|
+
const hit = evt.toolCallId
|
|
486
|
+
? progress.recentTools.find((t) => t.toolCallId === evt.toolCallId)
|
|
487
|
+
: undefined;
|
|
488
|
+
if (hit) {
|
|
489
|
+
hit.status = "done";
|
|
490
|
+
// Prefer the end event's final results over the last throttled
|
|
491
|
+
// update — throttling can drop the trailing update, leaving stale
|
|
492
|
+
// children visible on a tool that has actually completed.
|
|
493
|
+
const finalResult = evt.result as { details?: { results?: unknown } } | undefined;
|
|
494
|
+
const finalChildren = finalResult?.details?.results;
|
|
495
|
+
if (evt.toolName === "subagent" && Array.isArray(finalChildren)) {
|
|
496
|
+
hit.children = finalChildren as AgentResult[];
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
fireUpdate();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (evt.type === "tool_result_end") {
|
|
503
|
+
fireUpdate();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (evt.type === "message_end" && evt.message) {
|
|
507
|
+
if (evt.message.role === "assistant") {
|
|
508
|
+
result.usage.turns++;
|
|
509
|
+
const u = evt.message.usage;
|
|
510
|
+
if (u) {
|
|
511
|
+
result.usage.input += u.input || 0;
|
|
512
|
+
result.usage.output += u.output || 0;
|
|
513
|
+
result.usage.cacheRead += u.cacheRead || 0;
|
|
514
|
+
result.usage.cacheWrite += u.cacheWrite || 0;
|
|
515
|
+
result.usage.cost += u.cost?.total || 0;
|
|
516
|
+
// Context-window gauge: snapshot of the LATEST assistant turn's usage,
|
|
517
|
+
// NOT a cumulative sum across turns. Each turn re-sends the whole
|
|
518
|
+
// conversation as input + cacheRead, so one assistant message already
|
|
519
|
+
// represents the current context size. Summing across N turns would
|
|
520
|
+
// inflate the displayed % by roughly Nx (the bug this replaced).
|
|
521
|
+
// Matches pi's `calculateContextTokens` in core/compaction/compaction.js:
|
|
522
|
+
// prefer the provider-reported totalTokens, fall back to the 4-component sum.
|
|
523
|
+
progress.tokens = (u as { totalTokens?: number }).totalTokens
|
|
524
|
+
|| (u.input || 0) + (u.output || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0);
|
|
525
|
+
}
|
|
526
|
+
if (evt.message.model) result.model = evt.message.model;
|
|
527
|
+
if (evt.message.errorMessage) progress.error = evt.message.errorMessage;
|
|
528
|
+
|
|
529
|
+
const text = extractTextFromContent(evt.message.content);
|
|
530
|
+
if (text) {
|
|
531
|
+
result.output = text;
|
|
532
|
+
// Extract just the prose "thinking" text — skip code blocks
|
|
533
|
+
const proseLines: string[] = [];
|
|
534
|
+
let inCodeBlock = false;
|
|
535
|
+
for (const line of text.split("\n")) {
|
|
536
|
+
if (line.trimStart().startsWith("```")) {
|
|
537
|
+
inCodeBlock = !inCodeBlock;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (!inCodeBlock && line.trim()) {
|
|
541
|
+
proseLines.push(line.trim());
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (proseLines.length > 0) {
|
|
545
|
+
progress.lastMessage = proseLines.slice(0, 3).join(" ");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
fireUpdate();
|
|
551
|
+
}
|
|
552
|
+
} catch {
|
|
553
|
+
// Non-JSON lines are expected
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
proc.stdout.on("data", (d: Buffer) => {
|
|
558
|
+
buf += d.toString();
|
|
559
|
+
const lines = buf.split("\n");
|
|
560
|
+
buf = lines.pop() || "";
|
|
561
|
+
lines.forEach(processLine);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
proc.stderr.on("data", (d: Buffer) => {
|
|
565
|
+
stderrBuf += d.toString();
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
proc.on("close", (code) => {
|
|
569
|
+
if (buf.trim()) processLine(buf);
|
|
570
|
+
if (code !== 0 && stderrBuf.trim() && !progress.error) {
|
|
571
|
+
progress.error = stderrBuf.trim();
|
|
572
|
+
}
|
|
573
|
+
resolve(code ?? 1);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
proc.on("error", () => resolve(1));
|
|
577
|
+
|
|
578
|
+
if (signal) {
|
|
579
|
+
const kill = () => {
|
|
580
|
+
proc.kill("SIGTERM");
|
|
581
|
+
setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
|
|
582
|
+
};
|
|
583
|
+
if (signal.aborted) kill();
|
|
584
|
+
else signal.addEventListener("abort", kill, { once: true });
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// Cleanup temp dir
|
|
589
|
+
try {
|
|
590
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
591
|
+
} catch {}
|
|
592
|
+
|
|
593
|
+
result.exitCode = exitCode;
|
|
594
|
+
progress.status = exitCode === 0 && !progress.error ? "completed" : "failed";
|
|
595
|
+
progress.durationMs = Date.now() - startTime;
|
|
596
|
+
if (progress.error) result.output = result.output || `Error: ${progress.error}`;
|
|
597
|
+
|
|
598
|
+
// Truncate output if very large
|
|
599
|
+
if (result.output.length > DEFAULT_MAX_BYTES) {
|
|
600
|
+
const trunc = truncateHead(result.output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
601
|
+
result.output = trunc.content;
|
|
602
|
+
if (trunc.truncated) {
|
|
603
|
+
result.output += "\n\n[Output truncated]";
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return result;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ── Throttle ──────────────────────────────────────────────────────────
|
|
611
|
+
|
|
612
|
+
function throttle<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
|
613
|
+
let lastCall = 0;
|
|
614
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
615
|
+
return ((...args: any[]) => {
|
|
616
|
+
const now = Date.now();
|
|
617
|
+
const remaining = ms - (now - lastCall);
|
|
618
|
+
if (remaining <= 0) {
|
|
619
|
+
lastCall = now;
|
|
620
|
+
if (timer) { clearTimeout(timer); timer = undefined; }
|
|
621
|
+
fn(...args);
|
|
622
|
+
} else if (!timer) {
|
|
623
|
+
timer = setTimeout(() => {
|
|
624
|
+
lastCall = Date.now();
|
|
625
|
+
timer = undefined;
|
|
626
|
+
fn(...args);
|
|
627
|
+
}, remaining);
|
|
628
|
+
}
|
|
629
|
+
}) as T;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ── Parallel Execution with Concurrency Limit ─────────────────────────
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Process-wide cap on simultaneous `runSubagent` calls. Each `execute()` of the
|
|
636
|
+
* `subagent` tool is independent (pi runs LLM tool calls via `Promise.all`), so
|
|
637
|
+
* we serialize at the `runSubagent` boundary. Per-process scope only — nested
|
|
638
|
+
* subagent processes have their own semaphore, so the cap applies to direct
|
|
639
|
+
* children, not the whole tree (which keeps things deadlock-free).
|
|
640
|
+
*/
|
|
641
|
+
class Semaphore {
|
|
642
|
+
private inFlight = 0;
|
|
643
|
+
private readonly waiters: Array<() => void> = [];
|
|
644
|
+
constructor(private readonly max: number) {}
|
|
645
|
+
async run<T>(fn: () => Promise<T>): Promise<T> {
|
|
646
|
+
if (this.inFlight >= this.max) {
|
|
647
|
+
await new Promise<void>((r) => this.waiters.push(r));
|
|
648
|
+
}
|
|
649
|
+
this.inFlight++;
|
|
650
|
+
try {
|
|
651
|
+
return await fn();
|
|
652
|
+
} finally {
|
|
653
|
+
this.inFlight--;
|
|
654
|
+
const next = this.waiters.shift();
|
|
655
|
+
if (next) next();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ── Rendering ─────────────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
type Theme = ExtensionContext["ui"]["theme"];
|
|
663
|
+
type Component = ReturnType<typeof Text.prototype.render> extends string[] ? Text : any;
|
|
664
|
+
|
|
665
|
+
function getTermWidth(): number {
|
|
666
|
+
return process.stdout.columns || 120;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function renderAgentProgress(
|
|
670
|
+
r: AgentResult,
|
|
671
|
+
theme: Theme,
|
|
672
|
+
expanded: boolean,
|
|
673
|
+
w: number,
|
|
674
|
+
depth: number = 0,
|
|
675
|
+
): Container {
|
|
676
|
+
const c = new Container();
|
|
677
|
+
const prog = r.progress;
|
|
678
|
+
const isRunning = prog.status === "running";
|
|
679
|
+
const isPending = prog.status === "pending";
|
|
680
|
+
const nested = depth > 0;
|
|
681
|
+
|
|
682
|
+
// Indent prefix for nested levels. ANSI escapes are zero-width so this works
|
|
683
|
+
// with colored content. Children are visually offset by 2 spaces per depth.
|
|
684
|
+
const indent = nested ? " ".repeat(depth) : "";
|
|
685
|
+
// Available width shrinks with indent so truncLine still fits one line.
|
|
686
|
+
const innerW = Math.max(20, w - indent.length);
|
|
687
|
+
|
|
688
|
+
// `line(content)`: emit one indented, optionally-truncated row.
|
|
689
|
+
// In expanded mode we still indent but don't truncate — the Text component
|
|
690
|
+
// wraps and we want every wrapped line to share the same left margin, so we
|
|
691
|
+
// keep the indent as a hard prefix on the first line only (pi-tui Text
|
|
692
|
+
// doesn't expose a per-line gutter). Wrapping at depth is rare anyway since
|
|
693
|
+
// the lines that wrap (lastMessage, full output) only render at depth 0.
|
|
694
|
+
const addLine = (content: string) => {
|
|
695
|
+
if (expanded) {
|
|
696
|
+
c.addChild(new Text(indent + content, 0, 0));
|
|
697
|
+
} else {
|
|
698
|
+
c.addChild(new Text(indent + truncLine(content, innerW), 0, 0));
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
// Header: icon + agent + stats (always one line)
|
|
703
|
+
const icon = isRunning
|
|
704
|
+
? theme.fg("warning", "⟳")
|
|
705
|
+
: isPending
|
|
706
|
+
? theme.fg("dim", "○")
|
|
707
|
+
: r.exitCode === 0
|
|
708
|
+
? theme.fg("success", "✓")
|
|
709
|
+
: theme.fg("error", "✗");
|
|
710
|
+
const stats = `${prog.toolCount} tools · ${formatDuration(prog.durationMs)}`;
|
|
711
|
+
const modelStr = r.model ? theme.fg("dim", ` (${r.model})`) : "";
|
|
712
|
+
addLine(`${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${modelStr} — ${theme.fg("dim", stats)}`);
|
|
713
|
+
|
|
714
|
+
// NOTE: the task body used to be rendered here at depth 0 (truncated when
|
|
715
|
+
// collapsed, full when expanded). It's now owned by `renderCall` above this
|
|
716
|
+
// block in the same tool shell — the call header shows the truncated
|
|
717
|
+
// preview when collapsed and the full streaming prompt when expanded — so
|
|
718
|
+
// repeating it here would duplicate the prompt on screen. Nested children
|
|
719
|
+
// never rendered Task in the first place; the parent's recentTools row
|
|
720
|
+
// above each child already conveys the dispatch.
|
|
721
|
+
|
|
722
|
+
// Helper for rendering one tool row + recursively rendering its children.
|
|
723
|
+
const renderToolRow = (
|
|
724
|
+
toolName: string,
|
|
725
|
+
args: string,
|
|
726
|
+
children: AgentResult[] | undefined,
|
|
727
|
+
isCurrent: boolean,
|
|
728
|
+
) => {
|
|
729
|
+
const body = args ? `${toolName}: ${args}` : toolName;
|
|
730
|
+
if (isCurrent) {
|
|
731
|
+
addLine(theme.fg("warning", `▸ ${body}`));
|
|
732
|
+
} else {
|
|
733
|
+
addLine(theme.fg("muted", ` ${body}`));
|
|
734
|
+
}
|
|
735
|
+
if (children && children.length > 0) {
|
|
736
|
+
for (const child of children) {
|
|
737
|
+
c.addChild(renderAgentProgress(child, theme, expanded, w, depth + 1));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
// Tool log — running and done interleaved in chronological order. Running
|
|
743
|
+
// entries get the `▸` marker; done ones get a muted ` ` prefix. Children
|
|
744
|
+
// (live subagent activity) render inline beneath each row.
|
|
745
|
+
for (const t of prog.recentTools) {
|
|
746
|
+
renderToolRow(t.tool, t.args, t.children, t.status === "running");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Latest assistant message (prose "thinking"). Rendered at every depth so a
|
|
750
|
+
// nested subagent's current thought sits at the bottom of its own indented
|
|
751
|
+
// block, mirroring how the master box shows it under all tool rows. At depth
|
|
752
|
+
// 0 we precede it with a blank line for visual separation from the tool log;
|
|
753
|
+
// at depth>=1 we skip the spacer so the row stays grouped with the child's
|
|
754
|
+
// tool list above and doesn't break the visual run between sibling children.
|
|
755
|
+
if (prog.lastMessage) {
|
|
756
|
+
if (!nested) c.addChild(new Spacer(1));
|
|
757
|
+
addLine(theme.fg("text", prog.lastMessage));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Expanded final output — only at depth 0. Nested levels are summarized via
|
|
761
|
+
// their own tool list; the master-level result block is enough context.
|
|
762
|
+
if (!nested && !isRunning && r.output && expanded) {
|
|
763
|
+
c.addChild(new Spacer(1));
|
|
764
|
+
const mdTheme = getMarkdownTheme();
|
|
765
|
+
c.addChild(new Markdown(r.output, 0, 0, mdTheme));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Usage line. Includes the context %/max gauge at every depth — each
|
|
769
|
+
// subagent carries its own model/contextWindow and its own token count, so
|
|
770
|
+
// the gauge is meaningful per-row even for nested children.
|
|
771
|
+
if (!nested) c.addChild(new Spacer(1));
|
|
772
|
+
const usageParts: string[] = [];
|
|
773
|
+
if (r.usage.input) usageParts.push(theme.fg("dim", `↑${formatTokens(r.usage.input)}`));
|
|
774
|
+
if (r.usage.output) usageParts.push(theme.fg("dim", `↓${formatTokens(r.usage.output)}`));
|
|
775
|
+
if (r.usage.cacheRead) usageParts.push(theme.fg("dim", `R${formatTokens(r.usage.cacheRead)}`));
|
|
776
|
+
if (r.usage.cacheWrite) usageParts.push(theme.fg("dim", `W${formatTokens(r.usage.cacheWrite)}`));
|
|
777
|
+
if (r.usage.cost) usageParts.push(theme.fg("dim", `$${r.usage.cost.toFixed(3)}`));
|
|
778
|
+
if (prog.tokens > 0) {
|
|
779
|
+
const ctxStr = formatContextUsage(prog.tokens, r.contextWindow);
|
|
780
|
+
const pct = r.contextWindow ? (prog.tokens / r.contextWindow) * 100 : 0;
|
|
781
|
+
const coloredCtx = pct > 90 ? theme.fg("error", ctxStr) : pct > 70 ? theme.fg("warning", ctxStr) : theme.fg("dim", ctxStr);
|
|
782
|
+
usageParts.push(coloredCtx);
|
|
783
|
+
}
|
|
784
|
+
if (usageParts.length) {
|
|
785
|
+
addLine(usageParts.join(" "));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Error
|
|
789
|
+
if (prog.error) {
|
|
790
|
+
addLine(theme.fg("error", `Error: ${prog.error}`));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return c;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ── Extension ─────────────────────────────────────────────────────────
|
|
797
|
+
|
|
798
|
+
export default function (pi: ExtensionAPI) {
|
|
799
|
+
const config = loadConfig();
|
|
800
|
+
const semaphore = new Semaphore(config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY);
|
|
801
|
+
agents = loadAgents();
|
|
802
|
+
|
|
803
|
+
// If spawned as a child by a parent subagent process, PI_SUBAGENT_ALLOWED
|
|
804
|
+
// pins which agents we're allowed to expose. Filter the registry now, before
|
|
805
|
+
// any tool description sees the agent list — the child LLM should not even
|
|
806
|
+
// know that other agents exist.
|
|
807
|
+
if (SUBAGENT_ALLOWLIST) {
|
|
808
|
+
agents = agents.filter((a) => SUBAGENT_ALLOWLIST.includes(a.name));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
pi.registerTool({
|
|
812
|
+
name: "subagent",
|
|
813
|
+
label: "Subagent",
|
|
814
|
+
description:
|
|
815
|
+
"Run a subagent to complete a task. Subagents have NO context from the current conversation — include all necessary context in the task description.",
|
|
816
|
+
promptSnippet: "Run subagents for delegated tasks",
|
|
817
|
+
promptGuidelines: [
|
|
818
|
+
"Parallel tool calls are your primary parallelism mechanism — put multiple independent read/fetch/search calls in one function_calls block. Don't use subagents to parallelize simple I/O.",
|
|
819
|
+
"Use subagent to delegate *reasoning and decisions*: codebase exploration (scout), web research (researcher), or isolated code changes (worker)",
|
|
820
|
+
"For multiple independent subagent tasks, emit multiple `subagent` tool calls in the same turn — they run in parallel automatically.",
|
|
821
|
+
"Subagents have NO context from the current conversation — include ALL necessary context in the task description",
|
|
822
|
+
],
|
|
823
|
+
parameters: Type.Object({
|
|
824
|
+
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
825
|
+
task: Type.String({ description: "Task description" }),
|
|
826
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
827
|
+
}),
|
|
828
|
+
|
|
829
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
830
|
+
const cwd = ctx.cwd;
|
|
831
|
+
|
|
832
|
+
if (!params.agent || !params.task) {
|
|
833
|
+
throw new Error("`subagent` requires both `agent` and `task`. To fan out work, emit multiple `subagent` tool calls in the same turn — they run in parallel.");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const agent = agents.find((a) => a.name === params.agent);
|
|
837
|
+
if (!agent) {
|
|
838
|
+
const available = agents.map((a) => a.name).join(", ") || "none";
|
|
839
|
+
throw new Error(`Unknown agent: ${params.agent}. Available agents: ${available}`);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const [provider, modelId] = (agent.model || "").split("/");
|
|
843
|
+
const contextWindow = provider && modelId ? ctx.modelRegistry.find(provider, modelId)?.contextWindow : undefined;
|
|
844
|
+
const liveResult: AgentResult = {
|
|
845
|
+
agent: params.agent,
|
|
846
|
+
task: params.task,
|
|
847
|
+
output: "",
|
|
848
|
+
exitCode: -1,
|
|
849
|
+
model: agent.model,
|
|
850
|
+
contextWindow,
|
|
851
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
|
|
852
|
+
progress: { agent: params.agent, status: "running" as const, task: params.task, recentTools: [], toolCount: 0, tokens: 0, durationMs: 0, lastMessage: "" },
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
const result = await semaphore.run(() =>
|
|
856
|
+
runSubagent(agent, params.task!, params.cwd ?? cwd, signal, (progress, usage) => {
|
|
857
|
+
liveResult.progress = progress;
|
|
858
|
+
liveResult.usage = { ...usage };
|
|
859
|
+
onUpdate?.({
|
|
860
|
+
content: [{ type: "text", text: "(running...)" }],
|
|
861
|
+
details: { results: [liveResult] },
|
|
862
|
+
});
|
|
863
|
+
}),
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
result.contextWindow = contextWindow;
|
|
867
|
+
const isError = result.exitCode !== 0 || !!result.progress.error;
|
|
868
|
+
return {
|
|
869
|
+
content: [{ type: "text", text: result.output || "(no output)" }],
|
|
870
|
+
details: { results: [result] },
|
|
871
|
+
...(isError ? { isError: true } : {}),
|
|
872
|
+
};
|
|
873
|
+
},
|
|
874
|
+
|
|
875
|
+
// ── Render: tool call header ──
|
|
876
|
+
//
|
|
877
|
+
// Two views, toggled by ctrl+o (pi flips `context.expanded` and re-invokes
|
|
878
|
+
// this on every flip). pi-agent-core also re-invokes this on every streamed
|
|
879
|
+
// args delta, so in the expanded branch the full task text grows token by
|
|
880
|
+
// token while the master LLM is still writing the prompt — mirroring how
|
|
881
|
+
// `write`/`edit` reveal their `content` field live.
|
|
882
|
+
renderCall(args, theme, context) {
|
|
883
|
+
// Collapsed view (default): single-line header + 60-char task preview.
|
|
884
|
+
if (!context.expanded) {
|
|
885
|
+
if (!args.agent) {
|
|
886
|
+
return new Text(theme.fg("toolTitle", theme.bold("subagent")), 0, 0);
|
|
887
|
+
}
|
|
888
|
+
const taskPreview = args.task
|
|
889
|
+
? (args.task.length > 60 ? args.task.slice(0, 60) + "…" : args.task).replace(/\n/g, " ")
|
|
890
|
+
: "";
|
|
891
|
+
return new Text(
|
|
892
|
+
`${theme.fg("toolTitle", theme.bold("subagent"))} ${theme.fg("accent", args.agent)} ${theme.fg("dim", taskPreview)}`,
|
|
893
|
+
0, 0,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Expanded view: header + full streaming task body. Reuse the previous
|
|
898
|
+
// Container so we don't allocate on every streamed token (same pattern
|
|
899
|
+
// the built-in write/edit tools use via context.lastComponent).
|
|
900
|
+
const c = context.lastComponent instanceof Container
|
|
901
|
+
? (context.lastComponent.clear(), context.lastComponent)
|
|
902
|
+
: new Container();
|
|
903
|
+
const agentLabel = args.agent ? ` ${theme.fg("accent", args.agent)}` : "";
|
|
904
|
+
const cwdLabel = args.cwd ? theme.fg("dim", ` (cwd: ${args.cwd})`) : "";
|
|
905
|
+
c.addChild(new Text(`${theme.fg("toolTitle", theme.bold("subagent"))}${agentLabel}${cwdLabel}`, 0, 0));
|
|
906
|
+
if (args.task) {
|
|
907
|
+
c.addChild(new Spacer(1));
|
|
908
|
+
// Plain Text wraps to terminal width. Markdown would also work but
|
|
909
|
+
// the task prompt is the master's raw instruction text, not authored
|
|
910
|
+
// markdown, and parsing partial markdown mid-stream looks jittery.
|
|
911
|
+
c.addChild(new Text(theme.fg("text", args.task), 0, 0));
|
|
912
|
+
}
|
|
913
|
+
return c;
|
|
914
|
+
},
|
|
915
|
+
|
|
916
|
+
// ── Render: result ──
|
|
917
|
+
renderResult(result, options, theme, context) {
|
|
918
|
+
const details = result.details as Details | undefined;
|
|
919
|
+
if (!details?.results?.length) {
|
|
920
|
+
const t = result.content[0];
|
|
921
|
+
const text = t?.type === "text" ? t.text : "(no output)";
|
|
922
|
+
return new Text(text.slice(0, 200), 0, 0);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const w = getTermWidth() - 4;
|
|
926
|
+
const expanded = options.expanded;
|
|
927
|
+
const c = new Container();
|
|
928
|
+
c.addChild(renderAgentProgress(details.results[0], theme, expanded, w));
|
|
929
|
+
return c;
|
|
930
|
+
},
|
|
931
|
+
});
|
|
932
|
+
}
|