pi-subagents 0.24.3 → 0.25.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +26 -5
  2. package/README.md +19 -11
  3. package/package.json +4 -8
  4. package/prompts/review-loop.md +1 -1
  5. package/skills/pi-subagents/SKILL.md +46 -10
  6. package/src/agents/agent-management.ts +5 -0
  7. package/src/agents/agent-serializer.ts +2 -0
  8. package/src/agents/agents.ts +30 -6
  9. package/src/agents/skills.ts +25 -23
  10. package/src/extension/config.ts +16 -0
  11. package/src/extension/fanout-child.ts +170 -0
  12. package/src/extension/index.ts +13 -25
  13. package/src/intercom/intercom-bridge.ts +2 -1
  14. package/src/intercom/result-intercom.ts +108 -0
  15. package/src/runs/background/async-execution.ts +107 -7
  16. package/src/runs/background/async-job-tracker.ts +57 -14
  17. package/src/runs/background/async-resume.ts +28 -15
  18. package/src/runs/background/async-status.ts +60 -30
  19. package/src/runs/background/result-watcher.ts +111 -54
  20. package/src/runs/background/run-id-resolver.ts +83 -0
  21. package/src/runs/background/run-status.ts +79 -3
  22. package/src/runs/background/stale-run-reconciler.ts +46 -1
  23. package/src/runs/background/subagent-runner.ts +66 -18
  24. package/src/runs/foreground/chain-execution.ts +6 -0
  25. package/src/runs/foreground/execution.ts +21 -5
  26. package/src/runs/foreground/subagent-executor.ts +314 -18
  27. package/src/runs/shared/completion-guard.ts +23 -1
  28. package/src/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  29. package/src/runs/shared/nested-events.ts +819 -0
  30. package/src/runs/shared/nested-path.ts +52 -0
  31. package/src/runs/shared/nested-render.ts +115 -0
  32. package/src/runs/shared/parallel-utils.ts +1 -0
  33. package/src/runs/shared/pi-args.ts +67 -5
  34. package/src/runs/shared/run-history.ts +12 -7
  35. package/src/runs/shared/single-output.ts +12 -2
  36. package/src/runs/shared/subagent-prompt-runtime.ts +25 -5
  37. package/src/shared/artifacts.ts +2 -2
  38. package/src/shared/types.ts +95 -0
  39. package/src/shared/utils.ts +11 -1
  40. package/src/tui/render.ts +254 -153
@@ -6,6 +6,7 @@ import { execSync } from "node:child_process";
6
6
  import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
+ import { getAgentDir } from "../shared/utils.ts";
9
10
 
10
11
  export type SkillSource =
11
12
  | "project"
