@mystilleef/pi-subagent 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,7 @@ pi -e npm:@mystilleef/pi-subagent
25
25
 
26
26
  ## Features
27
27
 
28
- - **Asynchronous:** Agents run in the background.
28
+ - **Asynchronous:** Agents always run in the background.
29
29
  - **Parallel:** Run many agents simultaneously.
30
30
  - **Isolated:** Each delegated task receives a separate context window.
31
31
  - **Nested:** `Subagents` can spawn other `subagents`.
@@ -108,9 +108,54 @@ YAML `frontmatter` and a Markdown system prompt body.
108
108
 
109
109
  ### Discovery locations
110
110
 
111
- - User-global agents: `~/.pi/agents/*.md`
111
+ - User-global agents: `~/.pi/agent/agents/*.md`
112
112
  - Project-local agents: nearest `.pi/agents/*.md`
113
113
 
114
+ ### Example of an agent file
115
+
116
+ **PATH:** _~/.pi/agent/agents/lifehacks.md_
117
+
118
+ <!-- prettier-ignore-start -->
119
+ ```md
120
+ ---
121
+ name: lifehacks
122
+ description: Daily lifehacks
123
+ replace_prompt: true
124
+ context: false
125
+ thinking: xhigh
126
+ skills: false
127
+ extensions: pi-mcp-adapter
128
+ tools: read, grep, find, ls, mcp
129
+ ---
130
+
131
+ # Role
132
+
133
+ Embody an expert philosopher and life coach. Your specialize in
134
+ _lifehacks_.
135
+
136
+ ## Workflow
137
+
138
+ - Research and deliver 3 _profound_ and _transformative lifehacks_.
139
+ - Expound upon each of them.
140
+ - Emit unmodified result to the calling agent.
141
+
142
+ ## Directives
143
+
144
+ - Forbid `copular` verb forms.
145
+ - **Minimum** words. **Maximum** signal.
146
+ - Keep prose vivid but terse.
147
+ - Optimize prose for token and context efficiency.
148
+ - Use lists and sub-lists over paragraphs and long sentences.
149
+ - Use elegant, well-structured, idiomatic markdown.
150
+
151
+ ## Constraints
152
+
153
+ - Operate in read-only mode.
154
+ - Forbid all write operations.
155
+ - **NEVER** wrap the entire result in a code block.
156
+ ```
157
+ <!-- prettier-ignore-end -->
158
+
114
159
  ### Required front matter
115
160
 
116
161
  ```yaml
@@ -123,21 +168,48 @@ description: Review code for correctness and maintainability.
123
168
  ```yaml
124
169
  tools: read, bash, edit
125
170
  skills: code-review
171
+ extensions: git-summary
172
+ context: false
126
173
  thinking: medium
127
174
  provider: deepseek
128
175
  model: deepseek-v4-flash
129
176
  temperature: 0.7
130
177
  top_p: 0.9
