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
@@ -0,0 +1,52 @@
1
+ import * as path from "node:path";
2
+
3
+ const MAX_NESTED_ID_LENGTH = 128;
4
+ export const MAX_NESTED_PATH_ENTRIES = 4;
5
+
6
+ export type NestedPathEntry = { runId: string; stepIndex?: number; agent?: string };
7
+
8
+ export function isSafeNestedPathId(value: unknown): value is string {
9
+ return typeof value === "string"
10
+ && value.length > 0
11
+ && value.length <= MAX_NESTED_ID_LENGTH
12
+ && !path.isAbsolute(value)
13
+ && !value.includes("/")
14
+ && !value.includes("\\")
15
+ && !value.includes("..");
16
+ }
17
+
18
+ function finiteNumber(value: unknown): number | undefined {
19
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
20
+ }
21
+
22
+ function nonEmptyString(value: unknown, max: number): string | undefined {
23
+ return typeof value === "string" && value.length > 0 ? value.slice(0, max) : undefined;
24
+ }
25
+
26
+ export function sanitizeNestedPath(value: unknown): NestedPathEntry[] {
27
+ if (!Array.isArray(value)) return [];
28
+ return value.map((part) => {
29
+ if (!part || typeof part !== "object") return undefined;
30
+ const record = part as Record<string, unknown>;
31
+ if (!isSafeNestedPathId(record.runId)) return undefined;
32
+ return {
33
+ runId: record.runId,
34
+ ...(finiteNumber(record.stepIndex) !== undefined ? { stepIndex: finiteNumber(record.stepIndex) } : {}),
35
+ ...(nonEmptyString(record.agent, 128) ? { agent: nonEmptyString(record.agent, 128) } : {}),
36
+ };
37
+ }).filter((part): part is NestedPathEntry => Boolean(part)).slice(0, MAX_NESTED_PATH_ENTRIES);
38
+ }
39
+
40
+ export function parseNestedPathEnv(value: string | undefined): NestedPathEntry[] {
41
+ if (!value) return [];
42
+ try {
43
+ return sanitizeNestedPath(JSON.parse(value) as unknown);
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ export function encodeNestedPathEnv(value: NestedPathEntry[]): string {
50
+ const sanitized = sanitizeNestedPath(value);
51
+ return sanitized.length ? JSON.stringify(sanitized) : "";
52
+ }
@@ -0,0 +1,115 @@
1
+ import { formatDuration, formatTokens, shortenPath } from "../../shared/formatters.ts";
2
+ import { formatActivityLabel } from "../../shared/status-format.ts";
3
+ import type { ActivityState, NestedRunSummary, NestedStepSummary } from "../../shared/types.ts";
4
+
5
+ export interface NestedRunCounts {
6
+ total: number;
7
+ running: number;
8
+ paused: number;
9
+ complete: number;
10
+ failed: number;
11
+ queued: number;
12
+ }
13
+
14
+ export function countNestedRuns(children: NestedRunSummary[] | undefined): NestedRunCounts {
15
+ const counts: NestedRunCounts = { total: 0, running: 0, paused: 0, complete: 0, failed: 0, queued: 0 };
16
+ for (const child of children ?? []) {
17
+ counts.total++;
18
+ counts[child.state]++;
19
+ const nested = countNestedRuns([...(child.children ?? []), ...(child.steps?.flatMap((step) => step.children ?? []) ?? [])]);
20
+ counts.total += nested.total;
21
+ counts.running += nested.running;
22
+ counts.paused += nested.paused;
23
+ counts.complete += nested.complete;
24
+ counts.failed += nested.failed;
25
+ counts.queued += nested.queued;
26
+ }
27
+ return counts;
28
+ }
29
+
30
+ export function formatNestedAggregate(children: NestedRunSummary[] | undefined): string | undefined {
31
+ const counts = countNestedRuns(children);
32
+ if (counts.total === 0) return undefined;
33
+ const parts = [
34
+ counts.running > 0 ? `${counts.running} running` : "",
35
+ counts.paused > 0 ? `${counts.paused} paused` : "",
36
+ counts.failed > 0 ? `${counts.failed} failed` : "",
37
+ counts.complete > 0 ? `${counts.complete} complete` : "",
38
+ counts.queued > 0 ? `${counts.queued} queued` : "",
39
+ ].filter(Boolean);
40
+ return `+${counts.total} nested run${counts.total === 1 ? "" : "s"}${parts.length ? ` (${parts.join(", ")})` : ""}`;
41
+ }
42
+
43
+ function nestedRunLabel(run: NestedRunSummary): string {
44
+ if (run.agent) return run.agent;
45
+ if (run.agents?.length) return run.agents.length === 1 ? run.agents[0]! : `${run.agents.slice(0, 2).join(", ")}${run.agents.length > 2 ? ` +${run.agents.length - 2}` : ""}`;
46
+ return run.id;
47
+ }
48
+
49
+ function formatNestedActivity(input: {
50
+ activityState?: ActivityState;
51
+ lastActivityAt?: number;
52
+ currentTool?: string;
53
+ currentToolStartedAt?: number;
54
+ currentPath?: string;
55
+ turnCount?: number;
56
+ toolCount?: number;
57
+ totalTokens?: NestedRunSummary["totalTokens"];
58
+ }): string | undefined {
59
+ const facts: string[] = [];
60
+ if (input.currentTool && input.currentToolStartedAt !== undefined) facts.push(`tool ${input.currentTool} ${formatDuration(Math.max(0, Date.now() - input.currentToolStartedAt))}`);
61
+ else if (input.currentTool) facts.push(`tool ${input.currentTool}`);
62
+ if (input.currentPath) facts.push(shortenPath(input.currentPath));
63
+ if (input.turnCount !== undefined) facts.push(`${input.turnCount} turns`);
64
+ if (input.toolCount !== undefined) facts.push(`${input.toolCount} tools`);
65
+ if (input.totalTokens) facts.push(`${formatTokens(input.totalTokens.total)} tok`);
66
+ const activity = formatActivityLabel(input.lastActivityAt, input.activityState as ActivityState | undefined);
67
+ return activity || facts.length ? [activity, ...facts].filter(Boolean).join(" | ") : undefined;
68
+ }
69
+
70
+ function formatNestedRunLines(children: NestedRunSummary[] | undefined, options: { indent: string; maxDepth: number; maxLines: number; commandHints?: boolean }): string[] {
71
+ const lines: string[] = [];
72
+ const append = (items: NestedRunSummary[] | undefined, depth: number, indent: string): void => {
73
+ if (!items?.length || lines.length >= options.maxLines) return;
74
+ if (depth > options.maxDepth) {
75
+ const aggregate = formatNestedAggregate(items);
76
+ if (aggregate && lines.length < options.maxLines) lines.push(`${indent}↳ ${aggregate}`);
77
+ return;
78
+ }
79
+ for (let index = 0; index < items.length; index++) {
80
+ const child = items[index]!;
81
+ if (lines.length >= options.maxLines) {
82
+ const aggregate = formatNestedAggregate(items.slice(index));
83
+ if (aggregate) lines[lines.length - 1] = `${indent}↳ ${aggregate}`;
84
+ return;
85
+ }
86
+ const activity = child.state === "running" ? formatNestedActivity(child) : undefined;
87
+ const error = child.error ? ` | error: ${child.error}` : "";
88
+ lines.push(`${indent}↳ ${nestedRunLabel(child)} [${child.id}] ${child.state}${activity ? ` | ${activity}` : ""}${error}`);
89
+ if (options.commandHints && lines.length < options.maxLines) lines.push(`${indent} Status: subagent({ action: "status", id: "${child.id}" })`);
90
+ if (depth === options.maxDepth) {
91
+ const aggregate = formatNestedAggregate([...(child.steps?.flatMap((step) => step.children ?? []) ?? []), ...(child.children ?? [])]);
92
+ if (aggregate && lines.length < options.maxLines) lines.push(`${indent} ↳ ${aggregate}`);
93
+ continue;
94
+ }
95
+ for (const [stepIndex, step] of (child.steps ?? []).entries()) {
96
+ if (lines.length >= options.maxLines) return;
97
+ const stepActivity = step.status === "running" ? formatNestedActivity(step) : undefined;
98
+ lines.push(`${indent} ${stepIndex + 1}. ${step.agent} ${step.status}${stepActivity ? ` | ${stepActivity}` : ""}${step.error ? ` | error: ${step.error}` : ""}`);
99
+ append(step.children, depth + 1, `${indent} `);
100
+ }
101
+ append(child.children, depth + 1, `${indent} `);
102
+ }
103
+ };
104
+ append(children, 0, options.indent);
105
+ return lines;
106
+ }
107
+
108
+ export function formatNestedRunStatusLines(children: NestedRunSummary[] | undefined, options: { indent?: string; maxDepth?: number; maxLines?: number; commandHints?: boolean } = {}): string[] {
109
+ return formatNestedRunLines(children, {
110
+ indent: options.indent ?? " ",
111
+ maxDepth: options.maxDepth ?? 2,
112
+ maxLines: options.maxLines ?? 40,
113
+ commandHints: options.commandHints ?? false,
114
+ });
115
+ }
@@ -8,6 +8,7 @@ export interface RunnerSubagentStep {
8
8
  tools?: string[];
9
9
  extensions?: string[];
10
10
  mcpDirectTools?: string[];
11
+ completionGuard?: boolean;
11
12
  systemPrompt?: string | null;
12
13
  systemPromptMode?: "append" | "replace";
13
14
  inheritProjectContext: boolean;
@@ -2,15 +2,27 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { encodeNestedPathEnv, parseNestedPathEnv, type NestedPathEntry } from "./nested-path.ts";
6
+ import { resolveMcpDirectToolNames } from "./mcp-direct-tool-allowlist.ts";
5
7
 
6
8
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
7
9
  const TASK_ARG_LIMIT = 8000;
8
10
  const PROMPT_RUNTIME_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-prompt-runtime.ts");
11
+ const FANOUT_CHILD_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "extension", "fanout-child.ts");
9
12
  export const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
10
13
  export const SUBAGENT_ORCHESTRATOR_TARGET_ENV = "PI_SUBAGENT_ORCHESTRATOR_TARGET";
11
14
  export const SUBAGENT_RUN_ID_ENV = "PI_SUBAGENT_RUN_ID";
12
15
  export const SUBAGENT_CHILD_AGENT_ENV = "PI_SUBAGENT_CHILD_AGENT";
13
16
  export const SUBAGENT_CHILD_INDEX_ENV = "PI_SUBAGENT_CHILD_INDEX";
17
+ export const SUBAGENT_FANOUT_CHILD_ENV = "PI_SUBAGENT_FANOUT_CHILD";
18
+ export const SUBAGENT_PARENT_EVENT_SINK_ENV = "PI_SUBAGENT_PARENT_EVENT_SINK";
19
+ export const SUBAGENT_PARENT_CONTROL_INBOX_ENV = "PI_SUBAGENT_PARENT_CONTROL_INBOX";
20
+ export const SUBAGENT_PARENT_ROOT_RUN_ID_ENV = "PI_SUBAGENT_PARENT_ROOT_RUN_ID";
21
+ export const SUBAGENT_PARENT_RUN_ID_ENV = "PI_SUBAGENT_PARENT_RUN_ID";
22
+ export const SUBAGENT_PARENT_CHILD_INDEX_ENV = "PI_SUBAGENT_PARENT_CHILD_INDEX";
23
+ export const SUBAGENT_PARENT_DEPTH_ENV = "PI_SUBAGENT_PARENT_DEPTH";
24
+ export const SUBAGENT_PARENT_PATH_ENV = "PI_SUBAGENT_PARENT_PATH";
25
+ export const SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV = "PI_SUBAGENT_PARENT_CAPABILITY_TOKEN";
14
26
 
15
27
  interface BuildPiArgsInput {
16
28
  baseArgs: string[];
@@ -27,12 +39,21 @@ interface BuildPiArgsInput {
27
39
  extensions?: string[];
28
40
  systemPrompt?: string | null;
29
41
  mcpDirectTools?: string[];
42
+ cwd?: string;
30
43
  promptFileStem?: string;
31
44
  intercomSessionName?: string;
32
45
  orchestratorIntercomTarget?: string;
33
46
  runId?: string;
34
47
  childAgentName?: string;
35
48
  childIndex?: number;
49
+ parentEventSink?: string;
50
+ parentControlInbox?: string;
51
+ parentRootRunId?: string;
52
+ parentRunId?: string;
53
+ parentChildIndex?: number;
54
+ parentDepth?: number;
55
+ parentPath?: NestedPathEntry[];
56
+ parentCapabilityToken?: string;
36
57
  }
37
58
 
38
59
  interface BuildPiArgsResult {
@@ -69,22 +90,27 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
69
90
  args.push("--model", modelArg);
70
91
  }
71
92
 
93
+ const declaredBuiltinTools = input.tools?.filter((tool) => !(tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) ?? [];
94
+ const fanoutAuthorized = declaredBuiltinTools.includes("subagent");
72
95
  const toolExtensionPaths: string[] = [];
73
96
  if (input.tools?.length) {
74
- const builtinTools: string[] = [];
97
+ const builtinTools = [...declaredBuiltinTools];
75
98
  for (const tool of input.tools) {
76
- if (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")) {
99
+ if (!declaredBuiltinTools.includes(tool) && (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) {
77
100
  toolExtensionPaths.push(tool);
78
- } else {
79
- builtinTools.push(tool);
80
101
  }
81
102
  }
82
103
  if (builtinTools.length > 0) {
104
+ if (input.mcpDirectTools?.length) {
105
+ builtinTools.push(...resolveMcpDirectToolNames(input.mcpDirectTools, input.cwd));
106
+ }
83
107
  args.push("--tools", builtinTools.join(","));
84
108
  }
85
109
  }
86
110
 
87
- const runtimeExtensions = [PROMPT_RUNTIME_EXTENSION_PATH];
111
+ const runtimeExtensions = fanoutAuthorized
112
+ ? [PROMPT_RUNTIME_EXTENSION_PATH, FANOUT_CHILD_EXTENSION_PATH]
113
+ : [PROMPT_RUNTIME_EXTENSION_PATH];
88
114
  if (input.extensions !== undefined) {
89
115
  args.push("--no-extensions");
90
116
  for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...input.extensions])]) {
@@ -122,6 +148,40 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
122
148
 
123
149
  const env: Record<string, string | undefined> = {};
124
150
  env[SUBAGENT_CHILD_ENV] = "1";
151
+ env[SUBAGENT_FANOUT_CHILD_ENV] = fanoutAuthorized ? "1" : "0";
152
+ const inheritedNestedRoute = Boolean(process.env[SUBAGENT_PARENT_EVENT_SINK_ENV] && process.env[SUBAGENT_PARENT_ROOT_RUN_ID_ENV] && process.env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV]);
153
+ const parentRunId = input.parentRunId ?? input.runId ?? (inheritedNestedRoute ? process.env[SUBAGENT_RUN_ID_ENV] : undefined) ?? process.env[SUBAGENT_PARENT_RUN_ID_ENV] ?? "";
154
+ const parentChildIndex = input.parentChildIndex !== undefined
155
+ ? String(input.parentChildIndex)
156
+ : input.childIndex !== undefined
157
+ ? String(input.childIndex)
158
+ : process.env[SUBAGENT_PARENT_CHILD_INDEX_ENV] ?? "";
159
+ const inheritedDepth = Number(process.env[SUBAGENT_PARENT_DEPTH_ENV]);
160
+ const parentDepth = input.parentDepth ?? (inheritedNestedRoute && Number.isFinite(inheritedDepth) ? inheritedDepth + 1 : 1);
161
+ const parentPath = input.parentPath ?? [
162
+ ...parseNestedPathEnv(process.env[SUBAGENT_PARENT_PATH_ENV]),
163
+ ...(parentRunId ? [{
164
+ runId: parentRunId,
165
+ ...(parentChildIndex && /^\d+$/.test(parentChildIndex) ? { stepIndex: Number(parentChildIndex) } : {}),
166
+ ...(input.childAgentName ? { agent: input.childAgentName } : {}),
167
+ }] : []),
168
+ ];
169
+ env[SUBAGENT_PARENT_EVENT_SINK_ENV] = fanoutAuthorized
170
+ ? input.parentEventSink ?? process.env[SUBAGENT_PARENT_EVENT_SINK_ENV] ?? ""
171
+ : "";
172
+ env[SUBAGENT_PARENT_CONTROL_INBOX_ENV] = fanoutAuthorized
173
+ ? input.parentControlInbox ?? process.env[SUBAGENT_PARENT_CONTROL_INBOX_ENV] ?? ""
174
+ : "";
175
+ env[SUBAGENT_PARENT_ROOT_RUN_ID_ENV] = fanoutAuthorized
176
+ ? input.parentRootRunId ?? process.env[SUBAGENT_PARENT_ROOT_RUN_ID_ENV] ?? input.runId ?? ""
177
+ : "";
178
+ env[SUBAGENT_PARENT_RUN_ID_ENV] = fanoutAuthorized ? parentRunId : "";
179
+ env[SUBAGENT_PARENT_CHILD_INDEX_ENV] = fanoutAuthorized ? parentChildIndex : "";
180
+ env[SUBAGENT_PARENT_DEPTH_ENV] = fanoutAuthorized ? String(parentDepth) : "";
181
+ env[SUBAGENT_PARENT_PATH_ENV] = fanoutAuthorized ? encodeNestedPathEnv(parentPath) : "";
182
+ env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV] = fanoutAuthorized
183
+ ? input.parentCapabilityToken ?? process.env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV] ?? ""
184
+ : "";
125
185
  env.PI_SUBAGENT_INHERIT_PROJECT_CONTEXT = input.inheritProjectContext ? "1" : "0";
126
186
  env.PI_SUBAGENT_INHERIT_SKILLS = input.inheritSkills ? "1" : "0";
127
187
  if (input.intercomSessionName) {
@@ -148,6 +208,8 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
148
208
  return { args, env, tempDir };
149
209
  }
150
210
 
211
+ export const parseParentPathEnv = parseNestedPathEnv;
212
+
151
213
  export function cleanupTempDir(tempDir: string | null | undefined): void {
152
214
  if (!tempDir) return;
153
215
  try {
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
- import * as os from "node:os";
3
2
  import * as path from "node:path";
3
+ import { getAgentDir } from "../../shared/utils.ts";
4
4
 
5
5
  export interface RunEntry {
6
6
  agent: string;
@@ -11,10 +11,13 @@ export interface RunEntry {
11
11
  exit?: number;
12
12
  }
13
13
 
14
- const HISTORY_PATH = path.join(os.homedir(), ".pi", "agent", "run-history.jsonl");
15
14
  const ROTATE_READ_THRESHOLD = 1200;
16
15
  const ROTATE_KEEP = 1000;
17
16
 
17
+ function getHistoryPath(): string {
18
+ return path.join(getAgentDir(), "run-history.jsonl");
19
+ }
20
+
18
21
  export function recordRun(agent: string, task: string, exitCode: number, durationMs: number): void {
19
22
  try {
20
23
  const entry: RunEntry = {
@@ -25,18 +28,20 @@ export function recordRun(agent: string, task: string, exitCode: number, duratio
25
28
  duration: durationMs,
26
29
  ...(exitCode !== 0 ? { exit: exitCode } : {}),
27
30
  };
28
- fs.mkdirSync(path.dirname(HISTORY_PATH), { recursive: true });
29
- fs.appendFileSync(HISTORY_PATH, `${JSON.stringify(entry)}\n`);
31
+ const historyPath = getHistoryPath();
32
+ fs.mkdirSync(path.dirname(historyPath), { recursive: true });
33
+ fs.appendFileSync(historyPath, `${JSON.stringify(entry)}\n`);
30
34
  } catch {
31
35
  // Best-effort — never crash the execution flow for history recording
32
36
  }
33
37
  }
34
38
 
35
39
  export function loadRunsForAgent(agent: string): RunEntry[] {
36
- if (!fs.existsSync(HISTORY_PATH)) return [];
40
+ const historyPath = getHistoryPath();
41
+ if (!fs.existsSync(historyPath)) return [];
37
42
  let raw: string;
38
43
  try {
39
- raw = fs.readFileSync(HISTORY_PATH, "utf-8");
44
+ raw = fs.readFileSync(historyPath, "utf-8");
40
45
  } catch {
41
46
  return [];
42
47
  }
@@ -45,7 +50,7 @@ export function loadRunsForAgent(agent: string): RunEntry[] {
45
50
 
46
51
  if (lines.length > ROTATE_READ_THRESHOLD) {
47
52
  lines = lines.slice(-ROTATE_KEEP);
48
- try { fs.writeFileSync(HISTORY_PATH, `${lines.join("\n")}\n`, "utf-8"); } catch {}
53
+ try { fs.writeFileSync(historyPath, `${lines.join("\n")}\n`, "utf-8"); } catch {}
49
54
  }
50
55
 
51
56
  return lines
@@ -8,12 +8,22 @@ export interface SingleOutputSnapshot {
8
8
  size?: number;
9
9
  }
10
10
 
11
+ export function normalizeSingleOutputOverride(
12
+ output: string | boolean | undefined,
13
+ defaultOutput: string | undefined,
14
+ ): string | false | undefined {
15
+ if (output === false || output === "false") return false;
16
+ if (output === true || output === "true") return defaultOutput;
17
+ if (typeof output === "string" && output.length > 0) return output;
18
+ return undefined;
19
+ }
20
+
11
21
  export function resolveSingleOutputPath(
12
- output: string | false | undefined,
22
+ output: string | boolean | undefined,
13
23
  runtimeCwd: string,
14
24
  requestedCwd?: string,
15
25
  ): string | undefined {
16
- if (typeof output !== "string" || !output) return undefined;
26
+ if (typeof output !== "string" || !output || output === "false" || output === "true") return undefined;
17
27
  if (path.isAbsolute(output)) return output;
18
28
  const baseCwd = requestedCwd
19
29
  ? (path.isAbsolute(requestedCwd) ? requestedCwd : path.resolve(runtimeCwd, requestedCwd))
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { SUBAGENT_FANOUT_CHILD_ENV } from "./pi-args.ts";
2
3
 
3
4
  const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEXT";
4
5
  const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
@@ -12,6 +13,15 @@ export const CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS = [
12
13
  "If you need to edit files, call the actual edit/write tools. Do not print tool-call syntax, patches, or pseudo-tool calls as text.",
13
14
  ].join("\n");
14
15
 
16
+ export const CHILD_FANOUT_BOUNDARY_INSTRUCTIONS = [
17
+ "You are a child subagent with explicit fanout responsibility for this assigned task.",
18
+ "The parent session owns final orchestration, acceptance, and follow-up implementation launches.",
19
+ "You may use the `subagent` tool only for the fanout work explicitly requested in this task.",
20
+ "Do not broaden yourself into general parent orchestration. Do not launch follow-up workers unless the task explicitly asks for that.",
21
+ "The maxSubagentDepth cap still applies and may block further fanout.",
22
+ "If you need to edit files, call the actual edit/write tools. Do not print tool-call syntax, patches, or pseudo-tool calls as text.",
23
+ ].join("\n");
24
+
15
25
  const PARENT_ONLY_CUSTOM_MESSAGE_TYPES = new Set([
16
26
  "subagent-orchestration-instructions",
17
27
  "subagent-slash-result",
@@ -62,9 +72,17 @@ export function stripSubagentOrchestrationSkill(prompt: string): string {
62
72
  .replace(/[ \t]*<skill>\s*[\s\S]*?<\/skill>\s*/g, (block) => SUBAGENT_ORCHESTRATION_SKILL_NAME_PATTERN.test(block) ? "" : block);
63
73
  }
64
74
 
75
+ function stripChildBoundaryInstructions(prompt: string): string {
76
+ let rewritten = prompt;
77
+ for (const boundary of [CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS, CHILD_FANOUT_BOUNDARY_INSTRUCTIONS]) {
78
+ rewritten = rewritten.split(boundary).join("");
79
+ }
80
+ return rewritten.replace(/^(?:[ \t]*\r?\n)+/, "");
81
+ }
82
+
65
83
  export function rewriteSubagentPrompt(
66
84
  prompt: string,
67
- options: { inheritProjectContext: boolean; inheritSkills: boolean },
85
+ options: { inheritProjectContext: boolean; inheritSkills: boolean; fanoutChild?: boolean },
68
86
  ): string {
69
87
  let rewritten = prompt;
70
88
  if (!options.inheritProjectContext) {
@@ -74,9 +92,9 @@ export function rewriteSubagentPrompt(
74
92
  rewritten = stripInheritedSkills(rewritten);
75
93
  }
76
94
  rewritten = stripSubagentOrchestrationSkill(rewritten);
77
- return rewritten.includes(CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS)
78
- ? rewritten
79
- : `${CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS}\n\n${rewritten}`;
95
+ rewritten = stripChildBoundaryInstructions(rewritten);
96
+ const boundary = options.fanoutChild ? CHILD_FANOUT_BOUNDARY_INSTRUCTIONS : CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS;
97
+ return `${boundary}\n\n${rewritten}`;
80
98
  }
81
99
 
82
100
  function isParentOnlySubagentMessage(message: unknown): boolean {
@@ -139,10 +157,12 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
139
157
 
140
158
  const inheritProjectContext = readBooleanEnv(SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV);
141
159
  const inheritSkills = readBooleanEnv(SUBAGENT_INHERIT_SKILLS_ENV);
142
- if (inheritProjectContext === undefined && inheritSkills === undefined) return;
160
+ const fanoutChild = readBooleanEnv(SUBAGENT_FANOUT_CHILD_ENV);
161
+ if (inheritProjectContext === undefined && inheritSkills === undefined && fanoutChild === undefined) return;
143
162
  const rewritten = rewriteSubagentPrompt(event.systemPrompt, {
144
163
  inheritProjectContext: inheritProjectContext ?? true,
145
164
  inheritSkills: inheritSkills ?? true,
165
+ fanoutChild: fanoutChild === true,
146
166
  });
147
167
  if (rewritten === event.systemPrompt) return;
148
168
  return { systemPrompt: rewritten };
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
- import * as os from "node:os";
3
2
  import * as path from "node:path";
4
3
  import { TEMP_ARTIFACTS_DIR, type ArtifactPaths } from "./types.ts";
4
+ import { getAgentDir } from "./utils.ts";
5
5
  const CLEANUP_MARKER_FILE = ".last-cleanup";
6
6
 
7
7
  export function getArtifactsDir(sessionFile: string | null): string {
@@ -74,7 +74,7 @@ export function cleanupOldArtifacts(dir: string, maxAgeDays: number): void {
74
74
  export function cleanupAllArtifactDirs(maxAgeDays: number): void {
75
75
  cleanupOldArtifacts(TEMP_ARTIFACTS_DIR, maxAgeDays);
76
76
 
77
- const sessionsBase = path.join(os.homedir(), ".pi", "agent", "sessions");
77
+ const sessionsBase = path.join(getAgentDir(), "sessions");
78
78
  if (!fs.existsSync(sessionsBase)) return;
79
79
 
80
80
  let dirs: string[];
@@ -83,6 +83,8 @@ export interface ControlEvent {
83
83
  agent: string;
84
84
  index?: number;
85
85
  runId: string;
86
+ nestedRunId?: string;
87
+ nestingPath?: NestedRunAddress["path"];
86
88
  message: string;
87
89
  reason?: "idle" | "completion_guard" | "active_long_running" | "tool_failures" | "time_threshold" | "turn_threshold" | "token_threshold";
88
90
  turns?: number;
@@ -98,6 +100,21 @@ export interface ControlEvent {
98
100
  export type SubagentResultStatus = "completed" | "failed" | "paused" | "detached";
99
101
  export type SubagentRunMode = "single" | "parallel" | "chain";
100
102
 
103
+ export type PublicNestedStepSummary = Pick<
104
+ NestedStepSummary,
105
+ "agent" | "status" | "sessionFile" | "activityState" | "lastActivityAt" | "currentTool" | "currentToolStartedAt" | "currentPath" | "turnCount" | "toolCount" | "startedAt" | "endedAt" | "error"
106
+ > & {
107
+ children?: PublicNestedRunSummary[];
108
+ };
109
+
110
+ export type PublicNestedRunSummary = Pick<
111
+ NestedRunSummary,
112
+ "id" | "parentRunId" | "parentStepIndex" | "parentAgent" | "depth" | "path" | "asyncDir" | "sessionId" | "sessionFile" | "intercomTarget" | "ownerIntercomTarget" | "leafIntercomTarget" | "ownerState" | "mode" | "state" | "agent" | "agents" | "currentStep" | "chainStepCount" | "parallelGroups" | "activityState" | "lastActivityAt" | "currentTool" | "currentToolStartedAt" | "currentPath" | "turnCount" | "toolCount" | "totalTokens" | "startedAt" | "endedAt" | "lastUpdate" | "error"
113
+ > & {
114
+ steps?: PublicNestedStepSummary[];
115
+ children?: PublicNestedRunSummary[];
116
+ };
117
+
101
118
  export interface SubagentResultIntercomChild {
102
119
  agent: string;
103
120
  status: SubagentResultStatus;
@@ -106,6 +123,7 @@ export interface SubagentResultIntercomChild {
106
123
  artifactPath?: string;
107
124
  sessionPath?: string;
108
125
  intercomTarget?: string;
126
+ children?: PublicNestedRunSummary[];
109
127
  }
110
128
 
111
129
  export interface SubagentResultIntercomPayload {
@@ -261,6 +279,76 @@ export interface AsyncParallelGroupStatus {
261
279
  stepIndex: number;
262
280
  }
263
281
 
282
+ export type NestedRunState = "queued" | "running" | "complete" | "failed" | "paused";
283
+ export type NestedOwnerState = "live" | "gone" | "unknown";
284
+
285
+ export interface NestedRunAddress {
286
+ id: string;
287
+ parentRunId: string;
288
+ parentStepIndex?: number;
289
+ parentAgent?: string;
290
+ depth: number;
291
+ path: Array<{ runId: string; stepIndex?: number; agent?: string }>;
292
+ }
293
+
294
+ export interface NestedStepSummary {
295
+ agent: string;
296
+ status: "pending" | "running" | "complete" | "completed" | "failed" | "paused";
297
+ sessionFile?: string;
298
+ activityState?: ActivityState;
299
+ lastActivityAt?: number;
300
+ currentTool?: string;
301
+ currentToolStartedAt?: number;
302
+ currentPath?: string;
303
+ turnCount?: number;
304
+ toolCount?: number;
305
+ startedAt?: number;
306
+ endedAt?: number;
307
+ error?: string;
308
+ children?: NestedRunSummary[];
309
+ }
310
+
311
+ export interface NestedRunSummary extends NestedRunAddress {
312
+ asyncDir?: string;
313
+ pid?: number;
314
+ sessionId?: string;
315
+ sessionFile?: string;
316
+ intercomTarget?: string;
317
+ ownerIntercomTarget?: string;
318
+ leafIntercomTarget?: string;
319
+ ownerState?: NestedOwnerState;
320
+ controlInbox?: string;
321
+ capabilityToken?: string;
322
+ mode?: SubagentRunMode;
323
+ state: NestedRunState;
324
+ agent?: string;
325
+ agents?: string[];
326
+ currentStep?: number;
327
+ chainStepCount?: number;
328
+ parallelGroups?: AsyncParallelGroupStatus[];
329
+ steps?: NestedStepSummary[];
330
+ children?: NestedRunSummary[];
331
+ activityState?: ActivityState;
332
+ lastActivityAt?: number;
333
+ currentTool?: string;
334
+ currentToolStartedAt?: number;
335
+ currentPath?: string;
336
+ turnCount?: number;
337
+ toolCount?: number;
338
+ totalTokens?: TokenUsage;
339
+ startedAt?: number;
340
+ endedAt?: number;
341
+ lastUpdate?: number;
342
+ error?: string;
343
+ }
344
+
345
+ export interface NestedRouteInfo {
346
+ rootRunId: string;
347
+ eventSink: string;
348
+ controlInbox: string;
349
+ capabilityToken: string;
350
+ }
351
+
264
352
  export interface AsyncStartedEvent {
265
353
  id?: string;
266
354
  asyncDir?: string;
@@ -272,6 +360,7 @@ export interface AsyncStartedEvent {
272
360
  chain?: string[];
273
361
  chainStepCount?: number;
274
362
  parallelGroups?: AsyncParallelGroupStatus[];
363
+ nestedRoute?: NestedRouteInfo;
275
364
  }
276
365
 
277
366
  export interface AsyncStatus {
@@ -297,6 +386,7 @@ export interface AsyncStatus {
297
386
  steps?: Array<{
298
387
  agent: string;
299
388
  status: "pending" | "running" | "complete" | "completed" | "failed" | "paused";
389
+ children?: NestedRunSummary[];
300
390
  sessionFile?: string;
301
391
  activityState?: ActivityState;
302
392
  lastActivityAt?: number;
@@ -361,6 +451,8 @@ export interface AsyncJobState {
361
451
  totalTokens?: TokenUsage;
362
452
  sessionFile?: string;
363
453
  controlEventCursor?: number;
454
+ nestedRoute?: NestedRouteInfo;
455
+ nestedChildren?: NestedRunSummary[];
364
456
  }
365
457
 
366
458
  export interface ForegroundResumeChild {
@@ -398,6 +490,8 @@ export interface SubagentState {
398
490
  turnCount?: number;
399
491
  tokens?: number;
400
492
  toolCount?: number;
493
+ nestedRoute?: NestedRouteInfo;
494
+ nestedChildren?: NestedRunSummary[];
401
495
  interrupt?: () => boolean;
402
496
  }>;
403
497
  lastForegroundControlId: string | null;
@@ -473,6 +567,7 @@ export interface RunSyncOptions {
473
567
  outputPath?: string;
474
568
  outputMode?: OutputMode;
475
569
  maxSubagentDepth?: number;
570
+ nestedRoute?: NestedRouteInfo;
476
571
  /** Override the agent's default model (format: "provider/id" or just "id") */
477
572
  modelOverride?: string;
478
573
  /** Registry models available for heuristic bare-model resolution */
@@ -13,6 +13,13 @@ import type { AgentProgress, AsyncStatus, Details, DisplayItem, ErrorInfo, Singl
13
13
  // File System Utilities
14
14
  // ============================================================================
15
15
 
16
+ export function getAgentDir(): string {
17
+ const configured = process.env.PI_CODING_AGENT_DIR;
18
+ if (configured === "~") return os.homedir();
19
+ if (configured?.startsWith("~/")) return path.join(os.homedir(), configured.slice(2));
20
+ return configured || path.join(os.homedir(), ".pi", "agent");
21
+ }
22
+
16
23
  const statusCache = new Map<string, { mtime: number; status: AsyncStatus }>();
17
24
 
18
25
  function getErrorMessage(error: unknown): string {
@@ -182,8 +189,11 @@ export function getFinalOutput(messages: Message[]): string {
182
189
  for (let i = messages.length - 1; i >= 0; i--) {
183
190
  const msg = messages[i];
184
191
  if (msg.role === "assistant") {
192
+ const hasAssistantError = ("errorMessage" in msg && typeof msg.errorMessage === "string" && msg.errorMessage.length > 0)
193
+ || ("stopReason" in msg && msg.stopReason === "error");
194
+ if (hasAssistantError) continue;
185
195
  for (const part of msg.content) {
186
- if (part.type === "text") return part.text;
196
+ if (part.type === "text" && part.text.trim().length > 0) return part.text;
187
197
  }
188
198
  }
189
199
  }