@@ -46,11 +47,10 @@ interface SkillSearchPath {
46
47
  const skillCache = new Map<string, SkillCacheEntry>();
47
48
  const MAX_CACHE_SIZE = 50;
48
49
 
49
- let loadSkillsCache: { cwd: string; skills: CachedSkillEntry[]; timestamp: number } | null = null;
50
+ let loadSkillsCache: { cwd: string; agentDir: string; skills: CachedSkillEntry[]; timestamp: number } | null = null;
50
51
  const LOAD_SKILLS_CACHE_TTL_MS = 5000;
51
52
 
52
53
  const CONFIG_DIR = ".pi";
53
- const AGENT_DIR = path.join(os.homedir(), ".pi", "agent");
54
54
  const SUBAGENT_ORCHESTRATION_SKILL = "pi-subagents";
55
55
 
56
56
  const SOURCE_PRIORITY: Record<SkillSource, number> = {
@@ -133,10 +133,10 @@ function getGlobalNpmRoot(): string | null {
133
133
  }
134
134
  }
135
135
 
136
- function collectInstalledPackageSkillPaths(cwd: string): SkillSearchPath[] {
136
+ function collectInstalledPackageSkillPaths(cwd: string, agentDir: string): SkillSearchPath[] {
137
137
  const dirs: SkillSearchPath[] = [
138
138
  { path: path.join(cwd, CONFIG_DIR, "npm", "node_modules"), source: "project-package" },
139
- { path: path.join(AGENT_DIR, "npm", "node_modules"), source: "user-package" },
139
+ { path: path.join(agentDir, "npm", "node_modules"), source: "user-package" },
140
140
  ];
141
141
 
142
142
  const globalRoot = getGlobalNpmRoot();
@@ -184,11 +184,11 @@ function collectInstalledPackageSkillPaths(cwd: string): SkillSearchPath[] {
184
184
  return results;
185
185
  }
186
186
 
187
- function collectSettingsSkillPaths(cwd: string): SkillSearchPath[] {
187
+ function collectSettingsSkillPaths(cwd: string, agentDir: string): SkillSearchPath[] {
188
188
  const results: SkillSearchPath[] = [];
189
189
  const settingsFiles = [
190
190
  { file: path.join(cwd, CONFIG_DIR, "settings.json"), base: path.join(cwd, CONFIG_DIR), source: "project-settings" as const },
191
- { file: path.join(AGENT_DIR, "settings.json"), base: AGENT_DIR, source: "user-settings" as const },
191
+ { file: path.join(agentDir, "settings.json"), base: agentDir, source: "user-settings" as const },
192
192
  ];
193
193
 
194
194
  for (const { file, base, source } of settingsFiles) {
@@ -285,10 +285,10 @@ function resolveSettingsPackageRoot(source: string, baseDir: string): string | u
285
285
  return undefined;
286
286
  }
287
287
 
288
- function collectSettingsPackageSkillPaths(cwd: string): SkillSearchPath[] {
288
+ function collectSettingsPackageSkillPaths(cwd: string, agentDir: string): SkillSearchPath[] {
289
289
  const settingsFiles = [
290
290
  { file: path.join(cwd, CONFIG_DIR, "settings.json"), base: path.join(cwd, CONFIG_DIR), source: "project-package" as const },
291
- { file: path.join(AGENT_DIR, "settings.json"), base: AGENT_DIR, source: "user-package" as const },
291
+ { file: path.join(agentDir, "settings.json"), base: agentDir, source: "user-package" as const },
292
292
  ];
293
293
  const results: SkillSearchPath[] = [];
294
294
 
@@ -315,16 +315,16 @@ function collectSettingsPackageSkillPaths(cwd: string): SkillSearchPath[] {
315
315
  return results;
316
316
  }
317
317
 
318
- function buildSkillPaths(cwd: string): SkillSearchPath[] {
318
+ function buildSkillPaths(cwd: string, agentDir: string): SkillSearchPath[] {
319
319
  const skillPaths: SkillSearchPath[] = [
320
320
  { path: path.join(cwd, CONFIG_DIR, "skills"), source: "project" },
321
321
  { path: path.join(cwd, ".agents", "skills"), source: "project" },
322
- { path: path.join(AGENT_DIR, "skills"), source: "user" },
322
+ { path: path.join(agentDir, "skills"), source: "user" },
323
323
  { path: path.join(os.homedir(), ".agents", "skills"), source: "user" },
324
- ...collectInstalledPackageSkillPaths(cwd),
325
- ...collectSettingsPackageSkillPaths(cwd),
324
+ ...collectInstalledPackageSkillPaths(cwd, agentDir),
325
+ ...collectSettingsPackageSkillPaths(cwd, agentDir),
326
326
  ...extractSkillPathsFromPackageRoot(cwd, "project-package"),
327
- ...collectSettingsSkillPaths(cwd),
327
+ ...collectSettingsSkillPaths(cwd, agentDir),
328
328
  ];
329
329
 
330
330
  const deduped = new Map<string, SkillSearchPath>();
@@ -337,15 +337,16 @@ function buildSkillPaths(cwd: string): SkillSearchPath[] {
337
337
  return [...deduped.values()];
338
338
  }
339
339
 
340
- function inferSkillSource(filePath: string, cwd: string, sourceHint?: SkillSource): SkillSource {
340
+ function inferSkillSource(filePath: string, cwd: string, agentDir: string, sourceHint?: SkillSource): SkillSource {
341
341
  if (sourceHint) return sourceHint;
342
342
 
343
343
  const projectConfigRoot = path.resolve(cwd, CONFIG_DIR);
344
344
  const projectSkillsRoot = path.resolve(cwd, CONFIG_DIR, "skills");
345
345
  const projectPackagesRoot = path.resolve(cwd, CONFIG_DIR, "npm", "node_modules");
346
346
  const projectAgentsRoot = path.resolve(cwd, ".agents");
347
- const userSkillsRoot = path.resolve(AGENT_DIR, "skills");
348
- const userPackagesRoot = path.resolve(AGENT_DIR, "npm", "node_modules");
347
+ const userSkillsRoot = path.resolve(agentDir, "skills");
348
+ const userPackagesRoot = path.resolve(agentDir, "npm", "node_modules");
349
+ const userAgentRoot = path.resolve(agentDir);
349
350
  const userAgentsRoot = path.resolve(os.homedir(), ".agents");
350
351
 
351
352
  if (isWithinPath(filePath, projectPackagesRoot)) return "project-package";
@@ -354,7 +355,7 @@ function inferSkillSource(filePath: string, cwd: string, sourceHint?: SkillSourc
354
355
 
355
356
  if (isWithinPath(filePath, userPackagesRoot)) return "user-package";
356
357
  if (isWithinPath(filePath, userSkillsRoot) || isWithinPath(filePath, userAgentsRoot)) return "user";
357
- if (isWithinPath(filePath, AGENT_DIR)) return "user-settings";
358
+ if (isWithinPath(filePath, userAgentRoot)) return "user-settings";
358
359
 
359
360
  const globalRoot = getGlobalNpmRoot();
360
361
  if (globalRoot && isWithinPath(filePath, globalRoot)) return "user-package";
@@ -390,7 +391,7 @@ function maybeReadSkillDescription(filePath: string): string | undefined {
390
391
  }
391
392
  }
392
393
 
393
- function collectFilesystemSkills(cwd: string, skillPaths: SkillSearchPath[]): CachedSkillEntry[] {
394
+ function collectFilesystemSkills(cwd: string, agentDir: string, skillPaths: SkillSearchPath[]): CachedSkillEntry[] {
394
395
  const entries: CachedSkillEntry[] = [];
395
396
  const seen = new Set<string>();
396
397
  let order = 0;
@@ -403,7 +404,7 @@ function collectFilesystemSkills(cwd: string, skillPaths: SkillSearchPath[]): Ca
403
404
  entries.push({
404
405
  name,
405
406
  filePath: resolvedFile,
406
- source: inferSkillSource(resolvedFile, cwd, sourceHint),
407
+ source: inferSkillSource(resolvedFile, cwd, agentDir, sourceHint),
407
408
  description: maybeReadSkillDescription(resolvedFile),
408
409
  order: order++,
409
410
  });
@@ -464,12 +465,13 @@ function collectFilesystemSkills(cwd: string, skillPaths: SkillSearchPath[]): Ca
464
465
 
465
466
  function getCachedSkills(cwd: string): CachedSkillEntry[] {
466
467
  const now = Date.now();
467
- if (loadSkillsCache && loadSkillsCache.cwd === cwd && now - loadSkillsCache.timestamp < LOAD_SKILLS_CACHE_TTL_MS) {
468
+ const agentDir = getAgentDir();
469
+ if (loadSkillsCache && loadSkillsCache.cwd === cwd && loadSkillsCache.agentDir === agentDir && now - loadSkillsCache.timestamp < LOAD_SKILLS_CACHE_TTL_MS) {
468
470
  return loadSkillsCache.skills;
469
471
  }
470
472
 
471
- const skillPaths = buildSkillPaths(cwd);
472
- const loaded = collectFilesystemSkills(cwd, skillPaths);
473
+ const skillPaths = buildSkillPaths(cwd, agentDir);
474
+ const loaded = collectFilesystemSkills(cwd, agentDir, skillPaths);
473
475
  const dedupedByName = new Map<string, CachedSkillEntry>();
474
476
 
475
477
  for (const entry of loaded) {
@@ -478,7 +480,7 @@ function getCachedSkills(cwd: string): CachedSkillEntry[] {
478
480
  }
479
481
 
480
482
  const skills = [...dedupedByName.values()].sort((a, b) => a.order - b.order);
481
- loadSkillsCache = { cwd, skills, timestamp: now };
483
+ loadSkillsCache = { cwd, agentDir, skills, timestamp: now };
482
484
  return skills;
483
485
  }
484
486
 
@@ -0,0 +1,16 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ExtensionConfig } from "../shared/types.ts";
4
+ import { getAgentDir } from "../shared/utils.ts";
5
+
6
+ export function loadConfig(): ExtensionConfig {
7
+ const configPath = path.join(getAgentDir(), "extensions", "subagent", "config.json");
8
+ try {
9
+ if (fs.existsSync(configPath)) {
10
+ return JSON.parse(fs.readFileSync(configPath, "utf-8")) as ExtensionConfig;
11
+ }
12
+ } catch (error) {
13
+ console.error(`Failed to load subagent config from '${configPath}':`, error);
14
+ }
15
+ return {};
16
+ }
@@ -0,0 +1,170 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
5
+ import { discoverAgents } from "../agents/agents.ts";
6
+ import { getArtifactsDir } from "../shared/artifacts.ts";
7
+ import { createSubagentExecutor, type SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
8
+ import { SUBAGENT_CHILD_ENV, SUBAGENT_FANOUT_CHILD_ENV } from "../runs/shared/pi-args.ts";
9
+ import { readNestedControlRequests, resolveNestedRouteFromEnv, writeNestedControlResult } from "../runs/shared/nested-events.ts";
10
+ import { deliverSubagentIntercomMessageEvent } from "../intercom/result-intercom.ts";
11
+ import { resolveSubagentIntercomTarget } from "../intercom/intercom-bridge.ts";
12
+ import { SubagentParams } from "./schemas.ts";
13
+ import { loadConfig } from "./config.ts";
14
+ import { type Details, type SubagentState } from "../shared/types.ts";
15
+
16
+ function getSubagentSessionRoot(parentSessionFile: string | null): string {
17
+ if (parentSessionFile) {
18
+ const baseName = path.basename(parentSessionFile, ".jsonl");
19
+ const sessionsDir = path.dirname(parentSessionFile);
20
+ return path.join(sessionsDir, baseName);
21
+ }
22
+ return fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-session-"));
23
+ }
24
+
25
+ function expandTilde(p: string): string {
26
+ return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
27
+ }
28
+
29
+ function createChildSafeState(): SubagentState {
30
+ return {
31
+ baseCwd: "",
32
+ currentSessionId: null,
33
+ asyncJobs: new Map(),
34
+ foregroundRuns: new Map(),
35
+ foregroundControls: new Map(),
36
+ lastForegroundControlId: null,
37
+ pendingForegroundControlNotices: new Map(),
38
+ cleanupTimers: new Map(),
39
+ lastUiContext: null,
40
+ poller: null,
41
+ completionSeen: new Map(),
42
+ watcher: null,
43
+ watcherRestartTimer: null,
44
+ resultFileCoalescer: {
45
+ schedule: () => false,
46
+ clear: () => {},
47
+ },
48
+ };
49
+ }
50
+
51
+ function startNestedControlInboxListener(pi: ExtensionAPI, state: SubagentState): NodeJS.Timeout | undefined {
52
+ let route;
53
+ try {
54
+ route = resolveNestedRouteFromEnv();
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ if (!route) return undefined;
59
+ const seen = new Set<string>();
60
+ const inFlight = new Set<string>();
61
+ const pendingResults = new Map<string, Parameters<typeof writeNestedControlResult>[1]>();
62
+ const timer = setInterval(() => {
63
+ try {
64
+ for (const request of readNestedControlRequests(route)) {
65
+ if (seen.has(request.requestId) || inFlight.has(request.requestId)) continue;
66
+ inFlight.add(request.requestId);
67
+ void (async () => {
68
+ try {
69
+ let result = pendingResults.get(request.requestId);
70
+ if (!result) {
71
+ let ok = false;
72
+ let message = "Control request failed.";
73
+ try {
74
+ const control = state.foregroundControls.get(request.targetRunId);
75
+ if (!control) {
76
+ message = `Nested run ${request.targetRunId} is not active in this fanout child.`;
77
+ } else if (request.action === "interrupt") {
78
+ ok = control.interrupt?.() === true;
79
+ message = ok
80
+ ? `Interrupt requested for nested run ${request.targetRunId}.`
81
+ : `Nested run ${request.targetRunId} has no active child step to interrupt.`;
82
+ } else if (!request.message?.trim()) {
83
+ message = "Nested resume requires message.";
84
+ } else if (!control.currentAgent) {
85
+ message = `Nested run ${request.targetRunId} has no active child message route.`;
86
+ } else {
87
+ const index = control.currentIndex ?? 0;
88
+ const target = resolveSubagentIntercomTarget(request.targetRunId, control.currentAgent, index);
89
+ ok = await deliverSubagentIntercomMessageEvent(
90
+ pi.events,
91
+ target,
92
+ `Follow-up for nested run ${request.targetRunId} (${control.currentAgent}):\n\n${request.message.trim()}`,
93
+ 500,
94
+ { source: "nested-resume", runId: request.targetRunId, agent: control.currentAgent, index },
95
+ );
96
+ message = ok
97
+ ? `Delivered follow-up to live nested run ${request.targetRunId}.`
98
+ : `Nested child intercom target is not registered: ${target}`;
99
+ }
100
+ } catch (error) {
101
+ message = error instanceof Error ? error.message : String(error);
102
+ }
103
+ result = { ts: Date.now(), requestId: request.requestId, targetRunId: request.targetRunId, ok, message };
104
+ }
105
+ try {
106
+ writeNestedControlResult(route, result);
107
+ } catch (error) {
108
+ pendingResults.set(request.requestId, result);
109
+ console.error(`Failed to write nested control result for request '${request.requestId}' targeting '${request.targetRunId}' via inbox '${route.controlInbox}'; keeping request for retry:`, error);
110
+ return;
111
+ }
112
+ pendingResults.delete(request.requestId);
113
+ seen.add(request.requestId);
114
+ try { fs.unlinkSync(request.filePath); } catch {}
115
+ } finally {
116
+ inFlight.delete(request.requestId);
117
+ }
118
+ })();
119
+ }
120
+ } catch (error) {
121
+ console.error(`Failed to poll nested control inbox '${route.controlInbox}' for root '${route.rootRunId}':`, error);
122
+ }
123
+ }, 200);
124
+ timer.unref?.();
125
+ return timer;
126
+ }
127
+
128
+ export default function registerFanoutChildSubagentExtension(pi: ExtensionAPI): void {
129
+ if (process.env[SUBAGENT_CHILD_ENV] !== "1" || process.env[SUBAGENT_FANOUT_CHILD_ENV] !== "1") return;
130
+
131
+ const globalStore = globalThis as Record<string, unknown>;
132
+ const registeredKey = "__piSubagentFanoutChildRegisteredApis";
133
+ const registeredApis = globalStore[registeredKey] instanceof WeakSet
134
+ ? globalStore[registeredKey] as WeakSet<ExtensionAPI>
135
+ : new WeakSet<ExtensionAPI>();
136
+ globalStore[registeredKey] = registeredApis;
137
+ if (registeredApis.has(pi)) return;
138
+ registeredApis.add(pi);
139
+
140
+ const config = loadConfig();
141
+ const state = createChildSafeState();
142
+ const executor = createSubagentExecutor({
143
+ pi,
144
+ state,
145
+ config,
146
+ asyncByDefault: config.asyncByDefault === true,
147
+ tempArtifactsDir: getArtifactsDir(null),
148
+ getSubagentSessionRoot,
149
+ expandTilde,
150
+ discoverAgents,
151
+ allowMutatingManagementActions: false,
152
+ });
153
+
154
+ const tool: ToolDefinition<typeof SubagentParams, Details> = {
155
+ name: "subagent",
156
+ label: "Subagent",
157
+ description: [
158
+ "Delegate to subagents from child-safe fanout mode.",
159
+ "Allowed management/control actions: list, get, status, interrupt, resume, doctor.",
160
+ "Agent config mutation actions create, update, and delete are blocked in this mode.",
161
+ ].join("\n"),
162
+ parameters: SubagentParams,
163
+ execute(id, params, signal, onUpdate, ctx) {
164
+ return executor.execute(id, params as SubagentParamsLike, signal, onUpdate, ctx);
165
+ },
166
+ };
167
+
168
+ pi.registerTool(tool);
169
+ startNestedControlInboxListener(pi, state);
170
+ }
@@ -22,7 +22,7 @@ import { discoverAgents } from "../agents/agents.ts";
22
22
  import { cleanupAllArtifactDirs, cleanupOldArtifacts, getArtifactsDir } from "../shared/artifacts.ts";
23
23
  import { resolveCurrentSessionId } from "../shared/session-identity.ts";
24
24
  import { cleanupOldChainDirs } from "../shared/settings.ts";
25
- import { renderWidget, renderSubagentResult, stopResultAnimations, stopWidgetAnimation, syncResultAnimation } from "../tui/render.ts";
25
+ import { clearLegacyResultAnimationTimer, renderWidget, renderSubagentResult } from "../tui/render.ts";
26
26
  import { SubagentParams } from "./schemas.ts";
27
27
  import { createSubagentExecutor, type SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
28
28
  import { createAsyncJobTracker } from "../runs/background/async-job-tracker.ts";
@@ -33,11 +33,12 @@ import { registerSlashSubagentBridge } from "../slash/slash-bridge.ts";
33
33
  import { clearSlashSnapshots, getSlashRenderableSnapshot, resolveSlashMessageDetails, restoreSlashFinalSnapshots, type SlashMessageDetails } from "../slash/slash-live-state.ts";
34
34
  import { inspectSubagentStatus } from "../runs/background/run-status.ts";
35
35
  import registerSubagentNotify, { type SubagentNotifyDetails } from "../runs/background/notify.ts";
36
- import { SUBAGENT_CHILD_ENV } from "../runs/shared/pi-args.ts";
36
+ import { SUBAGENT_CHILD_ENV, SUBAGENT_FANOUT_CHILD_ENV } from "../runs/shared/pi-args.ts";
37
+ import registerFanoutChildSubagentExtension from "./fanout-child.ts";
37
38
  import { formatDuration, shortenPath } from "../shared/formatters.ts";
39
+ import { loadConfig } from "./config.ts";
38
40
  import {
39
41
  type Details,
40
- type ExtensionConfig,
41
42
  type SubagentState,
42
43
  ASYNC_DIR,
43
44
  DEFAULT_ARTIFACT_CONFIG,
@@ -56,6 +57,8 @@ import {
56
57
  type SubagentControlMessageDetails,
57
58
  } from "./control-notices.ts";
58
59
 
60
+ export { loadConfig } from "./config.ts";
61
+
59
62
  /**
60
63
  * Derive subagent session base directory from parent session file.
61
64
  * If parent session is ~/.pi/agent/sessions/abc123.jsonl,
@@ -72,18 +75,6 @@ function getSubagentSessionRoot(parentSessionFile: string | null): string {
72
75
  return fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-session-"));
73
76
  }
74
77
 
75
- function loadConfig(): ExtensionConfig {
76
- const configPath = path.join(os.homedir(), ".pi", "agent", "extensions", "subagent", "config.json");
77
- try {
78
- if (fs.existsSync(configPath)) {
79
- return JSON.parse(fs.readFileSync(configPath, "utf-8")) as ExtensionConfig;
80
- }
81
- } catch (error) {
82
- console.error(`Failed to load subagent config from '${configPath}':`, error);
83
- }
84
- return {};
85
- }
86
-
87
78
  function expandTilde(p: string): string {
88
79
  return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
89
80
  }
@@ -142,14 +133,11 @@ function createSlashResultComponent(
142
133
  details: SlashMessageDetails,
143
134
  options: { expanded: boolean },
144
135
  theme: ExtensionContext["ui"]["theme"],
145
- requestRender: () => void,
146
136
  ): Container {
147
137
  const container = new Container();
148
- const animationState: { subagentResultAnimationTimer?: ReturnType<typeof setInterval> } = {};
149
138
  let lastVersion = -1;
150
139
  container.render = (width: number): string[] => {
151
140
  const snapshot = getSlashRenderableSnapshot(details);
152
- syncResultAnimation(snapshot.result, { state: animationState, invalidate: requestRender });
153
141
  if (snapshot.version !== lastVersion || isSlashResultRunning(snapshot.result)) {
154
142
  lastVersion = snapshot.version;
155
143
  rebuildSlashResultContainer(container, snapshot.result, options, theme);
@@ -220,7 +208,10 @@ class SubagentControlNoticeComponent implements Component {
220
208
  }
221
209
 
222
210
  export default function registerSubagentExtension(pi: ExtensionAPI): void {
223
- if (process.env[SUBAGENT_CHILD_ENV] === "1") return;
211
+ if (process.env[SUBAGENT_CHILD_ENV] === "1") {
212
+ if (process.env[SUBAGENT_FANOUT_CHILD_ENV] === "1") registerFanoutChildSubagentExtension(pi);
213
+ return;
214
+ }
224
215
  const globalStore = globalThis as Record<string, unknown>;
225
216
  const runtimeCleanupStoreKey = "__piSubagentRuntimeCleanup";
226
217
  const previousRuntimeCleanup = globalStore[runtimeCleanupStoreKey];
@@ -271,8 +262,6 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
271
262
  primeExistingResults();
272
263
 
273
264
  const runtimeCleanup = () => {
274
- stopWidgetAnimation();
275
- stopResultAnimations();
276
265
  stopResultWatcher();
277
266
  clearPendingForegroundControlNotices(state);
278
267
  if (state.poller) {
@@ -297,7 +286,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
297
286
  pi.registerMessageRenderer<SlashMessageDetails>(SLASH_RESULT_TYPE, (message, options, theme) => {
298
287
  const details = resolveSlashMessageDetails(message.details);
299
288
  if (!details) return undefined;
300
- return createSlashResultComponent(details, options, theme, () => state.lastUiContext?.ui.requestRender?.());
289
+ return createSlashResultComponent(details, options, theme);
301
290
  });
302
291
 
303
292
  pi.registerMessageRenderer<SubagentNotifyDetails>("subagent-notify", (message, options, theme) => {
@@ -466,7 +455,7 @@ DIAGNOSTICS:
466
455
  },
467
456
 
468
457
  renderResult(result, options, theme, context) {
469
- syncResultAnimation(result, context);
458
+ clearLegacyResultAnimationTimer(context);
470
459
  return renderSubagentResult(result, options, theme);
471
460
  },
472
461
 
@@ -514,6 +503,7 @@ DIAGNOSTICS:
514
503
  state.lastUiContext = ctx;
515
504
  if (state.asyncJobs.size > 0) {
516
505
  renderWidget(ctx, Array.from(state.asyncJobs.values()));
506
+ ctx.ui.requestRender?.();
517
507
  ensurePoller();
518
508
  }
519
509
  });
@@ -569,8 +559,6 @@ DIAGNOSTICS:
569
559
  slashBridge.dispose();
570
560
  promptTemplateBridge.cancelAll();
571
561
  promptTemplateBridge.dispose();
572
- stopWidgetAnimation();
573
- stopResultAnimations();
574
562
  if (globalStore[runtimeCleanupStoreKey] === runtimeCleanup) {
575
563
  delete globalStore[runtimeCleanupStoreKey];
576
564
  }
@@ -4,12 +4,13 @@ import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import type { AgentConfig } from "../agents/agents.ts";
6
6
  import type { ExtensionConfig, IntercomBridgeConfig, IntercomBridgeMode } from "../shared/types.ts";
7
+ import { getAgentDir } from "../shared/utils.ts";
7
8
 
8
9
  const PI_INTERCOM_PACKAGE_NAME = "pi-intercom";
9
10
  const CONFIG_DIR = ".pi";
10
11
 
11
12
  function defaultAgentDir(): string {
12
- return path.join(os.homedir(), ".pi", "agent");
13
+ return getAgentDir();
13
14
  }
14
15
 
15
16
  function defaultIntercomExtensionDir(agentDir = defaultAgentDir()): string {
@@ -3,6 +3,8 @@ import * as fs from "node:fs";
3
3
  import {
4
4
  type Details,
5
5
  type IntercomEventBus,
6
+ type NestedRunSummary,
7
+ type PublicNestedRunSummary,
6
8
  type SingleResult,
7
9
  type SubagentResultIntercomChild,
8
10
  type SubagentResultIntercomPayload,
@@ -60,6 +62,110 @@ function resolveGroupedStatus(children: SubagentResultIntercomChild[]): Subagent
60
62
  return "failed";
61
63
  }
62
64
 
65
+ function compactNestedRun(run: NestedRunSummary | PublicNestedRunSummary, depth = 0): PublicNestedRunSummary {
66
+ return {
67
+ id: run.id,
68
+ parentRunId: run.parentRunId,
69
+ ...(run.parentStepIndex !== undefined ? { parentStepIndex: run.parentStepIndex } : {}),
70
+ ...(run.parentAgent ? { parentAgent: run.parentAgent } : {}),
71
+ depth: run.depth,
72
+ path: run.path.slice(0, 4).map((part) => ({
73
+ runId: part.runId,
74
+ ...(part.stepIndex !== undefined ? { stepIndex: part.stepIndex } : {}),
75
+ ...(part.agent ? { agent: part.agent } : {}),
76
+ })),
77
+ ...(run.asyncDir ? { asyncDir: run.asyncDir } : {}),
78
+ ...(run.sessionId ? { sessionId: run.sessionId } : {}),
79
+ ...(run.sessionFile ? { sessionFile: run.sessionFile } : {}),
80
+ ...(run.intercomTarget ? { intercomTarget: run.intercomTarget } : {}),
81
+ ...(run.ownerIntercomTarget ? { ownerIntercomTarget: run.ownerIntercomTarget } : {}),
82
+ ...(run.leafIntercomTarget ? { leafIntercomTarget: run.leafIntercomTarget } : {}),
83
+ ...(run.ownerState ? { ownerState: run.ownerState } : {}),
84
+ ...(run.mode ? { mode: run.mode } : {}),
85
+ state: run.state,
86
+ ...(run.agent ? { agent: run.agent } : {}),
87
+ ...(run.agents?.length ? { agents: run.agents.slice(0, 12) } : {}),
88
+ ...(run.currentStep !== undefined ? { currentStep: run.currentStep } : {}),
89
+ ...(run.chainStepCount !== undefined ? { chainStepCount: run.chainStepCount } : {}),
90
+ ...(run.parallelGroups?.length ? { parallelGroups: run.parallelGroups.slice(0, 8) } : {}),
91
+ ...(run.activityState ? { activityState: run.activityState } : {}),
92
+ ...(run.lastActivityAt !== undefined ? { lastActivityAt: run.lastActivityAt } : {}),
93
+ ...(run.currentTool ? { currentTool: run.currentTool } : {}),
94
+ ...(run.currentToolStartedAt !== undefined ? { currentToolStartedAt: run.currentToolStartedAt } : {}),
95
+ ...(run.currentPath ? { currentPath: run.currentPath } : {}),
96
+ ...(run.turnCount !== undefined ? { turnCount: run.turnCount } : {}),
97
+ ...(run.toolCount !== undefined ? { toolCount: run.toolCount } : {}),
98
+ ...(run.totalTokens ? { totalTokens: run.totalTokens } : {}),
99
+ ...(run.startedAt !== undefined ? { startedAt: run.startedAt } : {}),
100
+ ...(run.endedAt !== undefined ? { endedAt: run.endedAt } : {}),
101
+ ...(run.lastUpdate !== undefined ? { lastUpdate: run.lastUpdate } : {}),
102
+ ...(run.error ? { error: run.error } : {}),
103
+ ...(run.steps?.length ? { steps: run.steps.slice(0, 12).map((step) => ({
104
+ agent: step.agent,
105
+ status: step.status,
106
+ ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
107
+ ...(step.activityState ? { activityState: step.activityState } : {}),
108
+ ...(step.lastActivityAt !== undefined ? { lastActivityAt: step.lastActivityAt } : {}),
109
+ ...(step.currentTool ? { currentTool: step.currentTool } : {}),
110
+ ...(step.currentToolStartedAt !== undefined ? { currentToolStartedAt: step.currentToolStartedAt } : {}),
111
+ ...(step.currentPath ? { currentPath: step.currentPath } : {}),
112
+ ...(step.turnCount !== undefined ? { turnCount: step.turnCount } : {}),
113
+ ...(step.toolCount !== undefined ? { toolCount: step.toolCount } : {}),
114
+ ...(step.startedAt !== undefined ? { startedAt: step.startedAt } : {}),
115
+ ...(step.endedAt !== undefined ? { endedAt: step.endedAt } : {}),
116
+ ...(step.error ? { error: step.error } : {}),
117
+ ...(depth < 2 && step.children?.length ? { children: step.children.slice(0, 8).map((child) => compactNestedRun(child, depth + 1)) } : {}),
118
+ })) } : {}),
119
+ ...(depth < 2 && run.children?.length ? { children: run.children.slice(0, 8).map((child) => compactNestedRun(child, depth + 1)) } : {}),
120
+ };
121
+ }
122
+
123
+ export function compactNestedResultChildren(children: Array<NestedRunSummary | PublicNestedRunSummary> | undefined): PublicNestedRunSummary[] | undefined {
124
+ if (!children?.length) return undefined;
125
+ return children.slice(0, 16).map((child) => compactNestedRun(child));
126
+ }
127
+
128
+ export function attachNestedChildrenToResultChildren(
129
+ runId: string,
130
+ children: SubagentResultIntercomChild[],
131
+ nestedChildren: NestedRunSummary[] | undefined,
132
+ ): SubagentResultIntercomChild[] {
133
+ const compact = compactNestedResultChildren(nestedChildren);
134
+ if (!compact?.length) return children.map((child) => ({ ...child, children: compactNestedResultChildren(child.children) }));
135
+ return children.map((child, index) => {
136
+ const childIndex = child.index ?? index;
137
+ const alreadyAttachedIds = new Set(child.children?.map((nested) => nested.id) ?? []);
138
+ const attached = compact.filter((nested) => nested.parentRunId === runId && nested.parentStepIndex === childIndex && !alreadyAttachedIds.has(nested.id));
139
+ const fallbackAttached = children.length === 1
140
+ ? compact.filter((nested) => nested.parentRunId === runId && nested.parentStepIndex === undefined && !alreadyAttachedIds.has(nested.id))
141
+ : [];
142
+ const merged = compactNestedResultChildren([...(child.children ?? []), ...attached, ...fallbackAttached]);
143
+ return merged?.length ? { ...child, children: merged } : { ...child, children: undefined };
144
+ });
145
+ }
146
+
147
+ function formatNestedResultLines(children: PublicNestedRunSummary[] | undefined): string[] {
148
+ if (!children?.length) return [];
149
+ const lines = ["Nested subagents:"];
150
+ let remaining = 10;
151
+ const append = (runs: PublicNestedRunSummary[] | undefined, indent: string): void => {
152
+ for (const run of runs ?? []) {
153
+ if (remaining <= 0) {
154
+ lines.push(`${indent}↳ +more nested runs; inspect status for full tree`);
155
+ return;
156
+ }
157
+ remaining--;
158
+ const label = run.agent ?? run.agents?.join("+") ?? run.id;
159
+ lines.push(`${indent}↳ ${label} — ${run.state} [${run.id}]`);
160
+ if (run.sessionFile) lines.push(`${indent} Session: ${run.sessionFile}`);
161
+ append(run.children, `${indent} `);
162
+ for (const step of run.steps ?? []) append(step.children, `${indent} `);
163
+ }
164
+ };
165
+ append(children, "");
166
+ return lines;
167
+ }
168
+
63
169
  interface GroupedResultIntercomMessageInput {
64
170
  to: string;
65
171
  runId: string;
@@ -128,6 +234,7 @@ function formatSubagentResultIntercomMessage(input: {
128
234
  if (child.intercomTarget) lines.push(`${input.source === "async" ? "Previous intercom target" : "Run intercom target"}: ${child.intercomTarget}`);
129
235
  if (child.artifactPath) lines.push(`Output artifact: ${child.artifactPath}`);
130
236
  if (child.sessionPath) lines.push(`Session: ${child.sessionPath}`);
237
+ lines.push(...formatNestedResultLines(child.children));
131
238
  lines.push("Summary:");
132
239
  lines.push(child.summary);
133
240
  }
@@ -139,6 +246,7 @@ export function buildSubagentResultIntercomPayload(input: GroupedResultIntercomM
139
246
  const children = input.children.map((child) => ({
140
247
  ...child,
141
248
  summary: child.summary.trim() || "(no output)",
249
+ children: compactNestedResultChildren(child.children),
142
250
  }));
143
251
  const status = resolveGroupedStatus(children);
144
252
  const summary = formatStatusCounts(countStatuses(children));