178
+ replace_prompt: true
131
179
  ```
132
180
 
133
- ### Accepted thinking values
134
-
135
- - `off`
136
- - `minimal`
137
- - `low`
138
- - `medium`
139
- - `high`
140
- - `xhigh`
181
+ - `tools`: Specifies a comma-separated list of enabled tools. Omission
182
+ defaults to the child process default `toolset`.
183
+ - `skills`: Specifies a comma-separated list of enabled skills. Setting
184
+ `false` disables all skills. Omission inherits the active workspace
185
+ skills.
186
+ - `extensions`: Specifies a comma-separated list of enabled extensions.
187
+ Setting `false` disables all extensions except core subagent
188
+ extensions. Omission inherits the active workspace extensions.
189
+ - `context`: Controls the inclusion of project context files,
190
+ `AGENTS.md`. Setting `false` excludes default workspace files from the
191
+ child context window.
192
+ - `thinking`: Controls the model thinking level (values: `off`,
193
+ `minimal`, `low`, `medium`, `high`, `xhigh`). The child runner clamps
194
+ unsupported levels to supported values and prints warnings.
195
+ - `provider`: Specifies the model provider. Requires setting the `model`
196
+ field. Omission of the `model` field when defining a `provider`
197
+ invalidates the agent configuration.
198
+ - `model`: Specifies the model identifier. Agent-level model settings
199
+ override parent settings.
200
+ - `temperature`: Sets the sampling temperature. Accepts numeric values
201
+ between `0.0` and `1.0` inclusive. Incorrect values trigger warnings
202
+ during discovery and the system ignores them.
203
+ - `top_p`: Sets the sampling top-p value. Accepts numeric values between
204
+ `0.0` and `1.0` inclusive. Incorrect values trigger warnings during
205
+ discovery and the system ignores them.
206
+ - `replace_prompt`: Set `true` to replace the child system prompt base
207
+ with the agent body. Omitted or `false` appends the body to the
208
+ existing system prompt, `SYSTEM.md`, preserving current behavior.
209
+ Requires a non-empty agent body; `replace_prompt: true` with an empty
210
+ or whitespace-only body fails discovery. **Warning:** `true` replaces
211
+ pi built-in defaults and any project/global `SYSTEM.md`, including
212
+ their safety and behavior guardrails.
141
213
 
142
214
  ---
143
215
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mystilleef/pi-subagent",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Pi subagent for the SPAE Framework",
5
5
  "author": "Lateef Alabi-Oki <mystilleef@gmail.com>",
6
6
  "license": "MIT",
@@ -64,14 +64,14 @@
64
64
  "typebox": "*"
65
65
  },
66
66
  "devDependencies": {
67
- "@biomejs/biome": "^2.5.1",
68
- "@earendil-works/pi-agent-core": "^0.80.2",
69
- "@earendil-works/pi-ai": "^0.80.2",
70
- "@earendil-works/pi-coding-agent": "^0.80.2",
71
- "@earendil-works/pi-tui": "^0.80.2",
67
+ "@biomejs/biome": "^2.5.2",
68
+ "@earendil-works/pi-agent-core": "^0.80.3",
69
+ "@earendil-works/pi-ai": "^0.80.3",
70
+ "@earendil-works/pi-coding-agent": "^0.80.3",
71
+ "@earendil-works/pi-tui": "^0.80.3",
72
72
  "@types/bun": "^1.3.14",
73
- "@types/node": "^26.0.1",
74
- "typebox": "^1.3.0",
73
+ "@types/node": "^26.1.0",
74
+ "typebox": "^1.3.4",
75
75
  "typescript": "^6.0.3"
76
76
  }
77
77
  }
@@ -21,13 +21,16 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
21
21
  export interface AgentConfig {
22
22
  name: string;
23
23
  description: string;
24
+ context?: false | undefined;
24
25
  tools?: string[] | undefined;
25
- skills?: string[] | undefined;
26
+ skills?: string[] | false | undefined;
27
+ extensions?: string[] | undefined;
26
28
  thinking?: ThinkingLevel | undefined;
27
29
  model?: string | undefined;
28
30
  provider?: string | undefined;
29
31
  temperature?: number | undefined;
30
32
  topP?: number | undefined;
33
+ replacePrompt?: true | undefined;
31
34
  systemPrompt: string;
32
35
  source: AgentSource;
33
36
  filePath: string;
@@ -71,6 +74,21 @@ function parseCommaList(raw: unknown): string[] | undefined {
71
74
  return items.length > 0 ? items : undefined;
72
75
  }
73
76
 
77
+ function parseExtensions(raw: unknown): string[] | undefined {
78
+ if (raw === undefined) return undefined;
79
+ if (raw === false || Array.isArray(raw)) return [];
80
+ if (typeof raw === "string") {
81
+ const trimmed = raw.trim();
82
+ if (trimmed.length === 0) return [];
83
+ const items = trimmed
84
+ .split(",")
85
+ .map((s) => s.trim())
86
+ .filter(Boolean);
87
+ return items.length > 0 ? items : [];
88
+ }
89
+ return undefined;
90
+ }
91
+
74
92
  function parseThinkingLevel(raw: unknown): ThinkingLevel | undefined {
75
93
  if (typeof raw !== "string") return undefined;
76
94
  const normalized = raw.trim().toLowerCase();
@@ -124,29 +142,49 @@ function parseAgentConfig(
124
142
  const {
125
143
  name,
126
144
  description,
145
+ context: rawContext,
127
146
  tools: rawTools,
128
147
  skills: rawSkills,
148
+ extensions: rawExtensions,
129
149
  thinking: rawThinking,
130
150
  model: rawModel,
131
151
  provider: rawProvider,
132
152
  temperature: rawTemperature,
133
153
  top_p: rawTopP,
154
+ replace_prompt: rawReplacePrompt,
134
155
  } = frontmatter;
135
156
  if (typeof name !== "string" || typeof description !== "string") return null;
157
+ if (rawContext !== undefined && typeof rawContext !== "boolean") return null;
158
+ if (rawReplacePrompt !== undefined && typeof rawReplacePrompt !== "boolean")
159
+ return null;
136
160
  if (isNonStringOptional(rawTools)) return null;
137
- if (isNonStringOptional(rawSkills)) return null;
161
+ if (rawSkills != null && typeof rawSkills !== "string" && rawSkills !== false)
162
+ return null;
163
+ if (
164
+ rawExtensions !== undefined &&
165
+ rawExtensions !== false &&
166
+ typeof rawExtensions !== "string" &&
167
+ !(Array.isArray(rawExtensions) && rawExtensions.length === 0)
168
+ )
169
+ return null;
138
170
  if (isNonStringOptional(rawThinking)) return null;
139
171
  if (isNonStringOptional(rawModel)) return null;
140
172
  if (isNonStringOptional(rawProvider)) return null;
141
173
  const tools = parseCommaList(rawTools);
142
174
  const skills =
143
- rawSkills !== undefined ? (parseCommaList(rawSkills) ?? []) : undefined;
175
+ rawSkills === false
176
+ ? false
177
+ : rawSkills !== undefined
178
+ ? (parseCommaList(rawSkills) ?? [])
179
+ : undefined;
144
180
  const thinking = parseThinkingLevel(rawThinking);
145
181
  const model = parseOptionalString(rawModel);
146
182
  const provider = parseOptionalString(rawProvider);
147
183
  if (provider !== undefined && model === undefined) return null;
148
184
  const temperature = parseSamplingValue(rawTemperature, "temperature", name);
149
185
  const topP = parseSamplingValue(rawTopP, "top_p", name);
186
+ const extensions = parseExtensions(rawExtensions);
187
+ if (rawReplacePrompt === true && body.trim().length === 0) return null;
150
188
  return {
151
189
  name,
152
190
  description,
@@ -158,8 +196,11 @@ function parseAgentConfig(
158
196
  systemPrompt: body,
159
197
  source,
160
198
  filePath,
199
+ ...(rawContext === false && { context: false }),
161
200
  ...(temperature !== undefined && { temperature }),
162
201
  ...(topP !== undefined && { topP }),
202
+ ...(extensions !== undefined && { extensions }),
203
+ ...(rawReplacePrompt === true && { replacePrompt: true }),
163
204
  };
164
205
  }
165
206
 
@@ -43,6 +43,17 @@ export function resolveSamplingExtensionPath(dir?: string): string {
43
43
  return resolveExtensionPath("sampling-extension", dir);
44
44
  }
45
45
 
46
+ /**
47
+ * Resolves the absolute path to the pi-subagent package entry extension file.
48
+ * This is the main index that registers the subagent tool and commands.
49
+ * Prefers the extension matching the current runtime, then falls back to the
50
+ * alternate extension. Throws if neither exists.
51
+ * Pass `dir` in tests to use a controlled directory.
52
+ */
53
+ export function resolvePackageExtensionPath(dir?: string): string {
54
+ return resolveExtensionPath("../index", dir);
55
+ }
56
+
46
57
  /**
47
58
  * Appends data to a string while enforcing a maximum byte limit.
48
59
  * Handles both string and Buffer inputs, truncating at valid UTF-8 boundaries.
@@ -21,6 +21,7 @@ import {
21
21
  getPiInvocation,
22
22
  getSubagentDepth,
23
23
  getSubagentRuntimeLimits,
24
+ resolveAgentExtensionPaths,
24
25
  resolveAgentSkillArgs,
25
26
  subagentDepthEnv,
26
27
  } from "../shared/utils.js";
@@ -40,9 +41,10 @@ import {
40
41
  import {
41
42
  appendWithByteLimit,
42
43
  resolveCompleteExtensionPath,
44
+ resolvePackageExtensionPath,
43
45
  resolveSamplingExtensionPath,
44
46
  } from "./process-utils.js";
45
- import { appendSubagentResultContract } from "./prompt-contract.js";
47
+ import { SUBAGENT_RESULT_CONTRACT } from "./prompt-contract.js";
46
48
  import {
47
49
  beginPromptSetup,
48
50
  cleanupPromptSetupResult,
@@ -69,6 +71,7 @@ import {
69
71
 
70
72
  const COMPLETE_EXTENSION_PATH = resolveCompleteExtensionPath();
71
73
  const SAMPLING_EXTENSION_PATH = resolveSamplingExtensionPath();
74
+ const PACKAGE_EXTENSION_PATH = resolvePackageExtensionPath();
72
75
 
73
76
  export { resolveThinkingLevel } from "./model-resolution.js";
74
77
  export { makeEmitUpdate } from "./streaming-progress.js";
@@ -365,15 +368,40 @@ function buildSamplingEnv(agent: AgentConfig): string | undefined {
365
368
  });
366
369
  }
367
370
 
368
- function buildPiArgs(
369
- agent: AgentConfig,
370
- task: string,
371
- effectiveModel: ChildModelSettings,
372
- thinking: ThinkingLevel,
373
- resolvedSkills: { args: string[] },
374
- tmpPrompt: { filePath: string } | null,
375
- ): string[] {
376
- const args: string[] = ["--mode", "json", "-p", "--no-session", "--approve"];
371
+ export interface BuildPiArgsConfig {
372
+ agent: AgentConfig;
373
+ task: string;
374
+ effectiveModel: ChildModelSettings;
375
+ thinking: ThinkingLevel;
376
+ resolvedSkills: { args: string[] };
377
+ tmpPrompt: { filePath: string } | null;
378
+ resolvedExtensionPaths?: string[] | undefined;
379
+ samplingEnv?: string | undefined;
380
+ }
381
+
382
+ export function buildPiArgs(config: BuildPiArgsConfig): string[] {
383
+ const {
384
+ agent,
385
+ task,
386
+ effectiveModel,
387
+ thinking,
388
+ resolvedSkills,
389
+ tmpPrompt,
390
+ resolvedExtensionPaths,
391
+ samplingEnv,
392
+ } = config;
393
+ const args: string[] = [
394
+ "--mode",
395
+ "json",
396
+ "-p",
397
+ "--no-session",
398
+ "--approve",
399
+ "--no-themes",
400
+ "--no-prompt-templates",
401
+ ];
402
+ if (agent.extensions !== undefined) {
403
+ args.push("--no-extensions");
404
+ }
377
405
  if (effectiveModel.provider && effectiveModel.id)
378
406
  args.push("--provider", effectiveModel.provider);
379
407
  if (effectiveModel.id) args.push("--model", effectiveModel.id);
@@ -383,15 +411,35 @@ function buildPiArgs(
383
411
  tools.add("complete");
384
412
  args.push("--tools", [...tools].join(","));
385
413
  }
386
- if (agent.skills) args.push("--no-skills", ...resolvedSkills.args);
414
+ if (agent.skills !== undefined)
415
+ args.push("--no-skills", ...resolvedSkills.args);
416
+ if (agent.context === false) args.push("--no-context-files");
387
417
  if (tmpPrompt) {
388
- args.push("--append-system-prompt", tmpPrompt.filePath);
418
+ if (agent.replacePrompt) {
419
+ args.push("--system-prompt", tmpPrompt.filePath);
420
+ } else {
421
+ args.push("--append-system-prompt", tmpPrompt.filePath);
422
+ }
423
+ }
424
+ if (agent.extensions !== undefined) {
425
+ args.push("--extension", PACKAGE_EXTENSION_PATH);
426
+ if (resolvedExtensionPaths && resolvedExtensionPaths.length > 0) {
427
+ for (const rp of resolvedExtensionPaths) {
428
+ if (rp !== PACKAGE_EXTENSION_PATH) {
429
+ args.push("--extension", rp);
430
+ }
431
+ }
432
+ }
389
433
  }
390
434
  args.push("--extension", COMPLETE_EXTENSION_PATH);
435
+ if (samplingEnv) {
436
+ args.push("--extension", SAMPLING_EXTENSION_PATH);
437
+ }
438
+ args.push("--append-system-prompt", SUBAGENT_RESULT_CONTRACT);
391
439
  const taskPrompt = task
392
440
  ? `Task: ${task}`
393
441
  : "Run according to your system prompt. If no explicit task was provided, use the default context described there.";
394
- args.push(appendSubagentResultContract(taskPrompt));
442
+ args.push(taskPrompt);
395
443
  return args;
396
444
  }
397
445
 
@@ -548,10 +596,24 @@ export async function runSingleAgent(
548
596
  agent.skills
549
597
  ? resolveAgentSkillArgs(defaultCwd, agent.skills)
550
598
  : Promise.resolve({ args: [] });
599
+ const extensionNames =
600
+ agent.extensions && agent.extensions.length > 0
601
+ ? agent.extensions
602
+ : undefined;
603
+ const resolvedExtensionsPromise: Promise<
604
+ { resolvedPaths: string[] } | { error: string }
605
+ > = extensionNames
606
+ ? resolveAgentExtensionPaths(defaultCwd, extensionNames)
607
+ : Promise.resolve({ resolvedPaths: [] });
551
608
  const promptSetupPromise = beginPromptSetup(agent);
552
- const resolvedSkills = await resolvedSkillsPromise;
553
- if ("error" in resolvedSkills) {
554
- const promptSetup = await promptSetupPromise;
609
+ const [resolvedSkills, resolvedExtensions, promptSetup] = await Promise.all([
610
+ resolvedSkillsPromise,
611
+ resolvedExtensionsPromise,
612
+ promptSetupPromise,
613
+ ]);
614
+ const abortWithError = async (
615
+ error: string,
616
+ ): Promise<RunSingleAgentResult> => {
555
617
  await cleanupPromptSetupResult(promptSetup);
556
618
  return {
557
619
  kind: "completed",
@@ -559,13 +621,30 @@ export async function runSingleAgent(
559
621
  agentName,
560
622
  agent.source,
561
623
  task,
562
- resolvedSkills.error,
624
+ error,
625
+ modelDisplay,
626
+ ),
627
+ };
628
+ };
629
+ if ("error" in resolvedSkills) return abortWithError(resolvedSkills.error);
630
+ if ("error" in resolvedExtensions)
631
+ return abortWithError(resolvedExtensions.error);
632
+ if ("error" in promptSetup) {
633
+ return {
634
+ kind: "completed",
635
+ result: createErrorResult(
636
+ agentName,
637
+ agent.source,
638
+ task,
639
+ `Failed to write prompt: ${
640
+ promptSetup.error instanceof Error
641
+ ? promptSetup.error.message
642
+ : String(promptSetup.error)
643
+ }`,
563
644
  modelDisplay,
564
645
  ),
565
646
  };
566
647
  }
567
- const promptSetup = await promptSetupPromise;
568
- if ("error" in promptSetup) throw promptSetup.error;
569
648
  const startedAt = Date.now();
570
649
  const state: SubagentState = {
571
650
  result: initRuntimeResult(agentName, agent.source, task, modelDisplay),
@@ -576,17 +655,19 @@ export async function runSingleAgent(
576
655
  const tmpPrompt = promptSetup.tmpPrompt;
577
656
  const samplingEnv = buildSamplingEnv(agent);
578
657
  try {
579
- const args = buildPiArgs(
658
+ const args = buildPiArgs({
580
659
  agent,
581
660
  task,
582
661
  effectiveModel,
583
662
  thinking,
584
663
  resolvedSkills,
585
664
  tmpPrompt,
586
- );
587
- if (samplingEnv) {
588
- args.push("--extension", SAMPLING_EXTENSION_PATH);
589
- }
665
+ resolvedExtensionPaths:
666
+ agent.extensions !== undefined
667
+ ? resolvedExtensions.resolvedPaths
668
+ : undefined,
669
+ samplingEnv,
670
+ });
590
671
  const invocation = getPiInvocation(args);
591
672
  const terminateOptions = {
592
673
  tree: true,
@@ -5,16 +5,20 @@ export const SUBAGENT_RESULT_CONTRACT = `
5
5
 
