oira666_pi-subagent 0.3.10 → 0.3.12

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 |
@@ -169,7 +175,10 @@ result so every waiting parent can settle.
169
175
  ## Timestamps & Status Footer
170
176
 
171
177
  Subagent tool calls and live activity lines render a dim `hh:mm:ss` timestamp
172
- (call start, per-subagent run start, and each live log entry).
178
+ (call start, per-subagent run start, and each live log entry). The collapsed tool
179
+ row keeps the normal compact progress text. Press `Ctrl+O` to expand it into the
180
+ subagent tree immediately; every running node shows its latest six activity
181
+ lines (thinking, tool starts/completions, and completed turns).
173
182
 
174
183
  In the interactive TUI the extension publishes the combined `total` usage line
175
184
  (parent + all subagents, recursively) via Pi's normal `ctx.ui.setStatus()`
@@ -190,6 +199,7 @@ The same detection also runs after navigating the session tree in the TUI (Esc n
190
199
  - TUI mode asks: **Resume subagents?**
191
200
  - Non-UI modes (`pi -p`, JSON/RPC) resume automatically.
192
201
  - Already-finished subagents are reused as completed; unfinished ones continue from their own saved sessions.
202
+ - 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
203
  - Nested subagents use the same mechanism recursively.
194
204
  - 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
205
  - 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 +230,7 @@ Naming is deliberately unambiguous: `agent` (in `subagents`) selects an agent
220
230
  subagent *instance* by its unique name.
221
231
 
222
232
  - All resumes in one call run **in parallel**.
233
+ - The preferred shape is `{"resumes":[...]}`. For compatibility, the common single-item shorthand `{"subagent":"name","task":"..."}` is normalized automatically before validation.
223
234
  - Names are unique within one delegation tree (everything spawned from one
224
235
  top-level session) and are persisted in a registry file under the subagent
225
236
  session root, so they survive restarts: you can resume a subagent in a later
@@ -336,7 +347,7 @@ interface SingleResult {
336
347
  agent: string; // agent name
337
348
  agentSource: "user" | "project" | "builtin" | "unknown";
338
349
  task: string; // task string passed to this agent
339
- exitCode: number; // 0 = success, >0 = error, -1 = still running
350
+ exitCode: number; // 0 = process success, >0 = error, -1 = still running
340
351
  messages: Message[]; // full conversation history of the subagent
341
352
  stderr: string;
342
353
  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.12",
4
4
  "description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/render.ts CHANGED
@@ -7,12 +7,19 @@
7
7
  */
8
8
 
9
9
  import { Container, Spacer, Text } from "@mariozechner/pi-tui";
10
- import type { SubagentDetails } from "./types.js";
10
+ import {
11
+ MAX_LIVE_LOG_ENTRIES,
12
+ isResultError,
13
+ isSubagentDetails,
14
+ type SingleResult,
15
+ type SubagentDetails,
16
+ } from "./types.js";
11
17
  import {
12
18
  type ThemeFg,
13
19
  buildTopLevelNodes,
14
20
  countNodes,
15
21
  formatClockTime,
22
+ formatLiveLogEntry,
16
23
  hasNestedChildren,
17
24
  renderTreeLines,
18
25
  setBroadcastNumberingActive,
@@ -122,37 +129,119 @@ export function renderResumeCall(
122
129
  // renderResult — shown after the tool completes / streams updates
123
130
  // ---------------------------------------------------------------------------
124
131
 
132
+ function getResultText(
133
+ result: { content?: Array<{ type: string; text?: string }> },
134
+ ): string {
135
+ const first = Array.isArray(result.content) ? result.content[0] : undefined;
136
+ return first?.type === "text" && typeof first.text === "string" && first.text
137
+ ? first.text
138
+ : "(no output)";
139
+ }
140
+
141
+ function compactText(text: string, maxLength = 240): string {
142
+ const firstLine = text.replace(/\r\n?/g, "\n").split("\n").find((line) => line.trim()) ?? "(no output)";
143
+ return truncate(firstLine, maxLength);
144
+ }
145
+
146
+ function isRenderableResult(value: unknown): value is SingleResult {
147
+ return value !== null &&
148
+ typeof value === "object" &&
149
+ !Array.isArray(value) &&
150
+ typeof (value as Partial<SingleResult>).agent === "string" &&
151
+ typeof (value as Partial<SingleResult>).exitCode === "number";
152
+ }
153
+
154
+ function getCollapsedResultText(details: SubagentDetails, fallbackText: string): string {
155
+ const results = details.results.filter(isRenderableResult);
156
+ if (results.length === 0) return compactText(fallbackText);
157
+
158
+ const running = results.filter((item) => item.exitCode === -1).length;
159
+ const finished = results.length - running;
160
+ const succeeded = results.filter((item) => item.exitCode === 0 && !isResultError(item)).length;
161
+ const failed = finished - succeeded;
162
+
163
+ if (details.mode === "parallel") {
164
+ return running > 0
165
+ ? `Parallel: ${finished}/${results.length} done, ${running} running...`
166
+ : `Parallel: ${succeeded}/${results.length} succeeded${failed > 0 ? `, ${failed} failed` : ""}`;
167
+ }
168
+
169
+ const item = results[0];
170
+ if (item.exitCode === -1) return `Agent ${item.agent}: running...`;
171
+ if (isResultError(item)) {
172
+ const reason = [item.errorMessage, item.stderr, item.stopReason]
173
+ .find((value): value is string => typeof value === "string" && value.length > 0);
174
+ return `Agent ${item.agent}: failed${reason ? ` — ${compactText(reason, 180)}` : ""}`;
175
+ }
176
+ return `Agent ${item.agent}: completed`;
177
+ }
178
+
125
179
  export function renderResult(
126
180
  result: { content: Array<{ type: string; text?: string }>; details?: unknown },
127
- _expanded: boolean,
181
+ expanded: boolean,
128
182
  theme: { fg: ThemeFg; bold: (s: string) => string },
129
183
  ): Container | Text {
130
- const details = result.details as SubagentDetails | undefined;
131
- if (!details || details.results.length === 0) {
132
- const first = result.content[0];
133
- return new Text(first?.type === "text" && first.text ? first.text : "(no output)", 0, 0);
184
+ const fallbackText = getResultText(result);
185
+
186
+ // Keep the normal row compact. Ctrl+O switches to the live tree below.
187
+ if (!expanded) {
188
+ const text = isSubagentDetails(result.details)
189
+ ? getCollapsedResultText(result.details, fallbackText)
190
+ : compactText(fallbackText);
191
+ return new Text(text, 0, 0);
134
192
  }
135
193
 
136
- const nodes = buildTopLevelNodes(details);
137
- const counts = countNodes(nodes);
138
- const showOutputPreview = !hasNestedChildren(nodes);
139
- const icon = counts.running > 0
140
- ? theme.fg("warning", "⏳")
141
- : counts.error > 0
142
- ? theme.fg("error", "❌")
143
- : theme.fg("success", "✅");
194
+ if (!isSubagentDetails(result.details) || result.details.results.length === 0) {
195
+ return new Text(fallbackText, 0, 0);
196
+ }
197
+ const details: SubagentDetails = result.details;
144
198
 
145
- const container = new Container();
146
- container.addChild(
147
- new Text(
148
- `${icon} ${theme.fg("toolTitle", theme.bold("subagent tree "))}${theme.fg("dim", topLevelSummary(details, counts))}`,
149
- 0,
150
- 0,
151
- ),
152
- );
199
+ try {
200
+ const nodes = buildTopLevelNodes(details);
201
+ const counts = countNodes(nodes);
202
+ const showOutputPreview = !hasNestedChildren(nodes);
203
+ const icon = counts.running > 0
204
+ ? theme.fg("warning", "⏳")
205
+ : counts.error > 0
206
+ ? theme.fg("error", "❌")
207
+ : theme.fg("success", "✅");
153
208
 
154
- container.addChild(new Spacer(1));
155
- container.addChild(new Text(renderTreeLines(nodes, theme, showOutputPreview).join("\n"), 0, 0));
209
+ const container = new Container();
210
+ container.addChild(
211
+ new Text(
212
+ `${icon} ${theme.fg("toolTitle", theme.bold("subagent tree "))}${theme.fg("dim", topLevelSummary(details, counts))}`,
213
+ 0,
214
+ 0,
215
+ ),
216
+ );
156
217
 
157
- return container;
218
+ container.addChild(new Spacer(1));
219
+ container.addChild(new Text(renderTreeLines(nodes, theme, showOutputPreview).join("\n"), 0, 0));
220
+ return container;
221
+ } catch {
222
+ // Pi falls back to raw result.content when a custom renderer throws, which
223
+ // made Ctrl+O appear broken until the offending rolling log entry expired.
224
+ // Keep an expanded tree visible even for malformed third-party/RPC data.
225
+ const lines: string[] = [];
226
+ for (const item of details.results) {
227
+ const agent = typeof item?.agent === "string" ? item.agent : "unknown agent";
228
+ const icon = item?.exitCode === -1
229
+ ? theme.fg("warning", "⏳")
230
+ : item?.exitCode === 0
231
+ ? theme.fg("success", "✅")
232
+ : theme.fg("error", "❌");
233
+ lines.push(`${icon} ${theme.fg("accent", agent)}`);
234
+ const liveLog = Array.isArray(item?.liveLog)
235
+ ? item.liveLog.slice(-MAX_LIVE_LOG_ENTRIES)
236
+ : [];
237
+ for (const entry of liveLog) {
238
+ lines.push(` ${formatLiveLogEntry(entry, theme)}`);
239
+ }
240
+ }
241
+ return new Text(
242
+ `${theme.fg("toolTitle", theme.bold("subagent tree"))}\n${lines.join("\n") || fallbackText}`,
243
+ 0,
244
+ 0,
245
+ );
246
+ }
158
247
  }