oira666_pi-subagent 0.3.10 → 0.3.11

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
@@ -130,7 +130,7 @@ The Markdown body becomes the agent's system prompt (appended to Pi's default, n
130
130
 
131
131
  ## Delegation Guards
132
132
 
133
- Depth and cycle guards prevent runaway recursive delegation. Layer availability is evaluated for the child being launched: depth 1 is the first layer, and `PI_SUBAGENT_MAX_DEPTH` is the last layer. The bundled `team-lead` agent sets `last-layer: disabled` so it cannot consume the final delegation layer.
133
+ Depth and cycle guards prevent runaway recursive delegation. Layer availability is evaluated for the child being launched: depth 1 is the first layer, and `PI_SUBAGENT_MAX_DEPTH` is the last layer. The bundled `team-lead` agent sets `last-layer: disabled` so it cannot consume the final delegation layer. Cycle checks are applied per task: in a mixed parallel call, cyclic tasks fail while legal siblings still run.
134
134
 
135
135
  | Config | Default | Description |
136
136
  | ------------------------------ | ------- | ------------------------------------------------ |
@@ -160,6 +160,12 @@ cancellation, the runner terminates the child process tree and bounds cleanup;
160
160
  even if a wedged OS process never reports `close`, the tool returns an error
161
161
  result so every waiting parent can settle.
162
162
 
163
+ RPC completion is based on Pi's `agent_settled` event—not `agent_end`.
164
+ `agent_end` is only a low-level run boundary and may be followed by Pi's normal
165
+ provider retry, overflow compaction, or queued continuation. Rejected prompt
166
+ commands, signal exits, and processes that exit before `agent_settled` are
167
+ reported immediately as failures.
168
+
163
169
  | Env Var | Default | Description |
164
170
  | --- | --- | --- |
165
171
  | `PI_SUBAGENT_STARTUP_TIMEOUT` | `120000` | Milliseconds allowed to reach the first model turn; `0` disables |
