@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/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
+ }