pi-subagents 0.31.0 → 0.32.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 (42) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +170 -8
  3. package/package.json +1 -1
  4. package/skills/pi-subagents/SKILL.md +6 -1
  5. package/src/agents/agent-management.ts +6 -1
  6. package/src/agents/agents.ts +55 -11
  7. package/src/extension/companion-suggestions.ts +359 -0
  8. package/src/extension/config.ts +27 -4
  9. package/src/extension/doctor.ts +2 -0
  10. package/src/extension/fanout-child.ts +1 -0
  11. package/src/extension/index.ts +69 -4
  12. package/src/extension/schemas.ts +2 -2
  13. package/src/intercom/intercom-bridge.ts +25 -1
  14. package/src/profiles/profiles.ts +637 -0
  15. package/src/runs/background/async-execution.ts +138 -33
  16. package/src/runs/background/async-job-tracker.ts +77 -1
  17. package/src/runs/background/async-resume.ts +11 -13
  18. package/src/runs/background/async-status.ts +41 -9
  19. package/src/runs/background/chain-root-attachment.ts +34 -4
  20. package/src/runs/background/control-channel.ts +227 -0
  21. package/src/runs/background/run-status.ts +1 -0
  22. package/src/runs/background/stale-run-reconciler.ts +28 -1
  23. package/src/runs/background/subagent-runner.ts +459 -113
  24. package/src/runs/foreground/chain-execution.ts +29 -7
  25. package/src/runs/foreground/execution.ts +24 -6
  26. package/src/runs/foreground/subagent-executor.ts +240 -44
  27. package/src/runs/shared/acceptance.ts +45 -22
  28. package/src/runs/shared/dynamic-fanout.ts +1 -1
  29. package/src/runs/shared/model-fallback.ts +4 -0
  30. package/src/runs/shared/nested-events.ts +58 -0
  31. package/src/runs/shared/parallel-utils.ts +49 -1
  32. package/src/runs/shared/pi-args.ts +5 -3
  33. package/src/runs/shared/pi-spawn.ts +52 -20
  34. package/src/runs/shared/single-output.ts +2 -0
  35. package/src/runs/shared/subagent-prompt-runtime.ts +3 -2
  36. package/src/runs/shared/worktree.ts +28 -5
  37. package/src/shared/artifacts.ts +15 -1
  38. package/src/shared/fork-context.ts +133 -22
  39. package/src/shared/types.ts +82 -3
  40. package/src/shared/utils.ts +99 -14
  41. package/src/slash/slash-commands.ts +726 -40
  42. package/src/tui/render.ts +16 -4
@@ -710,13 +710,15 @@ export function aggregateAcceptanceReport(input: {
710
710
  };
711
711
  }
712
712
 
713
- function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string): Promise<AcceptanceVerifyResult> {
713
+ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string, options: { signal?: AbortSignal; abortMessage?: string } = {}): Promise<AcceptanceVerifyResult> {
714
714
  return new Promise((resolve) => {
715
715
  const startedAt = Date.now();
716
716
  const cwd = command.cwd ? path.resolve(defaultCwd, command.cwd) : defaultCwd;
717
717
  let stdout = "";
718
718
  let stderr = "";
719
719
  let timedOut = false;
720
+ let settled = false;
721
+ let hardKill: NodeJS.Timeout | undefined;
720
722
  const child = spawn(command.command, {
721
723
  cwd,
722
724
  env: { ...process.env, ...(command.env ?? {}) },
@@ -724,12 +726,39 @@ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string):
724
726
  stdio: ["ignore", "pipe", "pipe"],
725
727
  windowsHide: true,
726
728
  });
727
- const timeout = setTimeout(() => {
729
+ const finish = (result: Omit<AcceptanceVerifyResult, "id" | "command" | "cwd" | "durationMs">) => {
730
+ if (settled) return;
731
+ settled = true;
732
+ clearTimeout(timeout);
733
+ if (hardKill) clearTimeout(hardKill);
734
+ options.signal?.removeEventListener("abort", abortVerification);
735
+ resolve({
736
+ id: command.id,
737
+ command: command.command,
738
+ cwd,
739
+ durationMs: Date.now() - startedAt,
740
+ ...result,
741
+ });
742
+ };
743
+ const abortVerification = () => {
744
+ if (settled || timedOut) return;
728
745
  timedOut = true;
729
746
  child.kill("SIGTERM");
730
- setTimeout(() => child.kill("SIGKILL"), 1000).unref?.();
731
- }, command.timeoutMs ?? 120_000);
747
+ hardKill = setTimeout(() => {
748
+ child.kill("SIGKILL");
749
+ finish({
750
+ exitCode: null,
751
+ status: "timed-out",
752
+ stdout: trimOutput(stdout),
753
+ stderr: trimOutput(stderr || options.abortMessage || "Acceptance verification timed out."),
754
+ });
755
+ }, 1000);
756
+ hardKill.unref?.();
757
+ };
758
+ const timeout = setTimeout(abortVerification, command.timeoutMs ?? 120_000);
732
759
  timeout.unref?.();
760
+ if (options.signal?.aborted) abortVerification();
761
+ else options.signal?.addEventListener("abort", abortVerification, { once: true });
733
762
  child.stdout.on("data", (chunk: Buffer) => {
734
763
  stdout += chunk.toString();
735
764
  });
@@ -737,30 +766,19 @@ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string):
737
766
  stderr += chunk.toString();
738
767
  });