@@ -190,6 +196,7 @@ The same detection also runs after navigating the session tree in the TUI (Esc n
190
196
  - TUI mode asks: **Resume subagents?**
191
197
  - Non-UI modes (`pi -p`, JSON/RPC) resume automatically.
192
198
  - Already-finished subagents are reused as completed; unfinished ones continue from their own saved sessions.
199
+ - Durable child refs retain final output, own usage, model, and tool counts, so completed siblings survive a JSON/session restart without becoming `(no output)` or losing accounting.
193
200
  - Nested subagents use the same mechanism recursively.
194
201
  - Provider fallback goes through the selected model's effective Pi provider, so custom providers, custom APIs, auth-derived endpoints, headers, and provider-scoped environment are preserved.
195
202
  - Pending resume state and delayed callbacks are discarded on `/resume`, `/new`, `/fork`, and `/reload`, preventing stale work from an old runtime from leaking into the replacement session.
@@ -220,6 +227,7 @@ Naming is deliberately unambiguous: `agent` (in `subagents`) selects an agent
220
227
  subagent *instance* by its unique name.
221
228
 
222
229
  - All resumes in one call run **in parallel**.
230
+ - The preferred shape is `{"resumes":[...]}`. For compatibility, the common single-item shorthand `{"subagent":"name","task":"..."}` is normalized automatically before validation.
223
231
  - Names are unique within one delegation tree (everything spawned from one
224
232
  top-level session) and are persisted in a registry file under the subagent
225
233
  session root, so they survive restarts: you can resume a subagent in a later
@@ -336,7 +344,7 @@ interface SingleResult {
336
344
  agent: string; // agent name
337
345
  agentSource: "user" | "project" | "builtin" | "unknown";
338
346
  task: string; // task string passed to this agent
339
- exitCode: number; // 0 = success, >0 = error, -1 = still running
347
+ exitCode: number; // 0 = process success, >0 = error, -1 = still running
340
348
  messages: Message[]; // full conversation history of the subagent
341
349
  stderr: string;
342
350
  usage: UsageStats; // this agent's OWN token usage only
package/index.ts CHANGED
@@ -76,6 +76,8 @@ import {
76
76
  getNestedSubagentResults,
77
77
  isResultError,
78
78
  isSubagentDetails,
79
+ prepareResumeArguments,
80
+ subagentDetailsHaveErrors,
79
81
  isSubagentToolName,
80
82
  RESUME_SUBAGENTS_TOOL_NAME,
81
83
  SUBAGENT_TOOL_NAME,
@@ -442,7 +444,14 @@ function liveDetailsSignature(details: SubagentDetails): string {
442
444
  function collectLiveUsageSummary(details: SubagentDetails): SubagentUsageSummary {
443
445
  const summary = emptyUsageSummary();
444
446
  for (const result of details.results) {
447
+ if (result.subtreeUsageSummary) {
448
+ addUsageSummary(summary, result.subtreeUsageSummary);
449
+ continue;
450
+ }
445
451
  addUsageSummary(summary, usageSummaryFromUsage(result.usage));
452
+ if (result.priorDescendantUsageSummary) {
453
+ addUsageSummary(summary, result.priorDescendantUsageSummary);
454
+ }
446
455
 
447
456
  const completedNested = getNestedSubagentResults(result.messages ?? []);
448
457
  const completedNestedIds = new Set<string>();
@@ -519,15 +528,6 @@ function collectCombinedUsageStatusLine(
519
528
  return formatCombinedUsageStatusLine(parentUsage, subagents.subagentCount);
520
529
  }
521
530
 
522
- function getCycleViolations(
523
- requestedNames: Set<string>,
524
- ancestorAgentStack: string[],
525
- ): string[] {
526
- if (requestedNames.size === 0 || ancestorAgentStack.length === 0) return [];
527
- const stackSet = new Set(ancestorAgentStack);
528
- return Array.from(requestedNames).filter((name) => stackSet.has(name));
529
- }
530
-
531
531
  /** Get project-local agents referenced by the current request. */
532
532
  function getRequestedProjectAgents(
533
533
  agents: AgentConfig[],
@@ -983,6 +983,8 @@ export default function (pi: ExtensionAPI) {
983
983
  const activeSubagents = new Map<number, { agent: string; task: string; taskIndex: number; handle: RunningSubagentHandle; name?: string }>();
984
984
  /** Names with a resume in flight in this process (same-process race guard). */
985
985
  const activeResumeNames = new Set<string>();
986
+ /** Tool calls whose execute result requested an actual Pi error result. */
987
+ const forcedErrorToolCallIds = new Set<string>();
986
988
  const activeSubagentUsageSummaries = new Map<string, SubagentUsageSummary>();
987
989
  const latestBroadcastTargets = {
988
990
  all: [] as BroadcastTarget[],
@@ -1515,6 +1517,7 @@ export default function (pi: ExtensionAPI) {
1515
1517
  latestSessionCtx = undefined;
1516
1518
  resumeModelRegistry = undefined;
1517
1519
  activeSubagentUsageSummaries.clear();
1520
+ forcedErrorToolCallIds.clear();
1518
1521
  activeSubagents.clear();
1519
1522
  });
1520
1523
 
@@ -1655,6 +1658,16 @@ export default function (pi: ExtensionAPI) {
1655
1658
  scheduleSessionTask(() => updateCombinedUsageStatus(ctx), 0);
1656
1659
  });
1657
1660
 
1661
+ // A custom tool must throw to make Pi set isError, but throwing would discard
1662
+ // the durable subagent details needed for crash-resume. Convert our internal
1663
+ // outcome marker through Pi's supported tool_result middleware instead.
1664
+ pi.on("tool_result", (event: any) => {
1665
+ if (!isSubagentToolName(event?.toolName)) return;
1666
+ const detailsFailed = subagentDetailsHaveErrors(event.details);
1667
+ const forced = forcedErrorToolCallIds.delete(event.toolCallId);
1668
+ if (forced || detailsFailed) return { isError: true };
1669
+ });
1670
+
1658
1671
  pi.on("tool_execution_end", (event, ctx) => {
1659
1672
  latestSessionCtx = ctx;
1660
1673
  if (isSubagentToolName(event.toolName)) {
@@ -1776,6 +1789,7 @@ ${agentList}\n\n${subagentsGuidance}${resumeGuidance ? `\n\n${resumeGuidance}` :
1776
1789
  parameters: SubagentParams,
1777
1790
 
1778
1791
  async execute(toolCallId, params, signal, onUpdate, ctx) {
1792
+ const toolResult = await (async () => {
1779
1793
  try {
1780
1794
  recordToolCallStart(toolCallId);
1781
1795
  updateLatestBroadcastTargets(undefined);
@@ -1797,6 +1811,7 @@ ${agentList}\n\n${subagentsGuidance}${resumeGuidance ? `\n\n${resumeGuidance}` :
1797
1811
  },
1798
1812
  ],
1799
1813
  details: makeDetails("single")([]),
1814
+ isError: true,
1800
1815
  };
1801
1816
  }
1802
1817
 
@@ -1813,35 +1828,20 @@ ${agentList}\n\n${subagentsGuidance}${resumeGuidance ? `\n\n${resumeGuidance}` :
1813
1828
  onUpdate?.(partial);
1814
1829
  };
1815
1830
 
1816
- // Security: guard project-local agents before running
1817
- const requested = new Set<string>();
1818
- for (const t of tasks) requested.add(t.agent);
1819
-
1831
+ // Apply whole-call security preflight only to tasks that can actually
1832
+ // run. Cyclic tasks become aligned structured failures in the runner;
1833
+ // they must not block legal siblings or consume durable names.
1834
+ const cyclicTaskIndexes = new Set<number>();
1820
1835
  if (preventCycles) {
1821
- const cycleViolations = getCycleViolations(
1822
- requested,
1823
- ancestorAgentStack,
1824
- );
1825
- if (cycleViolations.length > 0) {
1826
- const stackText =
1827
- ancestorAgentStack.length > 0
1828
- ? ancestorAgentStack.join(" -> ")
1829
- : "(root)";
1830
- return {
1831
- content: [
1832
- {
1833
- type: "text",
1834
- text: `Blocked: delegation cycle detected. Requested agent(s) already in the delegation stack: ${cycleViolations.join(", ")}.
1835
- Current stack: ${stackText}
1836
-
1837
- This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A).`,
1838
- },
1839
- ],
1840
- details: makeDetails(executionMode)([]),
1841
- isError: true,
1842
- };
1843
- }
1836
+ const stack = new Set(ancestorAgentStack);
1837
+ tasks.forEach((task, index) => {
1838
+ if (stack.has(task.agent)) cyclicTaskIndexes.add(index);
1839
+ });
1844
1840
  }
1841
+ const requested = new Set<string>();
1842
+ tasks.forEach((task, index) => {
1843
+ if (!cyclicTaskIndexes.has(index)) requested.add(task.agent);
1844
+ });
1845
1845
 
1846
1846
  const requestedProjectAgents = getRequestedProjectAgents(
1847
1847
  agents,
@@ -1907,7 +1907,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1907
1907
  if (currentNamesFile) {
1908
1908
  const pendingAllocation = tasks
1909
1909
  .map((task, index) => ({ task, index }))
1910
- .filter(({ index }) => !names[index]);
1910
+ .filter(({ index }) => !cyclicTaskIndexes.has(index) && !names[index]);
1911
1911
  if (pendingAllocation.length > 0) {
1912
1912
  try {
1913
1913
  const allocated = await allocateSubagentNames(
@@ -1980,7 +1980,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1980
1980
  isError: true,
1981
1981
  };
1982
1982
  }
1983
-
1983
+ })();
1984
+ if ((toolResult as any)?.isError === true) forcedErrorToolCallIds.add(toolCallId);
1985
+ return toolResult;
1984
1986
  },
1985
1987
 
1986
1988
  renderCall: (args, theme, context) => renderCall(args, theme, context),
@@ -2003,8 +2005,10 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
2003
2005
  'Example: { resumes: [{ subagent: "code-writer-01", task: "Also update the tests." }] }',
2004
2006
  ].join("\n"),
2005
2007
  parameters: ResumeSubagentsParams,
2008
+ prepareArguments: prepareResumeArguments,
2006
2009
 
2007
2010
  async execute(toolCallId, params, signal, onUpdate, ctx) {
2011
+ const toolResult = await (async () => {
2008
2012
  const markedNames: string[] = [];
2009
2013
  try {
2010
2014
  recordToolCallStart(toolCallId);
@@ -2192,6 +2196,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
2192
2196
  }
2193
2197
  }
2194
2198
  }
2199
+ })();
2200
+ if ((toolResult as any)?.isError === true) forcedErrorToolCallIds.add(toolCallId);
2201
+ return toolResult;
2195
2202
  },
2196
2203
 
2197
2204
  renderCall: (args, theme, context) => renderResumeCall(args, theme, context),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/runner.ts CHANGED
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
8
8
  import * as fs from "node:fs";
9
9
  import * as os from "node:os";
10
10
  import * as path from "node:path";
11
+ import { StringDecoder } from "node:string_decoder";
11
12
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
12
13
  import type { Message } from "@mariozechner/pi-ai";
13
14
  import type { AgentConfig } from "./agents.js";
@@ -15,11 +16,14 @@ import {
15
16
  type LiveLogEntry,
16
17
  type SingleResult,
17
18
  type SubagentDetails,
19
+ type SubagentUsageSummary,
18
20
  MAX_LIVE_LOG_ENTRIES,
19
21
  emptyUsage,
20
22
  extractToolCalls,
21
23
  getFinalOutput,
22
24
  getNestedSubagentErrorSummary,
25
+ isResultError,
26
+ isResultSuccess,
23
27
  isSubagentDetails,
24
28
  isSubagentToolName,
25
29
  } from "./types.js";
@@ -38,7 +42,7 @@ import {
38
42
  } from "./shared.js";
39
43
 
40
44
  const SIGKILL_TIMEOUT_MS = 5000;
41
- const HANG_GUARD_DELAY_MS = 5000;
45
+ const RETRY_WAIT_GRACE_MS = 60_000;
42
46
  const DEFAULT_STARTUP_TIMEOUT_MS = 120_000; // only for startup (before first assistant turn)
43
47
  const SUBAGENT_STARTUP_TIMEOUT_ENV = "PI_SUBAGENT_STARTUP_TIMEOUT";
44
48
  // Once startup succeeds, a child can otherwise remain alive forever if Pi loses
@@ -57,16 +61,24 @@ const SUBAGENT_PI_COMMAND_ENV = "PI_SUBAGENT_PI_COMMAND";
57
61
  const SUBAGENT_PI_ARGS_PREFIX_ENV = "PI_SUBAGENT_PI_ARGS_PREFIX";
58
62
  const MAX_CAPTURED_STDERR_CHARS = 64_000;
59
63
 
60
- /**
61
- * Stop reasons that indicate the agent has truly finished its work.
62
- * "tool_use" is NOT terminal — the agent is still working (calling a tool).
63
- */
64
- // pi emits "stop" (and occasionally "end_turn") as the terminal reason; include both.
65
- // "toolUse"/"tool_use" are NOT terminal — the agent is still mid-turn calling a tool.
66
- const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop", "max_tokens", "error", "stop_sequence"]);
67
-
68
- function isTerminalStopReason(reason: string | undefined): boolean {
69
- return reason !== undefined && TERMINAL_STOP_REASONS.has(reason);
64
+ function priorDescendantUsage(result: SingleResult | undefined): SubagentUsageSummary | undefined {
65
+ if (!result) return undefined;
66
+ if (result.priorDescendantUsageSummary) return { ...result.priorDescendantUsageSummary };
67
+ const subtree = result.subtreeUsageSummary;
68
+ if (!subtree) return undefined;
69
+ const own = result.usage ?? emptyUsage();
70
+ const descendants = {
71
+ subagentCount: Math.max(0, subtree.subagentCount - 1),
72
+ inputTokens: Math.max(0, subtree.inputTokens - own.input),
73
+ outputTokens: Math.max(0, subtree.outputTokens - own.output),
74
+ cacheReadTokens: Math.max(0, subtree.cacheReadTokens - own.cacheRead),
75
+ cacheWriteTokens: Math.max(0, subtree.cacheWriteTokens - own.cacheWrite),
76
+ costUsd: Math.max(0, subtree.costUsd - own.cost),
77
+ turns: Math.max(0, subtree.turns - own.turns),
78
+ };
79
+ return descendants.subagentCount > 0 || descendants.inputTokens > 0 || descendants.outputTokens > 0 || descendants.costUsd > 0
80
+ ? descendants
81
+ : undefined;
70
82
  }
71
83
 
72
84
  function appendBoundedStderr(result: SingleResult, text: string): void {
@@ -478,8 +490,12 @@ export function processJsonLine(line: string, result: SingleResult): boolean {
478
490
  result.usage.contextTokens = usage.totalTokens || 0;
479
491
  }
480
492
  if (msg.model && msg.model !== "synthetic-tool-call") result.model = msg.model;
481
- if (msg.stopReason) result.stopReason = msg.stopReason;
482
- if (msg.errorMessage) result.errorMessage = msg.errorMessage;
493
+ if (msg.stopReason) {
494
+ result.stopReason = msg.stopReason;
495
+ // A later successful retry supersedes the prior transport error. Keep
496
+ // only the error attached to the latest terminal assistant message.
497
+ result.errorMessage = msg.errorMessage || undefined;
498
+ }
483
499
  }
484
500
  return true;
485
501
  }
@@ -664,6 +680,8 @@ export interface RunAgentOptions {
664
680
  startupTimeoutMsOverride?: number;
665
681
  /** Test/debug override for post-startup semantic inactivity timeout. */
666
682
  idleTimeoutMsOverride?: number;
683
+ /** Test/debug override for graceful-stop to SIGKILL escalation. */
684
+ terminationTimeoutMsOverride?: number;
667
685
  /** Called once the child RPC process is ready to receive steering messages. */
668
686
  onHandle?: RunningSubagentStartedCallback;
669
687
  }
@@ -694,6 +712,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
694
712
  piCommandOverride,
695
713
  startupTimeoutMsOverride,
696
714
  idleTimeoutMsOverride,
715
+ terminationTimeoutMsOverride,
697
716
  } = opts;
698
717
 
699
718
  const agent = agents.find((a) => a.name === agentName);
@@ -738,6 +757,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
738
757
  liveToolExecutions: initialResult?.liveToolExecutions,
739
758
  liveLog: initialResult?.liveLog ? [...initialResult.liveLog] : [],
740
759
  liveNestedSubagents: initialResult?.liveNestedSubagents ? { ...initialResult.liveNestedSubagents } : undefined,
760
+ priorDescendantUsageSummary: priorDescendantUsage(initialResult),
741
761
  sessionDir,
742
762
  };
743
763
 
@@ -755,6 +775,19 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
755
775
 
756
776
  emitUpdate();
757
777
 
778
+ // Enforce cycle prevention per task rather than rejecting an entire parallel
779
+ // call. Legal siblings can still run while the cyclic task returns a normal
780
+ // structured failure.
781
+ if (preventCycles && parentAgentStack.includes(agentName)) {
782
+ const stackText = parentAgentStack.length > 0 ? parentAgentStack.join(" -> ") : "(root)";
783
+ result.exitCode = 1;
784
+ result.stopReason = "error";
785
+ result.errorMessage = `Delegation cycle detected: agent "${agentName}" is already in the delegation stack (${stackText}).`;
786
+ result.stderr = result.errorMessage;
787
+ emitUpdate();
788
+ return result;
789
+ }
790
+
758
791
  // Write system prompt to temp file if needed
759
792
  let promptTmpDir: string | null = null;
760
793
  let promptTmpPath: string | null = null;
@@ -817,17 +850,29 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
817
850
  });
818
851
 
819
852
  let buffer = "";
853
+ const stdoutDecoder = new StringDecoder("utf8");
820
854
  let resolved = false;
821
- let hangTimer: ReturnType<typeof setTimeout> | undefined;
822
855
  let startupTimer: ReturnType<typeof setTimeout> | undefined;
823
856
  let idleTimer: ReturnType<typeof setTimeout> | undefined;
857
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
824
858
  let receivedFirstEvent = false;
859
+ let agentSettled = false;
825
860
  let forcedExitCode: number | undefined;
826
861
  let lastNestedProgressSignature: string | undefined;
827
862
  let abortHandler: (() => void) | undefined;
863
+ const promptRequestId = `pi-subagent-${process.pid}-${Date.now()}-${attempt}`;
864
+ let steeringRequest = 0;
828
865
 
829
- const sendRpc = (command: Record<string, unknown>) => {
830
- proc.stdin?.write(`${JSON.stringify(command)}\n`);
866
+ const sendRpc = (command: Record<string, unknown>): boolean => {
867
+ try {
868
+ if (!proc.stdin?.writable) return false;
869
+ proc.stdin.write(`${JSON.stringify(command)}\n`);
870
+ return true;
871
+ } catch (err) {
872
+ const message = err instanceof Error ? err.message : String(err);
873
+ appendBoundedStderr(result, `[pi-subagent] RPC stdin write failed: ${message}\n`);
874
+ return false;
875
+ }
831
876
  };
832
877
 
833
878
  opts.onHandle?.({
@@ -839,12 +884,15 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
839
884
  // them to its own (grand)children. `session.prompt()` emits the
840
885
  // `input` event first and still queues the message as a steering
841
886
  // message while the child is streaming.
842
- sendRpc({ type: "prompt", message, streamingBehavior: "steer" });
887
+ sendRpc({
888
+ id: `${promptRequestId}-steer-${++steeringRequest}`,
889
+ type: "prompt",
890
+ message,
891
+ streamingBehavior: "steer",
892
+ });
843
893
  },
844
894
  });
845
895
 
846
- sendRpc({ type: "prompt", message: prompt });
847
-
848
896
  // Startup timeout: kill the process if it never produces its first
849
897
  // model-turn event. Once startup succeeds, the semantic-inactivity
850
898
  // watchdog below takes over.
@@ -859,11 +907,10 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
859
907
  const doResolve = (code: number) => {
860
908
  if (resolved) return;
861
909
  resolved = true;
862
- if (hangTimer) { clearTimeout(hangTimer); hangTimer = undefined; }
863
910
  if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; }
864
911
  if (idleTimer) { clearTimeout(idleTimer); idleTimer = undefined; }
912
+ if (killTimer) { clearTimeout(killTimer); killTimer = undefined; }
865
913
  if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
866
- if (buffer.trim()) flushLine(buffer);
867
914
  resolve(code);
868
915
  };
869
916
 
@@ -895,7 +942,8 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
895
942
 
896
943
  const forceStopAndSettle = (settleCode = forcedExitCode ?? 1) => {
897
944
  stopChild(false);
898
- setTimeout(() => {
945
+ if (killTimer) clearTimeout(killTimer);
946
+ killTimer = setTimeout(() => {
899
947
  if (resolved) return;
900
948
  stopChild(true);
901
949
  // Never depend exclusively on a close event from a wedged child.
@@ -904,12 +952,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
904
952
  proc.stderr?.destroy();
905
953
  proc.unref();
906
954
  doResolve(settleCode);
907
- }, SIGKILL_TIMEOUT_MS);
908
- };
909
-
910
- /** Cancel the terminal-stop hang guard when new work appears. */
911
- const cancelHangGuard = () => {
912
- if (hangTimer) { clearTimeout(hangTimer); hangTimer = undefined; }
955
+ }, terminationTimeoutMsOverride ?? SIGKILL_TIMEOUT_MS);
913
956
  };
914
957
 
915
958
  const idleTimeoutMs = (() => {
@@ -920,64 +963,71 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
920
963
  return parsed !== null ? parsed : DEFAULT_IDLE_TIMEOUT_MS;
921
964
  })();
922
965
 
923
- const noteSemanticActivity = () => {
966
+ const noteSemanticActivity = (minimumQuietPeriodMs = 0) => {
924
967
  if (!receivedFirstEvent || idleTimeoutMs === 0 || resolved) return;
925
968
  if (idleTimer) clearTimeout(idleTimer);
969
+ const quietPeriodMs = Math.max(idleTimeoutMs, minimumQuietPeriodMs);
926
970
  idleTimer = setTimeout(() => {
927
971
  if (resolved) return;
928
- const message = `Subagent inactivity timeout: no new RPC activity for ${idleTimeoutMs}ms.`;
972
+ const message = `Subagent inactivity timeout: no new RPC activity for ${quietPeriodMs}ms.`;
929
973
  forcedExitCode = 1;
930
974
  result.stopReason = "error";
931
975
  result.errorMessage = message;
932
976
  appendBoundedStderr(result, `\n[pi-subagent] Killed: ${message}`);
933
977
  emitUpdate();
934
978
  forceStopAndSettle();
935
- }, idleTimeoutMs);
936
- };
937
-
938
- /**
939
- * Schedule a hang guard: if the child process produced a terminal
940
- * stopReason (agent finished) but doesn't exit on its own (due to
941
- * open handles like MCP connections, dangling timers, etc.),
942
- * force-kill it so the parent doesn't hang forever.
943
- *
944
- * The guard is reset on every new activity and only armed for
945
- * truly terminal stop reasons (not "tool_use").
946
- */
947
- const scheduleHangGuard = () => {
948
- if (resolved) return;
949
- cancelHangGuard();
950
- hangTimer = setTimeout(() => {
951
- if (resolved) return;
952
- // Process produced all output but won't exit — force-kill its tree.
953
- forceStopAndSettle(forcedExitCode ?? 0);
954
- }, HANG_GUARD_DELAY_MS);
979
+ }, quietPeriodMs);
955
980
  };
956
981
 
957
982
  const flushLine = (line: string) => {
958
983
  let event: any;
959
984
  try { event = JSON.parse(line); } catch { event = null; }
985
+
986
+ if (
987
+ event?.type === "response" &&
988
+ event.id === promptRequestId &&
989
+ event.command === "prompt"
990
+ ) {
991
+ if (event.success !== true) {
992
+ const message = `Subagent prompt rejected: ${typeof event.error === "string" ? event.error : "unknown RPC error"}`;
993
+ forcedExitCode = 1;
994
+ result.stopReason = "error";
995
+ result.errorMessage = message;
996
+ appendBoundedStderr(result, `[pi-subagent] ${message}\n`);
997
+ forceStopAndSettle(1);
998
+ }
999
+ return;
1000
+ }
1001
+
1002
+ // agent_end is only a low-level run boundary. Pi may now auto-retry,
1003
+ // compact-and-retry, or process a queued continuation. Killing here was
1004
+ // the direct cause of the WebSocket failures in the inspected session.
960
1005
  if (event?.type === "agent_end") {
961
- if (result.exitCode === -1) result.exitCode = forcedExitCode ?? 0;
962
- stopChild(false);
963
- doResolve(forcedExitCode ?? 0);
1006
+ noteSemanticActivity(
1007
+ event.willRetry === true ? RETRY_WAIT_GRACE_MS : 0,
1008
+ );
964
1009
  return;
965
1010
  }
1011
+
1012
+ if (event?.type === "agent_settled") {
1013
+ agentSettled = true;
1014
+ if (result.stopReason === "length" && !result.errorMessage) {
1015
+ result.errorMessage = "Subagent output was incomplete because the model reached its output limit.";
1016
+ }
1017
+ const settledCode = forcedExitCode ?? (isResultError({ ...result, exitCode: 0 }) ? 1 : 0);
1018
+ // RPC mode remains alive waiting for more commands. Terminate its
1019
+ // process tree, but do not report completion until close (or bounded
1020
+ // SIGKILL escalation) confirms it stopped.
1021
+ forceStopAndSettle(settledCode);
1022
+ return;
1023
+ }
1024
+
966
1025
  const accepted = processJsonLine(line, result);
967
1026
  if (accepted) {
968
1027
  // Cancel the startup timer as soon as the subprocess proves it has
969
1028
  // reached the LLM-call phase. Two conditions qualify:
970
- // 1. A turn has started (turn_start sets turnInProgress=true) —
971
- // the subprocess has initialised, loaded all extensions (including
972
- // MCP adapters), and sent its first request to the LLM. The LLM
973
- // may now take any amount of time to respond (especially with
974
- // extended thinking enabled) and must NOT be killed by the startup
975
- // timer.
976
- // 2. A complete assistant turn has arrived (turns > 0) — the LLM
977
- // already responded; startup trivially succeeded.
978
- // User message echoes alone (before turn_start) don't qualify:
979
- // the process could still stall before dispatching the LLM call,
980
- // e.g. in a hanging before_agent_start extension hook.
1029
+ // 1. A turn has started (turn_start sets turnInProgress=true).
1030
+ // 2. A complete assistant turn has arrived (turns > 0).
981
1031
  if (!receivedFirstEvent && (result.usage.turns > 0 || result.turnInProgress)) {
982
1032
  receivedFirstEvent = true;
983
1033
  if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; }
@@ -994,22 +1044,24 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
994
1044
  }
995
1045
  if (semanticActivity) noteSemanticActivity();
996
1046
  emitUpdate();
997
- // Any accepted message means the child is alive and producing
998
- // output — cancel any pending hang guard so we don't kill it
999
- // while it's still working (e.g. during tool execution).
1000
- cancelHangGuard();
1001
- // Only arm the hang guard when the agent has truly finished.
1002
- // "tool_use" means the agent is still working — NOT terminal.
1003
- if (isTerminalStopReason(result.stopReason)) {
1004
- scheduleHangGuard();
1047
+ } else if (receivedFirstEvent) {
1048
+ if (event?.type === "message_update" || event?.type === "tool_execution_update") {
1049
+ // Streaming deltas are intentionally not retained in result.messages,
1050
+ // but they prove the model/tool is still making real progress.
1051
+ noteSemanticActivity();
1052
+ } else if (event?.type === "auto_retry_start") {
1053
+ const delayMs = Number.isFinite(event.delayMs) ? Math.max(0, Number(event.delayMs)) : 0;
1054
+ noteSemanticActivity(delayMs + RETRY_WAIT_GRACE_MS);
1055
+ } else if (
1056
+ event?.type === "auto_retry_end" ||
1057
+ event?.type === "compaction_start" ||
1058
+ event?.type === "compaction_end" ||
1059
+ event?.type === "summarization_retry_scheduled" ||
1060
+ event?.type === "summarization_retry_attempt_start" ||
1061
+ event?.type === "summarization_retry_finished"
1062
+ ) {
1063
+ noteSemanticActivity();
1005
1064
  }
1006
- } else if (
1007
- receivedFirstEvent &&
1008
- (event?.type === "message_update" || event?.type === "tool_execution_update")
1009
- ) {
1010
- // Streaming deltas are intentionally not retained in result.messages,
1011
- // but they prove the model/tool is still making real progress.
1012
- noteSemanticActivity();
1013
1065
  }
1014
1066
  };
1015
1067
 
@@ -1021,7 +1073,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
1021
1073
  startupTimer = setTimeout(() => {
1022
1074
  if (resolved || receivedFirstEvent) return;
1023
1075
  startupTimedOut = true;
1024
- const message = `Subagent startup timeout: no JSON output after ${startupTimeoutMs}ms.`;
1076
+ const message = `Subagent startup timeout: no model turn after ${startupTimeoutMs}ms.`;
1025
1077
  forcedExitCode = 1;
1026
1078
  result.stopReason = "error";
1027
1079
  result.errorMessage = message;
@@ -1031,10 +1083,16 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
1031
1083
  }
1032
1084
 
1033
1085
  proc.stdout.on("data", (chunk: Buffer) => {
1034
- buffer += chunk.toString();
1086
+ buffer += stdoutDecoder.write(chunk);
1035
1087
  const lines = buffer.split("\n");
1036
1088
  buffer = lines.pop() || "";
1037
- for (const line of lines) flushLine(line);
1089
+ for (let line of lines) {
1090
+ if (line.endsWith("\r")) line = line.slice(0, -1);
1091
+ flushLine(line);
1092
+ }
1093
+ });
1094
+ proc.stdout.on("end", () => {
1095
+ buffer += stdoutDecoder.end();
1038
1096
  });
1039
1097
 
1040
1098
  let stderrBuffer = "";
@@ -1064,18 +1122,47 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
1064
1122
  stderrBuffer = "";
1065
1123
  };
1066
1124
 
1067
- proc.on("close", (code) => {
1125
+ const classifyUnexpectedExit = (code: number | null, exitSignal: NodeJS.Signals | null): number => {
1126
+ if (forcedExitCode !== undefined) return forcedExitCode;
1127
+ if (agentSettled) return isResultError({ ...result, exitCode: 0 }) ? 1 : (code ?? 0);
1128
+
1129
+ const message = exitSignal
1130
+ ? `Subagent process exited from signal ${exitSignal} before agent_settled.`
1131
+ : `Subagent process exited with code ${code ?? "null"} before agent_settled.`;
1132
+ result.stopReason = "error";
1133
+ result.errorMessage = message;
1134
+ appendBoundedStderr(result, `[pi-subagent] ${message}\n`);
1135
+ return code !== null && code !== 0 ? code : 1;
1136
+ };
1137
+
1138
+ const flushRemainingStdout = () => {
1139
+ if (!buffer.trim()) return;
1140
+ const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
1141
+ buffer = "";
1142
+ flushLine(line);
1143
+ };
1144
+
1145
+ proc.on("close", (code, exitSignal) => {
1146
+ flushRemainingStdout();
1068
1147
  flushRemainingStderr();
1069
- doResolve(forcedExitCode ?? code ?? 0);
1148
+ if (!resolved) doResolve(classifyUnexpectedExit(code, exitSignal));
1070
1149
  });
1071
1150
 
1072
- proc.on("exit", (code) => {
1073
- // If the process exits, resolve as soon as possible.
1074
- // Give a tiny grace period for any remaining buffered stdout data.
1075
- setTimeout(() => {
1076
- flushRemainingStderr();
1077
- doResolve(forcedExitCode ?? code ?? 0);
1078
- }, 100);
1151
+ proc.on("exit", (code, exitSignal) => {
1152
+ if (resolved) return;
1153
+ // `close` waits for stdio. A descendant can inherit stdout and keep it
1154
+ // open after the immediate Pi process exits, so bound that drain while
1155
+ // still allowing normal buffered JSONL to arrive before settlement.
1156
+ forceStopAndSettle(classifyUnexpectedExit(code, exitSignal));
1157
+ });
1158
+
1159
+ proc.stdin?.on("error", (err) => {
1160
+ if (resolved) return;
1161
+ forcedExitCode = 1;
1162
+ result.stopReason = "error";
1163
+ result.errorMessage = `Subagent RPC stdin failed: ${err.message}`;
1164
+ appendBoundedStderr(result, `[pi-subagent] ${result.errorMessage}\n`);
1165
+ forceStopAndSettle(1);
1079
1166
  });
1080
1167
 
1081
1168
  proc.on("error", (err) => {
@@ -1095,6 +1182,13 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
1095
1182
  if (signal.aborted) abortHandler();
1096
1183
  else signal.addEventListener("abort", abortHandler, { once: true });
1097
1184
  }
1185
+
1186
+ if (!wasAborted && !resolved && !sendRpc({ id: promptRequestId, type: "prompt", message: prompt })) {
1187
+ forcedExitCode = 1;
1188
+ result.stopReason = "error";
1189
+ result.errorMessage = "Failed to write the initial prompt to the subagent RPC process.";
1190
+ forceStopAndSettle(1);
1191
+ }
1098
1192
  });
1099
1193
 
1100
1194
  const noProgress = result.messages.length <= initialMessageCount;
@@ -1117,6 +1211,13 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
1117
1211
 
1118
1212
  result.exitCode = exitCode;
1119
1213
  result.toolCalls = extractToolCalls(result.messages); // populate from parsed messages
1214
+ if (result.exitCode === 0 && isResultError(result)) {
1215
+ result.exitCode = 1;
1216
+ }
1217
+ if (result.stopReason === "length" && !result.errorMessage) {
1218
+ result.errorMessage = "Subagent output was incomplete because the model reached its output limit.";
1219
+ if (!result.stderr.trim()) result.stderr = result.errorMessage;
1220
+ }
1120
1221
  if (wasAborted) {
1121
1222
  result.exitCode = 130;
1122
1223
  result.stopReason = "aborted";
@@ -1196,6 +1297,7 @@ export async function executeParallelSubprocess(
1196
1297
  ): Promise<{
1197
1298
  content: Array<{ type: "text"; text: string }>;
1198
1299
  details: SubagentDetails;
1300
+ isError?: boolean;
1199
1301
  }> {
1200
1302
  const maxParallelTasksRaw = process.env[SUBAGENT_MAX_PARALLEL_TASKS_ENV];
1201
1303
  const maxParallelTasksParsed = parseNonNegativeInt(maxParallelTasksRaw);
@@ -1224,6 +1326,7 @@ export async function executeParallelSubprocess(
1224
1326
  },
1225
1327
  ],
1226
1328
  details: makeDetails([]),
1329
+ isError: true,
1227
1330
  };
1228
1331
  }
1229
1332
 
@@ -1270,7 +1373,7 @@ export async function executeParallelSubprocess(
1270
1373
  try {
1271
1374
  results = await mapConcurrent(tasks, maxConcurrency, async (t, index) => {
1272
1375
  const previousResult = resumeResults?.[index];
1273
- if (previousResult?.exitCode === 0) {
1376
+ if (previousResult && isResultSuccess(previousResult)) {
1274
1377
  allResults[index] = previousResult;
1275
1378
  emitProgress();
1276
1379
  return previousResult;
@@ -1321,11 +1424,15 @@ export async function executeParallelSubprocess(
1321
1424
  if (heartbeat) clearInterval(heartbeat);
1322
1425
  }
1323
1426
 
1324
- const successCount = results.filter((r) => r.exitCode === 0).length;
1427
+ const successCount = results.filter(isResultSuccess).length;
1325
1428
  const summaries = results.map((r) => {
1326
- const output = getFinalOutput(r.messages, r.finalOutput);
1429
+ const succeeded = isResultSuccess(r);
1430
+ const output = succeeded
1431
+ ? getFinalOutput(r.messages, r.finalOutput)
1432
+ : r.errorMessage || r.stderr || getFinalOutput(r.messages, r.finalOutput);
1327
1433
  const nameTag = r.name ? ` • name: ${r.name}` : "";
1328
- return `[${r.agent}${nameTag}] ${r.exitCode === 0 ? "completed" : "failed"}: ${output || "(no output)"}`;
1434
+ const status = succeeded ? "completed" : r.exitCode === -1 ? "unfinished" : "failed";
1435
+ return `[${r.agent}${nameTag}] ${status}: ${output || "(no output)"}`;
1329
1436
  });
1330
1437
 
1331
1438
  return {
@@ -1336,5 +1443,6 @@ export async function executeParallelSubprocess(
1336
1443
  },
1337
1444
  ],
1338
1445
  details: makeDetails(results),
1446
+ ...(successCount === results.length ? {} : { isError: true }),
1339
1447
  };
1340
1448
  }
package/shims.d.ts CHANGED
@@ -70,7 +70,6 @@ declare module "@mariozechner/pi-agent-core" {
70
70
  export interface AgentToolResult<TDetails = unknown> {
71
71
  content: Array<{ type: string; text?: string }>;
72
72
  details?: TDetails;
73
- isError?: boolean;
74
73
  }
75
74
  }
76
75
 
package/types.ts CHANGED
@@ -58,7 +58,8 @@ export type LiveLogEntry =
58
58
  export const MAX_LIVE_LOG_ENTRIES = 6;
59
59
 
60
60
  /** Result of a single subagent invocation. Live results include rich fields;
61
- * durable parent-session refs make those fields non-enumerable/omitted. */
61
+ * durable parent-session refs retain the compact completion/outcome fields needed
62
+ * for crash-resume while omitting full transcripts and live-only state. */
62
63
  export interface SingleResult {
63
64
  agent: string;
64
65
  agentSource: "user" | "project" | "builtin" | "unknown";
@@ -77,6 +78,10 @@ export interface SingleResult {
77
78
  errorMessage?: string;
78
79
  /** Cached final assistant text so durable details can omit full transcripts. */
79
80
  finalOutput?: string;
81
+ /** Own + descendant usage retained when full nested transcripts are omitted. */
82
+ subtreeUsageSummary?: SubagentUsageSummary;
83
+ /** Live resume baseline for pre-crash descendants whose transcripts were compacted. */
84
+ priorDescendantUsageSummary?: SubagentUsageSummary;
80
85
  /** Number of stderr characters omitted from the durable parent-session details. */
81
86
  stderrTruncatedChars?: number;
82
87
  /** Session directory used by this subagent process, when persisted. */
@@ -224,6 +229,8 @@ function buildUsageTreeNode(result: SingleResult): UsageTreeNode {
224
229
 
225
230
  const aggregatedUsage = emptyUsage();
226
231
  addUsage(aggregatedUsage, ownUsage);
232
+ const priorDescendantUsage = usageSummaryToUsageStats(result.priorDescendantUsageSummary);
233
+ if (priorDescendantUsage) addUsage(aggregatedUsage, priorDescendantUsage);
227
234
  for (const child of children) addUsage(aggregatedUsage, child.aggregatedUsage);
228
235
 
229
236
  const aggregatedToolCalls: ToolCallCounts = { ...ownToolCalls };
@@ -250,27 +257,31 @@ export function compactSingleResultForDurableDetails(result: SingleResult): Sing
250
257
  agentSource: result.agentSource,
251
258
  task: result.task,
252
259
  exitCode: result.exitCode,
260
+ usage: result.usage ?? emptyUsage(),
261
+ toolCalls: result.toolCalls ?? {},
262
+ completedTurns: result.completedTurns ?? 0,
263
+ finalOutput: result.finalOutput ?? getFinalOutput(result.messages ?? []),
264
+ subtreeUsageSummary: result.subtreeUsageSummary ?? buildUsageSummary([result]),
253
265
  ...(result.name !== undefined ? { name: result.name } : {}),
254
266
  ...(result.startedAt !== undefined ? { startedAt: result.startedAt } : {}),
255
267
  ...(result.stopReason !== undefined ? { stopReason: result.stopReason } : {}),
256
268
  ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
269
+ ...(result.model !== undefined ? { model: result.model } : {}),
257
270
  ...(result.sessionDir !== undefined ? { sessionDir: result.sessionDir } : {}),
258
271
  ...(result.sessionId !== undefined ? { sessionId: result.sessionId } : {}),
259
272
  };
260
- // Compatibility for in-memory/unit-test consumers only. These properties are
261
- // deliberately non-enumerable so parent session JSON persists a pure ref.
273
+ // Full transcripts and live process state stay non-enumerable so parent
274
+ // sessions remain compact. Completion output and own usage above must survive
275
+ // a JSON round-trip; otherwise crash-resume reuses a successful sibling as
276
+ // "(no output)" with zero accounting.
262
277
  Object.defineProperties(ref, {
263
278
  messages: { value: [], enumerable: false },
264
279
  stderr: { value: "", enumerable: false },
265
- usage: { value: result.usage ?? emptyUsage(), enumerable: false },
266
- toolCalls: { value: {}, enumerable: false },
267
- model: { value: result.model, enumerable: false },
268
- finalOutput: { value: result.finalOutput ?? getFinalOutput(result.messages ?? []), enumerable: false },
269
280
  stderrTruncatedChars: { value: result.stderrTruncatedChars ?? Math.max(0, (result.stderr?.length ?? 0)), enumerable: false },
270
- completedTurns: { value: result.completedTurns, enumerable: false },
271
- turnInProgress: { value: result.turnInProgress, enumerable: false },
281
+ turnInProgress: { value: false, enumerable: false },
272
282
  liveLog: { value: [], enumerable: false },
273
283
  liveNestedSubagents: { value: undefined, enumerable: false },
284
+ priorDescendantUsageSummary: { value: undefined, enumerable: false },
274
285
  });
275
286
  return ref as SingleResult;
276
287
  }
@@ -312,7 +323,17 @@ export function usageSummaryFromUsage(usage: UsageStats | undefined): SubagentUs
312
323
  export function buildUsageSummary(results: SingleResult[]): SubagentUsageSummary {
313
324
  const total = emptyUsageSummary();
314
325
  for (const result of results) {
326
+ // Completed siblings restored from durable details no longer have their
327
+ // nested transcripts. Reuse the persisted subtree total instead of
328
+ // silently dropping descendant accounting during a crash-resume rebuild.
329
+ if (result.subtreeUsageSummary) {
330
+ addUsageSummary(total, result.subtreeUsageSummary);
331
+ continue;
332
+ }
315
333
  addUsageSummary(total, usageSummaryFromUsage(result.usage));
334
+ if (result.priorDescendantUsageSummary) {
335
+ addUsageSummary(total, result.priorDescendantUsageSummary);
336
+ }
316
337
  for (const nested of getNestedSubagentResults(result.messages ?? [])) {
317
338
  addUsageSummary(total, nested.details.usageSummary ?? buildUsageSummary(nested.details.results));
318
339
  }
@@ -402,9 +423,46 @@ export function buildSubagentDetails(
402
423
  return details as SubagentDetails;
403
424
  }
404
425
 
405
- /** Whether a result represents an error. */
426
+ /** Terminal stop reasons that never represent a complete successful task. */
427
+ const INCOMPLETE_STOP_REASONS = new Set([
428
+ "error",
429
+ "aborted",
430
+ "length",
431
+ "incomplete",
432
+ "max_tokens",
433
+ ]);
434
+
435
+ /** Whether a result represents an error or incomplete run. */
406
436
  export function isResultError(r: SingleResult): boolean {
407
- return r.exitCode > 0 || r.stopReason === "error" || r.stopReason === "aborted";
437
+ // -1 is the live "still running" sentinel, not a failure.
438
+ return r.exitCode > 0 || INCOMPLETE_STOP_REASONS.has(r.stopReason ?? "");
439
+ }
440
+
441
+ /** Whether a result is fully settled and successful. */
442
+ export function isResultSuccess(r: SingleResult): boolean {
443
+ return r.exitCode === 0 && !isResultError(r);
444
+ }
445
+
446
+ /** Whether durable details contain any failed/incomplete direct child. */
447
+ export function subagentDetailsHaveErrors(value: unknown): boolean {
448
+ return isSubagentDetails(value) && value.results.some((result) => isResultError(result));
449
+ }
450
+
451
+ /** Normalize common legacy/model-generated resume argument shorthands. */
452
+ export function prepareResumeArguments(args: unknown): unknown {
453
+ if (!args || typeof args !== "object" || Array.isArray(args)) return args;
454
+ const record = args as Record<string, unknown>;
455
+ if (
456
+ record.resumes === undefined &&
457
+ typeof record.subagent === "string" &&
458
+ typeof record.task === "string"
459
+ ) {
460
+ return { resumes: [{ subagent: record.subagent, task: record.task }] };
461
+ }
462
+ if (record.resumes && typeof record.resumes === "object" && !Array.isArray(record.resumes)) {
463
+ return { ...record, resumes: [record.resumes] };
464
+ }
465
+ return args;
408
466
  }
409
467
 
410
468
  /** Check whether a value looks like SubagentDetails. */
@@ -476,13 +534,14 @@ function collectSubagentErrorLinesFromDetails(
476
534
  }
477
535
  const nested = getNestedSubagentResults(result.messages ?? []);
478
536
  for (const child of nested) {
479
- if (child.isError) {
480
- collectSubagentErrorLinesFromDetails(
481
- child.details,
482
- lines,
483
- `${prefix}${result.agent} -> `,
484
- );
485
- }
537
+ // Older pi-subagent versions returned an unsupported `isError` field
538
+ // from execute(), so Pi persisted the outer tool result as successful.
539
+ // Inspect durable child outcomes regardless of that unreliable flag.
540
+ collectSubagentErrorLinesFromDetails(
541
+ child.details,
542
+ lines,
543
+ `${prefix}${result.agent} -> `,
544
+ );
486
545
  }
487
546
  }
488
547
  }
@@ -491,7 +550,8 @@ function collectSubagentErrorLinesFromDetails(
491
550
  export function getNestedSubagentErrorSummary(messages: Message[]): string | null {
492
551
  const lines: string[] = [];
493
552
  for (const nested of getNestedSubagentResults(messages)) {
494
- if (!nested.isError) continue;
553
+ // Do not trust the outer tool-result error bit: releases before Pi 0.83
554
+ // could persist failed subagents with isError=false.
495
555
  collectSubagentErrorLinesFromDetails(nested.details, lines);
496
556
  }
497
557
  if (lines.length === 0) return null;