@narumitw/pi-subagents 0.30.1 → 0.31.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "typebox": "^1.3.3"
32
+ "typebox": "^1.3.8"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "@earendil-works/pi-agent-core": "*",
@@ -38,11 +38,11 @@
38
38
  "@earendil-works/pi-tui": "*"
39
39
  },
40
40
  "devDependencies": {
41
- "@biomejs/biome": "2.5.3",
42
- "@earendil-works/pi-agent-core": "0.80.10",
43
- "@earendil-works/pi-ai": "0.80.10",
44
- "@earendil-works/pi-coding-agent": "0.80.10",
45
- "@earendil-works/pi-tui": "0.80.10",
41
+ "@biomejs/biome": "2.5.5",
42
+ "@earendil-works/pi-agent-core": "0.82.1",
43
+ "@earendil-works/pi-ai": "0.82.1",
44
+ "@earendil-works/pi-coding-agent": "0.82.1",
45
+ "@earendil-works/pi-tui": "0.82.1",
46
46
  "typescript": "7.0.2"
47
47
  },
48
48
  "repository": {
package/src/agents.ts CHANGED
@@ -6,15 +6,7 @@ import * as fs from "node:fs";
6
6
  import * as path from "node:path";
7
7
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
 
9
- export const THINKING_LEVELS = [
10
- "off",
11
- "minimal",
12
- "low",
13
- "medium",
14
- "high",
15
- "xhigh",
16
- "max",
17
- ] as const;
9
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
18
10
 
19
11
  export type SubagentThinkingLevel = (typeof THINKING_LEVELS)[number];
20
12
 
@@ -72,7 +64,8 @@ export interface SubagentSettings {
72
64
  const BUILT_IN_AGENTS: AgentConfig[] = [
73
65
  {
74
66
  name: "scout",
75
- description: "Read-only codebase reconnaissance; returns concise findings with paths and evidence.",
67
+ description:
68
+ "Read-only codebase reconnaissance; returns concise findings with paths and evidence.",
76
69
  tools: ["read", "grep", "find", "ls", "bash"],
77
70
  source: "built-in",
78
71
  filePath: "built-in:scout",
@@ -185,7 +178,9 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
185
178
  description: frontmatter.description,
186
179
  tools: tools && tools.length > 0 ? tools : undefined,
187
180
  model: frontmatter.model,
188
- thinkingLevel: isThinkingLevel(frontmatter.thinkingLevel) ? frontmatter.thinkingLevel : undefined,
181
+ thinkingLevel: isThinkingLevel(frontmatter.thinkingLevel)
182
+ ? frontmatter.thinkingLevel
183
+ : undefined,
189
184
  systemPrompt: body,
190
185
  source,
191
186
  filePath,
@@ -228,7 +223,8 @@ export function discoverAgents(
228
223
  const projectAgentsDir = findNearestProjectAgentsDir(cwd);
229
224
 
230
225
  const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
231
- const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
226
+ const projectAgents =
227
+ scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
232
228
 
233
229
  const agentMap = new Map<string, AgentConfig>();
234
230
 
@@ -258,7 +254,8 @@ export function discoverAgents(
258
254
  nextAgent.model = override.model === null ? undefined : override.model;
259
255
  }
260
256
  if (hasOwn(override, "thinkingLevel")) {
261
- nextAgent.thinkingLevel = override.thinkingLevel === null ? undefined : override.thinkingLevel;
257
+ nextAgent.thinkingLevel =
258
+ override.thinkingLevel === null ? undefined : override.thinkingLevel;
262
259
  }
263
260
  if (hasOwn(override, "timeoutMs")) {
264
261
  nextAgent.timeoutMs = override.timeoutMs === null ? undefined : override.timeoutMs;
@@ -269,7 +266,10 @@ export function discoverAgents(
269
266
  return { agents: Array.from(agentMap.values()), projectAgentsDir };
270
267
  }
271
268
 
272
- export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
269
+ export function formatAgentList(
270
+ agents: AgentConfig[],
271
+ maxItems: number,
272
+ ): { text: string; remaining: number } {
273
273
  if (agents.length === 0) return { text: "none", remaining: 0 };
274
274
  const listed = agents.slice(0, maxItems);
275
275
  const remaining = agents.length - listed.length;
package/src/context.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import {
3
- DEFAULT_MAX_CONTEXT_BYTES,
4
- truncateUtf8,
5
- truncateUtf8Tail,
6
- } from "./limits.js";
2
+ import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8, truncateUtf8Tail } from "./limits.js";
7
3
 
8
4
  export type ContextMode = "none" | "all" | "summary" | number;
9
5
 
@@ -107,19 +103,20 @@ export function buildContextSnapshot(
107
103
  const raw =
108
104
  mode === "summary" && selected.length > 4
109
105
  ? [
110
- `## Earlier context checkpoint\n${truncateUtf8(
111
- selected
112
- .slice(0, -4)
113
- .map((message) => `${message.role}: ${message.text}`)
114
- .join("\n"),
115
- Math.floor(maxBytes / 3),
116
- ).text}`,
117
- ...selected
118
- .slice(-4)
119
- .map((message) => `## ${message.role}\n${message.text}`),
106
+ `## Earlier context checkpoint\n${
107
+ truncateUtf8(
108
+ selected
109
+ .slice(0, -4)
110
+ .map((message) => `${message.role}: ${message.text}`)
111
+ .join("\n"),
112
+ Math.floor(maxBytes / 3),
113
+ ).text
114
+ }`,
115
+ ...selected.slice(-4).map((message) => `## ${message.role}\n${message.text}`),
120
116
  ].join("\n\n")
121
117
  : selected.map((message) => `## ${message.role}\n${message.text}`).join("\n\n");
122
- const bounded = mode === "summary" ? truncateUtf8Tail(raw, maxBytes) : truncateUtf8(raw, maxBytes);
118
+ const bounded =
119
+ mode === "summary" ? truncateUtf8Tail(raw, maxBytes) : truncateUtf8(raw, maxBytes);
123
120
  return {
124
121
  text: bounded.text,
125
122
  turns: selected.filter((message) => message.role === "user").length,
package/src/execution.ts CHANGED
@@ -105,375 +105,398 @@ export async function executeSubagent(
105
105
  onUpdate: AgentToolUpdateCallback<SubagentDetails> | undefined,
106
106
  ctx: ExtensionContext,
107
107
  ): Promise<AgentToolResult<SubagentDetails> & { isError?: boolean }> {
108
- assertSubagentDepthAllowed();
109
- const agentScope: AgentScope = params.agentScope ?? "user";
110
- const aggregator = hasUsableAggregator(params.aggregator) ? params.aggregator : undefined;
111
- const config = readSubagentSettings();
112
- const discovery = discoverAgents(ctx.cwd, agentScope, config);
113
- const agents = discovery.agents;
114
- const confirmProjectAgents = params.confirmProjectAgents ?? true;
115
- const resolveTimeoutMs = (agentName: string, localTimeoutMs?: number) =>
116
- localTimeoutMs ??
117
- params.timeoutMs ??
118
- agents.find((agent) => agent.name === agentName)?.timeoutMs ??
119
- resolveDefaultSubagentTimeoutMs();
120
- const resolveThinkingLevel = (agentName: string, localThinkingLevel?: SubagentThinkingLevel) =>
121
- resolveSubagentThinkingLevel(agents, agentName, params.thinkingLevel, localThinkingLevel);
122
-
123
- const hasChain = (params.chain?.length ?? 0) > 0;
124
- const hasTasks = (params.tasks?.length ?? 0) > 0;
125
- const hasSingle = Boolean(params.agent && params.task);
126
- const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
127
-
128
- const makeDetails =
129
- (mode: "single" | "parallel" | "chain") =>
130
- (results: SingleResult[], aggregator?: SingleResult): SubagentDetails => ({
131
- mode,
132
- agentScope,
133
- projectAgentsDir: discovery.projectAgentsDir,
134
- results,
135
- aggregator,
136
- });
137
-
138
- if (modeCount !== 1 || (aggregator && !hasTasks)) {
139
- const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
140
- const reason =
141
- modeCount !== 1
142
- ? "Provide exactly one mode."
143
- : "Aggregator is only valid with parallel tasks.";
144
- return {
145
- content: [
146
- {
147
- type: "text",
148
- text: `Invalid parameters. ${reason}\nAvailable agents: ${available}`,
149
- },
150
- ],
151
- details: makeDetails("single")([]),
152
- };
153
- }
108
+ assertSubagentDepthAllowed();
109
+ const agentScope: AgentScope = params.agentScope ?? "user";
110
+ const aggregator = hasUsableAggregator(params.aggregator) ? params.aggregator : undefined;
111
+ const config = readSubagentSettings();
112
+ const discovery = discoverAgents(ctx.cwd, agentScope, config);
113
+ const agents = discovery.agents;
114
+ const confirmProjectAgents = params.confirmProjectAgents ?? true;
115
+ const resolveTimeoutMs = (agentName: string, localTimeoutMs?: number) =>
116
+ localTimeoutMs ??
117
+ params.timeoutMs ??
118
+ agents.find((agent) => agent.name === agentName)?.timeoutMs ??
119
+ resolveDefaultSubagentTimeoutMs();
120
+ const resolveThinkingLevel = (agentName: string, localThinkingLevel?: SubagentThinkingLevel) =>
121
+ resolveSubagentThinkingLevel(agents, agentName, params.thinkingLevel, localThinkingLevel);
122
+
123
+ const hasChain = (params.chain?.length ?? 0) > 0;
124
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
125
+ const hasSingle = Boolean(params.agent && params.task);
126
+ const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
127
+
128
+ const makeDetails =
129
+ (mode: "single" | "parallel" | "chain") =>
130
+ (results: SingleResult[], aggregator?: SingleResult): SubagentDetails => ({
131
+ mode,
132
+ agentScope,
133
+ projectAgentsDir: discovery.projectAgentsDir,
134
+ results,
135
+ aggregator,
136
+ });
137
+
138
+ if (modeCount !== 1 || (aggregator && !hasTasks)) {
139
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
140
+ const reason =
141
+ modeCount !== 1
142
+ ? "Provide exactly one mode."
143
+ : "Aggregator is only valid with parallel tasks.";
144
+ return {
145
+ content: [
146
+ {
147
+ type: "text",
148
+ text: `Invalid parameters. ${reason}\nAvailable agents: ${available}`,
149
+ },
150
+ ],
151
+ details: makeDetails("single")([]),
152
+ };
153
+ }
154
154
 
155
- if (agentScope === "project" || agentScope === "both") {
156
- const requestedAgentNames = new Set<string>();
157
- if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
158
- if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
159
- if (aggregator) requestedAgentNames.add(aggregator.agent);
160
- if (params.agent) requestedAgentNames.add(params.agent);
161
-
162
- const projectAgentsRequested = Array.from(requestedAgentNames)
163
- .map((name) => agents.find((a) => a.name === name))
164
- .filter((a): a is AgentConfig => a?.source === "project");
165
-
166
- if (projectAgentsRequested.length > 0) {
167
- if (!ctx.isProjectTrusted()) {
168
- throw new Error("Project-local subagent definitions require a trusted project");
169
- }
170
- if (confirmProjectAgents && ctx.hasUI) {
171
- const names = projectAgentsRequested.map((a) => a.name).join(", ");
172
- const dir = discovery.projectAgentsDir ?? "(unknown)";
173
- const ok = await ctx.ui.confirm(
174
- "Run project-local agents?",
175
- `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
176
- );
177
- if (!ok) {
178
- return {
179
- content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
180
- details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
181
- };
182
- }
183
- }
184
- }
185
- }
155
+ if (agentScope === "project" || agentScope === "both") {
156
+ const requestedAgentNames = new Set<string>();
157
+ if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
158
+ if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
159
+ if (aggregator) requestedAgentNames.add(aggregator.agent);
160
+ if (params.agent) requestedAgentNames.add(params.agent);
186
161
 
187
- if (params.chain && params.chain.length > 0) {
188
- const results: SingleResult[] = [];
189
- let previousOutput = "";
190
- const status = startSubagentStatus(ctx, toolCallId, chainStatus(0, params.chain.length));
191
-
192
- try {
193
- for (let i = 0; i < params.chain.length; i++) {
194
- const step = params.chain[i];
195
- status.update(chainStatus(i + 1, params.chain.length, step.agent));
196
- const taskWithContext = truncateUtf8(
197
- step.task.replace(/\{previous\}/g, previousOutput),
198
- DEFAULT_MAX_CONTEXT_BYTES,
199
- ).text;
200
-
201
- // Create update callback that includes all previous results
202
- const chainUpdate: OnUpdateCallback | undefined = onUpdate
203
- ? (partial) => {
204
- // Combine completed results with current streaming result
205
- const currentResult = partial.details?.results[0];
206
- if (currentResult) {
207
- const allResults = [...results, currentResult];
208
- onUpdate({
209
- content: partial.content,
210
- details: makeDetails("chain")(allResults),
211
- });
212
- }
213
- }
214
- : undefined;
162
+ const projectAgentsRequested = Array.from(requestedAgentNames)
163
+ .map((name) => agents.find((a) => a.name === name))
164
+ .filter((a): a is AgentConfig => a?.source === "project");
215
165
 
216
- const result = await runSingleAgent(
217
- ctx.cwd,
218
- agents,
219
- step.agent,
220
- taskWithContext,
221
- step.cwd,
222
- i + 1,
223
- signal,
224
- resolveThinkingLevel(step.agent, step.thinkingLevel),
225
- resolveTimeoutMs(step.agent, step.timeoutMs),
226
- chainUpdate,
227
- makeDetails("chain"),
228
- );
229
- results.push(result);
230
-
231
- const isError = isResultError(result);
232
- if (isError) {
233
- const errorMsg = formatResultFailure(result);
234
- return {
235
- content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
236
- details: { ...makeDetails("chain")(results), isError: true },
237
- isError: true,
238
- };
239
- }
240
- previousOutput = getResultFinalOutput(result);
241
- }
166
+ if (projectAgentsRequested.length > 0) {
167
+ if (!ctx.isProjectTrusted()) {
168
+ throw new Error("Project-local subagent definitions require a trusted project");
169
+ }
170
+ if (confirmProjectAgents && ctx.hasUI) {
171
+ const names = projectAgentsRequested.map((a) => a.name).join(", ");
172
+ const dir = discovery.projectAgentsDir ?? "(unknown)";
173
+ const ok = await ctx.ui.confirm(
174
+ "Run project-local agents?",
175
+ `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
176
+ );
177
+ if (!ok) {
242
178
  return {
243
- content: [{ type: "text", text: getResultFinalOutput(results[results.length - 1]) || "(no output)" }],
244
- details: makeDetails("chain")(results),
179
+ content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
180
+ details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
245
181
  };
246
- } finally {
247
- status.clear();
248
182
  }
249
183
  }
184
+ }
185
+ }
250
186
 
251
- if (params.tasks && params.tasks.length > 0) {
252
- if (params.tasks.length > MAX_PARALLEL_TASKS)
187
+ if (params.chain && params.chain.length > 0) {
188
+ const results: SingleResult[] = [];
189
+ let previousOutput = "";
190
+ const status = startSubagentStatus(ctx, toolCallId, chainStatus(0, params.chain.length));
191
+
192
+ try {
193
+ for (let i = 0; i < params.chain.length; i++) {
194
+ const step = params.chain[i];
195
+ status.update(chainStatus(i + 1, params.chain.length, step.agent));
196
+ const taskWithContext = truncateUtf8(
197
+ step.task.replace(/\{previous\}/g, previousOutput),
198
+ DEFAULT_MAX_CONTEXT_BYTES,
199
+ ).text;
200
+
201
+ // Create update callback that includes all previous results
202
+ const chainUpdate: OnUpdateCallback | undefined = onUpdate
203
+ ? (partial) => {
204
+ // Combine completed results with current streaming result
205
+ const currentResult = partial.details?.results[0];
206
+ if (currentResult) {
207
+ const allResults = [...results, currentResult];
208
+ onUpdate({
209
+ content: partial.content,
210
+ details: makeDetails("chain")(allResults),
211
+ });
212
+ }
213
+ }
214
+ : undefined;
215
+
216
+ const result = await runSingleAgent(
217
+ ctx.cwd,
218
+ agents,
219
+ step.agent,
220
+ taskWithContext,
221
+ step.cwd,
222
+ i + 1,
223
+ signal,
224
+ resolveThinkingLevel(step.agent, step.thinkingLevel),
225
+ resolveTimeoutMs(step.agent, step.timeoutMs),
226
+ chainUpdate,
227
+ makeDetails("chain"),
228
+ );
229
+ results.push(result);
230
+
231
+ const isError = isResultError(result);
232
+ if (isError) {
233
+ const errorMsg = formatResultFailure(result);
253
234
  return {
254
235
  content: [
255
- {
256
- type: "text",
257
- text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
258
- },
236
+ { type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` },
259
237
  ],
260
- details: makeDetails("parallel")([]),
238
+ details: { ...makeDetails("chain")(results), isError: true },
239
+ isError: true,
261
240
  };
241
+ }
242
+ previousOutput = getResultFinalOutput(result);
243
+ }
244
+ return {
245
+ content: [
246
+ {
247
+ type: "text",
248
+ text: getResultFinalOutput(results[results.length - 1]) || "(no output)",
249
+ },
250
+ ],
251
+ details: makeDetails("chain")(results),
252
+ };
253
+ } finally {
254
+ status.clear();
255
+ }
256
+ }
262
257
 
263
- const status = startSubagentStatus(ctx, toolCallId, parallelStatus(0, params.tasks.length, params.tasks.length));
264
-
265
- try {
266
- // Track all results for streaming updates
267
- const allResults: SingleResult[] = new Array(params.tasks.length);
268
-
269
- // Initialize placeholder results
270
- for (let i = 0; i < params.tasks.length; i++) {
271
- allResults[i] = {
272
- agent: params.tasks[i].agent,
273
- agentSource: "unknown",
274
- task: params.tasks[i].task,
275
- exitCode: -1, // -1 = still running
276
- messages: [],
277
- stderr: "",
278
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
279
- thinkingLevel: resolveThinkingLevel(params.tasks[i].agent, params.tasks[i].thinkingLevel),
280
- finalOutput: "",
281
- };
282
- }
283
-
284
- let doneCount = 0;
285
- let runningCount = params.tasks.length;
286
-
287
- const emitParallelUpdate = () => {
288
- status.update(parallelStatus(doneCount, allResults.length, runningCount));
289
- if (onUpdate) {
290
- const pendingAggregator: SingleResult | undefined =
291
- aggregator && !signal?.aborted && doneCount === allResults.length
292
- ? {
293
- agent: aggregator.agent,
294
- agentSource:
295
- agents.find((agent) => agent.name === aggregator.agent)?.source ?? "unknown",
296
- task: aggregator.task,
297
- exitCode: -1,
298
- messages: [],
299
- stderr: "",
300
- usage: {
301
- input: 0,
302
- output: 0,
303
- cacheRead: 0,
304
- cacheWrite: 0,
305
- cost: 0,
306
- contextTokens: 0,
307
- turns: 0,
308
- },
309
- thinkingLevel: resolveThinkingLevel(
310
- aggregator.agent,
311
- aggregator.thinkingLevel,
312
- ),
313
- timeoutMs: resolveTimeoutMs(aggregator.agent, aggregator.timeoutMs),
314
- finalOutput: "",
315
- }
316
- : undefined;
317
- onUpdate({
318
- content: [
319
- {
320
- type: "text",
321
- text: `Parallel: ${doneCount}/${allResults.length} done, ${runningCount} running...`,
322
- },
323
- ],
324
- details: makeDetails("parallel")([...allResults], pendingAggregator),
325
- });
326
- }
327
- };
258
+ if (params.tasks && params.tasks.length > 0) {
259
+ if (params.tasks.length > MAX_PARALLEL_TASKS)
260
+ return {
261
+ content: [
262
+ {
263
+ type: "text",
264
+ text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
265
+ },
266
+ ],
267
+ details: makeDetails("parallel")([]),
268
+ };
328
269
 
329
- const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
330
- const result = await runSingleAgent(
331
- ctx.cwd,
332
- agents,
333
- t.agent,
334
- t.task,
335
- t.cwd,
336
- undefined,
337
- signal,
338
- resolveThinkingLevel(t.agent, t.thinkingLevel),
339
- resolveTimeoutMs(t.agent, t.timeoutMs),
340
- // Per-task update callback
341
- (partial) => {
342
- if (partial.details?.results[0]) {
343
- allResults[index] = { ...partial.details.results[0], exitCode: -1 };
344
- emitParallelUpdate();
345
- }
346
- },
347
- makeDetails("parallel"),
348
- );
349
- allResults[index] = result;
350
- doneCount += 1;
351
- runningCount -= 1;
352
- emitParallelUpdate();
353
- return result;
354
- }, signal, (task, index) => {
355
- const skipped: SingleResult = {
356
- ...allResults[index],
357
- task: task.task,
358
- exitCode: 130,
359
- stopReason: "aborted",
360
- aborted: true,
361
- errorMessage: "Subagent was not started because the parent call was aborted",
362
- };
363
- allResults[index] = skipped;
364
- doneCount += 1;
365
- runningCount -= 1;
366
- emitParallelUpdate();
367
- return skipped;
368
- });
270
+ const status = startSubagentStatus(
271
+ ctx,
272
+ toolCallId,
273
+ parallelStatus(0, params.tasks.length, params.tasks.length),
274
+ );
275
+
276
+ try {
277
+ // Track all results for streaming updates
278
+ const allResults: SingleResult[] = new Array(params.tasks.length);
279
+
280
+ // Initialize placeholder results
281
+ for (let i = 0; i < params.tasks.length; i++) {
282
+ allResults[i] = {
283
+ agent: params.tasks[i].agent,
284
+ agentSource: "unknown",
285
+ task: params.tasks[i].task,
286
+ exitCode: -1, // -1 = still running
287
+ messages: [],
288
+ stderr: "",
289
+ usage: {
290
+ input: 0,
291
+ output: 0,
292
+ cacheRead: 0,
293
+ cacheWrite: 0,
294
+ cost: 0,
295
+ contextTokens: 0,
296
+ turns: 0,
297
+ },
298
+ thinkingLevel: resolveThinkingLevel(params.tasks[i].agent, params.tasks[i].thinkingLevel),
299
+ finalOutput: "",
300
+ };
301
+ }
369
302
 
370
- let aggregatorResult: SingleResult | undefined;
371
- if (aggregator && !signal?.aborted) {
372
- status.update(fanInStatus(aggregator.agent));
373
- const fanInContext = buildFanInContext(results);
374
- const aggregatorTask = truncateUtf8(
375
- aggregator.task.includes("{previous}")
376
- ? aggregator.task.replace(/\{previous\}/g, fanInContext)
377
- : `${aggregator.task}\n\nParallel task outputs:\n\n${fanInContext}`,
378
- DEFAULT_MAX_CONTEXT_BYTES,
379
- ).text;
380
- aggregatorResult = await runSingleAgent(
381
- ctx.cwd,
382
- agents,
383
- aggregator.agent,
384
- aggregatorTask,
385
- aggregator.cwd,
386
- undefined,
387
- signal,
388
- resolveThinkingLevel(aggregator.agent, aggregator.thinkingLevel),
389
- resolveTimeoutMs(aggregator.agent, aggregator.timeoutMs),
390
- (partial) => {
391
- status.update(fanInStatus(aggregator.agent));
392
- if (onUpdate && partial.details?.results[0]) {
393
- onUpdate({
394
- content: partial.content,
395
- details: makeDetails("parallel")(results, partial.details.results[0]),
396
- });
303
+ let doneCount = 0;
304
+ let runningCount = params.tasks.length;
305
+
306
+ const emitParallelUpdate = () => {
307
+ status.update(parallelStatus(doneCount, allResults.length, runningCount));
308
+ if (onUpdate) {
309
+ const pendingAggregator: SingleResult | undefined =
310
+ aggregator && !signal?.aborted && doneCount === allResults.length
311
+ ? {
312
+ agent: aggregator.agent,
313
+ agentSource:
314
+ agents.find((agent) => agent.name === aggregator.agent)?.source ?? "unknown",
315
+ task: aggregator.task,
316
+ exitCode: -1,
317
+ messages: [],
318
+ stderr: "",
319
+ usage: {
320
+ input: 0,
321
+ output: 0,
322
+ cacheRead: 0,
323
+ cacheWrite: 0,
324
+ cost: 0,
325
+ contextTokens: 0,
326
+ turns: 0,
327
+ },
328
+ thinkingLevel: resolveThinkingLevel(aggregator.agent, aggregator.thinkingLevel),
329
+ timeoutMs: resolveTimeoutMs(aggregator.agent, aggregator.timeoutMs),
330
+ finalOutput: "",
397
331
  }
398
- },
399
- makeDetails("parallel"),
400
- );
401
- }
402
-
403
- const successCount = results.filter((result) => !isResultError(result)).length;
404
- const summaries = results.map((result) => {
405
- const failed = isResultError(result);
406
- const output = getResultFinalOutput(result);
407
- const error = result.errorMessage || result.stderr.trim();
408
- const summaryText = failed ? formatResultFailure(result) : output || error;
409
- const preview = truncateUtf8(summaryText, 160).text;
410
- return `[${result.agent}] ${failed ? "failed" : "completed"}: ${preview || "(no output)"}`;
411
- });
412
- const aggregatorFailed = aggregatorResult ? isResultError(aggregatorResult) : false;
413
- const aggregatorOutput = aggregatorResult ? getResultFinalOutput(aggregatorResult) : "";
414
- const aggregatorError = aggregatorResult?.errorMessage || aggregatorResult?.stderr.trim() || "";
415
- return {
332
+ : undefined;
333
+ onUpdate({
416
334
  content: [
417
335
  {
418
336
  type: "text",
419
- text: aggregatorResult
420
- ? aggregatorFailed
421
- ? formatResultFailure(aggregatorResult)
422
- : aggregatorOutput ||
423
- aggregatorError ||
424
- `(aggregator ${aggregatorResult.agent} produced no output)`
425
- : `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
337
+ text: `Parallel: ${doneCount}/${allResults.length} done, ${runningCount} running...`,
426
338
  },
427
339
  ],
428
- details: {
429
- ...makeDetails("parallel")(results, aggregatorResult),
430
- isError: aggregatorFailed,
431
- },
432
- isError: aggregatorResult ? aggregatorFailed : undefined,
433
- };
434
- } finally {
435
- status.clear();
340
+ details: makeDetails("parallel")([...allResults], pendingAggregator),
341
+ });
436
342
  }
437
- }
438
-
439
- if (params.agent && params.task) {
440
- const status = startSubagentStatus(ctx, toolCallId, singleStatus(params.agent));
343
+ };
441
344
 
442
- try {
345
+ const results = await mapWithConcurrencyLimit(
346
+ params.tasks,
347
+ MAX_CONCURRENCY,
348
+ async (t, index) => {
443
349
  const result = await runSingleAgent(
444
350
  ctx.cwd,
445
351
  agents,
446
- params.agent,
447
- params.task,
448
- params.cwd,
352
+ t.agent,
353
+ t.task,
354
+ t.cwd,
449
355
  undefined,
450
356
  signal,
451
- resolveThinkingLevel(params.agent, params.thinkingLevel),
452
- resolveTimeoutMs(params.agent, params.timeoutMs),
453
- onUpdate,
454
- makeDetails("single"),
357
+ resolveThinkingLevel(t.agent, t.thinkingLevel),
358
+ resolveTimeoutMs(t.agent, t.timeoutMs),
359
+ // Per-task update callback
360
+ (partial) => {
361
+ if (partial.details?.results[0]) {
362
+ allResults[index] = { ...partial.details.results[0], exitCode: -1 };
363
+ emitParallelUpdate();
364
+ }
365
+ },
366
+ makeDetails("parallel"),
455
367
  );
456
- const isError = isResultError(result);
457
- if (isError) {
458
- const errorMsg = formatResultFailure(result);
459
- return {
460
- content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
461
- details: { ...makeDetails("single")([result]), isError: true },
462
- isError: true,
463
- };
464
- }
465
- return {
466
- content: [{ type: "text", text: getResultFinalOutput(result) || "(no output)" }],
467
- details: makeDetails("single")([result]),
368
+ allResults[index] = result;
369
+ doneCount += 1;
370
+ runningCount -= 1;
371
+ emitParallelUpdate();
372
+ return result;
373
+ },
374
+ signal,
375
+ (task, index) => {
376
+ const skipped: SingleResult = {
377
+ ...allResults[index],
378
+ task: task.task,
379
+ exitCode: 130,
380
+ stopReason: "aborted",
381
+ aborted: true,
382
+ errorMessage: "Subagent was not started because the parent call was aborted",
468
383
  };
469
- } finally {
470
- status.clear();
471
- }
384
+ allResults[index] = skipped;
385
+ doneCount += 1;
386
+ runningCount -= 1;
387
+ emitParallelUpdate();
388
+ return skipped;
389
+ },
390
+ );
391
+
392
+ let aggregatorResult: SingleResult | undefined;
393
+ if (aggregator && !signal?.aborted) {
394
+ status.update(fanInStatus(aggregator.agent));
395
+ const fanInContext = buildFanInContext(results);
396
+ const aggregatorTask = truncateUtf8(
397
+ aggregator.task.includes("{previous}")
398
+ ? aggregator.task.replace(/\{previous\}/g, fanInContext)
399
+ : `${aggregator.task}\n\nParallel task outputs:\n\n${fanInContext}`,
400
+ DEFAULT_MAX_CONTEXT_BYTES,
401
+ ).text;
402
+ aggregatorResult = await runSingleAgent(
403
+ ctx.cwd,
404
+ agents,
405
+ aggregator.agent,
406
+ aggregatorTask,
407
+ aggregator.cwd,
408
+ undefined,
409
+ signal,
410
+ resolveThinkingLevel(aggregator.agent, aggregator.thinkingLevel),
411
+ resolveTimeoutMs(aggregator.agent, aggregator.timeoutMs),
412
+ (partial) => {
413
+ status.update(fanInStatus(aggregator.agent));
414
+ if (onUpdate && partial.details?.results[0]) {
415
+ onUpdate({
416
+ content: partial.content,
417
+ details: makeDetails("parallel")(results, partial.details.results[0]),
418
+ });
419
+ }
420
+ },
421
+ makeDetails("parallel"),
422
+ );
472
423
  }
473
424
 
474
- const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
425
+ const successCount = results.filter((result) => !isResultError(result)).length;
426
+ const summaries = results.map((result) => {
427
+ const failed = isResultError(result);
428
+ const output = getResultFinalOutput(result);
429
+ const error = result.errorMessage || result.stderr.trim();
430
+ const summaryText = failed ? formatResultFailure(result) : output || error;
431
+ const preview = truncateUtf8(summaryText, 160).text;
432
+ return `[${result.agent}] ${failed ? "failed" : "completed"}: ${preview || "(no output)"}`;
433
+ });
434
+ const aggregatorFailed = aggregatorResult ? isResultError(aggregatorResult) : false;
435
+ const aggregatorOutput = aggregatorResult ? getResultFinalOutput(aggregatorResult) : "";
436
+ const aggregatorError =
437
+ aggregatorResult?.errorMessage || aggregatorResult?.stderr.trim() || "";
438
+ return {
439
+ content: [
440
+ {
441
+ type: "text",
442
+ text: aggregatorResult
443
+ ? aggregatorFailed
444
+ ? formatResultFailure(aggregatorResult)
445
+ : aggregatorOutput ||
446
+ aggregatorError ||
447
+ `(aggregator ${aggregatorResult.agent} produced no output)`
448
+ : `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
449
+ },
450
+ ],
451
+ details: {
452
+ ...makeDetails("parallel")(results, aggregatorResult),
453
+ isError: aggregatorFailed,
454
+ },
455
+ isError: aggregatorResult ? aggregatorFailed : undefined,
456
+ };
457
+ } finally {
458
+ status.clear();
459
+ }
460
+ }
461
+
462
+ if (params.agent && params.task) {
463
+ const status = startSubagentStatus(ctx, toolCallId, singleStatus(params.agent));
464
+
465
+ try {
466
+ const result = await runSingleAgent(
467
+ ctx.cwd,
468
+ agents,
469
+ params.agent,
470
+ params.task,
471
+ params.cwd,
472
+ undefined,
473
+ signal,
474
+ resolveThinkingLevel(params.agent, params.thinkingLevel),
475
+ resolveTimeoutMs(params.agent, params.timeoutMs),
476
+ onUpdate,
477
+ makeDetails("single"),
478
+ );
479
+ const isError = isResultError(result);
480
+ if (isError) {
481
+ const errorMsg = formatResultFailure(result);
482
+ return {
483
+ content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
484
+ details: { ...makeDetails("single")([result]), isError: true },
485
+ isError: true,
486
+ };
487
+ }
475
488
  return {
476
- content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
477
- details: makeDetails("single")([]),
489
+ content: [{ type: "text", text: getResultFinalOutput(result) || "(no output)" }],
490
+ details: makeDetails("single")([result]),
478
491
  };
492
+ } finally {
493
+ status.clear();
494
+ }
495
+ }
496
+
497
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
498
+ return {
499
+ content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
500
+ details: makeDetails("single")([]),
501
+ };
479
502
  }
package/src/limits.ts CHANGED
@@ -31,7 +31,10 @@ export function truncateUtf8(text: string, maxBytes: number): BoundedText {
31
31
  originalBytes,
32
32
  };
33
33
  }
34
- const prefix = Buffer.from(text, "utf8").subarray(0, limit - marker.length).toString("utf8").replace(/�+$/g, "");
34
+ const prefix = Buffer.from(text, "utf8")
35
+ .subarray(0, limit - marker.length)
36
+ .toString("utf8")
37
+ .replace(/�+$/g, "");
35
38
  return { text: `${prefix}${TRUNCATION_MARKER}`, truncated: true, originalBytes };
36
39
  }
37
40
 
@@ -45,7 +48,10 @@ export function truncateUtf8Tail(text: string, maxBytes: number): BoundedText {
45
48
  const marker = Buffer.from(TAIL_TRUNCATION_MARKER, "utf8");
46
49
  if (marker.length >= limit) {
47
50
  return {
48
- text: bytes.subarray(originalBytes - limit).toString("utf8").replace(/^�+/g, ""),
51
+ text: bytes
52
+ .subarray(originalBytes - limit)
53
+ .toString("utf8")
54
+ .replace(/^�+/g, ""),
49
55
  truncated: true,
50
56
  originalBytes,
51
57
  };
package/src/params.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
- import { Type, type Static } from "typebox";
2
+ import { type Static, Type } from "typebox";
3
3
  import { THINKING_LEVELS } from "./agents.js";
4
4
 
5
5
  const TimeoutMs = Type.Number({
@@ -31,9 +31,15 @@ const ChainItem = Type.Object({
31
31
 
32
32
  const AggregatorItem = Type.Object(
33
33
  {
34
- agent: Type.String({ description: "Name of the fan-in agent to invoke after parallel tasks complete" }),
35
- task: Type.String({ description: "Fan-in task. Use {previous} to include all parallel outputs." }),
36
- cwd: Type.Optional(Type.String({ description: "Working directory for the aggregator process" })),
34
+ agent: Type.String({
35
+ description: "Name of the fan-in agent to invoke after parallel tasks complete",
36
+ }),
37
+ task: Type.String({
38
+ description: "Fan-in task. Use {previous} to include all parallel outputs.",
39
+ }),
40
+ cwd: Type.Optional(
41
+ Type.String({ description: "Working directory for the aggregator process" }),
42
+ ),
37
43
  timeoutMs: Type.Optional(TimeoutMs),
38
44
  thinkingLevel: Type.Optional(ThinkingLevelSchema),
39
45
  },
@@ -50,16 +56,27 @@ const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
50
56
  });
51
57
 
52
58
  export const SubagentParams = Type.Object({
53
- agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })),
59
+ agent: Type.Optional(
60
+ Type.String({ description: "Name of the agent to invoke (for single mode)" }),
61
+ ),
54
62
  task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })),
55
- tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
56
- chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
63
+ tasks: Type.Optional(
64
+ Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" }),
65
+ ),
66
+ chain: Type.Optional(
67
+ Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" }),
68
+ ),
57
69
  aggregator: Type.Optional(AggregatorItem),
58
70
  agentScope: Type.Optional(AgentScopeSchema),
59
71
  confirmProjectAgents: Type.Optional(
60
- Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
72
+ Type.Boolean({
73
+ description: "Prompt before running project-local agents. Default: true.",
74
+ default: true,
75
+ }),
76
+ ),
77
+ cwd: Type.Optional(
78
+ Type.String({ description: "Working directory for the agent process (single mode)" }),
61
79
  ),
62
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
63
80
  timeoutMs: Type.Optional(TimeoutMs),
64
81
  thinkingLevel: Type.Optional(ThinkingLevelSchema),
65
82
  });
package/src/render.ts CHANGED
@@ -440,8 +440,7 @@ export function renderSubagentResult(
440
440
  const collapsed = getCollapsedDisplayItems(r);
441
441
  const finalOutput = getResultFinalOutput(r).trim();
442
442
  text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`;
443
- if (rFailed && r.errorMessage)
444
- text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
443
+ if (rFailed && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
445
444
  if (collapsed.items.length > 0)
446
445
  text += `\n${renderDisplayItems(collapsed.items, 5, collapsed.total)}`;
447
446
  else if (currentIsRunning && r === currentResult)
@@ -605,8 +604,7 @@ export function renderSubagentResult(
605
604
  const collapsed = getCollapsedDisplayItems(r);
606
605
  const finalOutput = getResultFinalOutput(r).trim();
607
606
  text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
608
- if (rFailed && r.errorMessage)
609
- text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
607
+ if (rFailed && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
610
608
  if (collapsed.items.length > 0)
611
609
  text += `\n${renderDisplayItems(collapsed.items, 5, collapsed.total)}`;
612
610
  else if (rRunning) text += `\n${theme.fg("muted", "(running...)")}`;
package/src/settings.ts CHANGED
@@ -261,8 +261,7 @@ export function inspectCompletionDeliverySettings(): CompletionDeliverySettingsS
261
261
  const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
262
262
  const settings = normalizeSubagentSettings(raw);
263
263
  if (!settings) throw new Error(`${SETTINGS_FILE} is not a valid settings object`);
264
- const explicit =
265
- isPlainObject(raw.stateful) && hasOwn(raw.stateful, "completionDelivery");
264
+ const explicit = isPlainObject(raw.stateful) && hasOwn(raw.stateful, "completionDelivery");
266
265
  return {
267
266
  path: configPath,
268
267
  value: settings.stateful?.completionDelivery ?? DEFAULT_COMPLETION_DELIVERY,
@@ -1,14 +1,11 @@
1
1
  import type { AgentConfig } from "./agents.js";
2
+ import { redactPrivateText } from "./context.js";
2
3
  import { resolveDefaultSubagentTimeoutMs } from "./execution.js";
3
4
  import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
4
- import { redactPrivateText } from "./context.js";
5
5
  import type { ManagedAgent } from "./registry.js";
6
6
 
7
7
  export function buildStatefulTurnPrompt(
8
- record: Pick<
9
- ManagedAgent,
10
- "context" | "history" | "mailbox" | "currentMailboxMessageIds"
11
- >,
8
+ record: Pick<ManagedAgent, "context" | "history" | "mailbox" | "currentMailboxMessageIds">,
12
9
  task: string,
13
10
  maxBytes = DEFAULT_MAX_CONTEXT_BYTES,
14
11
  ): { text: string; truncated: boolean } {
package/src/workspace.ts CHANGED
@@ -27,10 +27,10 @@ export class WorkspaceManager {
27
27
  if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) {
28
28
  throw new Error("Subagent cwd is outside the Git repository");
29
29
  }
30
- const status = (
31
- await execFileAsync("git", ["-C", repositoryRoot, "status", "--porcelain"])
32
- ).stdout;
33
- if (status.trim()) throw new Error("Isolated subagent workspace requires a clean Git repository");
30
+ const status = (await execFileAsync("git", ["-C", repositoryRoot, "status", "--porcelain"]))
31
+ .stdout;
32
+ if (status.trim())
33
+ throw new Error("Isolated subagent workspace requires a clean Git repository");
34
34
  const rootPath = await fs.promises.mkdtemp(path.join(os.tmpdir(), WORKTREE_PREFIX));
35
35
  let registered = false;
36
36
  try {
@@ -86,8 +86,9 @@ export class WorkspaceManager {
86
86
  workspace.rootPath,
87
87
  ]).catch(async () => {
88
88
  await fs.promises.rm(workspace.rootPath, { recursive: true, force: true });
89
- await execFileAsync("git", ["-C", workspace.repositoryRoot, "worktree", "prune"])
90
- .catch(() => undefined);
89
+ await execFileAsync("git", ["-C", workspace.repositoryRoot, "worktree", "prune"]).catch(
90
+ () => undefined,
91
+ );
91
92
  });
92
93
  await fs.promises.rm(`${workspace.rootPath}.owner`, { force: true });
93
94
  }