739
768
  child.on("close", (exitCode) => {
740
- clearTimeout(timeout);
741
- const durationMs = Date.now() - startedAt;
742
769
  const passed = exitCode === 0 && !timedOut;
743
- resolve({
744
- id: command.id,
745
- command: command.command,
746
- cwd,
770
+ finish({
747
771
  exitCode,
748
772
  status: timedOut ? "timed-out" : passed ? "passed" : command.allowFailure ? "allowed-failure" : "failed",
749
773
  stdout: trimOutput(stdout),
750
- stderr: trimOutput(stderr),
751
- durationMs,
774
+ stderr: trimOutput(stderr || (timedOut ? options.abortMessage ?? "" : "")),
752
775
  });
753
776
  });
754
777
  child.on("error", (error) => {
755
- clearTimeout(timeout);
756
- resolve({
757
- id: command.id,
758
- command: command.command,
759
- cwd,
760
- exitCode: 1,
761
- status: command.allowFailure ? "allowed-failure" : "failed",
762
- stderr: error instanceof Error ? error.message : String(error),
763
- durationMs: Date.now() - startedAt,
778
+ finish({
779
+ exitCode: timedOut ? null : 1,
780
+ status: timedOut ? "timed-out" : command.allowFailure ? "allowed-failure" : "failed",
781
+ stderr: timedOut ? trimOutput(stderr || options.abortMessage || "Acceptance verification timed out.") : error instanceof Error ? error.message : String(error),
764
782
  });
765
783
  });
766
784
  });
@@ -772,6 +790,8 @@ export async function evaluateAcceptance(input: {
772
790
  cwd: string;
773
791
  report?: AcceptanceReport;
774
792
  reviewResult?: AcceptanceReviewResult;
793
+ signal?: AbortSignal;
794
+ abortMessage?: string;
775
795
  }): Promise<AcceptanceLedger> {
776
796
  const acceptance = input.acceptance;
777
797
  const ledger: AcceptanceLedger = {
@@ -815,7 +835,10 @@ export async function evaluateAcceptance(input: {
815
835
  return ledger;
816
836
  }
817
837
  ledger.verifyRuns = [];
818
- for (const command of acceptance.verify) ledger.verifyRuns.push(await runVerifyCommand(command, input.cwd));
838
+ for (const command of acceptance.verify) {
839
+ ledger.verifyRuns.push(await runVerifyCommand(command, input.cwd, { signal: input.signal, abortMessage: input.abortMessage }));
840
+ if (input.signal?.aborted) break;
841
+ }
819
842
  if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
820
843
  ledger.status = "rejected";
821
844
  return ledger;
@@ -42,7 +42,7 @@ const ITEM_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
42
42
  const ITEM_REF_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([^{}]+))?\}/g;
43
43
  const RESERVED_TEMPLATE_NAMES = new Set(["task", "previous", "chain_dir", "outputs"]);
44
44
  const DYNAMIC_STEP_KEYS = new Set(["expand", "parallel", "collect", "concurrency", "failFast", "phase", "label", "acceptance"]);
45
- const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance"]);
45
+ const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance", "sessionFiles", "thinkingOverrides"]);
46
46
  const DYNAMIC_EXPAND_KEYS = new Set(["from", "item", "key", "maxItems", "onEmpty"]);
47
47
  const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
48
48
  const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "acceptance"]);
@@ -126,6 +126,10 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
126
126
  /\b502\b/,
127
127
  /\b503\b/,
128
128
  /\b504\b/,
129
+ /cold.?start/i,
130
+ /empty response/i,
131
+ /no output/i,
132
+ /model.*(?:load|fail|error)/i,
129
133
  ];
