pi-subagents 0.31.1 → 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 (39) hide show
  1. package/CHANGELOG.md +40 -4
  2. package/README.md +97 -7
  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 +58 -4
  12. package/src/extension/schemas.ts +2 -2
  13. package/src/runs/background/async-execution.ts +138 -33
  14. package/src/runs/background/async-job-tracker.ts +77 -1
  15. package/src/runs/background/async-status.ts +41 -9
  16. package/src/runs/background/chain-root-attachment.ts +34 -4
  17. package/src/runs/background/control-channel.ts +50 -0
  18. package/src/runs/background/run-status.ts +1 -0
  19. package/src/runs/background/stale-run-reconciler.ts +28 -1
  20. package/src/runs/background/subagent-runner.ts +454 -115
  21. package/src/runs/foreground/chain-execution.ts +29 -7
  22. package/src/runs/foreground/execution.ts +24 -6
  23. package/src/runs/foreground/subagent-executor.ts +209 -35
  24. package/src/runs/shared/acceptance.ts +45 -22
  25. package/src/runs/shared/dynamic-fanout.ts +1 -1
  26. package/src/runs/shared/model-fallback.ts +4 -0
  27. package/src/runs/shared/nested-events.ts +58 -0
  28. package/src/runs/shared/parallel-utils.ts +49 -1
  29. package/src/runs/shared/pi-args.ts +5 -3
  30. package/src/runs/shared/pi-spawn.ts +52 -20
  31. package/src/runs/shared/single-output.ts +2 -0
  32. package/src/runs/shared/subagent-prompt-runtime.ts +2 -2
  33. package/src/runs/shared/worktree.ts +28 -5
  34. package/src/shared/artifacts.ts +15 -1
  35. package/src/shared/fork-context.ts +133 -22
  36. package/src/shared/types.ts +81 -3
  37. package/src/shared/utils.ts +99 -14
  38. package/src/slash/slash-commands.ts +117 -0
  39. package/src/tui/render.ts +16 -4
@@ -1,27 +1,115 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import * as fs from "node:fs";
3
+ import * as path from "node:path";
2
4
  import { SessionManager } from "@earendil-works/pi-coding-agent";
3
5
 
4
6
  type SubagentExecutionContext = "fresh" | "fork";
5
7
 
8
+ interface BranchSessionEntry {
9
+ type: string;
10
+ id?: string;
11
+ parentId?: string | null;
12
+ timestamp?: string;
13
+ message?: {
14
+ role?: string;
15
+ content?: unknown;
16
+ provider?: string;
17
+ api?: string;
18
+ model?: string;
19
+ };
20
+ thinkingLevel?: string;
21
+ }
22
+
23
+ interface BranchSessionManager {
24
+ createBranchedSession(leafId: string): string | undefined;
25
+ getHeader?: () => BranchSessionEntry | null;
26
+ getEntries?: () => BranchSessionEntry[];
27
+ }
28
+
6
29
  interface ForkableSessionManager {
7
30
  getSessionFile(): string | undefined;
8
31
  getLeafId(): string | null;
9
32
  getSessionDir?(): string;
10
- openSession?: (path: string, sessionDir?: string) => { createBranchedSession(leafId: string): string | undefined };
33
+ openSession?: (path: string, sessionDir?: string) => BranchSessionManager;
11
34
  }
12
35
 
13
36
  interface ForkContextResolverOptions {
14
- openSession?: (path: string, sessionDir?: string) => { createBranchedSession(leafId: string): string | undefined };
37
+ openSession?: (path: string, sessionDir?: string) => BranchSessionManager;
38
+ }
39
+
40
+ interface ForkContextResolution {
41
+ sessionFile: string;
42
+ thinkingOverride?: "off";
15
43
  }
16
44
 
17
45
  interface ForkContextResolver {
18
46
  sessionFileForIndex(index?: number): string | undefined;
47
+ thinkingOverrideForIndex(index?: number): "off" | undefined;
19
48
  }
20
49
 
21
50
  export function resolveSubagentContext(value: unknown): SubagentExecutionContext {
22
51
  return value === "fork" ? "fork" : "fresh";
23
52
  }
