@jerryan/pi-subagent-tools 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agents.ts CHANGED
@@ -1,878 +1,881 @@
1
- /**
2
- * Agent manager — in-process subagent sessions with follow-up support.
3
- *
4
- * Each subagent is an AgentSession created via the pi SDK, running in the
5
- * parent's process. Sessions are in-memory (SessionManager.inMemory) — no
6
- * backing files, the in-process equivalent of --no-session.
7
- *
8
- * Lifetime
9
- * ────────
10
- * Agents live in a registry keyed by id ("delegate-1", "review-2", ...).
11
- * Cleanup is recency-based, not count-based:
12
- *
13
- * - The owning session's turn counter advances on every turn_end.
14
- * - An agent records lastActiveTurn at spawn and when each run completes.
15
- * - A sweep at turn_end disposes agents idle for more than
16
- * PROTECTION_TURNS turns. There is no cap — any number of recently
17
- * active agents survive.
18
- * - Running agents (session.isStreaming) are never disposed. With
19
- * turn-scoped execution this is structural — a turn cannot end while
20
- * its tool calls are still running — but the guard also covers a
21
- * future background mode.
22
- * - All agents are disposed when the owning session shuts down.
23
- *
24
- * Ownership
25
- * ─────────
26
- * A delegate child gets its own manager (own id namespace, own turn
27
- * tracking), stored on the spawning entry. Disposing an entry cascades to
28
- * its child manager, so no session in the tree outlives its owner.
29
- *
30
- * Recursion
31
- * ─────────
32
- * Structural, not env-based. Review/explore children are created with
33
- * noExtensions: true — they get exactly the tools injected here (read +
34
- * sandboxed bash) and are leaves. Delegate children discover user/project
35
- * extensions like a fresh pi (guard extensions included), minus this
36
- * extension itself — self-exclusion is the recursion guard — and get
37
- * review/explore/follow_up via an inline extension factory; their
38
- * delegate tool is registered but rejects.
39
- */
40
-
41
- import * as fs from "node:fs";
42
- import * as path from "node:path";
43
- import { fileURLToPath } from "node:url";
44
- import { Type, type TObject } from "@sinclair/typebox";
45
- import {
46
- createAgentSession,
47
- DefaultResourceLoader,
48
- getAgentDir,
49
- ModelRuntime,
50
- SessionManager,
51
- SettingsManager,
52
- CONFIG_DIR_NAME,
53
- type AgentSession,
54
- type AgentToolResult,
55
- type ExtensionAPI,
56
- type ExtensionContext,
57
- type ToolDefinition,
58
- } from "@earendil-works/pi-coding-agent";
59
- import { createUIBridge } from "./ui-bridge.ts";
60
- import { sandboxBashTool } from "./sandbox-bash.ts";
61
- import { renderSubagentCall, renderSubagentResult } from "./render.ts";
62
- import { shortenPath, truncate, TOOL_LINE_PREFIX, type UsageStats } from "./tui.ts";
63
-
64
- const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
65
- const PROMPTS_DIR = path.resolve(EXTENSION_DIR, "prompts");
66
-
67
- // ---------------------------------------------------------------------------
68
- // Roles — everything that distinguishes delegate/review/explore children
69
- // ---------------------------------------------------------------------------
70
-
71
- export type AgentRole = "delegate" | "review" | "explore";
72
-
73
- /**
74
- * Tool allowlist for read-only child roles. "bash" here denotes the
75
- * sandboxed read-only shell injected via customTools (custom tools shadow
76
- * builtins of the same name — fail-closed).
77
- */
78
- export const READONLY_TOOLS = ["read", "bash"];
79
-
80
- interface RoleConfig {
81
- promptFile: string;
82
- /** Built-in tool allowlist. Undefined = pi defaults (full access). */
83
- tools?: string[];
84
- customTools?: ToolDefinition<any>[];
85
- /** Whether children of this role get the (guarded) subagent tool surface. */
86
- childExtension: boolean;
87
- /**
88
- * Whether the child discovers and loads user/project extensions like a
89
- * fresh pi would. True for delegate (a worker needs the parent's full
90
- * environment — tools, guards); false for read-only leaf agents (minimal
91
- * surface is the point).
92
- */
93
- discoverExtensions: boolean;
94
- }
95
-
96
- const ROLES: Record<AgentRole, RoleConfig> = {
97
- delegate: {
98
- promptFile: path.join(PROMPTS_DIR, "delegate.md"),
99
- childExtension: true,
100
- discoverExtensions: true,
101
- },
102
- review: {
103
- promptFile: path.join(PROMPTS_DIR, "review.md"),
104
- tools: READONLY_TOOLS,
105
- customTools: [sandboxBashTool],
106
- childExtension: false,
107
- discoverExtensions: false,
108
- },
109
- explore: {
110
- promptFile: path.join(PROMPTS_DIR, "explore.md"),
111
- tools: READONLY_TOOLS,
112
- customTools: [sandboxBashTool],
113
- childExtension: false,
114
- discoverExtensions: false,
115
- },
116
- };
117
-
118
- /** Agents idle for more than this many owning-session turns are disposed. */
119
- export const PROTECTION_TURNS = 10;
120
-
121
- // ---------------------------------------------------------------------------
122
- // Parameter schemas
123
- // ---------------------------------------------------------------------------
124
-
125
- const TaskParam = Type.String({
126
- description: "Task to delegate to the subagent",
127
- });
128
-
129
- const SkillsParam = Type.Optional(
130
- Type.Array(Type.String(), {
131
- description: "Optional startup skills to load (paths, like --skill)",
132
- }),
133
- );
134
-
135
- const CwdParam = Type.Optional(
136
- Type.String({
137
- description:
138
- "Working directory for the subagent. Defaults to the parent's cwd.",
139
- }),
140
- );
141
-
142
- export const ReviewParams = Type.Object({
143
- task: TaskParam,
144
- skills: SkillsParam,
145
- });
146
-
147
- export const ExploreParams = Type.Object({
148
- task: TaskParam,
149
- cwd: Type.String({
150
- description:
151
- "Working directory for the explorer. Required — the explorer runs in the target project to pick up its settings, skills, and context files.",
152
- }),
153
- skills: SkillsParam,
154
- });
155
-
156
- export const DelegateParams = Type.Object({
157
- task: TaskParam,
158
- cwd: CwdParam,
159
- skills: SkillsParam,
160
- });
161
-
162
- export const FollowUpParams = Type.Object({
163
- agent: Type.String({
164
- description:
165
- 'Agent id from a previous spawn or follow_up result (e.g. "delegate-1"). The agent continues its session with full context and retains its original role, tools, and working directory.',
166
- }),
167
- task: Type.String({
168
- description: "Follow-up task or question for the agent",
169
- }),
170
- });
171
-
172
- /** Shared shape of all spawn-tool parameters (each schema is a subset). */
173
- interface SpawnToolParams {
174
- task: string;
175
- cwd?: string;
176
- skills?: string[];
177
- }
178
-
179
- // ---------------------------------------------------------------------------
180
- // Types
181
- // ---------------------------------------------------------------------------
182
-
183
- export interface AgentEntry {
184
- id: string;
185
- session: AgentSession;
186
- bridge: ProgressBridge;
187
- childManager?: AgentManager;
188
- lastActiveTurn: number;
189
- }
190
-
191
- export interface SpawnAgentOptions {
192
- role: AgentRole;
193
- task: string;
194
- cwd: string;
195
- skills?: string[];
196
- ctx: ExtensionContext;
197
- signal?: AbortSignal;
198
- onUpdate?: (result: AgentToolResult<any>) => void;
199
- }
200
-
201
- /**
202
- * Everything needed to create a child session. One named type shared by the
203
- * production default and the test seam — with two separate signatures the
204
- * two sides could drift apart; with one params type, drift is a compile
205
- * error.
206
- */
207
- export interface SpawnSessionParams {
208
- opts: SpawnAgentOptions;
209
- id: string;
210
- runtime: () => Promise<ModelRuntime>;
211
- childManager: AgentManager | undefined;
212
- }
213
-
214
- export interface FollowUpOptions {
215
- agent: string;
216
- task: string;
217
- signal?: AbortSignal;
218
- onUpdate?: (result: AgentToolResult<any>) => void;
219
- }
220
-
221
- export interface AgentManagerDeps {
222
- /** Shared model runtime. Defaults to a process-wide lazy singleton. */
223
- runtime?: () => Promise<ModelRuntime>;
224
- /** Test seam: override session creation. */
225
- spawnSession?: (params: SpawnSessionParams) => Promise<AgentSession>;
226
- }
227
-
228
- let sharedRuntime: Promise<ModelRuntime> | undefined;
229
-
230
- function defaultRuntime(): Promise<ModelRuntime> {
231
- if (!sharedRuntime) sharedRuntime = ModelRuntime.create();
232
- return sharedRuntime;
233
- }
234
-
235
- // ---------------------------------------------------------------------------
236
- // Throttle (leading + trailing)
237
- // ---------------------------------------------------------------------------
238
-
239
- function createThrottle(fn: () => void, interval: number) {
240
- let last = 0;
241
- let timer: ReturnType<typeof setTimeout> | null = null;
242
- return {
243
- trigger() {
244
- const now = Date.now();
245
- if (now - last >= interval) {
246
- last = now;
247
- fn();
248
- } else if (!timer) {
249
- timer = setTimeout(() => {
250
- timer = null;
251
- last = Date.now();
252
- fn();
253
- }, interval - (now - last));
254
- }
255
- },
256
- cancel() {
257
- if (timer) {
258
- clearTimeout(timer);
259
- timer = null;
260
- }
261
- },
262
- };
263
- }
264
-
265
- // ---------------------------------------------------------------------------
266
- // Progress bridge — translates session events into tool progress updates
267
- // ---------------------------------------------------------------------------
268
-
269
- const MAX_PREV_LINES = 20;
270
- const UPDATE_INTERVAL = 200;
271
-
272
- export class ProgressBridge {
273
- private previousTurnsText = "";
274
- private streamText = "";
275
- private turnCount = 0;
276
- private tokens = { input: 0, output: 0 };
277
- private startTime = Date.now();
278
- private onUpdate?: (result: AgentToolResult<any>) => void;
279
- private throttle = createThrottle(() => this.doUpdate(), UPDATE_INTERVAL);
280
-
281
- reset(onUpdate?: (result: AgentToolResult<any>) => void) {
282
- this.throttle.cancel();
283
- this.previousTurnsText = "";
284
- this.streamText = "";
285
- this.turnCount = 0;
286
- this.tokens = { input: 0, output: 0 };
287
- this.startTime = Date.now();
288
- this.onUpdate = onUpdate;
289
- }
290
-
291
- usage(): UsageStats {
292
- return {
293
- turns: this.turnCount,
294
- input: this.tokens.input,
295
- output: this.tokens.output,
296
- durationMs: Date.now() - this.startTime,
297
- };
298
- }
299
-
300
- private displayText() {
301
- return (
302
- this.previousTurnsText +
303
- (this.previousTurnsText && this.streamText ? "\n" : "") +
304
- this.streamText
305
- );
306
- }
307
-
308
- private pushPrev(text: string) {
309
- if (!text) return;
310
- this.previousTurnsText = this.previousTurnsText
311
- ? this.previousTurnsText + "\n" + text
312
- : text;
313
- const lines = this.previousTurnsText.split("\n");
314
- if (lines.length > MAX_PREV_LINES) {
315
- this.previousTurnsText = lines.slice(-MAX_PREV_LINES).join("\n");
316
- }
317
- }
318
-
319
- private doUpdate() {
320
- if (!this.onUpdate) return;
321
- this.onUpdate({
322
- content: [{ type: "text", text: this.displayText() }],
323
- details: { usage: this.usage() },
324
- });
325
- }
326
-
327
- /** Feed a session event. */
328
- handle(event: any) {
329
- if (event.type === "message_update" && event.assistantMessageEvent) {
330
- const delta = event.assistantMessageEvent;
331
- if (delta.type === "text_delta" || delta.type === "thinking_delta") {
332
- this.streamText += delta.delta;
333
- this.throttle.trigger();
334
- }
335
- return;
336
- }
337
-
338
- if (event.type === "message_end" && event.message) {
339
- const msg = event.message;
340
- if (msg.role !== "assistant") return;
341
- this.turnCount++;
342
- if (msg.usage) {
343
- this.tokens.input += msg.usage.input || 0;
344
- this.tokens.output += msg.usage.output || 0;
345
- }
346
- this.pushPrev(this.streamText);
347
- this.streamText = "";
348
- this.throttle.trigger();
349
- return;
350
- }
351
-
352
- if (event.type === "tool_execution_start") {
353
- this.pushPrev(formatToolCallLine(event.toolName, event.args));
354
- this.throttle.trigger();
355
- }
356
- }
357
- }
358
-
359
- /** One-line summary of a tool call: "▸ name k=v k=v", capped at 80 chars. */
360
- function formatToolCallLine(name: string, args: unknown): string {
361
- const parts = [`${TOOL_LINE_PREFIX} ${name}`];
362
- if (args && typeof args === "object" && !Array.isArray(args)) {
363
- const entries = Object.entries(args);
364
- const capPerParam = entries.length > 1;
365
- for (const [k, v] of entries) {
366
- const val = typeof v === "string" ? v : JSON.stringify(v);
367
- parts.push(`${k}=${capPerParam ? truncate(val, 40) : val}`);
368
- }
369
- }
370
- return truncate(parts.join(" "), 80);
371
- }
372
-
373
- // ---------------------------------------------------------------------------
374
- // Result extraction
375
- // ---------------------------------------------------------------------------
376
-
377
- function extractResult(messages: Array<any>): { output: string; isError: boolean } {
378
- for (let i = messages.length - 1; i >= 0; i--) {
379
- const msg = messages[i];
380
- if (msg.role !== "assistant") continue;
381
- if (msg.stopReason === "error") {
382
- return {
383
- output: `Subagent failed: ${msg.errorMessage || "unknown error"}`,
384
- isError: true,
385
- };
386
- }
387
- if (msg.stopReason === "aborted") {
388
- return { output: "Subagent was aborted", isError: true };
389
- }
390
- const text = (msg.content ?? [])
391
- .filter((c: any) => c.type === "text")
392
- .map((c: any) => c.text)
393
- .join("");
394
- if (text) return { output: text, isError: false };
395
- }
396
- return { output: "(no output)", isError: false };
397
- }
398
-
399
- // ---------------------------------------------------------------------------
400
- // Error helpers
401
- // ---------------------------------------------------------------------------
402
-
403
- /**
404
- * Signal a tool error. In current pi, errors are reported by throwing — the
405
- * agent loop catches and marks the tool result as an error with this message.
406
- * (Returning isError in the result object is no longer supported.)
407
- */
408
- function fail(text: string): never {
409
- throw new Error(text);
410
- }
411
-
412
- function assertCwd(cwd: string | undefined): asserts cwd is string {
413
- if (!cwd) fail("Cannot spawn subagent: cwd is required.");
414
- let stat: fs.Stats;
415
- try {
416
- stat = fs.statSync(cwd);
417
- } catch (err: any) {
418
- fail(
419
- `Cannot spawn subagent: cwd "${cwd}" does not exist or is not accessible (${err.message}).`,
420
- );
421
- }
422
- if (!stat.isDirectory()) {
423
- fail(`Cannot spawn subagent: "${cwd}" is not a directory.`);
424
- }
425
- }
426
-
427
- // ---------------------------------------------------------------------------
428
- // Agent manager
429
- // ---------------------------------------------------------------------------
430
-
431
- export class AgentManager {
432
- private entries = new Map<string, AgentEntry>();
433
- private counters = new Map<string, number>();
434
- private turn = 0;
435
- private runtime: () => Promise<ModelRuntime>;
436
- private spawnSession?: AgentManagerDeps["spawnSession"];
437
-
438
- constructor(deps: AgentManagerDeps = {}) {
439
- this.runtime = deps.runtime ?? defaultRuntime;
440
- this.spawnSession = deps.spawnSession;
441
- }
442
-
443
- /** Ids of live agents, in spawn order. */
444
- liveIds(): string[] {
445
- return [...this.entries.keys()];
446
- }
447
-
448
- /** Advance the owning session's turn counter and sweep stale agents. */
449
- noteTurnEnd() {
450
- this.turn++;
451
- for (const [id, entry] of this.entries) {
452
- if (entry.session.isStreaming) continue;
453
- if (this.turn - entry.lastActiveTurn > PROTECTION_TURNS) {
454
- this.disposeEntry(id, entry);
455
- }
456
- }
457
- }
458
-
459
- /** Dispose all agents (owning session shutdown/replacement). */
460
- disposeAll() {
461
- for (const [id, entry] of this.entries) {
462
- this.disposeEntry(id, entry);
463
- }
464
- }
465
-
466
- private disposeEntry(id: string, entry: AgentEntry) {
467
- this.entries.delete(id);
468
- // Cascade before disposing the session: no grandchild outlives its owner.
469
- entry.childManager?.disposeAll();
470
- try {
471
- entry.session.dispose();
472
- } catch {
473
- // Dispose is best-effort cleanup; a broken session must not jam the sweep.
474
- }
475
- }
476
-
477
- // -------------------------------------------------------------------------
478
- // Spawn
479
- // -------------------------------------------------------------------------
480
-
481
- async spawn(opts: SpawnAgentOptions): Promise<AgentToolResult<any>> {
482
- assertCwd(opts.cwd);
483
-
484
- const role = ROLES[opts.role];
485
- const next = (this.counters.get(opts.role) ?? 0) + 1;
486
- this.counters.set(opts.role, next);
487
- const id = `${opts.role}-${next}`;
488
- const childManager = role.childExtension
489
- ? new AgentManager({ runtime: this.runtime })
490
- : undefined;
491
-
492
- let session: AgentSession;
493
- try {
494
- session = await (this.spawnSession ?? defaultSpawnSession)({
495
- opts,
496
- id,
497
- runtime: this.runtime,
498
- childManager,
499
- });
500
- } catch (err: any) {
501
- fail(`Failed to start subagent: ${err.message || String(err)}`);
502
- }
503
-
504
- const entry: AgentEntry = {
505
- id,
506
- session,
507
- bridge: new ProgressBridge(),
508
- childManager,
509
- lastActiveTurn: this.turn,
510
- };
511
- session.subscribe((event) => entry.bridge.handle(event));
512
- this.entries.set(id, entry);
513
-
514
- return this.run(entry, opts.task, opts.signal, opts.onUpdate);
515
- }
516
-
517
- // -------------------------------------------------------------------------
518
- // Follow-up
519
- // -------------------------------------------------------------------------
520
-
521
- async followUp(opts: FollowUpOptions): Promise<AgentToolResult<any>> {
522
- const entry = this.entries.get(opts.agent);
523
- if (!entry) {
524
- const live = this.liveIds();
525
- const hint = live.length > 0 ? ` Live agents: ${live.join(", ")}.` : " No live agents.";
526
- fail(
527
- `Agent "${opts.agent}" not found (expired or never existed).${hint} Spawn a fresh agent instead.`,
528
- );
529
- }
530
- return this.run(entry, opts.task, opts.signal, opts.onUpdate);
531
- }
532
-
533
- // -------------------------------------------------------------------------
534
- // Run one prompt against an agent's session
535
- // -------------------------------------------------------------------------
536
-
537
- private async run(
538
- entry: AgentEntry,
539
- task: string,
540
- signal: AbortSignal | undefined,
541
- onUpdate: ((result: AgentToolResult<any>) => void) | undefined,
542
- ): Promise<AgentToolResult<any>> {
543
- // Any engagement — even an aborted or failed run — marks the agent active.
544
- entry.lastActiveTurn = this.turn;
545
- entry.bridge.reset(onUpdate);
546
-
547
- const onAbort = () => {
548
- void entry.session.abort();
549
- };
550
- if (signal?.aborted) fail("Subagent was aborted");
551
- if (signal) signal.addEventListener("abort", onAbort, { once: true });
552
-
553
- try {
554
- await entry.session.prompt(task, { expandPromptTemplates: false });
555
- } catch (err: any) {
556
- fail(`Subagent failed: ${err.message || String(err)}`);
557
- } finally {
558
- if (signal) signal.removeEventListener("abort", onAbort);
559
- }
560
-
561
- const { output, isError } = extractResult(entry.session.messages as any[]);
562
- const text = `${output}\n\n---\nagent: ${entry.id}`;
563
- if (isError) fail(text);
564
- return {
565
- content: [{ type: "text" as const, text }],
566
- details: { usage: entry.bridge.usage(), final: true },
567
- };
568
- }
569
- }
570
-
571
- // ---------------------------------------------------------------------------
572
- // Extension discovery filter (delegate children)
573
- // ---------------------------------------------------------------------------
574
-
575
- export interface ChildExtensionFilterOptions {
576
- /** Directory containing this extension's own files (dev/local installs). */
577
- selfDir: string;
578
- /** The child session's working directory. */
579
- childCwd: string;
580
- /** Whether the parent session treats its project as trusted. */
581
- projectTrusted: boolean;
582
- }
583
-
584
- /**
585
- * Filter the discovered extension set for a delegate child session.
586
- * Pure and exported for tests.
587
- *
588
- * Two exclusions:
589
- * 1. Self — the discovered copy of THIS extension would register the full
590
- * parent tool surface (an unguarded delegate) and conflict with the
591
- * inline factory's guarded tools. Self-exclusion IS the recursion
592
- * guard (it replaces the subprocess era's env-var role signal).
593
- * 2. Project-local extensions when the project is untrusted — mirrors
594
- * pi's trust model, which the raw SDK path does not enforce.
595
- */
596
- export function filterChildExtensions<T extends { path: string; resolvedPath?: string }>(
597
- extensions: T[],
598
- opts: ChildExtensionFilterOptions,
599
- ): T[] {
600
- const selfDir = path.resolve(opts.selfDir);
601
- const projectExtDir = path.resolve(opts.childCwd, CONFIG_DIR_NAME);
602
- return extensions.filter((ext) => {
603
- for (const p of [ext.path, ext.resolvedPath]) {
604
- if (!p) continue;
605
- // Skip pseudo-paths (e.g. "<inline:name>") — they are not filesystem
606
- // locations and must not be resolved against directory prefixes.
607
- if (p.startsWith("<")) continue;
608
- const normalized = p.replace(/\\/g, "/");
609
- if (normalized.includes("node_modules/@jerryan/pi-subagent-tools/")) return false;
610
- const resolved = path.resolve(p);
611
- if (resolved === selfDir || resolved.startsWith(selfDir + path.sep)) return false;
612
- if (
613
- !opts.projectTrusted &&
614
- (resolved === projectExtDir || resolved.startsWith(projectExtDir + path.sep))
615
- ) {
616
- return false;
617
- }
618
- }
619
- return true;
620
- });
621
- }
622
-
623
- // ---------------------------------------------------------------------------
624
- // Default session creation (the in-process equivalent of buildSpawnArgs)
625
- // ---------------------------------------------------------------------------
626
-
627
- async function defaultSpawnSession(
628
- params: SpawnSessionParams,
629
- ): Promise<AgentSession> {
630
- const { opts, id, runtime, childManager } = params;
631
- const modelRuntime = await runtime();
632
- const role = ROLES[opts.role];
633
- const agentDir = getAgentDir();
634
- const settingsManager = SettingsManager.create(opts.cwd, agentDir, {
635
- projectTrusted: opts.ctx.isProjectTrusted(),
636
- });
637
-
638
- const loader = new DefaultResourceLoader({
639
- cwd: opts.cwd,
640
- agentDir,
641
- settingsManager,
642
- noExtensions: !role.discoverExtensions,
643
- extensionsOverride: role.discoverExtensions
644
- ? (base) => ({
645
- ...base,
646
- extensions: filterChildExtensions(base.extensions, {
647
- selfDir: EXTENSION_DIR,
648
- childCwd: opts.cwd,
649
- projectTrusted: opts.ctx.isProjectTrusted(),
650
- }),
651
- })
652
- : undefined,
653
- additionalSkillPaths: opts.skills ?? [],
654
- appendSystemPrompt: [role.promptFile],
655
- extensionFactories: childManager
656
- ? [
657
- {
658
- name: "pi-subagent-tools",
659
- factory: (pi: ExtensionAPI) =>
660
- registerChildAgentTools(pi, childManager),
661
- },
662
- ]
663
- : [],
664
- });
665
- await loader.reload();
666
-
667
- const { session } = await createAgentSession({
668
- cwd: opts.cwd,
669
- model: opts.ctx.model,
670
- thinkingLevel: opts.ctx.thinkingLevel,
671
- modelRuntime,
672
- tools: role.tools ? [...role.tools] : undefined,
673
- customTools: role.customTools,
674
- resourceLoader: loader,
675
- sessionManager: SessionManager.inMemory(opts.cwd),
676
- settingsManager,
677
- });
678
-
679
- await session.bindExtensions({
680
- uiContext: createUIBridge(opts.ctx.ui, { label: id }),
681
- mode: "rpc",
682
- });
683
-
684
- return session;
685
- }
686
-
687
- // ---------------------------------------------------------------------------
688
- // Tool registration
689
- // ---------------------------------------------------------------------------
690
-
691
- interface SpawnToolConfig {
692
- role: AgentRole;
693
- label: string;
694
- description: string;
695
- promptSnippet: string;
696
- promptGuidelines: string[];
697
- parameters: TObject<any>;
698
- resolveCwd: (params: SpawnToolParams, ctx: ExtensionContext) => string;
699
- hint?: (params: SpawnToolParams) => string | undefined;
700
- }
701
-
702
- const DELEGATE_TOOL: SpawnToolConfig = {
703
- role: "delegate",
704
- label: "Delegate",
705
- description:
706
- "Delegate a task to a subagent. " +
707
- "For general-purpose work that doesn't fit the review or explore tools. " +
708
- "The result includes an agent id — use follow_up to continue working with the same agent.",
709
- promptSnippet: "Delegate a task to a worker subagent",
710
- promptGuidelines: [
711
- "Use the delegate tool for non-trivial, self-contained implementation tasks — it has full tool access unlike the read-only review and explore tools.",
712
- "Use follow_up with the agent id from the result to refine a subagent's work or recover from an incomplete result, instead of spawning a fresh agent.",
713
- ],
714
- parameters: DelegateParams,
715
- resolveCwd: (params, ctx) => params.cwd ?? ctx.cwd,
716
- hint: (params) => (params.cwd ? shortenPath(params.cwd) : undefined),
717
- };
718
-
719
- const REVIEW_TOOL: SpawnToolConfig = {
720
- role: "review",
721
- label: "Review",
722
- description:
723
- "Review code changes or files in the current project. " +
724
- "The reviewer is always read-only and runs in the parent's working directory. " +
725
- "Use for code review, diff inspection, issue analysis, and quality checks. " +
726
- "The result includes an agent id — use follow_up to ask the reviewer more questions.",
727
- promptSnippet: "Review code or files with a read-only subagent",
728
- promptGuidelines: [
729
- "Use the review tool for code review, diff inspection, and quality checks — it is read-only and cannot modify files.",
730
- ],
731
- parameters: ReviewParams,
732
- resolveCwd: (_params, ctx) => ctx.cwd,
733
- };
734
-
735
- const EXPLORE_TOOL: SpawnToolConfig = {
736
- role: "explore",
737
- label: "Explore",
738
- description:
739
- "Explore a project directory to understand its structure, patterns, and key files. " +
740
- "The explorer is always read-only and runs in the specified working directory. " +
741
- "Use for mapping codebases, scouting dependencies, or understanding unfamiliar projects. " +
742
- "The result includes an agent id — use follow_up to dig deeper with the same explorer.",
743
- promptSnippet: "Explore a project directory with a read-only subagent",
744
- promptGuidelines: [
745
- "Use the explore tool to map unfamiliar codebases or scout dependencies — it runs read-only in the specified directory.",
746
- ],
747
- parameters: ExploreParams,
748
- resolveCwd: (params) => params.cwd!,
749
- hint: (params) => shortenPath(params.cwd!),
750
- };
751
-
752
- /**
753
- * Build a spawn tool's definition. Everything is derived from the role
754
- * config except `execute` — so the parent's real tool and the delegate
755
- * child's rejecting guard differ by exactly one function.
756
- */
757
- function spawnToolDefinition(
758
- config: SpawnToolConfig,
759
- execute: (
760
- params: SpawnToolParams,
761
- signal: AbortSignal | undefined,
762
- onUpdate: ((result: AgentToolResult<any>) => void) | undefined,
763
- ctx: ExtensionContext,
764
- ) => Promise<AgentToolResult<any>>,
765
- ) {
766
- return {
767
- name: config.role,
768
- label: config.label,
769
- description: config.description,
770
- promptSnippet: config.promptSnippet,
771
- promptGuidelines: config.promptGuidelines,
772
- parameters: config.parameters,
773
- execute: (
774
- _toolCallId: string,
775
- params: SpawnToolParams,
776
- signal: AbortSignal | undefined,
777
- onUpdate: ((result: AgentToolResult<any>) => void) | undefined,
778
- ctx: ExtensionContext,
779
- ) => execute(params, signal, onUpdate, ctx),
780
- renderCall: (args: SpawnToolParams, theme: any) =>
781
- renderSubagentCall(
782
- `${config.role} `,
783
- { task: args.task, hint: config.hint?.(args) },
784
- theme,
785
- ),
786
- renderResult: (result: any, options: any, theme: any, context: any) =>
787
- renderSubagentResult(result, options, theme, context),
788
- };
789
- }
790
-
791
- function registerSpawnTool(
792
- pi: ExtensionAPI,
793
- manager: AgentManager,
794
- config: SpawnToolConfig,
795
- ): void {
796
- pi.registerTool(
797
- spawnToolDefinition(config, (params, signal, onUpdate, ctx) =>
798
- manager.spawn({
799
- role: config.role,
800
- task: params.task,
801
- cwd: config.resolveCwd(params, ctx),
802
- skills: params.skills,
803
- ctx,
804
- signal,
805
- onUpdate,
806
- }),
807
- ) as any,
808
- );
809
- }
810
-
811
- /** The rejecting delegate tool registered in delegate children. */
812
- function registerRejectingDelegateTool(pi: ExtensionAPI): void {
813
- pi.registerTool(
814
- spawnToolDefinition(DELEGATE_TOOL, () =>
815
- fail(
816
- "Delegation not available in delegate subagents. Use review or explore instead.",
817
- ),
818
- ) as any,
819
- );
820
- }
821
-
822
- function registerFollowUpTool(pi: ExtensionAPI, manager: AgentManager): void {
823
- pi.registerTool({
824
- name: "follow_up",
825
- label: "Follow Up",
826
- description:
827
- "Send a follow-up task to a previously spawned subagent, continuing its session with full context. " +
828
- "The agent retains its original role, tools, and working directory. " +
829
- "Errors if the agent id is unknown or expired — spawn a fresh agent instead.",
830
- promptSnippet: "Continue working with an existing subagent",
831
- promptGuidelines: [
832
- "Use follow_up to refine a subagent's work, ask questions about its findings, or recover from an incomplete or failed result — the agent keeps its full context.",
833
- ],
834
- parameters: FollowUpParams,
835
- async execute(_toolCallId, params, signal, onUpdate, _ctx) {
836
- return manager.followUp({
837
- agent: params.agent,
838
- task: params.task,
839
- signal,
840
- onUpdate,
841
- });
842
- },
843
- renderCall: (args, theme) =>
844
- renderSubagentCall("follow_up ", { task: args.task, hint: args.agent }, theme),
845
- renderResult: (result, options, theme, context) =>
846
- renderSubagentResult(result as any, options, theme, context),
847
- });
848
- }
849
-
850
-
851
- function wireLifecycle(pi: ExtensionAPI, manager: AgentManager): void {
852
- pi.on("turn_end", () => manager.noteTurnEnd());
853
- pi.on("session_shutdown", () => manager.disposeAll());
854
- }
855
-
856
- /** Full tool surface for the parent session. */
857
- export function registerAgentTools(pi: ExtensionAPI, manager: AgentManager): void {
858
- wireLifecycle(pi, manager);
859
- registerSpawnTool(pi, manager, DELEGATE_TOOL);
860
- registerSpawnTool(pi, manager, REVIEW_TOOL);
861
- registerSpawnTool(pi, manager, EXPLORE_TOOL);
862
- registerFollowUpTool(pi, manager);
863
- }
864
-
865
- /**
866
- * Guarded tool surface for delegate children: review/explore/follow_up are
867
- * active; delegate is registered but rejects — the structural recursion guard.
868
- */
869
- export function registerChildAgentTools(
870
- pi: ExtensionAPI,
871
- manager: AgentManager,
872
- ): void {
873
- wireLifecycle(pi, manager);
874
- registerRejectingDelegateTool(pi);
875
- registerSpawnTool(pi, manager, REVIEW_TOOL);
876
- registerSpawnTool(pi, manager, EXPLORE_TOOL);
877
- registerFollowUpTool(pi, manager);
878
- }
1
+ /**
2
+ * Agent manager — in-process subagent sessions with follow-up support.
3
+ *
4
+ * Each subagent is an AgentSession created via the pi SDK, running in the
5
+ * parent's process. Sessions are in-memory (SessionManager.inMemory) — no
6
+ * backing files, the in-process equivalent of --no-session.
7
+ *
8
+ * Lifetime
9
+ * ────────
10
+ * Agents live in a registry keyed by id ("delegate-1", "review-2", ...).
11
+ * Cleanup is recency-based, not count-based:
12
+ *
13
+ * - The owning session's turn counter advances on every turn_end.
14
+ * - An agent records lastActiveTurn at spawn and when each run completes.
15
+ * - A sweep at turn_end disposes agents idle for more than
16
+ * PROTECTION_TURNS turns. There is no cap — any number of recently
17
+ * active agents survive.
18
+ * - Running agents (session.isStreaming) are never disposed. With
19
+ * turn-scoped execution this is structural — a turn cannot end while
20
+ * its tool calls are still running — but the guard also covers a
21
+ * future background mode.
22
+ * - All agents are disposed when the owning session shuts down.
23
+ *
24
+ * Ownership
25
+ * ─────────
26
+ * A delegate child gets its own manager (own id namespace, own turn
27
+ * tracking), stored on the spawning entry. Disposing an entry cascades to
28
+ * its child manager, so no session in the tree outlives its owner.
29
+ *
30
+ * Recursion
31
+ * ─────────
32
+ * Structural, not env-based. Review/explore children are created with
33
+ * noExtensions: true — they get exactly the tools injected here (read +
34
+ * sandboxed bash + sandboxed python) and are leaves. Delegate children discover user/project
35
+ * extensions like a fresh pi (guard extensions included), minus this
36
+ * extension itself — self-exclusion is the recursion guard — and get
37
+ * review/explore/follow_up via an inline extension factory; their
38
+ * delegate tool is registered but rejects.
39
+ */
40
+
41
+ import * as fs from "node:fs";
42
+ import * as path from "node:path";
43
+ import { fileURLToPath } from "node:url";
44
+ import { Type, type TObject } from "@sinclair/typebox";
45
+ import {
46
+ createAgentSession,
47
+ DefaultResourceLoader,
48
+ getAgentDir,
49
+ ModelRuntime,
50
+ SessionManager,
51
+ SettingsManager,
52
+ CONFIG_DIR_NAME,
53
+ type AgentSession,
54
+ type AgentToolResult,
55
+ type ExtensionAPI,
56
+ type ExtensionContext,
57
+ type ToolDefinition,
58
+ } from "@earendil-works/pi-coding-agent";
59
+ import { createUIBridge } from "./ui-bridge.ts";
60
+ import { sandboxBashTool } from "./sandbox-bash.ts";
61
+ import { sandboxPythonTool } from "./sandbox-python.ts";
62
+ import { renderSubagentCall, renderSubagentResult } from "./render.ts";
63
+ import { shortenPath, truncate, TOOL_LINE_PREFIX, type UsageStats } from "./tui.ts";
64
+
65
+ const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
66
+ const PROMPTS_DIR = path.resolve(EXTENSION_DIR, "prompts");
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Roles — everything that distinguishes delegate/review/explore children
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export type AgentRole = "delegate" | "review" | "explore";
73
+
74
+ /**
75
+ * Tool allowlist for read-only child roles. pi applies this filter to ALL
76
+ * tools — builtins, extension tools, and customTools alike (custom tools
77
+ * shadow builtins of the same name — fail-closed). "bash" and "python"
78
+ * here denote the sandboxed read-only tools injected via customTools;
79
+ * there is no builtin python, so the name only admits the custom one.
80
+ */
81
+ export const READONLY_TOOLS = ["read", "bash", "python"];
82
+
83
+ interface RoleConfig {
84
+ promptFile: string;
85
+ /** Tool-name allowlist (gates builtins AND customTools — pi filters both). Undefined = pi defaults (full access). */
86
+ tools?: string[];
87
+ customTools?: ToolDefinition<any>[];
88
+ /** Whether children of this role get the (guarded) subagent tool surface. */
89
+ childExtension: boolean;
90
+ /**
91
+ * Whether the child discovers and loads user/project extensions like a
92
+ * fresh pi would. True for delegate (a worker needs the parent's full
93
+ * environment — tools, guards); false for read-only leaf agents (minimal
94
+ * surface is the point).
95
+ */
96
+ discoverExtensions: boolean;
97
+ }
98
+
99
+ export const ROLES: Record<AgentRole, RoleConfig> = {
100
+ delegate: {
101
+ promptFile: path.join(PROMPTS_DIR, "delegate.md"),
102
+ childExtension: true,
103
+ discoverExtensions: true,
104
+ },
105
+ review: {
106
+ promptFile: path.join(PROMPTS_DIR, "review.md"),
107
+ tools: READONLY_TOOLS,
108
+ customTools: [sandboxBashTool, sandboxPythonTool],
109
+ childExtension: false,
110
+ discoverExtensions: false,
111
+ },
112
+ explore: {
113
+ promptFile: path.join(PROMPTS_DIR, "explore.md"),
114
+ tools: READONLY_TOOLS,
115
+ customTools: [sandboxBashTool, sandboxPythonTool],
116
+ childExtension: false,
117
+ discoverExtensions: false,
118
+ },
119
+ };
120
+
121
+ /** Agents idle for more than this many owning-session turns are disposed. */
122
+ export const PROTECTION_TURNS = 10;
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Parameter schemas
126
+ // ---------------------------------------------------------------------------
127
+
128
+ const TaskParam = Type.String({
129
+ description: "Task to delegate to the subagent",
130
+ });
131
+
132
+ const SkillsParam = Type.Optional(
133
+ Type.Array(Type.String(), {
134
+ description: "Optional startup skills to load (paths, like --skill)",
135
+ }),
136
+ );
137
+
138
+ const CwdParam = Type.Optional(
139
+ Type.String({
140
+ description:
141
+ "Working directory for the subagent. Defaults to the parent's cwd.",
142
+ }),
143
+ );
144
+
145
+ export const ReviewParams = Type.Object({
146
+ task: TaskParam,
147
+ skills: SkillsParam,
148
+ });
149
+
150
+ export const ExploreParams = Type.Object({
151
+ task: TaskParam,
152
+ cwd: Type.String({
153
+ description:
154
+ "Working directory for the explorer. Required — the explorer runs in the target project to pick up its settings, skills, and context files.",
155
+ }),
156
+ skills: SkillsParam,
157
+ });
158
+
159
+ export const DelegateParams = Type.Object({
160
+ task: TaskParam,
161
+ cwd: CwdParam,
162
+ skills: SkillsParam,
163
+ });
164
+
165
+ export const FollowUpParams = Type.Object({
166
+ agent: Type.String({
167
+ description:
168
+ 'Agent id from a previous spawn or follow_up result (e.g. "delegate-1"). The agent continues its session with full context and retains its original role, tools, and working directory.',
169
+ }),
170
+ task: Type.String({
171
+ description: "Follow-up task or question for the agent",
172
+ }),
173
+ });
174
+
175
+ /** Shared shape of all spawn-tool parameters (each schema is a subset). */
176
+ interface SpawnToolParams {
177
+ task: string;
178
+ cwd?: string;
179
+ skills?: string[];
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Types
184
+ // ---------------------------------------------------------------------------
185
+
186
+ export interface AgentEntry {
187
+ id: string;
188
+ session: AgentSession;
189
+ bridge: ProgressBridge;
190
+ childManager?: AgentManager;
191
+ lastActiveTurn: number;
192
+ }
193
+
194
+ export interface SpawnAgentOptions {
195
+ role: AgentRole;
196
+ task: string;
197
+ cwd: string;
198
+ skills?: string[];
199
+ ctx: ExtensionContext;
200
+ signal?: AbortSignal;
201
+ onUpdate?: (result: AgentToolResult<any>) => void;
202
+ }
203
+
204
+ /**
205
+ * Everything needed to create a child session. One named type shared by the
206
+ * production default and the test seam — with two separate signatures the
207
+ * two sides could drift apart; with one params type, drift is a compile
208
+ * error.
209
+ */
210
+ export interface SpawnSessionParams {
211
+ opts: SpawnAgentOptions;
212
+ id: string;
213
+ runtime: () => Promise<ModelRuntime>;
214
+ childManager: AgentManager | undefined;
215
+ }
216
+
217
+ export interface FollowUpOptions {
218
+ agent: string;
219
+ task: string;
220
+ signal?: AbortSignal;
221
+ onUpdate?: (result: AgentToolResult<any>) => void;
222
+ }
223
+
224
+ export interface AgentManagerDeps {
225
+ /** Shared model runtime. Defaults to a process-wide lazy singleton. */
226
+ runtime?: () => Promise<ModelRuntime>;
227
+ /** Test seam: override session creation. */
228
+ spawnSession?: (params: SpawnSessionParams) => Promise<AgentSession>;
229
+ }
230
+
231
+ let sharedRuntime: Promise<ModelRuntime> | undefined;
232
+
233
+ function defaultRuntime(): Promise<ModelRuntime> {
234
+ if (!sharedRuntime) sharedRuntime = ModelRuntime.create();
235
+ return sharedRuntime;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Throttle (leading + trailing)
240
+ // ---------------------------------------------------------------------------
241
+
242
+ function createThrottle(fn: () => void, interval: number) {
243
+ let last = 0;
244
+ let timer: ReturnType<typeof setTimeout> | null = null;
245
+ return {
246
+ trigger() {
247
+ const now = Date.now();
248
+ if (now - last >= interval) {
249
+ last = now;
250
+ fn();
251
+ } else if (!timer) {
252
+ timer = setTimeout(() => {
253
+ timer = null;
254
+ last = Date.now();
255
+ fn();
256
+ }, interval - (now - last));
257
+ }
258
+ },
259
+ cancel() {
260
+ if (timer) {
261
+ clearTimeout(timer);
262
+ timer = null;
263
+ }
264
+ },
265
+ };
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Progress bridge — translates session events into tool progress updates
270
+ // ---------------------------------------------------------------------------
271
+
272
+ const MAX_PREV_LINES = 20;
273
+ const UPDATE_INTERVAL = 200;
274
+
275
+ export class ProgressBridge {
276
+ private previousTurnsText = "";
277
+ private streamText = "";
278
+ private turnCount = 0;
279
+ private tokens = { input: 0, output: 0 };
280
+ private startTime = Date.now();
281
+ private onUpdate?: (result: AgentToolResult<any>) => void;
282
+ private throttle = createThrottle(() => this.doUpdate(), UPDATE_INTERVAL);
283
+
284
+ reset(onUpdate?: (result: AgentToolResult<any>) => void) {
285
+ this.throttle.cancel();
286
+ this.previousTurnsText = "";
287
+ this.streamText = "";
288
+ this.turnCount = 0;
289
+ this.tokens = { input: 0, output: 0 };
290
+ this.startTime = Date.now();
291
+ this.onUpdate = onUpdate;
292
+ }
293
+
294
+ usage(): UsageStats {
295
+ return {
296
+ turns: this.turnCount,
297
+ input: this.tokens.input,
298
+ output: this.tokens.output,
299
+ durationMs: Date.now() - this.startTime,
300
+ };
301
+ }
302
+
303
+ private displayText() {
304
+ return (
305
+ this.previousTurnsText +
306
+ (this.previousTurnsText && this.streamText ? "\n" : "") +
307
+ this.streamText
308
+ );
309
+ }
310
+
311
+ private pushPrev(text: string) {
312
+ if (!text) return;
313
+ this.previousTurnsText = this.previousTurnsText
314
+ ? this.previousTurnsText + "\n" + text
315
+ : text;
316
+ const lines = this.previousTurnsText.split("\n");
317
+ if (lines.length > MAX_PREV_LINES) {
318
+ this.previousTurnsText = lines.slice(-MAX_PREV_LINES).join("\n");
319
+ }
320
+ }
321
+
322
+ private doUpdate() {
323
+ if (!this.onUpdate) return;
324
+ this.onUpdate({
325
+ content: [{ type: "text", text: this.displayText() }],
326
+ details: { usage: this.usage() },
327
+ });
328
+ }
329
+
330
+ /** Feed a session event. */
331
+ handle(event: any) {
332
+ if (event.type === "message_update" && event.assistantMessageEvent) {
333
+ const delta = event.assistantMessageEvent;
334
+ if (delta.type === "text_delta" || delta.type === "thinking_delta") {
335
+ this.streamText += delta.delta;
336
+ this.throttle.trigger();
337
+ }
338
+ return;
339
+ }
340
+
341
+ if (event.type === "message_end" && event.message) {
342
+ const msg = event.message;
343
+ if (msg.role !== "assistant") return;
344
+ this.turnCount++;
345
+ if (msg.usage) {
346
+ this.tokens.input += msg.usage.input || 0;
347
+ this.tokens.output += msg.usage.output || 0;
348
+ }
349
+ this.pushPrev(this.streamText);
350
+ this.streamText = "";
351
+ this.throttle.trigger();
352
+ return;
353
+ }
354
+
355
+ if (event.type === "tool_execution_start") {
356
+ this.pushPrev(formatToolCallLine(event.toolName, event.args));
357
+ this.throttle.trigger();
358
+ }
359
+ }
360
+ }
361
+
362
+ /** One-line summary of a tool call: "▸ name k=v k=v", capped at 80 chars. */
363
+ function formatToolCallLine(name: string, args: unknown): string {
364
+ const parts = [`${TOOL_LINE_PREFIX} ${name}`];
365
+ if (args && typeof args === "object" && !Array.isArray(args)) {
366
+ const entries = Object.entries(args);
367
+ const capPerParam = entries.length > 1;
368
+ for (const [k, v] of entries) {
369
+ const val = typeof v === "string" ? v : JSON.stringify(v);
370
+ parts.push(`${k}=${capPerParam ? truncate(val, 40) : val}`);
371
+ }
372
+ }
373
+ return truncate(parts.join(" "), 80);
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Result extraction
378
+ // ---------------------------------------------------------------------------
379
+
380
+ function extractResult(messages: Array<any>): { output: string; isError: boolean } {
381
+ for (let i = messages.length - 1; i >= 0; i--) {
382
+ const msg = messages[i];
383
+ if (msg.role !== "assistant") continue;
384
+ if (msg.stopReason === "error") {
385
+ return {
386
+ output: `Subagent failed: ${msg.errorMessage || "unknown error"}`,
387
+ isError: true,
388
+ };
389
+ }
390
+ if (msg.stopReason === "aborted") {
391
+ return { output: "Subagent was aborted", isError: true };
392
+ }
393
+ const text = (msg.content ?? [])
394
+ .filter((c: any) => c.type === "text")
395
+ .map((c: any) => c.text)
396
+ .join("");
397
+ if (text) return { output: text, isError: false };
398
+ }
399
+ return { output: "(no output)", isError: false };
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // Error helpers
404
+ // ---------------------------------------------------------------------------
405
+
406
+ /**
407
+ * Signal a tool error. In current pi, errors are reported by throwing — the
408
+ * agent loop catches and marks the tool result as an error with this message.
409
+ * (Returning isError in the result object is no longer supported.)
410
+ */
411
+ function fail(text: string): never {
412
+ throw new Error(text);
413
+ }
414
+
415
+ function assertCwd(cwd: string | undefined): asserts cwd is string {
416
+ if (!cwd) fail("Cannot spawn subagent: cwd is required.");
417
+ let stat: fs.Stats;
418
+ try {
419
+ stat = fs.statSync(cwd);
420
+ } catch (err: any) {
421
+ fail(
422
+ `Cannot spawn subagent: cwd "${cwd}" does not exist or is not accessible (${err.message}).`,
423
+ );
424
+ }
425
+ if (!stat.isDirectory()) {
426
+ fail(`Cannot spawn subagent: "${cwd}" is not a directory.`);
427
+ }
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Agent manager
432
+ // ---------------------------------------------------------------------------
433
+
434
+ export class AgentManager {
435
+ private entries = new Map<string, AgentEntry>();
436
+ private counters = new Map<string, number>();
437
+ private turn = 0;
438
+ private runtime: () => Promise<ModelRuntime>;
439
+ private spawnSession?: AgentManagerDeps["spawnSession"];
440
+
441
+ constructor(deps: AgentManagerDeps = {}) {
442
+ this.runtime = deps.runtime ?? defaultRuntime;
443
+ this.spawnSession = deps.spawnSession;
444
+ }
445
+
446
+ /** Ids of live agents, in spawn order. */
447
+ liveIds(): string[] {
448
+ return [...this.entries.keys()];
449
+ }
450
+
451
+ /** Advance the owning session's turn counter and sweep stale agents. */
452
+ noteTurnEnd() {
453
+ this.turn++;
454
+ for (const [id, entry] of this.entries) {
455
+ if (entry.session.isStreaming) continue;
456
+ if (this.turn - entry.lastActiveTurn > PROTECTION_TURNS) {
457
+ this.disposeEntry(id, entry);
458
+ }
459
+ }
460
+ }
461
+
462
+ /** Dispose all agents (owning session shutdown/replacement). */
463
+ disposeAll() {
464
+ for (const [id, entry] of this.entries) {
465
+ this.disposeEntry(id, entry);
466
+ }
467
+ }
468
+
469
+ private disposeEntry(id: string, entry: AgentEntry) {
470
+ this.entries.delete(id);
471
+ // Cascade before disposing the session: no grandchild outlives its owner.
472
+ entry.childManager?.disposeAll();
473
+ try {
474
+ entry.session.dispose();
475
+ } catch {
476
+ // Dispose is best-effort cleanup; a broken session must not jam the sweep.
477
+ }
478
+ }
479
+
480
+ // -------------------------------------------------------------------------
481
+ // Spawn
482
+ // -------------------------------------------------------------------------
483
+
484
+ async spawn(opts: SpawnAgentOptions): Promise<AgentToolResult<any>> {
485
+ assertCwd(opts.cwd);
486
+
487
+ const role = ROLES[opts.role];
488
+ const next = (this.counters.get(opts.role) ?? 0) + 1;
489
+ this.counters.set(opts.role, next);
490
+ const id = `${opts.role}-${next}`;
491
+ const childManager = role.childExtension
492
+ ? new AgentManager({ runtime: this.runtime })
493
+ : undefined;
494
+
495
+ let session: AgentSession;
496
+ try {
497
+ session = await (this.spawnSession ?? defaultSpawnSession)({
498
+ opts,
499
+ id,
500
+ runtime: this.runtime,
501
+ childManager,
502
+ });
503
+ } catch (err: any) {
504
+ fail(`Failed to start subagent: ${err.message || String(err)}`);
505
+ }
506
+
507
+ const entry: AgentEntry = {
508
+ id,
509
+ session,
510
+ bridge: new ProgressBridge(),
511
+ childManager,
512
+ lastActiveTurn: this.turn,
513
+ };
514
+ session.subscribe((event) => entry.bridge.handle(event));
515
+ this.entries.set(id, entry);
516
+
517
+ return this.run(entry, opts.task, opts.signal, opts.onUpdate);
518
+ }
519
+
520
+ // -------------------------------------------------------------------------
521
+ // Follow-up
522
+ // -------------------------------------------------------------------------
523
+
524
+ async followUp(opts: FollowUpOptions): Promise<AgentToolResult<any>> {
525
+ const entry = this.entries.get(opts.agent);
526
+ if (!entry) {
527
+ const live = this.liveIds();
528
+ const hint = live.length > 0 ? ` Live agents: ${live.join(", ")}.` : " No live agents.";
529
+ fail(
530
+ `Agent "${opts.agent}" not found (expired or never existed).${hint} Spawn a fresh agent instead.`,
531
+ );
532
+ }
533
+ return this.run(entry, opts.task, opts.signal, opts.onUpdate);
534
+ }
535
+
536
+ // -------------------------------------------------------------------------
537
+ // Run one prompt against an agent's session
538
+ // -------------------------------------------------------------------------
539
+
540
+ private async run(
541
+ entry: AgentEntry,
542
+ task: string,
543
+ signal: AbortSignal | undefined,
544
+ onUpdate: ((result: AgentToolResult<any>) => void) | undefined,
545
+ ): Promise<AgentToolResult<any>> {
546
+ // Any engagement — even an aborted or failed run — marks the agent active.
547
+ entry.lastActiveTurn = this.turn;
548
+ entry.bridge.reset(onUpdate);
549
+
550
+ const onAbort = () => {
551
+ void entry.session.abort();
552
+ };
553
+ if (signal?.aborted) fail("Subagent was aborted");
554
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
555
+
556
+ try {
557
+ await entry.session.prompt(task, { expandPromptTemplates: false });
558
+ } catch (err: any) {
559
+ fail(`Subagent failed: ${err.message || String(err)}`);
560
+ } finally {
561
+ if (signal) signal.removeEventListener("abort", onAbort);
562
+ }
563
+
564
+ const { output, isError } = extractResult(entry.session.messages as any[]);
565
+ const text = `${output}\n\n---\nagent: ${entry.id}`;
566
+ if (isError) fail(text);
567
+ return {
568
+ content: [{ type: "text" as const, text }],
569
+ details: { usage: entry.bridge.usage(), final: true },
570
+ };
571
+ }
572
+ }
573
+
574
+ // ---------------------------------------------------------------------------
575
+ // Extension discovery filter (delegate children)
576
+ // ---------------------------------------------------------------------------
577
+
578
+ export interface ChildExtensionFilterOptions {
579
+ /** Directory containing this extension's own files (dev/local installs). */
580
+ selfDir: string;
581
+ /** The child session's working directory. */
582
+ childCwd: string;
583
+ /** Whether the parent session treats its project as trusted. */
584
+ projectTrusted: boolean;
585
+ }
586
+
587
+ /**
588
+ * Filter the discovered extension set for a delegate child session.
589
+ * Pure and exported for tests.
590
+ *
591
+ * Two exclusions:
592
+ * 1. Self — the discovered copy of THIS extension would register the full
593
+ * parent tool surface (an unguarded delegate) and conflict with the
594
+ * inline factory's guarded tools. Self-exclusion IS the recursion
595
+ * guard (it replaces the subprocess era's env-var role signal).
596
+ * 2. Project-local extensions when the project is untrusted — mirrors
597
+ * pi's trust model, which the raw SDK path does not enforce.
598
+ */
599
+ export function filterChildExtensions<T extends { path: string; resolvedPath?: string }>(
600
+ extensions: T[],
601
+ opts: ChildExtensionFilterOptions,
602
+ ): T[] {
603
+ const selfDir = path.resolve(opts.selfDir);
604
+ const projectExtDir = path.resolve(opts.childCwd, CONFIG_DIR_NAME);
605
+ return extensions.filter((ext) => {
606
+ for (const p of [ext.path, ext.resolvedPath]) {
607
+ if (!p) continue;
608
+ // Skip pseudo-paths (e.g. "<inline:name>") — they are not filesystem
609
+ // locations and must not be resolved against directory prefixes.
610
+ if (p.startsWith("<")) continue;
611
+ const normalized = p.replace(/\\/g, "/");
612
+ if (normalized.includes("node_modules/@jerryan/pi-subagent-tools/")) return false;
613
+ const resolved = path.resolve(p);
614
+ if (resolved === selfDir || resolved.startsWith(selfDir + path.sep)) return false;
615
+ if (
616
+ !opts.projectTrusted &&
617
+ (resolved === projectExtDir || resolved.startsWith(projectExtDir + path.sep))
618
+ ) {
619
+ return false;
620
+ }
621
+ }
622
+ return true;
623
+ });
624
+ }
625
+
626
+ // ---------------------------------------------------------------------------
627
+ // Default session creation (the in-process equivalent of buildSpawnArgs)
628
+ // ---------------------------------------------------------------------------
629
+
630
+ async function defaultSpawnSession(
631
+ params: SpawnSessionParams,
632
+ ): Promise<AgentSession> {
633
+ const { opts, id, runtime, childManager } = params;
634
+ const modelRuntime = await runtime();
635
+ const role = ROLES[opts.role];
636
+ const agentDir = getAgentDir();
637
+ const settingsManager = SettingsManager.create(opts.cwd, agentDir, {
638
+ projectTrusted: opts.ctx.isProjectTrusted(),
639
+ });
640
+
641
+ const loader = new DefaultResourceLoader({
642
+ cwd: opts.cwd,
643
+ agentDir,
644
+ settingsManager,
645
+ noExtensions: !role.discoverExtensions,
646
+ extensionsOverride: role.discoverExtensions
647
+ ? (base) => ({
648
+ ...base,
649
+ extensions: filterChildExtensions(base.extensions, {
650
+ selfDir: EXTENSION_DIR,
651
+ childCwd: opts.cwd,
652
+ projectTrusted: opts.ctx.isProjectTrusted(),
653
+ }),
654
+ })
655
+ : undefined,
656
+ additionalSkillPaths: opts.skills ?? [],
657
+ appendSystemPrompt: [role.promptFile],
658
+ extensionFactories: childManager
659
+ ? [
660
+ {
661
+ name: "pi-subagent-tools",
662
+ factory: (pi: ExtensionAPI) =>
663
+ registerChildAgentTools(pi, childManager),
664
+ },
665
+ ]
666
+ : [],
667
+ });
668
+ await loader.reload();
669
+
670
+ const { session } = await createAgentSession({
671
+ cwd: opts.cwd,
672
+ model: opts.ctx.model,
673
+ thinkingLevel: opts.ctx.thinkingLevel,
674
+ modelRuntime,
675
+ tools: role.tools ? [...role.tools] : undefined,
676
+ customTools: role.customTools,
677
+ resourceLoader: loader,
678
+ sessionManager: SessionManager.inMemory(opts.cwd),
679
+ settingsManager,
680
+ });
681
+
682
+ await session.bindExtensions({
683
+ uiContext: createUIBridge(opts.ctx.ui, { label: id }),
684
+ mode: "rpc",
685
+ });
686
+
687
+ return session;
688
+ }
689
+
690
+ // ---------------------------------------------------------------------------
691
+ // Tool registration
692
+ // ---------------------------------------------------------------------------
693
+
694
+ interface SpawnToolConfig {
695
+ role: AgentRole;
696
+ label: string;
697
+ description: string;
698
+ promptSnippet: string;
699
+ promptGuidelines: string[];
700
+ parameters: TObject<any>;
701
+ resolveCwd: (params: SpawnToolParams, ctx: ExtensionContext) => string;
702
+ hint?: (params: SpawnToolParams) => string | undefined;
703
+ }
704
+
705
+ const DELEGATE_TOOL: SpawnToolConfig = {
706
+ role: "delegate",
707
+ label: "Delegate",
708
+ description:
709
+ "Delegate a task to a subagent. " +
710
+ "For general-purpose work that doesn't fit the review or explore tools. " +
711
+ "The result includes an agent id — use follow_up to continue working with the same agent.",
712
+ promptSnippet: "Delegate a task to a worker subagent",
713
+ promptGuidelines: [
714
+ "Use the delegate tool for non-trivial, self-contained implementation tasks — it has full tool access unlike the read-only review and explore tools.",
715
+ "Use follow_up with the agent id from the result to refine a subagent's work or recover from an incomplete result, instead of spawning a fresh agent.",
716
+ ],
717
+ parameters: DelegateParams,
718
+ resolveCwd: (params, ctx) => params.cwd ?? ctx.cwd,
719
+ hint: (params) => (params.cwd ? shortenPath(params.cwd) : undefined),
720
+ };
721
+
722
+ const REVIEW_TOOL: SpawnToolConfig = {
723
+ role: "review",
724
+ label: "Review",
725
+ description:
726
+ "Review code changes or files in the current project. " +
727
+ "The reviewer is always read-only and runs in the parent's working directory. " +
728
+ "Use for code review, diff inspection, issue analysis, and quality checks. " +
729
+ "The result includes an agent id — use follow_up to ask the reviewer more questions.",
730
+ promptSnippet: "Review code or files with a read-only subagent",
731
+ promptGuidelines: [
732
+ "Use the review tool for code review, diff inspection, and quality checks — it is read-only and cannot modify files.",
733
+ ],
734
+ parameters: ReviewParams,
735
+ resolveCwd: (_params, ctx) => ctx.cwd,
736
+ };
737
+
738
+ const EXPLORE_TOOL: SpawnToolConfig = {
739
+ role: "explore",
740
+ label: "Explore",
741
+ description:
742
+ "Explore a project directory to understand its structure, patterns, and key files. " +
743
+ "The explorer is always read-only and runs in the specified working directory. " +
744
+ "Use for mapping codebases, scouting dependencies, or understanding unfamiliar projects. " +
745
+ "The result includes an agent id — use follow_up to dig deeper with the same explorer.",
746
+ promptSnippet: "Explore a project directory with a read-only subagent",
747
+ promptGuidelines: [
748
+ "Use the explore tool to map unfamiliar codebases or scout dependencies — it runs read-only in the specified directory.",
749
+ ],
750
+ parameters: ExploreParams,
751
+ resolveCwd: (params) => params.cwd!,
752
+ hint: (params) => shortenPath(params.cwd!),
753
+ };
754
+
755
+ /**
756
+ * Build a spawn tool's definition. Everything is derived from the role
757
+ * config except `execute` — so the parent's real tool and the delegate
758
+ * child's rejecting guard differ by exactly one function.
759
+ */
760
+ function spawnToolDefinition(
761
+ config: SpawnToolConfig,
762
+ execute: (
763
+ params: SpawnToolParams,
764
+ signal: AbortSignal | undefined,
765
+ onUpdate: ((result: AgentToolResult<any>) => void) | undefined,
766
+ ctx: ExtensionContext,
767
+ ) => Promise<AgentToolResult<any>>,
768
+ ) {
769
+ return {
770
+ name: config.role,
771
+ label: config.label,
772
+ description: config.description,
773
+ promptSnippet: config.promptSnippet,
774
+ promptGuidelines: config.promptGuidelines,
775
+ parameters: config.parameters,
776
+ execute: (
777
+ _toolCallId: string,
778
+ params: SpawnToolParams,
779
+ signal: AbortSignal | undefined,
780
+ onUpdate: ((result: AgentToolResult<any>) => void) | undefined,
781
+ ctx: ExtensionContext,
782
+ ) => execute(params, signal, onUpdate, ctx),
783
+ renderCall: (args: SpawnToolParams, theme: any) =>
784
+ renderSubagentCall(
785
+ `${config.role} `,
786
+ { task: args.task, hint: config.hint?.(args) },
787
+ theme,
788
+ ),
789
+ renderResult: (result: any, options: any, theme: any, context: any) =>
790
+ renderSubagentResult(result, options, theme, context),
791
+ };
792
+ }
793
+
794
+ function registerSpawnTool(
795
+ pi: ExtensionAPI,
796
+ manager: AgentManager,
797
+ config: SpawnToolConfig,
798
+ ): void {
799
+ pi.registerTool(
800
+ spawnToolDefinition(config, (params, signal, onUpdate, ctx) =>
801
+ manager.spawn({
802
+ role: config.role,
803
+ task: params.task,
804
+ cwd: config.resolveCwd(params, ctx),
805
+ skills: params.skills,
806
+ ctx,
807
+ signal,
808
+ onUpdate,
809
+ }),
810
+ ) as any,
811
+ );
812
+ }
813
+
814
+ /** The rejecting delegate tool registered in delegate children. */
815
+ function registerRejectingDelegateTool(pi: ExtensionAPI): void {
816
+ pi.registerTool(
817
+ spawnToolDefinition(DELEGATE_TOOL, () =>
818
+ fail(
819
+ "Delegation not available in delegate subagents. Use review or explore instead.",
820
+ ),
821
+ ) as any,
822
+ );
823
+ }
824
+
825
+ function registerFollowUpTool(pi: ExtensionAPI, manager: AgentManager): void {
826
+ pi.registerTool({
827
+ name: "follow_up",
828
+ label: "Follow Up",
829
+ description:
830
+ "Send a follow-up task to a previously spawned subagent, continuing its session with full context. " +
831
+ "The agent retains its original role, tools, and working directory. " +
832
+ "Errors if the agent id is unknown or expired — spawn a fresh agent instead.",
833
+ promptSnippet: "Continue working with an existing subagent",
834
+ promptGuidelines: [
835
+ "Use follow_up to refine a subagent's work, ask questions about its findings, or recover from an incomplete or failed result — the agent keeps its full context.",
836
+ ],
837
+ parameters: FollowUpParams,
838
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
839
+ return manager.followUp({
840
+ agent: params.agent,
841
+ task: params.task,
842
+ signal,
843
+ onUpdate,
844
+ });
845
+ },
846
+ renderCall: (args, theme) =>
847
+ renderSubagentCall("follow_up ", { task: args.task, hint: args.agent }, theme),
848
+ renderResult: (result, options, theme, context) =>
849
+ renderSubagentResult(result as any, options, theme, context),
850
+ });
851
+ }
852
+
853
+
854
+ function wireLifecycle(pi: ExtensionAPI, manager: AgentManager): void {
855
+ pi.on("turn_end", () => manager.noteTurnEnd());
856
+ pi.on("session_shutdown", () => manager.disposeAll());
857
+ }
858
+
859
+ /** Full tool surface for the parent session. */
860
+ export function registerAgentTools(pi: ExtensionAPI, manager: AgentManager): void {
861
+ wireLifecycle(pi, manager);
862
+ registerSpawnTool(pi, manager, DELEGATE_TOOL);
863
+ registerSpawnTool(pi, manager, REVIEW_TOOL);
864
+ registerSpawnTool(pi, manager, EXPLORE_TOOL);
865
+ registerFollowUpTool(pi, manager);
866
+ }
867
+
868
+ /**
869
+ * Guarded tool surface for delegate children: review/explore/follow_up are
870
+ * active; delegate is registered but rejects — the structural recursion guard.
871
+ */
872
+ export function registerChildAgentTools(
873
+ pi: ExtensionAPI,
874
+ manager: AgentManager,
875
+ ): void {
876
+ wireLifecycle(pi, manager);
877
+ registerRejectingDelegateTool(pi);
878
+ registerSpawnTool(pi, manager, REVIEW_TOOL);
879
+ registerSpawnTool(pi, manager, EXPLORE_TOOL);
880
+ registerFollowUpTool(pi, manager);
881
+ }