130
134
 
131
135
  export function isRetryableModelFailure(error: string | undefined): boolean {
@@ -184,6 +184,17 @@ function sanitizeTokenUsage(value: unknown): NestedRunSummary["totalTokens"] | u
184
184
  : undefined;
185
185
  }
186
186
 
187
+ function sanitizeCost(value: unknown): NestedRunSummary["totalCost"] | undefined {
188
+ if (!value || typeof value !== "object") return undefined;
189
+ const raw = value as Record<string, unknown>;
190
+ const inputTokens = clampNumber(raw.inputTokens);
191
+ const outputTokens = clampNumber(raw.outputTokens);
192
+ const costUsd = clampNumber(raw.costUsd);
193
+ return inputTokens !== undefined && outputTokens !== undefined && costUsd !== undefined
194
+ ? { inputTokens, outputTokens, costUsd }
195
+ : undefined;
196
+ }
197
+
187
198
  function sanitizeState(value: unknown, fallback: NestedRunState): NestedRunState {
188
199
  return value === "queued" || value === "running" || value === "complete" || value === "failed" || value === "paused"
189
200
  ? value
@@ -212,6 +223,7 @@ function sanitizeStep(input: unknown, depth: number): NestedStepSummary | undefi
212
223
  ...(clampNumber(raw.startedAt) !== undefined ? { startedAt: clampNumber(raw.startedAt) } : {}),
213
224
  ...(clampNumber(raw.endedAt) !== undefined ? { endedAt: clampNumber(raw.endedAt) } : {}),
214
225
  ...(stringValue(raw.error, 1024) ? { error: stringValue(raw.error, 1024) } : {}),
226
+ ...(raw.timedOut === true ? { timedOut: true } : {}),
215
227
  ...(depth < MAX_DEPTH && Array.isArray(raw.children) ? { children: raw.children.map((child) => sanitizeSummary(child, depth + 1)).filter((child): child is NestedRunSummary => Boolean(child)).slice(0, MAX_CHILDREN) } : {}),
216
228
  };
217
229
  }
@@ -225,6 +237,7 @@ export function sanitizeSummary(input: unknown, depth = 0): NestedRunSummary | u
225
237
  ? raw.steps.map((step) => sanitizeStep(step, depth + 1)).filter((step): step is NestedStepSummary => Boolean(step)).slice(0, MAX_STEPS)
226
238
  : undefined;
227
239
  const totalTokens = sanitizeTokenUsage(raw.totalTokens);
240
+ const totalCost = sanitizeCost(raw.totalCost);
228
241
  return {
229
242
  id: raw.id,
230
243
  parentRunId: raw.parentRunId,
@@ -256,9 +269,13 @@ export function sanitizeSummary(input: unknown, depth = 0): NestedRunSummary | u
256
269
  ...(clampNumber(raw.turnCount) !== undefined ? { turnCount: clampNumber(raw.turnCount) } : {}),
257
270
  ...(clampNumber(raw.toolCount) !== undefined ? { toolCount: clampNumber(raw.toolCount) } : {}),
258
271
  ...(totalTokens ? { totalTokens } : {}),
272
+ ...(totalCost ? { totalCost } : {}),
259
273
  ...(clampNumber(raw.startedAt) !== undefined ? { startedAt: clampNumber(raw.startedAt) } : {}),
260
274
  ...(clampNumber(raw.endedAt) !== undefined ? { endedAt: clampNumber(raw.endedAt) } : {}),
261
275
  ...(clampNumber(raw.lastUpdate) !== undefined ? { lastUpdate: clampNumber(raw.lastUpdate) } : {}),
276
+ ...(clampNumber(raw.timeoutMs) !== undefined ? { timeoutMs: clampNumber(raw.timeoutMs) } : {}),
277
+ ...(clampNumber(raw.deadlineAt) !== undefined ? { deadlineAt: clampNumber(raw.deadlineAt) } : {}),
278
+ ...(raw.timedOut === true ? { timedOut: true } : {}),
262
279
  ...(stringValue(raw.error, 1024) ? { error: stringValue(raw.error, 1024) } : {}),
263
280
  ...(steps && steps.length > 0 ? { steps } : {}),
264
281
  ...(depth < MAX_DEPTH && Array.isArray(raw.children) ? { children: raw.children.map((child) => sanitizeSummary(child, depth + 1)).filter((child): child is NestedRunSummary => Boolean(child)).slice(0, MAX_CHILDREN) } : {}),
@@ -394,6 +411,42 @@ export function findNestedRouteForRootId(rootRunId: string): NestedRoute | undef
394
411
  return undefined;
395
412
  }
