@bacnh85/pi-subagent 0.1.1 → 0.2.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/agent-format.md +59 -0
- package/agents/reviewer.md +1 -1
- package/agents/scout.md +1 -1
- package/agents/worker.md +1 -1
- package/agents.ts +42 -2
- package/index.ts +158 -26
- package/package.json +3 -2
- package/render.ts +14 -4
- package/runner.ts +22 -9
package/agent-format.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Agent Definition Format
|
|
2
|
+
|
|
3
|
+
Sub-agents are defined as Markdown files with YAML frontmatter.
|
|
4
|
+
|
|
5
|
+
## File Location
|
|
6
|
+
|
|
7
|
+
| Location | Scope |
|
|
8
|
+
|----------|-------|
|
|
9
|
+
| `~/.pi/agent/agents/*.md` | User-level (all projects) |
|
|
10
|
+
| `.pi/agents/*.md` | Project-level |
|
|
11
|
+
| `<skill>/agents/*.md` | Bundled with pi-sugagents |
|
|
12
|
+
|
|
13
|
+
Project agents override user agents with the same name when `agentScope: "both"`.
|
|
14
|
+
|
|
15
|
+
## Frontmatter Fields
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
---
|
|
19
|
+
name: my-agent # Required. Unique identifier (kebab-case).
|
|
20
|
+
description: ... # Required. When to use this agent.
|
|
21
|
+
tools: read, grep, ... # Optional. Comma-separated tool names. Defaults to all.
|
|
22
|
+
model: claude-haiku-4-5 # Optional. Model ID. Defaults to parent's model.
|
|
23
|
+
---
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Only `name` and `description` are required.
|
|
27
|
+
|
|
28
|
+
## Body
|
|
29
|
+
|
|
30
|
+
The body after frontmatter becomes the agent's **entire system prompt**. No pi defaults, no AGENTS.md files, no skills — only what you write here. Keep it focused.
|
|
31
|
+
|
|
32
|
+
## Available Tools
|
|
33
|
+
|
|
34
|
+
Built-in pi tool names: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`
|
|
35
|
+
|
|
36
|
+
The `subagent` tool is never available to sub-agents (prevents accidental recursion). Sub-agents run at one level of delegation only; they cannot spawn further sub-agents.
|
|
37
|
+
|
|
38
|
+
Custom/extension tools are NOT available to sub-agents by default (each runs in an isolated in-memory session with no extensions).
|
|
39
|
+
|
|
40
|
+
## Model Resolution
|
|
41
|
+
|
|
42
|
+
Model IDs are resolved via `getModel("provider", "id")`. Common values:
|
|
43
|
+
- `claude-haiku-4-5` (Anthropic Haiku — fast, cheap)
|
|
44
|
+
- `claude-sonnet-4-20250514` (Anthropic Sonnet — balanced)
|
|
45
|
+
- `gpt-4o` (OpenAI)
|
|
46
|
+
- Any model available in your pi configuration.
|
|
47
|
+
|
|
48
|
+
If not specified, defaults to the parent session's model.
|
|
49
|
+
|
|
50
|
+
## Token Budget
|
|
51
|
+
|
|
52
|
+
Each sub-agent runs with:
|
|
53
|
+
- **System prompt**: agent body only (~200-1K tokens typical)
|
|
54
|
+
- **No AGENTS.md**: saves 500-5K tokens
|
|
55
|
+
- **No extensions/skills loaded**: saves 200-1K tokens
|
|
56
|
+
- **Thinking off**: saves reasoning overhead
|
|
57
|
+
- **No compaction**: avoids compaction token cost
|
|
58
|
+
|
|
59
|
+
This is ~10x leaner than spawning a full `pi` process.
|
package/agents/reviewer.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: reviewer
|
|
3
3
|
description: Code review specialist. Use for reviewing changes, finding bugs, suggesting improvements.
|
|
4
4
|
tools: read, grep, find, ls, bash
|
|
5
|
-
model:
|
|
5
|
+
model: openai-codex/gpt-5.5
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
You are a senior code reviewer. Review code changes and provide specific, actionable feedback.
|
package/agents/scout.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: scout
|
|
3
3
|
description: Fast codebase recon that returns compressed context for handoff. Use for finding files, understanding structure, locating symbols.
|
|
4
4
|
tools: read, grep, find, ls
|
|
5
|
-
model:
|
|
5
|
+
model: opencode-go/deepseek-v4-flash
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything.
|
package/agents/worker.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: worker
|
|
3
3
|
description: General-purpose coding agent with full tool access. Use for implementation, refactoring, debugging, and complex multi-step tasks.
|
|
4
|
-
model:
|
|
4
|
+
model: opencode-go/deepseek-v4-pro
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
You are a skilled software engineer. Implement the requested task with care and precision.
|
package/agents.ts
CHANGED
|
@@ -34,6 +34,8 @@ interface AgentCache {
|
|
|
34
34
|
bundledDir: string;
|
|
35
35
|
agents: AgentConfig[];
|
|
36
36
|
projectAgentsDir: string | null;
|
|
37
|
+
/** File-level signature per directory (name:mtime:size for each .md file) */
|
|
38
|
+
dirSignatures: Map<string, string>;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
let _cache: AgentCache | null = null;
|
|
@@ -98,6 +100,26 @@ function isDirectory(p: string): boolean {
|
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
/** Build a stable signature for agent .md files in a directory.
|
|
104
|
+
* Returns "missing" if the directory doesn't exist, or a sorted
|
|
105
|
+
* list of `name:mtimeMs:size` entries that catches both content
|
|
106
|
+
* edits and add/remove/rename operations. */
|
|
107
|
+
function dirSignature(dir: string): string {
|
|
108
|
+
try {
|
|
109
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
110
|
+
.filter((e) => e.name.endsWith(".md") && (e.isFile() || e.isSymbolicLink()))
|
|
111
|
+
.map((e) => {
|
|
112
|
+
const file = path.join(dir, e.name);
|
|
113
|
+
const st = fs.statSync(file);
|
|
114
|
+
return `${e.name}:${st.mtimeMs}:${st.size}`;
|
|
115
|
+
})
|
|
116
|
+
.sort();
|
|
117
|
+
return `exists:${entries.join("|")}`;
|
|
118
|
+
} catch {
|
|
119
|
+
return "missing";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
101
123
|
function findNearestProjectAgentsDir(cwd: string): string | null {
|
|
102
124
|
let currentDir = cwd;
|
|
103
125
|
while (true) {
|
|
@@ -124,14 +146,25 @@ export function discoverAgents(
|
|
|
124
146
|
const userDir = path.join(getAgentDir(), "agents");
|
|
125
147
|
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
|
126
148
|
|
|
127
|
-
// Check cache
|
|
149
|
+
// Check cache (with file-signature invalidation so editing agent .md files auto-detects changes)
|
|
128
150
|
if (
|
|
129
151
|
_cache &&
|
|
130
152
|
_cache.userDir === userDir &&
|
|
131
153
|
_cache.projectDir === projectAgentsDir &&
|
|
132
154
|
_cache.bundledDir === bundledAgentsDir
|
|
133
155
|
) {
|
|
134
|
-
|
|
156
|
+
let stale = false;
|
|
157
|
+
for (const [dir, cachedSig] of _cache.dirSignatures) {
|
|
158
|
+
if (dirSignature(dir) !== cachedSig) {
|
|
159
|
+
stale = true;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!stale) {
|
|
164
|
+
return { agents: _cache.agents, projectAgentsDir: _cache.projectAgentsDir };
|
|
165
|
+
}
|
|
166
|
+
// Cache is stale — rebuild below
|
|
167
|
+
_cache = null;
|
|
135
168
|
}
|
|
136
169
|
|
|
137
170
|
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
|
|
@@ -152,12 +185,19 @@ export function discoverAgents(
|
|
|
152
185
|
|
|
153
186
|
const agents = Array.from(agentMap.values());
|
|
154
187
|
|
|
188
|
+
const dirSignatures = new Map<string, string>();
|
|
189
|
+
for (const dir of [userDir, projectAgentsDir, bundledAgentsDir]) {
|
|
190
|
+
if (!dir) continue;
|
|
191
|
+
dirSignatures.set(dir, dirSignature(dir));
|
|
192
|
+
}
|
|
193
|
+
|
|
155
194
|
_cache = {
|
|
156
195
|
userDir,
|
|
157
196
|
projectDir: projectAgentsDir,
|
|
158
197
|
bundledDir: bundledAgentsDir,
|
|
159
198
|
agents,
|
|
160
199
|
projectAgentsDir,
|
|
200
|
+
dirSignatures,
|
|
161
201
|
};
|
|
162
202
|
|
|
163
203
|
return { agents, projectAgentsDir };
|
package/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
29
29
|
import { Type } from "typebox";
|
|
30
30
|
|
|
31
|
-
import { type AgentConfig, type AgentScope, discoverAgents, invalidateAgentCache } from "./agents.ts";
|
|
31
|
+
import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList, invalidateAgentCache } from "./agents.ts";
|
|
32
32
|
import {
|
|
33
33
|
type SubAgentResult,
|
|
34
34
|
getFinalOutput,
|
|
@@ -66,20 +66,34 @@ function truncateParallelOutput(output: string): string {
|
|
|
66
66
|
return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
interface ResolvedModel {
|
|
70
|
+
model: Model | null;
|
|
71
|
+
attempted: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
69
74
|
function resolveModel(
|
|
70
75
|
modelName: string | undefined,
|
|
71
76
|
parentModel: Model | undefined,
|
|
72
|
-
):
|
|
77
|
+
): ResolvedModel {
|
|
78
|
+
const attempted: string[] = [];
|
|
73
79
|
if (modelName) {
|
|
74
80
|
// Try as provider/id first, then fall back to anthropic/id
|
|
75
81
|
const parts = modelName.split("/");
|
|
76
82
|
if (parts.length === 2) {
|
|
77
|
-
|
|
83
|
+
attempted.push(modelName);
|
|
84
|
+
const found = getModel(parts[0], parts[1]) ?? null;
|
|
85
|
+
if (found) return { model: found, attempted };
|
|
86
|
+
} else {
|
|
87
|
+
// Assume Anthropic shorthand
|
|
88
|
+
attempted.push(`anthropic/${modelName}`);
|
|
89
|
+
const found = getModel("anthropic", modelName) ?? null;
|
|
90
|
+
if (found) return { model: found, attempted };
|
|
78
91
|
}
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
} else if (parentModel) {
|
|
93
|
+
attempted.push(`${parentModel.provider}/${parentModel.id}`);
|
|
94
|
+
return { model: parentModel, attempted };
|
|
81
95
|
}
|
|
82
|
-
return
|
|
96
|
+
return { model: null, attempted };
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
// ---------------------------------------------------------------------------
|
|
@@ -90,12 +104,14 @@ const TaskItem = Type.Object({
|
|
|
90
104
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
91
105
|
task: Type.String({ description: "Task to delegate to the agent" }),
|
|
92
106
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
107
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds for this task" })),
|
|
93
108
|
});
|
|
94
109
|
|
|
95
110
|
const ChainItem = Type.Object({
|
|
96
111
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
97
112
|
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
|
|
98
113
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
114
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds for this step" })),
|
|
99
115
|
});
|
|
100
116
|
|
|
101
117
|
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
@@ -123,6 +139,8 @@ const SubagentParams = Type.Object({
|
|
|
123
139
|
}),
|
|
124
140
|
),
|
|
125
141
|
cwd: Type.Optional(Type.String({ description: "Working directory (single mode)" })),
|
|
142
|
+
timeout: Type.Optional(Type.Number({ description: "Global timeout in milliseconds for all sub-agents (overridden by per-task/step timeouts)" })),
|
|
143
|
+
abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
|
|
126
144
|
});
|
|
127
145
|
|
|
128
146
|
// ---------------------------------------------------------------------------
|
|
@@ -171,6 +189,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
171
189
|
return;
|
|
172
190
|
}
|
|
173
191
|
|
|
192
|
+
// Handle listing keywords before agent lookup
|
|
193
|
+
if (cmd === "all" || cmd === "list" || cmd === "agents") {
|
|
194
|
+
const list = formatAgentList(discovery.agents, 20);
|
|
195
|
+
const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
|
|
196
|
+
const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
|
|
197
|
+
pi.sendMessage({
|
|
198
|
+
customType: "pi-subagent",
|
|
199
|
+
content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
|
|
200
|
+
display: true,
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
174
205
|
if (cmd) {
|
|
175
206
|
// Show details for a specific agent
|
|
176
207
|
const agent = discovery.agents.find(
|
|
@@ -308,6 +339,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
308
339
|
agentName: string,
|
|
309
340
|
task: string,
|
|
310
341
|
cwd: string | undefined,
|
|
342
|
+
parentSignal?: AbortSignal,
|
|
343
|
+
timeoutMs?: number,
|
|
311
344
|
): Promise<SubAgentResult> {
|
|
312
345
|
const agent = agents.find((a) => a.name === agentName);
|
|
313
346
|
|
|
@@ -324,35 +357,76 @@ export default function (pi: ExtensionAPI) {
|
|
|
324
357
|
};
|
|
325
358
|
}
|
|
326
359
|
|
|
327
|
-
const
|
|
328
|
-
if (!model) {
|
|
360
|
+
const resolved = resolveModel(agent.model, ctx.model);
|
|
361
|
+
if (!resolved.model) {
|
|
362
|
+
const tried = resolved.attempted.join(", ") || "none";
|
|
363
|
+
const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
|
|
329
364
|
return {
|
|
330
365
|
agent: agentName,
|
|
331
366
|
task,
|
|
332
367
|
exitCode: 1,
|
|
333
368
|
messages: [],
|
|
334
|
-
stderr: `
|
|
369
|
+
stderr: `Model not found for agent "${agentName}". Tried: ${tried}. Parent model: ${parentInfo}. Check agent definition and pi model configuration.`,
|
|
335
370
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
336
|
-
errorMessage:
|
|
371
|
+
errorMessage: `No model resolved (tried: ${tried})`,
|
|
337
372
|
};
|
|
338
373
|
}
|
|
339
374
|
|
|
340
375
|
// Inject parent's API key so --api-key and other runtime overrides work
|
|
341
|
-
await injectApiKey(model);
|
|
342
|
-
|
|
343
|
-
|
|
376
|
+
await injectApiKey(resolved.model);
|
|
377
|
+
|
|
378
|
+
// Resolve tools; strip "subagent" to prevent accidental recursion.
|
|
379
|
+
// Sub-agents cannot spawn further sub-agents (one level of delegation only).
|
|
380
|
+
const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
381
|
+
let tools = agent.tools ?? defaultTools;
|
|
382
|
+
tools = tools.filter((t) => t !== "subagent");
|
|
383
|
+
|
|
384
|
+
// Build timeout + parent signal into a combined AbortSignal
|
|
385
|
+
let combinedSignal = parentSignal;
|
|
386
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
387
|
+
let timeoutController: AbortController | undefined;
|
|
388
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
389
|
+
timeoutController = new AbortController();
|
|
390
|
+
timeoutId = setTimeout(() => {
|
|
391
|
+
timeoutController!.abort();
|
|
392
|
+
}, timeoutMs);
|
|
393
|
+
// Combine with parent signal if present (Node 20+ AbortSignal.any)
|
|
394
|
+
if (parentSignal && typeof (AbortSignal as any).any === "function") {
|
|
395
|
+
combinedSignal = (AbortSignal as any).any([parentSignal, timeoutController.signal]);
|
|
396
|
+
} else if (parentSignal) {
|
|
397
|
+
combinedSignal = timeoutController.signal;
|
|
398
|
+
// Link parent to timeout: if parent aborts, also abort our timeout controller
|
|
399
|
+
if (parentSignal.aborted) timeoutController.abort();
|
|
400
|
+
else parentSignal.addEventListener("abort", () => timeoutController!.abort(), { once: true });
|
|
401
|
+
} else {
|
|
402
|
+
combinedSignal = timeoutController.signal;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
344
405
|
|
|
345
|
-
|
|
406
|
+
const result = await runSubAgent({
|
|
346
407
|
cwd: cwd ?? ctx.cwd,
|
|
347
408
|
systemPrompt: agent.systemPrompt,
|
|
348
409
|
task,
|
|
349
410
|
tools,
|
|
350
|
-
model,
|
|
411
|
+
model: resolved.model,
|
|
351
412
|
authStorage,
|
|
352
413
|
modelRegistry,
|
|
353
|
-
signal,
|
|
414
|
+
signal: combinedSignal,
|
|
354
415
|
agentName,
|
|
355
416
|
});
|
|
417
|
+
|
|
418
|
+
// Clean up timeout
|
|
419
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
420
|
+
|
|
421
|
+
// Detect timeout: our timeout controller fired, not the parent
|
|
422
|
+
const timedOut = timeoutController?.signal.aborted && !parentSignal?.aborted;
|
|
423
|
+
if (timedOut) {
|
|
424
|
+
result.exitCode = 1;
|
|
425
|
+
result.stopReason = "timeout";
|
|
426
|
+
if (!result.errorMessage) result.errorMessage = `Timeout after ${timeoutMs}ms`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return result;
|
|
356
430
|
}
|
|
357
431
|
|
|
358
432
|
// --- Chain mode ---
|
|
@@ -364,7 +438,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
364
438
|
const step = params.chain[i];
|
|
365
439
|
const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
|
|
366
440
|
|
|
367
|
-
const result = await runOne(
|
|
441
|
+
const result = await runOne(
|
|
442
|
+
step.agent, taskWithContext, step.cwd,
|
|
443
|
+
signal, step.timeout ?? params.timeout,
|
|
444
|
+
);
|
|
368
445
|
results.push(result);
|
|
369
446
|
|
|
370
447
|
const isError = isFailedResult(result);
|
|
@@ -376,13 +453,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
376
453
|
details: makeDetails("chain")(results),
|
|
377
454
|
});
|
|
378
455
|
}
|
|
456
|
+
// Include successful previous step outputs in the error content
|
|
457
|
+
const prevCount = i;
|
|
458
|
+
let contentText = `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`;
|
|
459
|
+
if (prevCount > 0) {
|
|
460
|
+
const prevSummaries = results
|
|
461
|
+
.slice(0, prevCount)
|
|
462
|
+
.map((r, j) => {
|
|
463
|
+
const out = getResultOutput(r).slice(0, 500);
|
|
464
|
+
return `Step ${j + 1} (${r.agent}): ${out}`;
|
|
465
|
+
})
|
|
466
|
+
.join("\n");
|
|
467
|
+
contentText = `Chain stopped at step ${i + 1}/${params.chain.length}. ${prevCount} previous step(s) succeeded:\n\n${prevSummaries}\n\nError at step ${i + 1} (${step.agent}): ${errorMsg}`;
|
|
468
|
+
}
|
|
379
469
|
return {
|
|
380
|
-
content: [
|
|
381
|
-
{
|
|
382
|
-
type: "text",
|
|
383
|
-
text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`,
|
|
384
|
-
},
|
|
385
|
-
],
|
|
470
|
+
content: [{ type: "text", text: contentText }],
|
|
386
471
|
details: makeDetails("chain")(results),
|
|
387
472
|
isError: true,
|
|
388
473
|
};
|
|
@@ -421,6 +506,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
421
506
|
};
|
|
422
507
|
}
|
|
423
508
|
|
|
509
|
+
const abortOnFailure = params.abortOnFailure ?? false;
|
|
510
|
+
const parallelController = new AbortController();
|
|
511
|
+
|
|
512
|
+
// Combine parent signal with parallel abort controller
|
|
513
|
+
let parallelSignal: AbortSignal = parallelController.signal;
|
|
514
|
+
if (signal) {
|
|
515
|
+
// Always link parent abort into parallelController so queued tasks see aborted state
|
|
516
|
+
if (signal.aborted) parallelController.abort();
|
|
517
|
+
else signal.addEventListener("abort", () => parallelController.abort(), { once: true });
|
|
518
|
+
if (typeof (AbortSignal as any).any === "function") {
|
|
519
|
+
parallelSignal = (AbortSignal as any).any([signal, parallelController.signal]);
|
|
520
|
+
} else {
|
|
521
|
+
parallelSignal = parallelController.signal;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
424
525
|
const allResults: SubAgentResult[] = new Array(params.tasks.length);
|
|
425
526
|
// Initialize placeholder results for streaming
|
|
426
527
|
for (let i = 0; i < params.tasks.length; i++) {
|
|
@@ -454,14 +555,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
454
555
|
params.tasks,
|
|
455
556
|
MAX_CONCURRENCY,
|
|
456
557
|
async (t, index) => {
|
|
457
|
-
|
|
558
|
+
// Skip if already aborted by sibling failure or parent abort
|
|
559
|
+
if (parallelSignal.aborted || parallelController.signal.aborted) {
|
|
560
|
+
const skippedResult: SubAgentResult = {
|
|
561
|
+
agent: t.agent,
|
|
562
|
+
task: t.task,
|
|
563
|
+
exitCode: 1,
|
|
564
|
+
messages: [],
|
|
565
|
+
stderr: "",
|
|
566
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
567
|
+
stopReason: "aborted",
|
|
568
|
+
errorMessage: parallelController.signal.aborted
|
|
569
|
+
? "Cancelled: sibling task failed"
|
|
570
|
+
: "Cancelled: parent operation aborted",
|
|
571
|
+
};
|
|
572
|
+
allResults[index] = skippedResult;
|
|
573
|
+
emitParallelUpdate();
|
|
574
|
+
return skippedResult;
|
|
575
|
+
}
|
|
576
|
+
const result = await runOne(
|
|
577
|
+
t.agent, t.task, t.cwd,
|
|
578
|
+
parallelSignal, t.timeout ?? params.timeout,
|
|
579
|
+
);
|
|
458
580
|
allResults[index] = result;
|
|
581
|
+
// Early-abort: if this task failed and abortOnFailure is set
|
|
582
|
+
if (abortOnFailure && isFailedResult(result)) {
|
|
583
|
+
parallelController.abort();
|
|
584
|
+
}
|
|
459
585
|
emitParallelUpdate();
|
|
460
586
|
return result;
|
|
461
587
|
},
|
|
462
588
|
);
|
|
463
589
|
|
|
464
590
|
const successCount = results.filter((r) => !isFailedResult(r)).length;
|
|
591
|
+
const cancelCount = results.filter((r) => r.stopReason === "aborted" && r.errorMessage?.includes("Cancelled")).length;
|
|
465
592
|
const summaries = results.map((r) => {
|
|
466
593
|
const output = truncateParallelOutput(getResultOutput(r));
|
|
467
594
|
const status = isFailedResult(r)
|
|
@@ -470,11 +597,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
470
597
|
return `### [${r.agent}] ${status}\n\n${output}`;
|
|
471
598
|
});
|
|
472
599
|
|
|
600
|
+
let headerText = `Parallel: ${successCount}/${results.length} succeeded`;
|
|
601
|
+
if (cancelCount > 0) headerText += ` (${cancelCount} cancelled)`;
|
|
473
602
|
return {
|
|
474
603
|
content: [
|
|
475
604
|
{
|
|
476
605
|
type: "text",
|
|
477
|
-
text:
|
|
606
|
+
text: `${headerText}\n\n${summaries.join("\n\n---\n\n")}`,
|
|
478
607
|
},
|
|
479
608
|
],
|
|
480
609
|
details: makeDetails("parallel")(results),
|
|
@@ -483,7 +612,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
483
612
|
|
|
484
613
|
// --- Single mode ---
|
|
485
614
|
if (params.agent && params.task) {
|
|
486
|
-
const result = await runOne(
|
|
615
|
+
const result = await runOne(
|
|
616
|
+
params.agent, params.task, params.cwd,
|
|
617
|
+
signal, params.timeout,
|
|
618
|
+
);
|
|
487
619
|
const isError = isFailedResult(result);
|
|
488
620
|
|
|
489
621
|
if (onUpdate) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacnh85/pi-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Minimal-overhead sub-agent extension for pi. Delegate tasks to specialized agents with isolated context using the pi SDK in-process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"agents.ts",
|
|
31
31
|
"runner.ts",
|
|
32
32
|
"render.ts",
|
|
33
|
-
"agents/"
|
|
33
|
+
"agents/",
|
|
34
|
+
"agent-format.md"
|
|
34
35
|
],
|
|
35
36
|
"pi": {
|
|
36
37
|
"extensions": [
|
package/render.ts
CHANGED
|
@@ -180,10 +180,14 @@ export function renderSingleResult(
|
|
|
180
180
|
const mdTheme = getMarkdownTheme();
|
|
181
181
|
const container = new Container();
|
|
182
182
|
let header = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
|
|
183
|
-
if (isError && result.stopReason)
|
|
183
|
+
if (isError && result.stopReason) {
|
|
184
|
+
const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
185
|
+
header += ` ${theme.fg(reasonColor, `[${result.stopReason}]`)}`;
|
|
186
|
+
}
|
|
184
187
|
container.addChild(new Text(header, 0, 0));
|
|
185
188
|
if (isError && result.errorMessage) {
|
|
186
|
-
|
|
189
|
+
const messageColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
190
|
+
container.addChild(new Text(theme.fg(messageColor, `Error: ${result.errorMessage}`), 0, 0));
|
|
187
191
|
}
|
|
188
192
|
container.addChild(new Spacer(1));
|
|
189
193
|
container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
|
|
@@ -218,8 +222,14 @@ export function renderSingleResult(
|
|
|
218
222
|
|
|
219
223
|
// Collapsed
|
|
220
224
|
let text = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
|
|
221
|
-
if (isError && result.stopReason)
|
|
222
|
-
|
|
225
|
+
if (isError && result.stopReason) {
|
|
226
|
+
const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
227
|
+
text += ` ${theme.fg(reasonColor, `[${result.stopReason}]`)}`;
|
|
228
|
+
}
|
|
229
|
+
if (isError && result.errorMessage) {
|
|
230
|
+
const messageColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
231
|
+
text += `\n${theme.fg(messageColor, `Error: ${result.errorMessage}`)}`;
|
|
232
|
+
}
|
|
223
233
|
else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
|
|
224
234
|
else {
|
|
225
235
|
text += `\n${renderDisplayItems(displayItems, theme, COLLAPSED_ITEM_COUNT)}`;
|
package/runner.ts
CHANGED
|
@@ -123,6 +123,7 @@ export async function runSubAgent(options: {
|
|
|
123
123
|
|
|
124
124
|
try {
|
|
125
125
|
// Wire abort signal
|
|
126
|
+
let cleanupAbort: (() => void) | undefined;
|
|
126
127
|
if (signal) {
|
|
127
128
|
const onAbort = () => session.abort();
|
|
128
129
|
if (signal.aborted) {
|
|
@@ -134,10 +135,7 @@ export async function runSubAgent(options: {
|
|
|
134
135
|
return result;
|
|
135
136
|
}
|
|
136
137
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
137
|
-
|
|
138
|
-
// Cleanup listener on completion
|
|
139
|
-
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
140
|
-
// We'll clean up in finally via a flag
|
|
138
|
+
cleanupAbort = () => signal.removeEventListener("abort", onAbort);
|
|
141
139
|
}
|
|
142
140
|
|
|
143
141
|
// Collect all messages and usage stats from events
|
|
@@ -190,6 +188,7 @@ export async function runSubAgent(options: {
|
|
|
190
188
|
result.exitCode = 0;
|
|
191
189
|
return result;
|
|
192
190
|
} finally {
|
|
191
|
+
cleanupAbort?.();
|
|
193
192
|
try {
|
|
194
193
|
session.dispose();
|
|
195
194
|
} catch {
|
|
@@ -210,13 +209,26 @@ export async function runSubAgent(options: {
|
|
|
210
209
|
// ---------------------------------------------------------------------------
|
|
211
210
|
|
|
212
211
|
export function getFinalOutput(messages: Message[]): string {
|
|
212
|
+
// Prefer the last assistant message with non-empty text and NO tool calls (pure final answer).
|
|
213
213
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
214
214
|
const msg = messages[i];
|
|
215
|
-
if (msg.role
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
215
|
+
if (msg.role !== "assistant") continue;
|
|
216
|
+
const texts: string[] = [];
|
|
217
|
+
let hasToolCalls = false;
|
|
218
|
+
for (const part of msg.content) {
|
|
219
|
+
if (part.type === "text" && part.text.trim()) texts.push(part.text);
|
|
220
|
+
else if (part.type === "toolCall") hasToolCalls = true;
|
|
219
221
|
}
|
|
222
|
+
if (texts.length > 0 && !hasToolCalls) return texts.join("");
|
|
223
|
+
}
|
|
224
|
+
// Fallback: last assistant message with any non-empty text (even if it also has tool calls).
|
|
225
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
226
|
+
const msg = messages[i];
|
|
227
|
+
if (msg.role !== "assistant") continue;
|
|
228
|
+
const texts = msg.content
|
|
229
|
+
.filter((p): p is { type: "text"; text: string } => p.type === "text" && p.text.trim().length > 0)
|
|
230
|
+
.map((p) => p.text);
|
|
231
|
+
if (texts.length > 0) return texts.join("");
|
|
220
232
|
}
|
|
221
233
|
return "";
|
|
222
234
|
}
|
|
@@ -225,7 +237,8 @@ export function isFailedResult(result: SubAgentResult): boolean {
|
|
|
225
237
|
return (
|
|
226
238
|
result.exitCode !== 0 ||
|
|
227
239
|
result.stopReason === "error" ||
|
|
228
|
-
result.stopReason === "aborted"
|
|
240
|
+
result.stopReason === "aborted" ||
|
|
241
|
+
result.stopReason === "timeout"
|
|
229
242
|
);
|
|
230
243
|
}
|
|
231
244
|
|