6
6
  ### Directives
7
7
 
8
- - Write the complete result as a text response — mandatory, before calling \`complete\`.
9
- - Call \`complete\` as the final action; \`outcome\` carries a one-sentence progress
10
- summary only, not the result.
8
+ - Write the complete task result as assistant text — mandatory, non-empty, and trimmed,
9
+ before calling \`complete\`.
10
+ - Call \`complete\` as the final action after the result text; \`outcome\` must be a short,
11
+ one-sentence progress summary only, not the full result.
12
+ - This contract applies after any amount of tool use, reading, editing, or multi-step work.
11
13
 
12
14
  ### Constraints
13
15
 
14
- - **NEVER** omit the text response.
15
- - **NEVER** wrap the entire result in a code block.
16
+ - **NEVER** omit the result text response.
17
+ - **NEVER** emit whitespace-only, empty-string, or blank result text before \`complete\`.
18
+ - **NEVER** wrap the entire result in a code block or code fence.
19
+ - **NEVER** end the assistant response with text alone after tool calls; a terminal
20
+ \`complete\` call is required.
21
+ - **NEVER** write assistant text after \`complete\`.
22
+ - Call \`complete\` exactly once; multiple \`complete\` calls are not allowed.
23
+ - \`outcome\` must be concise and contain only a progress summary.
16
24
  `;