396
413
 
414
+ /**
415
+ * Scan the nested-events directory once and index every route by its root run
416
+ * id. Use this when resolving routes for many runs (e.g. listAsyncRuns) so the
417
+ * cost is O(routes) total instead of O(runs * routes) from calling
418
+ * findNestedRouteForRootId per run.
419
+ */
420
+ export function buildNestedRouteIndex(): Map<string, NestedRoute> {
421
+ let entries: string[];
422
+ try {
423
+ entries = fs.readdirSync(NESTED_EVENTS_DIR);
424
+ } catch (error) {
425
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Map();
426
+ throw error;
427
+ }
428
+ const index = new Map<string, NestedRoute>();
429
+ for (const entry of entries) {
430
+ const routeRoot = path.join(NESTED_EVENTS_DIR, entry);
431
+ try {
432
+ const metadata = JSON.parse(fs.readFileSync(path.join(routeRoot, ROUTE_FILE), "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown };
433
+ if (typeof metadata.rootRunId !== "string" || typeof metadata.capabilityToken !== "string") continue;
434
+ if (index.has(metadata.rootRunId)) continue;
435
+ const route: NestedRoute = {
436
+ rootRunId: metadata.rootRunId,
437
+ eventSink: path.join(routeRoot, "events"),
438
+ controlInbox: path.join(routeRoot, "controls"),
439
+ capabilityToken: metadata.capabilityToken,
440
+ };
441
+ validateRouteShape(route);
442
+ index.set(metadata.rootRunId, route);
443
+ } catch {
444
+ continue;
445
+ }
446
+ }
447
+ return index;
448
+ }
449
+
397
450
  export function projectNestedRegistryForRoot(rootRunId: string): NestedRegistry | undefined {
398
451
  const route = findNestedRouteForRootId(rootRunId);
399
452
  return route ? projectNestedEvents(route) : undefined;
@@ -778,6 +831,10 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
778
831
  ...(status.turnCount !== undefined ? { turnCount: status.turnCount } : {}),
779
832
  ...(status.toolCount !== undefined ? { toolCount: status.toolCount } : {}),
780
833
  ...(status.totalTokens ? { totalTokens: status.totalTokens } : {}),
834
+ ...(status.timeoutMs !== undefined ? { timeoutMs: status.timeoutMs } : {}),
835
+ ...(status.deadlineAt !== undefined ? { deadlineAt: status.deadlineAt } : {}),
836
+ ...(status.timedOut !== undefined ? { timedOut: status.timedOut } : {}),
837
+ ...(status.error ? { error: status.error } : {}),
781
838
  ...(status.startedAt !== undefined ? { startedAt: status.startedAt } : { startedAt: fallback.ts }),
782
839
  ...(status.endedAt !== undefined ? { endedAt: status.endedAt } : {}),
783
840
  lastUpdate: status.lastUpdate ?? fallback.ts,
@@ -796,6 +853,7 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
796
853
  ...(step.startedAt !== undefined ? { startedAt: step.startedAt } : {}),
797
854
  ...(step.endedAt !== undefined ? { endedAt: step.endedAt } : {}),
798
855
  ...(step.error ? { error: step.error } : {}),
856
+ ...(step.timedOut !== undefined ? { timedOut: step.timedOut } : {}),
799
857
  })).slice(0, MAX_STEPS) } : {}),
800
858
  };
801
859
  }
@@ -55,6 +55,8 @@ export interface DynamicRunnerGroup {
55
55
  failFast?: boolean;
56
56
  phase?: string;
57
57
  label?: string;
58
+ sessionFiles?: (string | undefined)[];
59
+ thinkingOverrides?: (string | undefined)[];
58
60
  effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
59
61
  }
60
62
 
@@ -82,10 +84,47 @@ export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
82
84
  return flat;
83
85
  }
84
86
 