24
53
 
54
+ function isUnsafeAnthropicThinkingBlock(message: BranchSessionEntry["message"], block: unknown): boolean {
55
+ if (!message || !block || typeof block !== "object" || !("type" in block)) return false;
56
+ const provider = typeof message.provider === "string" ? message.provider.toLowerCase() : "";
57
+ const api = typeof message.api === "string" ? message.api.toLowerCase() : "";
58
+ const model = typeof message.model === "string" ? message.model.toLowerCase() : "";
59
+ const isAnthropic = provider === "anthropic" || api === "anthropic-messages" || model.startsWith("anthropic/");
60
+ if (block.type === "redacted_thinking") return true;
61
+ if (block.type !== "thinking" || !isAnthropic) return false;
62
+ const signature = "thinkingSignature" in block ? block.thinkingSignature : "signature" in block ? block.signature : undefined;
63
+ return block.redacted === true || (typeof signature === "string" && signature.length > 0);
64
+ }
65
+
66
+ function createEntryId(entries: BranchSessionEntry[]): string {
67
+ const ids = new Set(entries.map((entry) => entry.id).filter((id): id is string => typeof id === "string"));
68
+ for (let attempt = 0; attempt < 100; attempt++) {
69
+ const id = randomUUID().slice(0, 8);
70
+ if (!ids.has(id)) return id;
71
+ }
72
+ return randomUUID();
73
+ }
74
+
75
+ function appendThinkingOffEntry(entries: BranchSessionEntry[]): void {
76
+ const last = entries[entries.length - 1];
77
+ if (last?.type === "thinking_level_change" && last.thinkingLevel === "off") return;
78
+ const parent = [...entries].reverse().find((entry) => typeof entry.id === "string");
79
+ entries.push({
80
+ type: "thinking_level_change",
81
+ id: createEntryId(entries),
82
+ parentId: parent?.id ?? null,
83
+ timestamp: new Date().toISOString(),
84
+ thinkingLevel: "off",
85
+ });
86
+ }
87
+
88
+ function sanitizeUnsafeThinkingBlocks(entries: BranchSessionEntry[]): boolean {
89
+ let sanitized = false;
90
+ for (const entry of entries) {
91
+ if (entry.type !== "message" || entry.message?.role !== "assistant" || !Array.isArray(entry.message.content)) continue;
92
+ const filtered = entry.message.content.filter((block) => !isUnsafeAnthropicThinkingBlock(entry.message, block));
93
+ if (filtered.length === entry.message.content.length) continue;
94
+ entry.message.content = filtered;
95
+ sanitized = true;
96
+ }
97
+ if (sanitized) appendThinkingOffEntry(entries);
98
+ return sanitized;
99
+ }
100
+
101
+ function readSessionEntries(sessionFile: string): BranchSessionEntry[] {
102
+ const lines = fs.readFileSync(sessionFile, "utf-8").split("\n").filter((line) => line.trim().length > 0);
103
+ return lines.map((line, index) => {
104
+ try {
105
+ return JSON.parse(line) as BranchSessionEntry;
106
+ } catch (error) {
107
+ const cause = error instanceof Error ? error : new Error(String(error));
108
+ throw new Error(`Unable to inspect forked session ${sessionFile}: invalid JSONL on line ${index + 1}: ${cause.message}`, { cause });
109
+ }
110
+ });
111
+ }
112
+
25
113
  export function createForkContextResolver(
26
114
  sessionManager: ForkableSessionManager,
27
115
  requestedContext: unknown,
@@ -30,6 +118,7 @@ export function createForkContextResolver(
30
118
  if (resolveSubagentContext(requestedContext) !== "fork") {
31
119
  return {
32
120
  sessionFileForIndex: () => undefined,
121
+ thinkingOverrideForIndex: () => undefined,
33
122
  };
34
123
  }
35
124
 
@@ -47,30 +136,52 @@ export function createForkContextResolver(
47
136
  ?? sessionManager.openSession
48
137
  ?? ((file: string, dir?: string) => SessionManager.open(file, dir));
49
138
  const sessionDir = sessionManager.getSessionDir?.();
50
- const cachedSessionFiles = new Map<number, string>();
139
+ const cachedResolutions = new Map<number, ForkContextResolution>();
51
140
 
52
- return {
53
- sessionFileForIndex(index = 0): string | undefined {
54
- const cached = cachedSessionFiles.get(index);
55
- if (cached) return cached;
56
- try {
57
- if (!fs.existsSync(parentSessionFile)) {
58
- throw new Error(`Parent session file does not exist: ${parentSessionFile}. Pi has not persisted enough history to fork yet.`);
59
- }
60
- const sourceManager = openSession(parentSessionFile, sessionDir);
61
- const sessionFile = sourceManager.createBranchedSession(leafId);
62
- if (!sessionFile) {
63
- throw new Error("Session manager did not return a forked session file.");
141
+ const resolveFork = (index = 0): ForkContextResolution => {
142
+ const cached = cachedResolutions.get(index);
143
+ if (cached) return cached;
144
+ try {
145
+ if (!fs.existsSync(parentSessionFile)) {
146
+ throw new Error(`Parent session file does not exist: ${parentSessionFile}. Pi has not persisted enough history to fork yet.`);
147
+ }
148
+ const sourceManager = openSession(parentSessionFile, sessionDir);
149
+ const sessionFile = sourceManager.createBranchedSession(leafId);
150
+ if (!sessionFile) {
151
+ throw new Error("Session manager did not return a forked session file.");
152
+ }
153
+ let thinkingOverride: "off" | undefined;
154
+ if (!fs.existsSync(sessionFile)) {
155
+ const header = sourceManager.getHeader?.();
156
+ const entries = sourceManager.getEntries?.();
157
+ if (!header || !entries) {
158
+ throw new Error(`Session manager returned a forked session file that does not exist and cannot be persisted by fallback: ${sessionFile}`);
64
159
  }
65
- if (!fs.existsSync(sessionFile)) {
66
- throw new Error(`Session manager returned a forked session file that does not exist: ${sessionFile}`);
160
+ if (sanitizeUnsafeThinkingBlocks(entries)) thinkingOverride = "off";
161
+ fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
162
+ fs.writeFileSync(sessionFile, `${[header, ...entries].map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf-8");
163
+ } else {
164
+ const entries = readSessionEntries(sessionFile);
165
+ if (sanitizeUnsafeThinkingBlocks(entries)) {
166
+ thinkingOverride = "off";
167
+ fs.writeFileSync(sessionFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf-8");
67
168
  }
68
- cachedSessionFiles.set(index, sessionFile);
69
- return sessionFile;
70
- } catch (error) {
71
- const cause = error instanceof Error ? error : new Error(String(error));
72
- throw new Error(`Failed to create forked subagent session: ${cause.message}`, { cause });
73
169
  }
170
+ const resolution = { sessionFile, ...(thinkingOverride ? { thinkingOverride } : {}) };
171
+ cachedResolutions.set(index, resolution);
172
+ return resolution;
173
+ } catch (error) {
174
+ const cause = error instanceof Error ? error : new Error(String(error));
175
+ throw new Error(`Failed to create forked subagent session: ${cause.message}`, { cause });
176
+ }
177
+ };
178
+
179
+ return {
180
+ sessionFileForIndex(index = 0): string | undefined {
181
+ return resolveFork(index).sessionFile;
182
+ },
183
+ thinkingOverrideForIndex(index = 0): "off" | undefined {
184
+ return resolveFork(index).thinkingOverride;
74
185
  },
75
186
  };
76
187
  }
@@ -144,17 +144,25 @@ export interface ControlEvent {
144
144
 
145
145
  export type SubagentResultStatus = "completed" | "failed" | "paused" | "detached";
146
146
  export type SubagentRunMode = "single" | "parallel" | "chain";
147
+ export const SUBAGENT_LIFECYCLE_ARTIFACT_VERSION = 1;
148
+ export type SubagentLifecycleArtifactVersion = typeof SUBAGENT_LIFECYCLE_ARTIFACT_VERSION;
147
149
 
148
150
  export type PublicNestedStepSummary = Pick<
149
151
  NestedStepSummary,
150
- "agent" | "status" | "sessionFile" | "activityState" | "lastActivityAt" | "currentTool" | "currentToolStartedAt" | "currentPath" | "turnCount" | "toolCount" | "startedAt" | "endedAt" | "error"
152
+ "agent" | "status" | "sessionFile" | "activityState" | "lastActivityAt" | "currentTool" | "currentToolStartedAt" | "currentPath" | "turnCount" | "toolCount" | "startedAt" | "endedAt" | "error" | "timedOut"
151
153
  > & {
152
154
  children?: PublicNestedRunSummary[];
153
155
  };
154
156
 
157
+ export type CostSummary = {
158
+ inputTokens: number;
159
+ outputTokens: number;
160
+ costUsd: number;
161
+ };
162
+
155
163
  export type PublicNestedRunSummary = Pick<
156
164
  NestedRunSummary,
157
- "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"
165
+ "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" | "totalCost" | "startedAt" | "endedAt" | "lastUpdate" | "error" | "timeoutMs" | "deadlineAt" | "timedOut"
158
166
  > & {
159
167
  steps?: PublicNestedStepSummary[];
160
168
  children?: PublicNestedRunSummary[];
@@ -416,6 +424,7 @@ export interface SingleResult {
416
424
  structuredOutputPath?: string;
417
425
  structuredOutputSchemaPath?: string;
418
426
  acceptance?: AcceptanceLedger;
427
+ children?: NestedRunSummary[];
419
428
  }
420
429
 
421
430
  export interface Details {
@@ -426,6 +435,9 @@ export interface Details {
426
435
  controlEvents?: ControlEvent[];
427
436
  asyncId?: string;
428
437
  asyncDir?: string;
438
+ timeoutMs?: number;
439
+ deadlineAt?: number;
440
+ timedOut?: boolean;
429
441
  progress?: AgentProgress[];
430
442
  progressSummary?: ProgressSummary;
431
443
  artifacts?: {
@@ -444,6 +456,10 @@ export interface Details {
444
456
  currentStepIndex?: number; // 0-indexed current step (for running chains)
445
457
  workflowGraph?: WorkflowGraphSnapshot;
446
458
  outputs?: ChainOutputMap;
459
+ // Aggregated child usage across all agents in the run
460
+ totalChildUsage?: Usage;
461
+ // Aggregated cost across all agents in the run
462
+ totalCost?: CostSummary;
447
463
  }
448
464
 
449
465
  // ============================================================================
@@ -502,6 +518,7 @@ export interface NestedStepSummary {
502
518
  startedAt?: number;
503
519
  endedAt?: number;
504
520
  error?: string;
521
+ timedOut?: boolean;
505
522
  children?: NestedRunSummary[];
506
523
  }
507
524
 
@@ -533,9 +550,13 @@ export interface NestedRunSummary extends NestedRunAddress {
533
550
  turnCount?: number;
534
551
  toolCount?: number;
535
552
  totalTokens?: TokenUsage;
553
+ totalCost?: CostSummary;
536
554
  startedAt?: number;
537
555
  endedAt?: number;
538
556
  lastUpdate?: number;
557
+ timeoutMs?: number;
558
+ deadlineAt?: number;
559
+ timedOut?: boolean;
539
560
  error?: string;
540
561
  }
541
562
 
@@ -547,6 +568,7 @@ export interface NestedRouteInfo {
547
568
  }
548
569
 
549
570
  export interface AsyncStartedEvent {
571
+ lifecycleArtifactVersion?: SubagentLifecycleArtifactVersion;
550
572
  id?: string;
551
573
  asyncDir?: string;
552
574
  pid?: number;
@@ -558,14 +580,18 @@ export interface AsyncStartedEvent {
558
580
  chainStepCount?: number;
559
581
  parallelGroups?: AsyncParallelGroupStatus[];
560
582
  workflowGraph?: WorkflowGraphSnapshot;
583
+ timeoutMs?: number;
584
+ deadlineAt?: number;
561
585
  nestedRoute?: NestedRouteInfo;
562
586
  }
563
587
 
564
588
  export interface AsyncStatus {
589
+ lifecycleArtifactVersion?: SubagentLifecycleArtifactVersion;
565
590
  runId: string;
566
591
  sessionId?: string;
567
592
  mode: SubagentRunMode;
568
593
  state: "queued" | "running" | "complete" | "failed" | "paused";
594
+ error?: string;
569
595
  activityState?: ActivityState;
570
596
  lastActivityAt?: number;
571
597
  currentTool?: string;
@@ -576,6 +602,9 @@ export interface AsyncStatus {
576
602
  startedAt: number;
577
603
  endedAt?: number;
578
604
  lastUpdate?: number;
605
+ timeoutMs?: number;
606
+ deadlineAt?: number;
607
+ timedOut?: boolean;
579
608
  pid?: number;
580
609
  cwd?: string;
581
610
  currentStep?: number;
@@ -606,12 +635,14 @@ export interface AsyncStatus {
606
635
  endedAt?: number;
607
636
  durationMs?: number;
608
637
  exitCode?: number | null;
638
+ timedOut?: boolean;
609
639
  tokens?: TokenUsage;
610
640
  skills?: string[];
611
641
  model?: string;
612
642
  thinking?: string;
613
643
  attemptedModels?: string[];
614
644
  modelAttempts?: ModelAttempt[];
645
+ totalCost?: CostSummary;
615
646
  error?: string;
616
647
  structuredOutput?: unknown;
617
648
  structuredOutputPath?: string;
@@ -621,6 +652,7 @@ export interface AsyncStatus {
621
652
  sessionDir?: string;
622
653
  outputFile?: string;
623
654
  totalTokens?: TokenUsage;
655
+ totalCost?: CostSummary;
624
656
  sessionFile?: string;
625
657
  outputs?: ChainOutputMap;
626
658
  }
@@ -655,6 +687,9 @@ export interface AsyncJobState {
655
687
  activeParallelGroup?: boolean;
656
688
  startedAt?: number;
657
689
  updatedAt?: number;
690
+ timeoutMs?: number;
691
+ deadlineAt?: number;
692
+ timedOut?: boolean;
658
693
  sessionDir?: string;
659
694
  outputFile?: string;
660
695
  totalTokens?: TokenUsage;
@@ -683,6 +718,7 @@ export interface SubagentState {
683
718
  baseCwd: string;
684
719
  currentSessionId: string | null;
685
720
  subagentInProgress?: boolean;
721
+ subagentSpawns?: { sessionId: string | null; count: number };
686
722
  asyncJobs: Map<string, AsyncJobState>;
687
723
  foregroundRuns?: Map<string, ForegroundResumeRun>;
688
724
  foregroundControls: Map<string, {
@@ -712,6 +748,8 @@ export interface SubagentState {
712
748
  completionSeen: Map<string, number>;
713
749
  watcher: FSWatcher | null;
714
750
  watcherRestartTimer: ReturnType<typeof setTimeout> | null;
751
+ companionSuggestionStartupShown?: boolean;
752
+ companionSuggestionListShown?: boolean;
715
753
  resultFileCoalescer: {
716
754
  schedule(file: string, delayMs?: number): boolean;
717
755
  clear(): void;
@@ -784,6 +822,8 @@ export interface RunSyncOptions {
784
822
  nestedRoute?: NestedRouteInfo;
785
823
  /** Override the agent's default model (format: "provider/id" or just "id") */
786
824
  modelOverride?: string;
825
+ /** Override the agent's default thinking level for this run */
826
+ thinkingOverride?: AgentConfig["thinking"];
787
827
  /** Registry models available for heuristic bare-model resolution */
788
828
  availableModels?: Array<{ provider: string; id: string; fullId: string }>;
789
829
  /** Current parent-session provider to prefer for ambiguous bare model ids */
@@ -829,18 +869,41 @@ export interface ProactiveSkillSubagentsConfig {
829
869
  preferredAgent?: string;
830
870
  }
831
871
 
872
+ export type CompanionSuggestionPackage = "pi-prompt-template-model" | "pi-intercom";
873
+ export type CompanionSuggestionSurface = "session_start" | "list" | "doctor";
874
+
875
+ export interface CompanionSuggestionPackageConfig {
876
+ enabled?: boolean;
877
+ surfaces?: CompanionSuggestionSurface[];
878
+ dismissed?: {
879
+ user?: boolean;
880
+ workspaces?: string[];
881
+ };
882
+ }
883
+
884
+ export interface CompanionSuggestionsConfig {
885
+ enabled?: boolean;
886
+ packages?: Partial<Record<CompanionSuggestionPackage, CompanionSuggestionPackageConfig>>;
887
+ }
888
+
832
889
  export interface ExtensionConfig {
833
890
  asyncByDefault?: boolean;
834
891
  forceTopLevelAsync?: boolean;
835
892
  defaultSessionDir?: string;
893
+ singleRunOutputBaseDir?: string;
836
894
  maxSubagentDepth?: number;
895
+ maxSubagentSpawnsPerSession?: number;
896
+ /** Global cap on simultaneously-running subagent tasks within a single run. Defaults to 20. */
897
+ globalConcurrencyLimit?: number;
837
898
  control?: ControlConfig;
838
899
  parallel?: TopLevelParallelConfig;
839
900
  chain?: ExtensionChainConfig;
840
901
  worktreeSetupHook?: string;
841
902
  worktreeSetupHookTimeoutMs?: number;
903
+ worktreeBaseDir?: string;
842
904
  intercomBridge?: IntercomBridgeConfig;
843
905
  proactiveSkillSubagents?: ProactiveSkillSubagentsConfig | false;
906
+ companionSuggestions?: CompanionSuggestionsConfig | false;
844
907
  }
845
908
 
846
909
  // ============================================================================
@@ -932,6 +995,7 @@ export const SLASH_SUBAGENT_CANCEL_EVENT = "subagent:slash:cancel";
932
995
  export const POLL_INTERVAL_MS = 250;
933
996
  export const MAX_WIDGET_JOBS = 4;
934
997
  export const DEFAULT_SUBAGENT_MAX_DEPTH = 2;
998
+ export const DEFAULT_MAX_SUBAGENT_SPAWNS_PER_SESSION = 40;
935
999
  export const SUBAGENT_ACTIONS = ["list", "get", "models", "create", "update", "delete", "status", "interrupt", "resume", "append-step", "doctor"] as const;
936
1000
 
937
1001
  export const DEFAULT_FORK_PREAMBLE =
@@ -975,12 +1039,16 @@ export function wrapForkTask(task: string, preamble?: string | false): string {
975
1039
  // Recursion Depth Guard
976
1040
  // ============================================================================
977
1041
 
978
- export function normalizeMaxSubagentDepth(value: unknown): number | undefined {
1042
+ function normalizeNonNegativeInteger(value: unknown): number | undefined {
979
1043
  const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
980
1044
  if (!Number.isInteger(parsed) || parsed < 0) return undefined;
981
1045
  return parsed;
982
1046
  }
983
1047
 
1048
+ export function normalizeMaxSubagentDepth(value: unknown): number | undefined {
1049
+ return normalizeNonNegativeInteger(value);
1050
+ }
1051
+
984
1052
  export function resolveCurrentMaxSubagentDepth(configMaxDepth?: number): number {
985
1053
  return normalizeMaxSubagentDepth(process.env.PI_SUBAGENT_MAX_DEPTH)
986
1054
  ?? normalizeMaxSubagentDepth(configMaxDepth)
@@ -1009,6 +1077,16 @@ export function getSubagentDepthEnv(maxDepth?: number): Record<string, string> {
1009
1077
  };
1010
1078
  }
1011
1079
 
1080
+ export function normalizeMaxSubagentSpawnsPerSession(value: unknown): number | undefined {
1081
+ return normalizeNonNegativeInteger(value);
1082
+ }
1083
+
1084
+ export function resolveMaxSubagentSpawnsPerSession(configMaxSpawns?: number): number {
1085
+ return normalizeMaxSubagentSpawnsPerSession(process.env.PI_SUBAGENT_MAX_SPAWNS_PER_SESSION)
1086
+ ?? normalizeMaxSubagentSpawnsPerSession(configMaxSpawns)
1087
+ ?? DEFAULT_MAX_SUBAGENT_SPAWNS_PER_SESSION;
1088
+ }
1089
+
1012
1090
  // ============================================================================
1013
1091
  // Utility Functions
1014
1092
  // ============================================================================
@@ -5,22 +5,60 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
- import * as piCodingAgent from "@earendil-works/pi-coding-agent";
9
8
  import type { Message } from "@earendil-works/pi-ai";
10
9
  import { formatToolCall } from "./formatters.ts";
11
- import type { AgentProgress, AsyncStatus, Details, DisplayItem, ErrorInfo, SingleResult, ToolCallSummary } from "./types.ts";
10
+ import type { AgentProgress, AsyncStatus, Details, DisplayItem, ErrorInfo, NestedRunSummary, SingleResult, ToolCallSummary, Usage } from "./types.ts";
12
11
 
13
12
  // ============================================================================
14
13
  // File System Utilities
15
14
  // ============================================================================
16
15
 
17
16
  const DEFAULT_CONFIG_DIR_NAME = ".pi";
17
+ const PI_CODING_AGENT_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
18
+ export const PI_CODING_AGENT_PACKAGE_ROOT_ENV = "PI_SUBAGENTS_PI_CODING_AGENT_PACKAGE_ROOT";
18
19
 
19
- export function resolveConfigDirName(codingAgentModule: unknown = piCodingAgent): string {
20
- const value = codingAgentModule && typeof codingAgentModule === "object"
21
- ? (codingAgentModule as { CONFIG_DIR_NAME?: unknown }).CONFIG_DIR_NAME
20
+ function validConfigDirName(value: unknown): string | undefined {
21
+ return typeof value === "string" && value.trim() ? value : undefined;
22
+ }
23
+
24
+ function readConfigDirNameFromPackageRoot(packageRoot: string | undefined): string | undefined {
25
+ if (!packageRoot) return undefined;
26
+ try {
27
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8")) as {
28
+ name?: unknown;
29
+ piConfig?: { configDir?: unknown };
30
+ };
31
+ if (pkg.name !== PI_CODING_AGENT_PACKAGE_NAME) return undefined;
32
+ return validConfigDirName(pkg.piConfig?.configDir);
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ function resolveConfigDirNameFromPackageJson(entryPoint = process.argv[1], packageRoot = process.env[PI_CODING_AGENT_PACKAGE_ROOT_ENV]): string | undefined {
39
+ const packageRootValue = readConfigDirNameFromPackageRoot(packageRoot);
40
+ if (packageRootValue) return packageRootValue;
41
+ if (!entryPoint) return undefined;
42
+ try {
43
+ let dir = path.dirname(fs.realpathSync(entryPoint));
44
+ while (dir !== path.dirname(dir)) {
45
+ const value = readConfigDirNameFromPackageRoot(dir);
46
+ if (value) return value;
47
+ dir = path.dirname(dir);
48
+ }
49
+ } catch {
50
+ // Package metadata lookup is best-effort; detached runners must not fail here.
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ export function resolveConfigDirName(codingAgentModule?: unknown, entryPoint?: string, packageRoot?: string): string {
56
+ const moduleValue = codingAgentModule && typeof codingAgentModule === "object"
57
+ ? validConfigDirName((codingAgentModule as { CONFIG_DIR_NAME?: unknown }).CONFIG_DIR_NAME)
22
58
  : undefined;
23
- return typeof value === "string" && value.trim() ? value : DEFAULT_CONFIG_DIR_NAME;
59
+ return moduleValue
60
+ ?? resolveConfigDirNameFromPackageJson(entryPoint, packageRoot)
61
+ ?? DEFAULT_CONFIG_DIR_NAME;
24
62
  }
25
63
 
26
64
  export function getConfigDirName(): string {
@@ -204,19 +242,28 @@ function writePrompt(agent: string, prompt: string): { dir: string; path: string
204
242
  * Get the final text output from a list of messages
205
243
  */
206
244
  export function getFinalOutput(messages: Message[]): string {
245
+ const validTextParts: string[] = [];
207
246
  for (let i = messages.length - 1; i >= 0; i--) {
208
247
  const msg = messages[i];
209
- if (msg.role === "assistant") {
210
- const hasAssistantError = ("errorMessage" in msg && typeof msg.errorMessage === "string" && msg.errorMessage.length > 0)
211
- || ("stopReason" in msg && msg.stopReason === "error");
212
- if (hasAssistantError) continue;
213
- for (let j = msg.content.length - 1; j >= 0; j--) {
214
- const part = msg.content[j];
215
- if (part.type === "text" && part.text.trim().length > 0) return part.text;
248
+ if (msg.role !== "assistant") continue;
249
+ const hasAssistantError = ("errorMessage" in msg && typeof msg.errorMessage === "string" && msg.errorMessage.length > 0)
250
+ || ("stopReason" in msg && msg.stopReason === "error");
251
+ if (hasAssistantError) continue;
252
+ for (let j = msg.content.length - 1; j >= 0; j--) {
253
+ const part = msg.content[j];
254
+ if (part.type !== "text" || part.text.trim().length === 0) continue;
255
+ validTextParts.push(part.text);
256
+ if (/```acceptance-report\s*\n[\s\S]*?```/i.test(part.text)) return part.text;
257
+ for (const match of part.text.matchAll(/```(?:json|jsonc|json5)\s*\n([\s\S]*?)```/gi)) {
258
+ const body = match[1] ?? "";
259
+ if (/"criteriaSatisfied"/.test(body) && /"(?:changedFiles|testsAddedOrUpdated|commandsRun|validationOutput|residualRisks|noStagedFiles|diffSummary|reviewFindings|manualNotes)"/.test(body)) {
260
+ return part.text;
261
+ }
216
262
  }
263
+ if (/ACCEPTANCE_REPORT\s*:/i.test(part.text)) return part.text;
217
264
  }
218
265
  }
219
- return "";
266
+ return validTextParts[0] ?? "";
220
267
  }
221
268
 
222
269
  export function getSingleResultOutput(result: Pick<SingleResult, "finalOutput" | "messages">): string {
@@ -278,6 +325,44 @@ function extractToolCallSummaries(messages: Message[] | undefined): ToolCallSumm
278
325
  return summaries;
279
326
  }
280
327
 
328
+ export function sumResultsUsage(results: SingleResult[]): Usage {
329
+ const usage: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
330
+ for (const result of results) {
331
+ usage.input += result.usage.input;
332
+ usage.output += result.usage.output;
333
+ usage.cacheRead += result.usage.cacheRead;
334
+ usage.cacheWrite += result.usage.cacheWrite;
335
+ usage.cost += result.usage.cost;
336
+ usage.turns += result.usage.turns;
337
+ }
338
+ return usage;
339
+ }
340
+
341
+ function addNestedCost(total: NonNullable<Details["totalCost"]>, children: NestedRunSummary[] | undefined): void {
342
+ for (const child of children ?? []) {
343
+ if (child.totalCost) {
344
+ total.inputTokens += child.totalCost.inputTokens;
345
+ total.outputTokens += child.totalCost.outputTokens;
346
+ total.costUsd += child.totalCost.costUsd;
347
+ continue;
348
+ }
349
+ addNestedCost(total, child.children);
350
+ for (const step of child.steps ?? []) addNestedCost(total, step.children);
351
+ }
352
+ }
353
+
354
+ /** Sum input tokens, output tokens, and cost across a set of SingleResults. */
355
+ export function sumResultsCost(results: SingleResult[]): NonNullable<Details["totalCost"]> {
356
+ const total = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
357
+ for (const result of results) {
358
+ total.inputTokens += result.usage.input;
359
+ total.outputTokens += result.usage.output;
360
+ total.costUsd += result.usage.cost;
361
+ addNestedCost(total, result.children);
362
+ }
363
+ return total;
364
+ }
365
+
281
366
  export function compactForegroundResult(result: SingleResult): SingleResult {
282
367
  if (result.progress?.status === "running") return result;
283
368
  const toolCalls = result.toolCalls?.length ? result.toolCalls : extractToolCallSummaries(result.messages);