17
-
18
- export function appendSubagentResultContract(prompt: string): string {
19
- return `${prompt}\n\n${SUBAGENT_RESULT_CONTRACT}`;
20
- }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Output and runtime limit configuration for subagent processes.
3
+ * Handles byte/line caps for child output and runtime safety thresholds.
4
+ */
5
+
6
+ export const DEFAULT_MAX_OUTPUT_BYTES = 50_000;
7
+ export const DEFAULT_MAX_OUTPUT_LINES = 500;
8
+ export const DEFAULT_AGENT_END_GRACE_MS = 250;
9
+ export const DEFAULT_MAX_STDERR_BYTES = 10_000;
10
+ export const DEFAULT_MAX_SUBAGENT_DEPTH = 3;
11
+ const MAX_SUBAGENT_DEPTH_CEILING = 10;
12
+
13
+ export interface SubagentOutputLimits {
14
+ maxBytes: number;
15
+ maxLines: number;
16
+ }
17
+
18
+ export interface SubagentRuntimeLimits {
19
+ agentEndGraceMs: number;
20
+ maxStderrBytes: number;
21
+ maxDepth: number;
22
+ }
23
+
24
+ type EnvLimitConfig = Partial<Record<string, string | number | undefined>>;
25
+
26
+ function parsePositiveInteger(
27
+ value: string | number | undefined,
28
+ ): number | undefined {
29
+ const parsed = typeof value === "number" ? value : Number(value);
30
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1)
31
+ return undefined;
32
+ return parsed;
33
+ }
34
+
35
+ export function getSubagentOutputLimits(
36
+ config: EnvLimitConfig = process.env,
37
+ ): SubagentOutputLimits {
38
+ return {
39
+ maxBytes:
40
+ parsePositiveInteger(config["PI_SUBAGENT_MAX_OUTPUT_BYTES"]) ??
41
+ DEFAULT_MAX_OUTPUT_BYTES,
42
+ maxLines:
43
+ parsePositiveInteger(config["PI_SUBAGENT_MAX_OUTPUT_LINES"]) ??
44
+ DEFAULT_MAX_OUTPUT_LINES,
45
+ };
46
+ }
47
+
48
+ export function getSubagentRuntimeLimits(
49
+ config: EnvLimitConfig = process.env,
50
+ ): SubagentRuntimeLimits {
51
+ const maxDepth =
52
+ parsePositiveInteger(config["PI_SUBAGENT_MAX_DEPTH"]) ??
53
+ DEFAULT_MAX_SUBAGENT_DEPTH;
54
+ return {
55
+ agentEndGraceMs:
56
+ parsePositiveInteger(config["PI_SUBAGENT_AGENT_END_GRACE_MS"]) ??
57
+ DEFAULT_AGENT_END_GRACE_MS,
58
+ maxStderrBytes:
59
+ parsePositiveInteger(config["PI_SUBAGENT_MAX_STDERR_BYTES"]) ??
60
+ DEFAULT_MAX_STDERR_BYTES,
61
+ maxDepth: Math.min(maxDepth, MAX_SUBAGENT_DEPTH_CEILING),
62
+ };
63
+ }
64
+
65
+ export function truncateOutput(
66
+ text: string,
67
+ limits: SubagentOutputLimits = getSubagentOutputLimits(),
68
+ ): string {
69
+ const lines = text.split("\n");
70
+ const maxBytes = Math.max(1, Math.floor(limits.maxBytes));
71
+ const maxLines = Math.max(1, Math.floor(limits.maxLines));
72
+ if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes)
73
+ return text;
74
+ let result = lines.slice(0, maxLines).join("\n");
75
+ if (Buffer.byteLength(result, "utf-8") > maxBytes) {
76
+ const buf = Buffer.from(result).subarray(0, maxBytes);
77
+ result = buf.toString("utf-8").replace(/\uFFFD$/, "");
78
+ }
79
+ const kept = result.split("\n").length;
80
+ return `[TRUNCATED: first ${kept} of ${lines.length} lines]\n${result}`;
81
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Message processing utilities for subagent results.
3
+ * Handles assistant message extraction, error detection, and failure判定.
4
+ */
5
+
6
+ import type { Message } from "@earendil-works/pi-ai";
7
+ import type { SingleResult } from "./types.js";
8
+
9
+ export function findLastAssistantTextMessage(messages: Message[]): number {
10
+ for (let i = messages.length - 1; i >= 0; i--) {
11
+ const msg = messages[i];
12
+ if (
13
+ msg?.role === "assistant" &&
14
+ Array.isArray(msg.content) &&
15
+ msg.content.some(
16
+ (c) =>
17
+ c.type === "text" &&
18
+ typeof c.text === "string" &&
19
+ c.text.trim().length > 0,
20
+ )
21
+ ) {
22
+ return i;
23
+ }
24
+ }
25
+ return -1;
26
+ }
27
+
28
+ export function extractFinalOutputFromMessages(messages: Message[]): string {
29
+ const lastAsstIdx = findLastAssistantTextMessage(messages);
30
+ if (lastAsstIdx < 0) return "";
31
+ const content = messages[lastAsstIdx]?.content;
32
+ if (!Array.isArray(content)) return "";
33
+ const lastText = content.findLast((p) => p.type === "text");
34
+ return lastText?.type === "text" ? (lastText.text ?? "") : "";
35
+ }
36
+
37
+ export function detectMessageError(messages: Message[]): boolean {
38
+ const lastAssistantIdx = findLastAssistantTextMessage(messages);
39
+ const from = lastAssistantIdx >= 0 ? lastAssistantIdx + 1 : 0;
40
+ for (let i = messages.length - 1; i >= from; i--) {
41
+ const msg = messages[i];
42
+ if (msg?.role === "toolResult" && msg.isError) return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ export function hasSubagentFailed(result: SingleResult): boolean {
48
+ if (result.outcome?.trim()) return false;
49
+ return (
50
+ result.exitCode !== 0 ||
51
+ result.stopReason === "error" ||
52
+ result.stopReason === "aborted" ||
53
+ Boolean(result.errorMessage?.trim()) ||
54
+ detectMessageError(result.messages ?? [])
55
+ );
56
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Generic resource resolution for skills and extensions.
3
+ * Handles caching, discovery via DefaultResourceLoader, and name-to-path mapping.
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import {
9
+ DefaultResourceLoader,
10
+ getAgentDir,
11
+ } from "@earendil-works/pi-coding-agent";
12
+
13
+ const RESOURCE_DISCOVERY_CACHE_TTL_MS = 300_000;
14
+
15
+ export const EXTENSION_DISCOVERY_CACHE_TTL_MS = RESOURCE_DISCOVERY_CACHE_TTL_MS;
16
+
17
+ type ResourceCacheEntry<T> = {
18
+ data: T;
19
+ ts: number;
20
+ };
21
+
22
+ class ResourceCache<T> {
23
+ private store = new Map<string, ResourceCacheEntry<T>>();
24
+
25
+ get(key: string): T | undefined {
26
+ const entry = this.store.get(key);
27
+ if (entry && Date.now() - entry.ts <= RESOURCE_DISCOVERY_CACHE_TTL_MS) {
28
+ return entry.data;
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ set(key: string, data: T): void {
34
+ this.store.set(key, { data, ts: Date.now() });
35
+ }
36
+
37
+ clear(): void {
38
+ this.store.clear();
39
+ }
40
+ }
41
+
42
+ const skillArgsCache = new ResourceCache<Map<string, string>>();
43
+ const extensionPathsCache = new ResourceCache<Map<string, string>>();
44
+
45
+ export function resetResolvedAgentSkillArgsCache(): void {
46
+ skillArgsCache.clear();
47
+ }
48
+
49
+ export function resetResolvedAgentExtensionPathsCache(): void {
50
+ extensionPathsCache.clear();
51
+ }
52
+
53
+ async function canonicalPath(filePath: string): Promise<string> {
54
+ try {
55
+ return await fs.promises.realpath(filePath);
56
+ } catch {
57
+ /* symlinks or missing paths fall back to absolute path resolution */
58
+ return path.resolve(filePath);
59
+ }
60
+ }
61
+
62
+ async function buildResourceCacheKey(
63
+ cwd: string,
64
+ agentDir: string,
65
+ names: string[],
66
+ ): Promise<string> {
67
+ const sortedNames = [...names].sort();
68
+ return JSON.stringify({
69
+ cwd: await canonicalPath(cwd),
70
+ agentDir: await canonicalPath(agentDir),
71
+ names: sortedNames,
72
+ });
73
+ }
74
+
75
+ type ResourceResolverResult<T> = { data: T } | { error: string };
76
+
77
+ interface ResourceResolverConfig<TResult> {
78
+ cache: ResourceCache<Map<string, string>>;
79
+ loaderOptions: Record<string, boolean>;
80
+ getResources: (loader: DefaultResourceLoader) => {
81
+ items: unknown[];
82
+ errors: Array<{ path: string; error: string }>;
83
+ };
84
+ buildNameToResource: (items: unknown[]) => Map<string, string>;
85
+ buildResult: (
86
+ requested: string[],
87
+ nameToResource: Map<string, string>,
88
+ ) => TResult;
89
+ resourceType: string;
90
+ }
91
+
92
+ async function resolveResources<TResult>(
93
+ cwd: string,
94
+ names: string[],
95
+ config: ResourceResolverConfig<TResult>,
96
+ ): Promise<ResourceResolverResult<TResult>> {
97
+ const requested = Array.from(new Set(names));
98
+ if (requested.length === 0) {
99
+ return { data: config.buildResult(requested, new Map()) };
100
+ }
101
+ const agentDir = getAgentDir();
102
+ const cacheKey = await buildResourceCacheKey(cwd, agentDir, requested);
103
+ const cached = config.cache.get(cacheKey);
104
+ if (cached) {
105
+ return { data: config.buildResult(requested, cached) };
106
+ }
107
+ const loader = new DefaultResourceLoader({
108
+ cwd,
109
+ agentDir,
110
+ ...config.loaderOptions,
111
+ });
112
+ try {
113
+ await loader.reload();
114
+ } catch (error) {
115
+ return {
116
+ error: `Failed to discover ${config.resourceType}s: ${error instanceof Error ? error.message : String(error)}`,
117
+ };
118
+ }
119
+ const { items, errors } = config.getResources(loader);
120
+ if (errors.length > 0) {
121
+ const details = errors.map((e) => ` ${e.path}: ${e.error}`).join("\n");
122
+ return {
123
+ error: `Failed to discover ${config.resourceType}s:\n${details}`,
124
+ };
125
+ }
126
+ const nameToResource = config.buildNameToResource(items);
127
+ const missing = requested.filter((name) => !nameToResource.has(name));
128
+ if (missing.length > 0) {
129
+ const available =
130
+ Array.from(nameToResource.keys()).sort().join(", ") || "none";
131
+ return {
132
+ error: `Unknown ${config.resourceType}${missing.length === 1 ? "" : "s"}: ${missing
133
+ .map((name) => `"${name}"`)
134
+ .join(", ")}. Available ${config.resourceType}s: ${available}.`,
135
+ };
136
+ }
137
+ const result = config.buildResult(requested, nameToResource);
138
+ config.cache.set(cacheKey, nameToResource);
139
+ return { data: result };
140
+ }
141
+
142
+ function buildSkillArgs(
143
+ requested: string[],
144
+ skillPaths: Map<string, string>,
145
+ ): string[] {
146
+ return requested.flatMap((name) => ["--skill", skillPaths.get(name) ?? name]);
147
+ }
148
+
149
+ export async function resolveAgentSkillArgs(
150
+ cwd: string,
151
+ skillNames: string[],
152
+ ): Promise<{ args: string[] } | { error: string }> {
153
+ const result = await resolveResources(cwd, skillNames, {
154
+ cache: skillArgsCache,
155
+ loaderOptions: {
156
+ noContextFiles: true,
157
+ noPromptTemplates: true,
158
+ noThemes: true,
159
+ },
160
+ getResources: (loader) => {
161
+ const { skills } = loader.getSkills();
162
+ return {
163
+ items: skills,
164
+ errors: [],
165
+ };
166
+ },
167
+ buildNameToResource: (items) => {
168
+ const skillMap = new Map<string, string>();
169
+ for (const item of items) {
170
+ const skill = item as { name: string; filePath: string };
171
+ skillMap.set(skill.name, skill.filePath ?? skill.name);
172
+ }
173
+ return skillMap;
174
+ },
175
+ buildResult: (requested, nameToResource) => {
176
+ return buildSkillArgs(requested, nameToResource);
177
+ },
178
+ resourceType: "skill",
179
+ });
180
+ return "error" in result ? result : { args: result.data };
181
+ }
182
+
183
+ function getTerminalPackageName(source: string): string {
184
+ const spec = source.startsWith("npm:") ? source.slice(4) : source;
185
+ let name: string;
186
+ if (spec.startsWith("@")) {
187
+ const slashIdx = spec.indexOf("/");
188
+ if (slashIdx >= 0) {
189
+ name = spec.slice(slashIdx + 1);
190
+ } else {
191
+ name = spec;
192
+ }
193
+ } else {
194
+ name = spec;
195
+ }
196
+ const versionIdx = name.indexOf("@");
197
+ if (versionIdx >= 0) {
198
+ return name.slice(0, versionIdx);
199
+ }
200
+ return name;
201
+ }
202
+
203
+ function isLocalPathSpec(source: string): boolean {
204
+ return (
205
+ source.startsWith(".") ||
206
+ source.startsWith("/") ||
207
+ source.startsWith("~") ||
208
+ source.startsWith("file:")
209
+ );
210
+ }
211
+
212
+ function buildExtensionShortName(ext: {
213
+ resolvedPath: string;
214
+ sourceInfo: { source: string; origin: string };
215
+ }): string {
216
+ if (
217
+ ext.sourceInfo.origin === "package" &&
218
+ !isLocalPathSpec(ext.sourceInfo.source)
219
+ ) {
220
+ return getTerminalPackageName(ext.sourceInfo.source);
221
+ }
222
+ const fileName = path.basename(ext.resolvedPath);
223
+ if (/^index\.(?:ts|js)$/.test(fileName)) {
224
+ const dirName = path.dirname(ext.resolvedPath);
225
+ const base = path.basename(dirName);
226
+ if (base === "src" || base === "dist") {
227
+ return path.basename(path.dirname(dirName));
228
+ }
229
+ return base;
230
+ }
231
+ const dotIdx = fileName.lastIndexOf(".");
232
+ if (dotIdx > 0) {
233
+ return fileName.slice(0, dotIdx);
234
+ }
235
+ return fileName;
236
+ }
237
+
238
+ export async function resolveAgentExtensionPaths(
239
+ cwd: string,
240
+ extensionNames: string[],
241
+ ): Promise<{ resolvedPaths: string[] } | { error: string }> {
242
+ const result = await resolveResources(cwd, extensionNames, {
243
+ cache: extensionPathsCache,
244
+ loaderOptions: {
245
+ noContextFiles: true,
246
+ noPromptTemplates: true,
247
+ noThemes: true,
248
+ noSkills: true,
249
+ },
250
+ getResources: (loader) => {
251
+ const { extensions, errors } = loader.getExtensions();
252
+ const shortNameToExtension = new Map<
253
+ string,
254
+ (typeof extensions)[number]
255
+ >();
256
+ for (const ext of extensions) {
257
+ const shortName = buildExtensionShortName(ext);
258
+ if (!shortNameToExtension.has(shortName)) {
259
+ shortNameToExtension.set(shortName, ext);
260
+ }
261
+ }
262
+ return {
263
+ items: Array.from(shortNameToExtension.values()),
264
+ errors,
265
+ };
266
+ },
267
+ buildNameToResource: (items) => {
268
+ const shortNameToPath = new Map<string, string>();
269
+ for (const item of items) {
270
+ const ext = item as {
271
+ resolvedPath: string;
272
+ sourceInfo: { source: string; origin: string };
273
+ };
274
+ const shortName = buildExtensionShortName(ext);
275
+ if (!shortNameToPath.has(shortName)) {
276
+ shortNameToPath.set(shortName, ext.resolvedPath ?? shortName);
277
+ }
278
+ }
279
+ return shortNameToPath;
280
+ },
281
+ buildResult: (requested, nameToResource) => {
282
+ return requested
283
+ .map((name) => nameToResource.get(name))
284
+ .filter((p): p is string => typeof p === "string");
285
+ },
286
+ resourceType: "extension",
287
+ });
288
+ return "error" in result ? result : { resolvedPaths: result.data };
289
+ }
@@ -1,89 +1,38 @@
1
+ /**
2
+ * Barrel export for shared utilities.
3
+ * Re-exports from focused modules; retains standalone utilities here.
4
+ */
5
+
1
6
  import * as fs from "node:fs";
2
7
  import * as os from "node:os";
3
8
  import * as path from "node:path";
4
- import type { Message } from "@earendil-works/pi-ai";
5
- import {
6
- DefaultResourceLoader,
7
- getAgentDir,
8
- } from "@earendil-works/pi-coding-agent";
9
- import type { SingleResult } from "./types.js";
10
-
11
- export const DEFAULT_MAX_OUTPUT_BYTES = 50_000;
12
- export const DEFAULT_MAX_OUTPUT_LINES = 500;
13
- export const DEFAULT_AGENT_END_GRACE_MS = 250;
14
- export const DEFAULT_MAX_STDERR_BYTES = 10_000;
15
- export const DEFAULT_MAX_SUBAGENT_DEPTH = 3;
16
- const MAX_SUBAGENT_DEPTH_CEILING = 10;
17
-
18
- export interface SubagentOutputLimits {
19
- maxBytes: number;
20
- maxLines: number;
21
- }
22
-
23
- export interface SubagentRuntimeLimits {
24
- agentEndGraceMs: number;
25
- maxStderrBytes: number;
26
- maxDepth: number;
27
- }
28
-
29
- type EnvLimitConfig = Partial<Record<string, string | number | undefined>>;
30
-
31
- function parsePositiveInteger(
32
- value: string | number | undefined,
33
- ): number | undefined {
34
- const parsed = typeof value === "number" ? value : Number(value);
35
- if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1)
36
- return undefined;
37
- return parsed;
38
- }
39
-
40
- export function getSubagentOutputLimits(
41
- config: EnvLimitConfig = process.env,
42
- ): SubagentOutputLimits {
43
- return {
44
- maxBytes:
45
- parsePositiveInteger(config["PI_SUBAGENT_MAX_OUTPUT_BYTES"]) ??
46
- DEFAULT_MAX_OUTPUT_BYTES,
47
- maxLines:
48
- parsePositiveInteger(config["PI_SUBAGENT_MAX_OUTPUT_LINES"]) ??
49
- DEFAULT_MAX_OUTPUT_LINES,
50
- };
51
- }
52
9
 
53
- export function getSubagentRuntimeLimits(
54
- config: EnvLimitConfig = process.env,
55
- ): SubagentRuntimeLimits {
56
- const maxDepth =
57
- parsePositiveInteger(config["PI_SUBAGENT_MAX_DEPTH"]) ??
58
- DEFAULT_MAX_SUBAGENT_DEPTH;
59
- return {
60
- agentEndGraceMs:
61
- parsePositiveInteger(config["PI_SUBAGENT_AGENT_END_GRACE_MS"]) ??
62
- DEFAULT_AGENT_END_GRACE_MS,
63
- maxStderrBytes:
64
- parsePositiveInteger(config["PI_SUBAGENT_MAX_STDERR_BYTES"]) ??
65
- DEFAULT_MAX_STDERR_BYTES,
66
- maxDepth: Math.min(maxDepth, MAX_SUBAGENT_DEPTH_CEILING),
67
- };
68
- }
69
-
70
- export function truncateOutput(
71
- text: string,
72
- limits: SubagentOutputLimits = getSubagentOutputLimits(),
73
- ): string {
74
- const lines = text.split("\n");
75
- const maxBytes = Math.max(1, Math.floor(limits.maxBytes));
76
- const maxLines = Math.max(1, Math.floor(limits.maxLines));
77
- if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes)
78
- return text;
79
- let result = lines.slice(0, maxLines).join("\n");
80
- if (Buffer.byteLength(result, "utf-8") > maxBytes) {
81
- const buf = Buffer.from(result).subarray(0, maxBytes);
82
- result = buf.toString("utf-8").replace(/\uFFFD$/, "");
83
- }
84
- const kept = result.split("\n").length;
85
- return `[TRUNCATED: first ${kept} of ${lines.length} lines]\n${result}`;
86
- }
10
+ // Re-export all public symbols from focused modules
11
+ export {
12
+ DEFAULT_AGENT_END_GRACE_MS,
13
+ DEFAULT_MAX_OUTPUT_BYTES,
14
+ DEFAULT_MAX_OUTPUT_LINES,
15
+ DEFAULT_MAX_STDERR_BYTES,
16
+ DEFAULT_MAX_SUBAGENT_DEPTH,
17
+ getSubagentOutputLimits,
18
+ getSubagentRuntimeLimits,
19
+ truncateOutput,
20
+ } from "./limits.js";
21
+ export {
22
+ detectMessageError,
23
+ extractFinalOutputFromMessages,
24
+ findLastAssistantTextMessage,
25
+ hasSubagentFailed,
26
+ } from "./message-utils.js";
27
+ export {
28
+ EXTENSION_DISCOVERY_CACHE_TTL_MS,
29
+ resetResolvedAgentExtensionPathsCache,
30
+ resetResolvedAgentSkillArgsCache,
31
+ resolveAgentExtensionPaths,
32
+ resolveAgentSkillArgs,
33
+ } from "./resource-resolution.js";
34
+
35
+ // Standalone utilities retained in this module
87
36
 
88
37
  export async function writePromptToTempFile(
89
38
  agentName: string,
@@ -118,89 +67,6 @@ export function getPiInvocation(args: string[]): {
118
67
  return { command: "pi", args };
119
68
  }
120
69
 
121
- const SKILL_DISCOVERY_CACHE_TTL_MS = 300_000;
122
-
123
- type ResolvedSkillArgsCacheEntry = {
124
- skillPaths: Map<string, string>;
125
- ts: number;
126
- };
127
-
128
- const resolvedSkillArgsCache = new Map<string, ResolvedSkillArgsCacheEntry>();
129
-
130
- async function canonicalPath(filePath: string): Promise<string> {
131
- try {
132
- return await fs.promises.realpath(filePath);
133
- } catch {
134
- /* symlinks or missing paths fall back to absolute path resolution */
135
- return path.resolve(filePath);
136
- }
137
- }
138
-
139
- function buildSkillArgs(
140
- requested: string[],
141
- skillPaths: Map<string, string>,
142
- ): string[] {
143
- return requested.flatMap((name) => ["--skill", skillPaths.get(name) ?? name]);
144
- }
145
-
146
- export function resetResolvedAgentSkillArgsCache(): void {
147
- resolvedSkillArgsCache.clear();
148
- }
149
-
150
- export async function resolveAgentSkillArgs(
151
- cwd: string,
152
- skillNames: string[],
153
- ): Promise<{ args: string[] } | { error: string }> {
154
- const requested = Array.from(new Set(skillNames));
155
- if (requested.length === 0) return { args: [] };
156
- const cacheIdentitySkills = [...requested].sort();
157
- const agentDir = getAgentDir();
158
- const cacheKey = JSON.stringify({
159
- cwd: await canonicalPath(cwd),
160
- agentDir: await canonicalPath(agentDir),
161
- skills: cacheIdentitySkills,
162
- });
163
- const cached = resolvedSkillArgsCache.get(cacheKey);
164
- if (cached && Date.now() - cached.ts <= SKILL_DISCOVERY_CACHE_TTL_MS) {
165
- return { args: buildSkillArgs(requested, cached.skillPaths) };
166
- }
167
- const loader = new DefaultResourceLoader({
168
- cwd,
169
- agentDir,
170
- noContextFiles: true,
171
- noPromptTemplates: true,
172
- noThemes: true,
173
- });
174
- try {
175
- await loader.reload();
176
- } catch (error) {
177
- return {
178
- error: `Failed to discover skills: ${error instanceof Error ? error.message : String(error)}`,
179
- };
180
- }
181
- const { skills } = loader.getSkills();
182
- const skillMap = new Map(skills.map((skill) => [skill.name, skill]));
183
- const missing = requested.filter((name) => !skillMap.has(name));
184
- if (missing.length > 0) {
185
- const available =
186
- skills
187
- .map((skill) => skill.name)
188
- .sort()
189
- .join(", ") || "none";
190
- return {
191
- error: `Unknown skill${missing.length === 1 ? "" : "s"}: ${missing
192
- .map((name) => `"${name}"`)
193
- .join(", ")}. Available skills: ${available}.`,
194
- };
195
- }
196
- const skillPaths = new Map(
197
- requested.map((name) => [name, skillMap.get(name)?.filePath ?? name]),
198
- );
199
- const args = buildSkillArgs(requested, skillPaths);
200
- resolvedSkillArgsCache.set(cacheKey, { skillPaths, ts: Date.now() });
201
- return { args };
202
- }
203
-
204
70
  export function getSubagentDepth(): number {
205
71
  const d = Number(process.env.PI_SUBAGENT_DEPTH ?? "0");
206
72
  if (!Number.isFinite(d) || d < 0) return 0;
@@ -210,52 +76,3 @@ export function getSubagentDepth(): number {
210
76
  export function subagentDepthEnv(): Record<string, string> {
211
77
  return { PI_SUBAGENT_DEPTH: String(getSubagentDepth() + 1) };
212
78
  }
213
-
214
- export function findLastAssistantTextMessage(messages: Message[]): number {
215
- for (let i = messages.length - 1; i >= 0; i--) {
216
- const msg = messages[i];
217
- if (
218
- msg?.role === "assistant" &&
219
- Array.isArray(msg.content) &&
220
- msg.content.some(
221
- (c) =>
222
- c.type === "text" &&
223
- typeof c.text === "string" &&
224
- c.text.trim().length > 0,
225
- )
226
- ) {
227
- return i;
228
- }
229
- }
230
- return -1;
231
- }
232
-
233
- export function extractFinalOutputFromMessages(messages: Message[]): string {
234
- const lastAsstIdx = findLastAssistantTextMessage(messages);
235
- if (lastAsstIdx < 0) return "";
236
- const content = messages[lastAsstIdx]?.content;
237
- if (!Array.isArray(content)) return "";
238
- const lastText = content.findLast((p) => p.type === "text");
239
- return lastText?.type === "text" ? (lastText.text ?? "") : "";
240
- }
241
-
242
- export function detectMessageError(messages: Message[]): boolean {
243
- const lastAssistantIdx = findLastAssistantTextMessage(messages);
244
- const from = lastAssistantIdx >= 0 ? lastAssistantIdx + 1 : 0;
245
- for (let i = messages.length - 1; i >= from; i--) {
246
- const msg = messages[i];
247
- if (msg?.role === "toolResult" && msg.isError) return true;
248
- }
249
- return false;
250
- }
251
-
252
- export function hasSubagentFailed(result: SingleResult): boolean {
253
- if (result.outcome?.trim()) return false;
254
- return (
255
- result.exitCode !== 0 ||
256
- result.stopReason === "error" ||
257
- result.stopReason === "aborted" ||
258
- Boolean(result.errorMessage?.trim()) ||
259
- detectMessageError(result.messages ?? [])
260
- );
261
- }