87
+ export const DEFAULT_GLOBAL_CONCURRENCY_LIMIT = 20;
88
+
89
+ /**
90
+ * A promise-based semaphore for limiting concurrent access across multiple
91
+ * mapConcurrent calls within a single run. Enforces a global cap on the total
92
+ * number of subagent tasks executing simultaneously, regardless of each step's
93
+ * per-step concurrency limit.
94
+ */
95
+ export class Semaphore {
96
+ private available: number;
97
+ private readonly queue: Array<() => void> = [];
98
+
99
+ constructor(limit: number) {
100
+ this.available = Math.max(1, Math.floor(limit) || 1);
101
+ }
102
+
103
+ acquire(): Promise<void> {
104
+ if (this.available > 0) {
105
+ this.available--;
106
+ return Promise.resolve();
107
+ }
108
+ return new Promise<void>((resolve) => {
109
+ this.queue.push(resolve);
110
+ });
111
+ }
112
+
113
+ release(): void {
114
+ const next = this.queue.shift();
115
+ if (next) {
116
+ next();
117
+ } else {
118
+ this.available++;
119
+ }
120
+ }
121
+ }
122
+
85
123
  export async function mapConcurrent<T, R>(
86
124
  items: T[],
87
125
  limit: number,
88
126
  fn: (item: T, i: number) => Promise<R>,
127
+ globalSemaphore?: Semaphore,
89
128
  ): Promise<R[]> {
90
129
  const safeLimit = Math.max(1, Math.floor(limit) || 1);
91
130
  const results: R[] = new Array(items.length);
@@ -94,7 +133,16 @@ export async function mapConcurrent<T, R>(
94
133
  async function worker(_workerIndex: number): Promise<void> {
95
134
  while (next < items.length) {
96
135
  const i = next++;
97
- results[i] = await fn(items[i], i);
136
+ if (globalSemaphore) {
137
+ await globalSemaphore.acquire();
138
+ try {
139
+ results[i] = await fn(items[i], i);
140
+ } finally {
141
+ globalSemaphore.release();
142
+ }
143
+ } else {
144
+ results[i] = await fn(items[i], i);
145
+ }
98
146
  }
99
147
  }
100
148
 
@@ -73,10 +73,12 @@ interface BuildPiArgsResult {
73
73
  tempDir?: string;
74
74
  }
75
75
 
76
- export function applyThinkingSuffix(model: string | undefined, thinking: string | undefined): string | undefined {
77
- if (!model || !thinking || thinking === "off") return model;
76
+ export function applyThinkingSuffix(model: string | undefined, thinking: string | undefined, replaceExisting = false): string | undefined {
77
+ if (!model || !thinking) return model;
78
78
  const colonIdx = model.lastIndexOf(":");
79
- if (colonIdx !== -1 && THINKING_LEVELS.includes(model.substring(colonIdx + 1))) return model;
79
+ if (colonIdx !== -1 && THINKING_LEVELS.includes(model.substring(colonIdx + 1))) {
80
+ return replaceExisting ? `${model.slice(0, colonIdx)}:${thinking}` : model;
81
+ }
80
82
  return `${model}:${thinking}`;
81
83
  }
82
84
 
@@ -3,13 +3,18 @@ import * as path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
5
  export const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
6
+ export const PI_SUBAGENT_PI_BINARY_ENV = "PI_SUBAGENT_PI_BINARY";
6
7
 
7
- export function findPiPackageRootFromEntry(entryPoint: string): string | undefined {
8
+ export function findPiPackageRootFromEntry(
9
+ entryPoint: string,
10
+ ): string | undefined {
8
11
  let dir = path.dirname(entryPoint);
9
12
  while (dir !== path.dirname(dir)) {
10
13
  const packageJsonPath = path.join(dir, "package.json");
11
14
  if (fs.existsSync(packageJsonPath)) {
12
- const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { name?: unknown };
15
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as {
16
+ name?: unknown;
17
+ };
13
18
  if (pkg.name === PI_CODING_AGENT_PACKAGE) return dir;
14
19
  }
15
20
  dir = path.dirname(dir);
@@ -18,13 +23,17 @@ export function findPiPackageRootFromEntry(entryPoint: string): string | undefin
18
23
  }
19
24
 
20
25
  export function resolveInstalledPiPackageRoot(): string | undefined {
21
- return findPiPackageRootFromEntry(fileURLToPath(import.meta.resolve(PI_CODING_AGENT_PACKAGE)));
26
+ return findPiPackageRootFromEntry(
27
+ fileURLToPath(import.meta.resolve(PI_CODING_AGENT_PACKAGE)),
28
+ );
22
29
  }
23
30
 
24
31
  export function resolvePiPackageRoot(): string | undefined {
25
32
  try {
26
33
  const entry = process.argv[1];
27
- return entry ? findPiPackageRootFromEntry(fs.realpathSync(entry)) : undefined;
34
+ return entry
35
+ ? findPiPackageRootFromEntry(fs.realpathSync(entry))
36
+ : undefined;
28
37
  } catch {
29
38
  // process.argv[1] probing is best-effort; callers can fall back to PATH/package resolution.
30
39
  return undefined;
@@ -40,6 +49,7 @@ export interface PiSpawnDeps {
40
49
  resolvePackageJson?: () => string;
41
50
  resolvePackageEntry?: () => string;
42
51
  piPackageRoot?: string;
52
+ env?: NodeJS.ProcessEnv;
43
53
  }
44
54
 
45
55
  interface PiSpawnCommand {
@@ -47,7 +57,10 @@ interface PiSpawnCommand {
47
57
  args: string[];
48
58
  }
49
59
 
50
- function isRunnableNodeScript(filePath: string, existsSync: (filePath: string) => boolean): boolean {
60
+ function isRunnableNodeScript(
61
+ filePath: string,
62
+ existsSync: (filePath: string) => boolean,
63
+ ): boolean {
51
64
  if (!existsSync(filePath)) return false;
52
65
  return /\.(?:mjs|cjs|js)$/i.test(filePath);
53
66
  }
@@ -56,9 +69,13 @@ function normalizePath(filePath: string): string {
56
69
  return path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
57
70
  }
58
71
 
59
- export function resolveWindowsPiCliScript(deps: PiSpawnDeps = {}): string | undefined {
72
+ export function resolveWindowsPiCliScript(
73
+ deps: PiSpawnDeps = {},
74
+ ): string | undefined {
60
75
  const existsSync = deps.existsSync ?? fs.existsSync;
61
- const readFileSync = deps.readFileSync ?? ((filePath, encoding) => fs.readFileSync(filePath, encoding));
76
+ const readFileSync =
77
+ deps.readFileSync ??
78
+ ((filePath, encoding) => fs.readFileSync(filePath, encoding));
62
79
  const argv1 = deps.argv1 ?? process.argv[1];
63
80
 
64
81
  if (argv1) {
@@ -69,23 +86,29 @@ export function resolveWindowsPiCliScript(deps: PiSpawnDeps = {}): string | unde
69
86
  }
70
87
 
71
88
  try {
72
- const resolvePackageJson = deps.resolvePackageJson ?? (() => {
73
- const root = deps.piPackageRoot ?? resolvePiPackageRoot();
74
- if (root) return path.join(root, "package.json");
75
- const packageRoot = deps.resolvePackageEntry
76
- ? findPiPackageRootFromEntry(deps.resolvePackageEntry())
77
- : resolveInstalledPiPackageRoot();
78
- if (!packageRoot) throw new Error(`Could not resolve ${PI_CODING_AGENT_PACKAGE} package root`);
79
- return path.join(packageRoot, "package.json");
80
- });
89
+ const resolvePackageJson =
90
+ deps.resolvePackageJson ??
91
+ (() => {
92
+ const root = deps.piPackageRoot ?? resolvePiPackageRoot();
93
+ if (root) return path.join(root, "package.json");
94
+ const packageRoot = deps.resolvePackageEntry
95
+ ? findPiPackageRootFromEntry(deps.resolvePackageEntry())
96
+ : resolveInstalledPiPackageRoot();
97
+ if (!packageRoot)
98
+ throw new Error(
99
+ `Could not resolve ${PI_CODING_AGENT_PACKAGE} package root`,
100
+ );
101
+ return path.join(packageRoot, "package.json");
102
+ });
81
103
  const packageJsonPath = resolvePackageJson();
82
104
  const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
83
105
  bin?: string | Record<string, string>;
84
106
  };
85
107
  const binField = packageJson.bin;
86
- const binPath = typeof binField === "string"
87
- ? binField
88
- : binField?.pi ?? Object.values(binField ?? {})[0];
108
+ const binPath =
109
+ typeof binField === "string"
110
+ ? binField
111
+ : (binField?.pi ?? Object.values(binField ?? {})[0]);
89
112
  if (!binPath) return undefined;
90
113
  const candidate = path.resolve(path.dirname(packageJsonPath), binPath);
91
114
  if (isRunnableNodeScript(candidate, existsSync)) {
@@ -99,7 +122,16 @@ export function resolveWindowsPiCliScript(deps: PiSpawnDeps = {}): string | unde
99
122
  return undefined;
100
123
  }
101
124
 
102
- export function getPiSpawnCommand(args: string[], deps: PiSpawnDeps = {}): PiSpawnCommand {
125
+ export function getPiSpawnCommand(
126
+ args: string[],
127
+ deps: PiSpawnDeps = {},
128
+ ): PiSpawnCommand {
129
+ const env = deps.env ?? process.env;
130
+ const piBinary = env[PI_SUBAGENT_PI_BINARY_ENV]?.trim();
131
+ if (piBinary) {
132
+ return { command: piBinary, args };
133
+ }
134
+
103
135
  const platform = deps.platform ?? process.platform;
104
136
  if (platform === "win32") {
105
137
  const piCliPath = resolveWindowsPiCliScript(deps);
@@ -22,9 +22,11 @@ export function resolveSingleOutputPath(
22
22
  output: string | boolean | undefined,
23
23
  runtimeCwd: string,
24
24
  requestedCwd?: string,
25
+ relativeBaseDir?: string,
25
26
  ): string | undefined {
26
27
  if (typeof output !== "string" || !output || output === "false" || output === "true") return undefined;
27
28
  if (path.isAbsolute(output)) return output;
29
+ if (relativeBaseDir) return path.resolve(relativeBaseDir, output);
28
30
  const baseCwd = requestedCwd
29
31
  ? (path.isAbsolute(requestedCwd) ? requestedCwd : path.resolve(runtimeCwd, requestedCwd))
30
32
  : runtimeCwd;
@@ -20,7 +20,7 @@ export const CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS = [
20
20
  "The parent session owns delegation, orchestration, review fanout, and follow-up worker launches.",
21
21
  "Ignore prior parent-only orchestration instructions in inherited conversation history.",
22
22
  "Do not propose or run subagents. Complete only your assigned role-specific task with the tools available to you.",
23
- "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
+ "If you need to edit files, use the available editing tools. Do not print tool-call syntax, patches, or pseudo-tool calls as text.",
24
24
  ].join("\n");
25
25
 
26
26
  export const CHILD_FANOUT_BOUNDARY_INSTRUCTIONS = [
@@ -29,12 +29,13 @@ export const CHILD_FANOUT_BOUNDARY_INSTRUCTIONS = [
29
29
  "You may use the `subagent` tool only for the fanout work explicitly requested in this task.",
30
30
  "Do not broaden yourself into general parent orchestration. Do not launch follow-up workers unless the task explicitly asks for that.",
31
31
  "The maxSubagentDepth cap still applies and may block further fanout.",
32
- "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.",
32
+ "If you need to edit files, use the available editing tools. Do not print tool-call syntax, patches, or pseudo-tool calls as text.",
33
33
  ].join("\n");
34
34
 
35
35
  const PARENT_ONLY_CUSTOM_MESSAGE_TYPES = new Set([
36
36
  "subagent-orchestration-instructions",
37
37
  "subagent-slash-result",
38
+ "subagent-slash-text-result",
38
39
  "subagent-notify",
39
40
  "subagent_control_notice",
40
41
  "subagent-control",
@@ -43,6 +43,7 @@ interface WorktreeSetupHookConfig {
43
43
  interface CreateWorktreesOptions {
44
44
  agents?: string[];
45
45
  setupHook?: WorktreeSetupHookConfig;
46
+ baseDir?: string;
46
47
  }
47
48
 
48
49
  interface ResolvedWorktreeSetupHook {
@@ -152,8 +153,26 @@ function buildWorktreeBranch(runId: string, index: number): string {
152
153
  return `pi-parallel-${runId}-${index}`;
153
154
  }
154
155
 
155
- function buildWorktreePath(runId: string, index: number): string {
156
- return path.join(os.tmpdir(), `pi-worktree-${runId}-${index}`);
156
+ function resolveWorktreeBaseDir(configuredBaseDir: string | undefined, repoRoot: string): string {
157
+ const rawBaseDir = configuredBaseDir ?? process.env.PI_SUBAGENTS_WORKTREE_DIR;
158
+ if (rawBaseDir === undefined) return os.tmpdir();
159
+
160
+ const trimmed = rawBaseDir.trim();
161
+ if (!trimmed) throw new Error("worktree base directory cannot be empty");
162
+
163
+ const expanded = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
164
+ const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(repoRoot, expanded);
165
+ try {
166
+ fs.mkdirSync(resolved, { recursive: true });
167
+ } catch (error) {
168
+ const message = error instanceof Error ? error.message : String(error);
169
+ throw new Error(`failed to create worktree base directory ${resolved}: ${message}`);
170
+ }
171
+ return resolved;
172
+ }
173
+
174
+ function buildWorktreePath(baseDir: string, runId: string, index: number): string {
175
+ return path.join(baseDir, `pi-worktree-${runId}-${index}`);
157
176
  }
158
177
 
159
178
  function resolveRepoCwdRelative(cwd: string): string {
@@ -168,9 +187,10 @@ function resolveRepoCwdRelative(cwd: string): string {
168
187
  return normalizedPrefix === "." ? "" : normalizedPrefix;
169
188
  }
170
189
 
171
- export function resolveExpectedWorktreeAgentCwd(cwd: string, runId: string, index: number): string {
190
+ export function resolveExpectedWorktreeAgentCwd(cwd: string, runId: string, index: number, baseDir?: string): string {
172
191
  const cwdRelative = resolveRepoCwdRelative(cwd);
173
- const worktreePath = buildWorktreePath(runId, index);
192
+ const repoRoot = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
193
+ const worktreePath = buildWorktreePath(resolveWorktreeBaseDir(baseDir, repoRoot), runId, index);
174
194
  return cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath;
175
195
  }
176
196
 
@@ -320,9 +340,10 @@ function createSingleWorktree(
320
340
  baseCommit: string,
321
341
  setupHook: ResolvedWorktreeSetupHook | undefined,
322
342
  agent: string | undefined,
343
+ baseDir: string,
323
344
  ): WorktreeInfo {
324
345
  const branch = buildWorktreeBranch(runId, index);
325
- const worktreePath = buildWorktreePath(runId, index);
346
+ const worktreePath = buildWorktreePath(baseDir, runId, index);
326
347
  const add = runGit(toplevel, ["worktree", "add", worktreePath, "-b", branch, "HEAD"]);
327
348
  if (add.status !== 0) {
328
349
  const message = add.stderr.trim() || add.stdout.trim() || `failed to create worktree ${worktreePath}`;
@@ -492,6 +513,7 @@ function hasWorktreeChanges(diff: WorktreeDiff): boolean {
492
513
  export function createWorktrees(cwd: string, runId: string, count: number, options?: CreateWorktreesOptions): WorktreeSetup {
493
514
  const repo = resolveRepoState(cwd);
494
515
  const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
516
+ const baseDir = resolveWorktreeBaseDir(options?.baseDir, repo.toplevel);
495
517
  const worktrees: WorktreeInfo[] = [];
496
518
 
497
519
  try {
@@ -504,6 +526,7 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
504
526
  repo.baseCommit,
505
527
  setupHook,
506
528
  options?.agents?.[index],
529
+ baseDir,
507
530
  ));
508
531
  }
509
532
  } catch (error) {
@@ -3,8 +3,22 @@ import * as path from "node:path";
3
3
  import { TEMP_ARTIFACTS_DIR, type ArtifactPaths } from "./types.ts";
4
4
  import { getAgentDir } from "./utils.ts";
5
5
  const CLEANUP_MARKER_FILE = ".last-cleanup";
6
+ const PROJECT_ARTIFACT_ROOT = ".pi-subagents";
6
7
 
7
- export function getArtifactsDir(sessionFile: string | null): string {
8
+ export function getProjectSubagentsDir(cwd: string): string {
9
+ return path.join(cwd, PROJECT_ARTIFACT_ROOT);
10
+ }
11
+
12
+ export function getProjectArtifactsDir(cwd: string): string {
13
+ return path.join(getProjectSubagentsDir(cwd), "artifacts");
14
+ }
15
+
16
+ export function getProjectChainRunsDir(cwd: string): string {
17
+ return path.join(getProjectSubagentsDir(cwd), "chain-runs");
18
+ }
19
+
20
+ export function getArtifactsDir(sessionFile: string | null, projectCwd?: string): string {
21
+ if (projectCwd) return getProjectArtifactsDir(projectCwd);
8
22
  if (sessionFile) {
9
23
  const sessionDir = path.dirname(sessionFile);
10
24
  return path.join(sessionDir, "subagent-artifacts");