@d3ara1n/pi-subagent 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -10
- package/package.json +2 -1
- package/src/config.ts +1 -0
- package/src/index.ts +264 -66
- package/src/roles.ts +72 -20
- package/src/spawn.ts +175 -28
- package/src/types.ts +33 -0
package/README.md
CHANGED
|
@@ -2,7 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Role-based subagent orchestration for [pi](https://github.com/earendil-works/pi).
|
|
4
4
|
|
|
5
|
-
Provides a `delegate` tool that lets the main model
|
|
5
|
+
Provides a `delegate` tool that lets the main model offload tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries.
|
|
6
|
+
|
|
7
|
+
## Design Philosophy
|
|
8
|
+
|
|
9
|
+
**The main model is the decision maker; subagents are executors.**
|
|
10
|
+
|
|
11
|
+
Your primary AI has the most complete context — it knows the full conversation history, project structure, and task at hand. Subagents are spawned with **clean, isolated contexts** to handle specific, well-defined tasks without polluting the main model's context window.
|
|
12
|
+
|
|
13
|
+
This means:
|
|
14
|
+
- **Subagents don't plan** — the main model decides what needs to be done and provides a clear task description
|
|
15
|
+
- **Subagents don't orchestrate** — if a task requires multiple steps, the main model examines each result and decides the next move
|
|
16
|
+
- **Subagents don't inherit history** — they don't need the full conversation; just a precise task description
|
|
17
|
+
- **Multiple subagents can run in parallel** — emit multiple `delegate` calls in one turn; pi executes them concurrently
|
|
18
|
+
- **Subagents can nest subagents** — a `worker` can delegate exploration to `explorer` without returning to the main model
|
|
19
|
+
|
|
20
|
+
> This design intentionally excludes chain pipelines and context-forking — those patterns are better suited when subagents act as advisors (planner, oracle), not executors.
|
|
6
21
|
|
|
7
22
|
## How it works
|
|
8
23
|
|
|
@@ -15,17 +30,21 @@ Provides a `delegate` tool that lets the main model delegate tasks to specialize
|
|
|
15
30
|
|
|
16
31
|
## Built-in Roles
|
|
17
32
|
|
|
18
|
-
| Role | Model Role | Tools | Description |
|
|
19
|
-
|
|
20
|
-
| `explorer` | fast | read,
|
|
21
|
-
| `reviewer` | heavy | read, bash, grep, glob | Deep code review (read-only) |
|
|
22
|
-
| `worker` | default | read, bash, edit, write, grep, glob | Implementation
|
|
23
|
-
| `researcher` | fast | web_search, fetch_content, read | Web research
|
|
33
|
+
| Role | Model Role | Tools | Can Delegate To | Description |
|
|
34
|
+
|------|-----------|-------|-----------------|-------------|
|
|
35
|
+
| `explorer` | fast | read, find, grep, glob | — | Fast code search (read-only, no bash) |
|
|
36
|
+
| `reviewer` | heavy | read, bash, grep, glob | — | Deep code review (read-only, bash for git/log) |
|
|
37
|
+
| `worker` | default | read, bash, edit, write, grep, glob, delegate | explorer, researcher | Implementation — the only role that can modify files |
|
|
38
|
+
| `researcher` | fast | web_search, fetch_content, read, bash, delegate | explorer | Web research + GitHub repo analysis |
|
|
39
|
+
|
|
40
|
+
**Nested delegation**: `worker` and `researcher` can spawn their own subagents. This keeps the main model's context clean — a worker can explore unfamiliar code via an `explorer` subagent without returning intermediate results to the main model.
|
|
41
|
+
|
|
42
|
+
**Parallel execution**: To run multiple subagents concurrently, emit multiple `delegate` calls in a single turn. Pi's framework executes them in parallel automatically, with each subagent getting its own TUI progress display.
|
|
24
43
|
|
|
25
44
|
## TUI Display
|
|
26
45
|
|
|
27
46
|
- **During execution**: Shows role, elapsed time, turn count, and live tool calls
|
|
28
|
-
- **Collapsed result**: `✓ explorer ·
|
|
47
|
+
- **Collapsed result**: `✓ explorer · Found login, registration, and token logic` + recent tool calls + usage stats
|
|
29
48
|
- **Expanded result** (Ctrl+O): Full task text, all tool calls, final output as rendered Markdown, and usage details
|
|
30
49
|
|
|
31
50
|
## Requirements
|
|
@@ -37,7 +56,7 @@ Provides a `delegate` tool that lets the main model delegate tasks to specialize
|
|
|
37
56
|
## Installation
|
|
38
57
|
|
|
39
58
|
```bash
|
|
40
|
-
pi
|
|
59
|
+
pi install @d3ara1n/pi-subagent
|
|
41
60
|
```
|
|
42
61
|
|
|
43
62
|
## Configuration
|
|
@@ -51,7 +70,7 @@ Edit `~/.pi/agent/settings.json`:
|
|
|
51
70
|
"timeoutMs": 300000,
|
|
52
71
|
|
|
53
72
|
// Summary generation — uses a lightweight model to create
|
|
54
|
-
// a one-line
|
|
73
|
+
// a one-line summary for the TUI display
|
|
55
74
|
"summary": {
|
|
56
75
|
"role": "utility", // pi-model-roles role for summarization
|
|
57
76
|
"enabled": true // set false to disable
|
|
@@ -62,8 +81,51 @@ Edit `~/.pi/agent/settings.json`:
|
|
|
62
81
|
|
|
63
82
|
All fields are optional. Defaults: `timeoutMs: 300000`, `summary.role: "utility"`, `summary.enabled: true`.
|
|
64
83
|
|
|
84
|
+
### Agent Overrides
|
|
85
|
+
|
|
86
|
+
Override, disable, or add subagent roles via `agentOverrides`. Built-in and custom roles are treated equally — all descriptions, examples, and decision triggers feed into the LLM's prompt dynamically.
|
|
87
|
+
|
|
88
|
+
```jsonc
|
|
89
|
+
{
|
|
90
|
+
"subagent": {
|
|
91
|
+
"agentOverrides": {
|
|
92
|
+
// ── Override a built-in role (only specify changed fields) ──
|
|
93
|
+
"worker": {
|
|
94
|
+
"role": "heavy" // use a stronger model
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// ── Disable a built-in role ──
|
|
98
|
+
"reviewer": {
|
|
99
|
+
"disabled": true
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
// ── Add a custom role (all required fields must be provided) ──
|
|
103
|
+
"tester": {
|
|
104
|
+
"role": "default",
|
|
105
|
+
"description": "Test automation & QA — write and run tests, validate fixes. Tools: read, bash, edit, write, grep. Can delegate to explorer.",
|
|
106
|
+
"examples": [
|
|
107
|
+
"Write unit tests for the auth module",
|
|
108
|
+
"Run the test suite and fix failing tests"
|
|
109
|
+
],
|
|
110
|
+
"decisionTrigger": "Task writes or runs tests?",
|
|
111
|
+
"tools": ["read", "bash", "edit", "write", "grep"],
|
|
112
|
+
"systemPrompt": "QA engineer. Write tests, run them, fix failures. After each change, re-run affected tests."
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Required fields for custom roles:** `role`, `description`, `examples`, `decisionTrigger`, `tools`, `systemPrompt`.
|
|
120
|
+
|
|
121
|
+
**Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `fallbackRole` (backup pi-model-roles role on provider errors).
|
|
122
|
+
|
|
123
|
+
Invalid custom roles (missing required fields) are silently skipped with an error notification at session start.
|
|
124
|
+
|
|
65
125
|
## Usage (by the main model)
|
|
66
126
|
|
|
127
|
+
Delegate tasks that would generate many tool calls or verbose output to keep your own context clean:
|
|
128
|
+
|
|
67
129
|
```json
|
|
68
130
|
{
|
|
69
131
|
"role": "explorer",
|
|
@@ -71,6 +133,24 @@ All fields are optional. Defaults: `timeoutMs: 300000`, `summary.role: "utility"
|
|
|
71
133
|
}
|
|
72
134
|
```
|
|
73
135
|
|
|
136
|
+
**Role-specific examples:**
|
|
137
|
+
|
|
138
|
+
| Role | Example task | Why delegate? |
|
|
139
|
+
|------|-------------|---------------|
|
|
140
|
+
| `explorer` | `"Map the routing structure of src/api/"` | You only need the conclusion, not every grep result |
|
|
141
|
+
| `reviewer` | `"Review error handling in auth.ts for security issues"` | Review output is longform; keep it isolated |
|
|
142
|
+
| `worker` | `"Rename all snake_case fields to camelCase in src/models/"` | Your context stays focused on high-level intent |
|
|
143
|
+
| `researcher` | `"Find the React 19 migration guide and summarize breaking changes"` | Search results are noisy; get a clean summary |
|
|
144
|
+
|
|
145
|
+
**Parallel usage:** emit multiple `delegate` calls in a single turn:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
[
|
|
149
|
+
{ "role": "explorer", "task": "Map the repository structure" },
|
|
150
|
+
{ "role": "researcher", "task": "Find latest docs on the library used here" }
|
|
151
|
+
]
|
|
152
|
+
```
|
|
153
|
+
|
|
74
154
|
## License
|
|
75
155
|
|
|
76
156
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"type": "module",
|
|
4
5
|
"description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
|
|
5
6
|
"main": "src/index.ts",
|
|
6
7
|
"keywords": [
|
package/src/config.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -15,11 +15,11 @@ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
|
15
15
|
import { Type } from "typebox";
|
|
16
16
|
import type { ModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
17
17
|
import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
18
|
-
import type { SubagentConfig, SubagentDetails, SubagentResult } from "./types.ts";
|
|
18
|
+
import type { SubagentConfig, SubagentDetails, SubagentResult, SubagentRole, ToolStatus, ActivityEntry } from "./types.ts";
|
|
19
19
|
import { DEFAULT_CONFIG } from "./types.ts";
|
|
20
20
|
import { loadSubagentConfig } from "./config.ts";
|
|
21
21
|
import { BUILTIN_ROLES } from "./roles.ts";
|
|
22
|
-
import { spawnSubagent } from "./spawn.ts";
|
|
22
|
+
import { spawnSubagent, getPiInvocation } from "./spawn.ts";
|
|
23
23
|
import * as os from "node:os";
|
|
24
24
|
|
|
25
25
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
@@ -44,25 +44,23 @@ function formatUsageStats(usage: SubagentResult["usage"], model?: string): strin
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
type DisplayItem =
|
|
47
|
-
| { type: "
|
|
48
|
-
| { type: "
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
items.push({ type: "toolCall", name: part.name, args: part.arguments ?? {} });
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return items;
|
|
47
|
+
| { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
|
|
48
|
+
| { type: "thinking"; status?: ToolStatus };
|
|
49
|
+
|
|
50
|
+
/** Map the real-time activity log into renderable display items (in order). */
|
|
51
|
+
function buildDisplayItems(activityLog: ActivityEntry[]): DisplayItem[] {
|
|
52
|
+
return activityLog.map((a) =>
|
|
53
|
+
a.kind === "thinking"
|
|
54
|
+
? { type: "thinking", status: a.status }
|
|
55
|
+
: { type: "toolCall", name: a.toolName ?? "?", args: a.args ?? {}, status: a.status },
|
|
56
|
+
);
|
|
62
57
|
}
|
|
63
58
|
|
|
64
59
|
function shortenPath(p: string): string {
|
|
65
60
|
const home = os.homedir();
|
|
61
|
+
if (process.platform === "win32") {
|
|
62
|
+
return p.toLowerCase().startsWith(home.toLowerCase()) ? `~${p.slice(home.length)}` : p;
|
|
63
|
+
}
|
|
66
64
|
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
67
65
|
}
|
|
68
66
|
|
|
@@ -123,6 +121,35 @@ function formatToolCall(
|
|
|
123
121
|
}
|
|
124
122
|
}
|
|
125
123
|
|
|
124
|
+
/** Per-tool-call visual styling: prefix glyph + color function keyed by status. */
|
|
125
|
+
function statusStyle(
|
|
126
|
+
status: ToolStatus | undefined,
|
|
127
|
+
fg: (color: string, text: string) => string,
|
|
128
|
+
): { prefix: string; color: (c: string, text: string) => string } {
|
|
129
|
+
switch (status) {
|
|
130
|
+
case "running":
|
|
131
|
+
return { prefix: fg("accent", "\u25CF "), color: fg };
|
|
132
|
+
case "failed":
|
|
133
|
+
return { prefix: fg("error", "\u2717 "), color: (_c, text) => fg("error", text) };
|
|
134
|
+
case "done":
|
|
135
|
+
default:
|
|
136
|
+
return { prefix: fg("dim", "\u2192 "), color: (_c, text) => fg("dim", text) };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Render a thinking-block row: diamond glyph + label, colored by status.
|
|
141
|
+
* Running = hollow diamond (unformed thought); done = solid diamond (settled). */
|
|
142
|
+
function formatThinking(
|
|
143
|
+
status: ToolStatus | undefined,
|
|
144
|
+
fg: (color: string, text: string) => string,
|
|
145
|
+
): string {
|
|
146
|
+
if (status === "running") {
|
|
147
|
+
return fg("accent", "\u25C7 thinking");
|
|
148
|
+
}
|
|
149
|
+
// done (or unknown) — dim past tense, solid diamond
|
|
150
|
+
return fg("dim", "\u25C6 thought");
|
|
151
|
+
}
|
|
152
|
+
|
|
126
153
|
function renderDisplayItems(
|
|
127
154
|
items: DisplayItem[],
|
|
128
155
|
limit: number | undefined,
|
|
@@ -133,11 +160,11 @@ function renderDisplayItems(
|
|
|
133
160
|
let text = "";
|
|
134
161
|
if (skipped > 0) text += fg("muted", `... ${skipped} earlier items\n`);
|
|
135
162
|
for (const item of toShow) {
|
|
136
|
-
if (item.type === "
|
|
137
|
-
|
|
138
|
-
text += `${fg("toolOutput", preview.length > 120 ? preview.slice(0, 120) + "..." : preview)}\n`;
|
|
163
|
+
if (item.type === "thinking") {
|
|
164
|
+
text += `${formatThinking(item.status, fg)}\n`;
|
|
139
165
|
} else {
|
|
140
|
-
|
|
166
|
+
const { prefix, color } = statusStyle(item.status, fg);
|
|
167
|
+
text += `${prefix}${formatToolCall(item.name, item.args, color)}\n`;
|
|
141
168
|
}
|
|
142
169
|
}
|
|
143
170
|
return text.trimEnd();
|
|
@@ -172,12 +199,20 @@ async function generateSummary(
|
|
|
172
199
|
const resolved = await rolesApi.resolveRoleAsync(summaryConfig.role);
|
|
173
200
|
if (!resolved.model) return undefined;
|
|
174
201
|
|
|
202
|
+
// Truncate large outputs to avoid wasting summary tokens (keep head + tail)
|
|
203
|
+
const SUMMARY_MAX_INPUT = 4000;
|
|
204
|
+
let summaryInput = outputText;
|
|
205
|
+
if (summaryInput.length > SUMMARY_MAX_INPUT) {
|
|
206
|
+
const half = Math.floor(SUMMARY_MAX_INPUT / 2);
|
|
207
|
+
summaryInput = summaryInput.slice(0, half) + "\n\n... [truncated for summary] ...\n\n" + summaryInput.slice(-half);
|
|
208
|
+
}
|
|
209
|
+
|
|
175
210
|
const result = await complete(
|
|
176
211
|
resolved.model,
|
|
177
212
|
{
|
|
178
213
|
systemPrompt:
|
|
179
|
-
"Summarize the following agent output in one concise
|
|
180
|
-
messages: [{ role: "user", content:
|
|
214
|
+
"Summarize the following agent output in one concise sentence (max 60 characters). Respond in the same language as the input. Focus on what was accomplished, not how. Output only the summary, no preamble.",
|
|
215
|
+
messages: [{ role: "user", content: summaryInput, timestamp: Date.now() }],
|
|
181
216
|
},
|
|
182
217
|
{
|
|
183
218
|
maxTokens: 100,
|
|
@@ -194,7 +229,12 @@ async function generateSummary(
|
|
|
194
229
|
|
|
195
230
|
return text || undefined;
|
|
196
231
|
} catch {
|
|
197
|
-
|
|
232
|
+
// Fall back to manual truncation: use first line of output as summary
|
|
233
|
+
const trimmed = outputText.trim();
|
|
234
|
+
if (!trimmed) return undefined;
|
|
235
|
+
const firstLine = trimmed.split("\n")[0];
|
|
236
|
+
if (firstLine.length <= 65) return firstLine;
|
|
237
|
+
return firstLine.slice(0, 62) + "...";
|
|
198
238
|
}
|
|
199
239
|
}
|
|
200
240
|
|
|
@@ -203,51 +243,126 @@ async function generateSummary(
|
|
|
203
243
|
export default function subagentExtension(pi: ExtensionAPI) {
|
|
204
244
|
let config: SubagentConfig = DEFAULT_CONFIG;
|
|
205
245
|
|
|
246
|
+
// If spawned as a child by a parent subagent, PI_SUBAGENT_ALLOWED restricts
|
|
247
|
+
// which roles are available. Filter before any tool description sees them.
|
|
248
|
+
const ALLOWLIST: string[] | undefined = (() => {
|
|
249
|
+
const raw = process.env.PI_SUBAGENT_ALLOWED;
|
|
250
|
+
if (!raw) return undefined;
|
|
251
|
+
const list = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
252
|
+
return list.length > 0 ? list : undefined;
|
|
253
|
+
})();
|
|
254
|
+
|
|
255
|
+
const availableRoles: Record<string, SubagentRole> = {};
|
|
256
|
+
for (const [name, role] of Object.entries(BUILTIN_ROLES)) {
|
|
257
|
+
if (!ALLOWLIST || ALLOWLIST.includes(name)) {
|
|
258
|
+
availableRoles[name] = role;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Mutable guidelines array — rebuilt in session_start to reflect agentOverrides
|
|
263
|
+
const guidelines: string[] = [];
|
|
264
|
+
|
|
265
|
+
function rebuildGuidelines(roles: Record<string, SubagentRole>): void {
|
|
266
|
+
const entries = Object.entries(roles);
|
|
267
|
+
const exampleLines: string[] = [];
|
|
268
|
+
const decisionLines: string[] = [];
|
|
269
|
+
|
|
270
|
+
for (const [name, role] of entries) {
|
|
271
|
+
// Decision flow
|
|
272
|
+
decisionLines.push(` ${role.decisionTrigger} → delegate(${name})`);
|
|
273
|
+
|
|
274
|
+
// Concrete examples — one line per role with comma-separated examples
|
|
275
|
+
const quotedExamples = role.examples.map((e) => `"${e}"`).join(", ");
|
|
276
|
+
exampleLines.push(` delegate(${name}): ${quotedExamples}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
guidelines.length = 0;
|
|
280
|
+
guidelines.push(
|
|
281
|
+
"WHEN TO DELEGATE — offload substantial work when you only need the result:",
|
|
282
|
+
"",
|
|
283
|
+
"- Delegate ONLY when a task involves significant work (heavy analysis, multi-step investigation, large-scope changes) AND you only care about the conclusion, not intermediate steps.",
|
|
284
|
+
"- DO NOT delegate simple tasks: a single read, a one-line edit, a basic grep. Just do them yourself.",
|
|
285
|
+
"- DO NOT delegate straightforward file modifications touching 1-2 files. Use edit/write directly.",
|
|
286
|
+
"- Delegation has overhead (spawning a child process). Reserve it for tasks that would genuinely clutter your context with 3+ turns of raw tool output.",
|
|
287
|
+
"",
|
|
288
|
+
"AVAILABLE ROLES:",
|
|
289
|
+
...entries.map(([name, role]) => ` - ${name}: ${role.description}`),
|
|
290
|
+
"",
|
|
291
|
+
"DECISION FLOW (which role for what):",
|
|
292
|
+
"",
|
|
293
|
+
...decisionLines,
|
|
294
|
+
"",
|
|
295
|
+
"CONCRETE EXAMPLES of good delegation targets:",
|
|
296
|
+
"",
|
|
297
|
+
...exampleLines,
|
|
298
|
+
"",
|
|
299
|
+
"For multiple independent substantial tasks, emit multiple delegate calls in one turn — they run in parallel.",
|
|
300
|
+
"Include ALL necessary context — subagents have no access to this conversation.",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Apply agent overrides on top of built-in roles
|
|
305
|
+
function applyAgentOverrides(roles: Record<string, SubagentRole>, overrides: Record<string, any>): void {
|
|
306
|
+
for (const [name, override] of Object.entries(overrides)) {
|
|
307
|
+
if (override.disabled) {
|
|
308
|
+
delete roles[name];
|
|
309
|
+
} else if (roles[name]) {
|
|
310
|
+
roles[name] = { ...roles[name], ...override };
|
|
311
|
+
} else {
|
|
312
|
+
// Custom role — must provide all required fields (validated in session_start)
|
|
313
|
+
roles[name] = override as SubagentRole;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Initial guidelines from built-in roles
|
|
319
|
+
rebuildGuidelines(availableRoles);
|
|
320
|
+
|
|
206
321
|
pi.on("session_start", async (_event, ctx) => {
|
|
207
322
|
config = loadSubagentConfig(ctx.cwd);
|
|
323
|
+
applyAgentOverrides(availableRoles, config.agentOverrides);
|
|
324
|
+
|
|
325
|
+
// Validate custom roles (skip built-in roles — they already have all fields)
|
|
326
|
+
const REQUIRED_FIELDS = ["role", "description", "examples", "decisionTrigger", "tools", "systemPrompt"] as const;
|
|
327
|
+
for (const [name, role] of Object.entries(availableRoles)) {
|
|
328
|
+
if (name in BUILTIN_ROLES) continue;
|
|
329
|
+
const missing = REQUIRED_FIELDS.filter((f) => !(f in (role as any)));
|
|
330
|
+
if (missing.length > 0) {
|
|
331
|
+
delete availableRoles[name];
|
|
332
|
+
ctx.ui.notify(
|
|
333
|
+
`[pi-subagent] Custom role "${name}" skipped — missing: ${missing.join(", ")}. Required: ${REQUIRED_FIELDS.join(", ")}.`,
|
|
334
|
+
"error",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
rebuildGuidelines(availableRoles);
|
|
208
340
|
});
|
|
209
341
|
|
|
210
342
|
pi.registerTool({
|
|
211
343
|
name: "delegate",
|
|
212
344
|
label: "Delegate to subagent",
|
|
213
|
-
description:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
" - explorer: fast code search and navigation (read-only)",
|
|
217
|
-
" - reviewer: deep code review with evidence (read-only)",
|
|
218
|
-
" - worker: implementation with file editing capabilities",
|
|
219
|
-
" - researcher: web research and documentation lookup",
|
|
220
|
-
"",
|
|
221
|
-
"Progress is shown in real-time via TUI (tool calls, turns, elapsed time).",
|
|
222
|
-
"Use Ctrl+O on a completed result to see full details.",
|
|
223
|
-
"",
|
|
224
|
-
"Note: Subagents only have built-in tools (read, bash, edit, write, grep, glob, find, web_search, fetch_content). They do NOT have access to MCP tools or custom tools from the main session.",
|
|
225
|
-
].join("\n"),
|
|
345
|
+
description: "Offload work to a specialized subagent to keep your own context clean and focused. Prefer this over doing work yourself when a task would generate many tool calls or verbose output. Subagents have isolated context — include all necessary info in the task description.",
|
|
346
|
+
promptSnippet: "Delegate tasks to specialized subagents",
|
|
347
|
+
promptGuidelines: guidelines,
|
|
226
348
|
|
|
227
349
|
parameters: Type.Object({
|
|
228
|
-
role: Type.
|
|
229
|
-
[
|
|
230
|
-
Type.Literal("explorer"),
|
|
231
|
-
Type.Literal("reviewer"),
|
|
232
|
-
Type.Literal("worker"),
|
|
233
|
-
Type.Literal("researcher"),
|
|
234
|
-
],
|
|
235
|
-
{ description: "Subagent role to use" },
|
|
236
|
-
),
|
|
350
|
+
role: Type.String({ description: "Subagent role to use" }),
|
|
237
351
|
task: Type.String({ description: "Specific task for the subagent" }),
|
|
238
352
|
cwd: Type.Optional(Type.String({ description: "Working directory (defaults to current)" })),
|
|
239
353
|
}),
|
|
240
354
|
|
|
241
355
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
242
|
-
const roleDef =
|
|
356
|
+
const roleDef = availableRoles[params.role];
|
|
243
357
|
if (!roleDef) {
|
|
244
358
|
return {
|
|
245
359
|
content: [
|
|
246
360
|
{
|
|
247
361
|
type: "text",
|
|
248
|
-
text: `Unknown subagent role: ${params.role}. Available: ${Object.keys(
|
|
362
|
+
text: `Unknown subagent role: ${params.role}. Available: ${Object.keys(availableRoles).join(", ")}`,
|
|
249
363
|
},
|
|
250
364
|
],
|
|
365
|
+
details: undefined as any,
|
|
251
366
|
};
|
|
252
367
|
}
|
|
253
368
|
|
|
@@ -258,6 +373,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
258
373
|
} catch {
|
|
259
374
|
return {
|
|
260
375
|
content: [{ type: "text", text: "pi-model-roles is not initialized. Cannot resolve model for subagent." }],
|
|
376
|
+
details: undefined as any,
|
|
261
377
|
};
|
|
262
378
|
}
|
|
263
379
|
|
|
@@ -265,6 +381,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
265
381
|
if (!resolved.model) {
|
|
266
382
|
return {
|
|
267
383
|
content: [{ type: "text", text: `Role "${roleDef.role}" could not be resolved. Model not available.` }],
|
|
384
|
+
details: undefined as any,
|
|
268
385
|
};
|
|
269
386
|
}
|
|
270
387
|
|
|
@@ -281,6 +398,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
281
398
|
output: "",
|
|
282
399
|
stderr: "",
|
|
283
400
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
401
|
+
activityLog: [],
|
|
284
402
|
};
|
|
285
403
|
onUpdate({
|
|
286
404
|
content: [{ type: "text", text: `${params.role}: running...` }],
|
|
@@ -289,10 +407,11 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
289
407
|
}
|
|
290
408
|
|
|
291
409
|
try {
|
|
292
|
-
|
|
410
|
+
let result = await spawnSubagent(modelRef, params.task, {
|
|
293
411
|
cwd: params.cwd ?? ctx.cwd,
|
|
294
412
|
tools: roleDef.tools,
|
|
295
413
|
systemPrompt: roleDef.systemPrompt,
|
|
414
|
+
subagentRoles: roleDef.subagentRoles,
|
|
296
415
|
timeoutMs: config.timeoutMs,
|
|
297
416
|
signal,
|
|
298
417
|
onProgress: (partial) => {
|
|
@@ -316,6 +435,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
316
435
|
},
|
|
317
436
|
model: partial.model,
|
|
318
437
|
stopReason: partial.stopReason,
|
|
438
|
+
activityLog: partial.activityLog ?? [],
|
|
319
439
|
};
|
|
320
440
|
const statusText = `${params.role} ${elapsed}s ${liveResult.usage.turns} turn${liveResult.usage.turns !== 1 ? "s" : ""}`;
|
|
321
441
|
onUpdate({
|
|
@@ -325,6 +445,27 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
325
445
|
},
|
|
326
446
|
});
|
|
327
447
|
|
|
448
|
+
// Retry with fallback role on provider errors (quota, auth, timeout, etc.)
|
|
449
|
+
if ((result.exitCode !== 0 || result.errorMessage) && roleDef.fallbackRole) {
|
|
450
|
+
const isProviderError = /429|quota|rate.?limit|auth|timeout|exhausted|unavailable/i.test(
|
|
451
|
+
(result.stderr || "") + (result.errorMessage || ""),
|
|
452
|
+
);
|
|
453
|
+
if (isProviderError) {
|
|
454
|
+
const fallback = await rolesApi.resolveRoleAsync(roleDef.fallbackRole);
|
|
455
|
+
if (fallback.model) {
|
|
456
|
+
const fbRef = `${fallback.model.provider}/${fallback.model.id}`;
|
|
457
|
+
result = await spawnSubagent(fbRef, params.task, {
|
|
458
|
+
cwd: params.cwd ?? ctx.cwd,
|
|
459
|
+
tools: roleDef.tools,
|
|
460
|
+
systemPrompt: roleDef.systemPrompt,
|
|
461
|
+
subagentRoles: roleDef.subagentRoles,
|
|
462
|
+
timeoutMs: config.timeoutMs,
|
|
463
|
+
signal,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
328
469
|
// Generate summary for TUI display
|
|
329
470
|
if (config.summary.enabled && result.output.trim()) {
|
|
330
471
|
result.summary = await generateSummary(rolesApi, result.output, config.summary);
|
|
@@ -399,7 +540,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
399
540
|
} else {
|
|
400
541
|
icon = theme.fg("success", "\u2713");
|
|
401
542
|
}
|
|
402
|
-
const displayItems =
|
|
543
|
+
const displayItems = buildDisplayItems(r.activityLog);
|
|
403
544
|
const finalOutput = getFinalOutput(r.messages);
|
|
404
545
|
const mdTheme = getMarkdownTheme();
|
|
405
546
|
|
|
@@ -420,20 +561,21 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
420
561
|
}
|
|
421
562
|
|
|
422
563
|
container.addChild(new Spacer(1));
|
|
423
|
-
const
|
|
424
|
-
if (
|
|
564
|
+
const activity = displayItems.filter((item) => item.type === "toolCall" || item.type === "thinking");
|
|
565
|
+
if (activity.length === 0) {
|
|
425
566
|
const runningLabel = isRunning ? "(waiting for first event...)" : "(none)";
|
|
426
567
|
container.addChild(new Text(theme.fg("muted", runningLabel), 0, 0));
|
|
427
568
|
} else {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
569
|
+
const fg = theme.fg.bind(theme) as (color: string, text: string) => string;
|
|
570
|
+
for (const item of activity) {
|
|
571
|
+
if (item.type === "thinking") {
|
|
572
|
+
container.addChild(new Text(formatThinking(item.status, fg), 0, 0));
|
|
573
|
+
} else {
|
|
574
|
+
const { prefix, color } = statusStyle(item.status, fg);
|
|
575
|
+
container.addChild(
|
|
576
|
+
new Text(prefix + formatToolCall(item.name, item.args, color), 0, 0),
|
|
577
|
+
);
|
|
578
|
+
}
|
|
437
579
|
}
|
|
438
580
|
}
|
|
439
581
|
|
|
@@ -457,11 +599,11 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
457
599
|
|
|
458
600
|
if (isRunning) {
|
|
459
601
|
// Running: show recent tool calls only
|
|
460
|
-
const
|
|
461
|
-
if (
|
|
602
|
+
const activity = displayItems.filter((item) => item.type === "toolCall" || item.type === "thinking");
|
|
603
|
+
if (activity.length === 0) {
|
|
462
604
|
text += `\n${theme.fg("muted", "(running...)")}`;
|
|
463
605
|
} else {
|
|
464
|
-
const rendered = renderDisplayItems(
|
|
606
|
+
const rendered = renderDisplayItems(activity, 5, theme.fg.bind(theme) as (color: string, text: string) => string);
|
|
465
607
|
if (rendered) text += `\n${rendered}`;
|
|
466
608
|
}
|
|
467
609
|
} else {
|
|
@@ -469,8 +611,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
469
611
|
if (r.summary) {
|
|
470
612
|
text += ` ${theme.fg("dim", "\u00b7")} ${theme.fg("text", r.summary)}`;
|
|
471
613
|
}
|
|
472
|
-
if (isError
|
|
473
|
-
|
|
614
|
+
if (isError) {
|
|
615
|
+
const errMsg = r.errorMessage || (r.stderr ? r.stderr.trim().split("\n")[0].slice(0, 80) : r.stopReason);
|
|
616
|
+
if (errMsg) text += `\n${theme.fg("error", `Error: ${errMsg}`)}`;
|
|
474
617
|
}
|
|
475
618
|
const usageStr = formatUsageStats(r.usage, r.model);
|
|
476
619
|
if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
|
|
@@ -478,4 +621,59 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
478
621
|
return new Text(text, 0, 0);
|
|
479
622
|
},
|
|
480
623
|
});
|
|
624
|
+
|
|
625
|
+
pi.registerCommand("subagent:doctor", {
|
|
626
|
+
description: "Diagnose pi-subagent configuration and dependencies",
|
|
627
|
+
handler: async (_args, ctx) => {
|
|
628
|
+
const lines: string[] = [];
|
|
629
|
+
let allOk = true;
|
|
630
|
+
|
|
631
|
+
// 1. pi executable
|
|
632
|
+
const inv = getPiInvocation(["--version"]);
|
|
633
|
+
lines.push(`[\u2713] pi invocation: ${inv.command} ${inv.args.slice(0, 1).join(" ")}`);
|
|
634
|
+
|
|
635
|
+
// 2. pi-model-roles
|
|
636
|
+
try {
|
|
637
|
+
const api = getModelRolesAPI();
|
|
638
|
+
lines.push("[\u2713] pi-model-roles: loaded");
|
|
639
|
+
|
|
640
|
+
// 3. config
|
|
641
|
+
try {
|
|
642
|
+
const cfg = loadSubagentConfig(ctx.cwd);
|
|
643
|
+
lines.push(`[\u2713] config: timeout=${cfg.timeoutMs}ms summary=${cfg.summary.enabled ? cfg.summary.role : "off"}`);
|
|
644
|
+
} catch {
|
|
645
|
+
lines.push("[\u2717] config: failed to load");
|
|
646
|
+
allOk = false;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 4. roles
|
|
650
|
+
for (const [name, role] of Object.entries(availableRoles)) {
|
|
651
|
+
try {
|
|
652
|
+
const resolved = await api.resolveRoleAsync(role.role);
|
|
653
|
+
if (resolved.model) {
|
|
654
|
+
lines.push(`[\u2713] role ${name}: \u2192 ${resolved.model.provider}/${resolved.model.id}`);
|
|
655
|
+
} else {
|
|
656
|
+
lines.push(`[\u2717] role ${name}: model not resolved (role config: ${role.role})`);
|
|
657
|
+
allOk = false;
|
|
658
|
+
}
|
|
659
|
+
} catch {
|
|
660
|
+
lines.push(`[\u2717] role ${name}: resolution failed`);
|
|
661
|
+
allOk = false;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
} catch {
|
|
665
|
+
lines.push("[\u2717] pi-model-roles: not initialized");
|
|
666
|
+
allOk = false;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// 5. ALLOWLIST
|
|
670
|
+
const allowed = process.env.PI_SUBAGENT_ALLOWED;
|
|
671
|
+
if (allowed) {
|
|
672
|
+
lines.push(`[i] PI_SUBAGENT_ALLOWED: ${allowed}`);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const summary = allOk ? "All checks passed" : "Some checks failed";
|
|
676
|
+
ctx.ui.notify(`${summary}\n\n${lines.join("\n")}`, "info");
|
|
677
|
+
},
|
|
678
|
+
});
|
|
481
679
|
}
|
package/src/roles.ts
CHANGED
|
@@ -11,47 +11,99 @@ import type { SubagentRole } from "./types.ts";
|
|
|
11
11
|
export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
12
12
|
explorer: {
|
|
13
13
|
role: "fast",
|
|
14
|
-
|
|
14
|
+
fallbackRole: "default",
|
|
15
|
+
description: "READ-ONLY codebase exploration — locate files, grep symbols, trace imports, explain structures. Tools: read, find, grep, glob. NO bash, NO edits, NO web access.",
|
|
16
|
+
examples: [
|
|
17
|
+
"Find where auth middleware is implemented",
|
|
18
|
+
"Map the routing structure",
|
|
19
|
+
],
|
|
20
|
+
decisionTrigger: "Task finds or maps code without touch?",
|
|
21
|
+
tools: ["read", "find", "grep", "glob"],
|
|
15
22
|
systemPrompt: [
|
|
16
|
-
"
|
|
17
|
-
"
|
|
23
|
+
"Fast code explorer. You have READ-ONLY tools only — no commands, no edits.",
|
|
24
|
+
"Grep/find to locate → read key sections only → identify types, interfaces, functions.",
|
|
25
|
+
"Never read entire files. Target specific line ranges.",
|
|
18
26
|
"",
|
|
19
|
-
"Output
|
|
20
|
-
"
|
|
27
|
+
"Output format (keep each section brief):",
|
|
28
|
+
"## Files: file paths with line ranges and one-line descriptions",
|
|
29
|
+
"## Findings: key types/functions with minimal code snippets",
|
|
30
|
+
"## Summary: direct answer to the task question",
|
|
21
31
|
].join("\n"),
|
|
22
32
|
},
|
|
23
33
|
reviewer: {
|
|
24
34
|
role: "heavy",
|
|
35
|
+
fallbackRole: "default",
|
|
36
|
+
description: "READ-ONLY code review & analysis — audit code, assess architecture, review diffs. Tools: read, bash, grep, glob. Has bash (git diff/log, test runs). NO edits, NO web access.",
|
|
37
|
+
examples: [
|
|
38
|
+
"Review the error handling in src/api/ for security issues",
|
|
39
|
+
"Audit this PR diff for performance regressions",
|
|
40
|
+
],
|
|
41
|
+
decisionTrigger: "Task audits or reviews code quality?",
|
|
25
42
|
tools: ["read", "bash", "grep", "glob"],
|
|
26
43
|
systemPrompt: [
|
|
27
|
-
"
|
|
28
|
-
"
|
|
44
|
+
"Senior code reviewer. READ-ONLY — you must NOT modify any file.",
|
|
45
|
+
"bash is for read-only commands only (git diff/log/show, test runs). Never use sed, tee, echo >, or any write command.",
|
|
46
|
+
"Provide evidence-backed findings with file:line references.",
|
|
29
47
|
"",
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
"",
|
|
33
|
-
"
|
|
48
|
+
"Output format (prioritize critical issues first):",
|
|
49
|
+
"## Issues: severity + file:line + description + suggested fix",
|
|
50
|
+
"## Observations: notable patterns or design concerns",
|
|
51
|
+
"## Summary: overall assessment in 1-2 sentences",
|
|
34
52
|
].join("\n"),
|
|
35
53
|
},
|
|
36
54
|
worker: {
|
|
37
55
|
role: "default",
|
|
38
|
-
|
|
56
|
+
description: "the ONLY role that can MODIFY files — edit, write, refactor, fix, implement. Tools: read, bash, edit, write, grep, glob, delegate. Can delegate to explorer/researcher.",
|
|
57
|
+
examples: [
|
|
58
|
+
"Rename all snake_case fields to camelCase",
|
|
59
|
+
"Add input validation to POST /login",
|
|
60
|
+
],
|
|
61
|
+
decisionTrigger: "Task modifies files?",
|
|
62
|
+
tools: ["read", "bash", "edit", "write", "grep", "glob", "delegate"],
|
|
63
|
+
subagentRoles: ["explorer", "researcher"],
|
|
39
64
|
systemPrompt: [
|
|
40
|
-
"
|
|
41
|
-
"
|
|
65
|
+
"Implementation worker. Work autonomously — all context is in the task description.",
|
|
66
|
+
"Always read a file before editing it. Make minimal, focused changes.",
|
|
67
|
+
"After each change, validate: run tests, check syntax, verify behavior.",
|
|
68
|
+
"",
|
|
69
|
+
"## Protecting your context",
|
|
70
|
+
"You have a `delegate` tool. Use it to offload exploration and research:",
|
|
71
|
+
"- delegate(role=explorer) when you need to map unfamiliar code before editing",
|
|
72
|
+
"- delegate(role=researcher) when you need external docs or library references",
|
|
73
|
+
"Don't delegate tasks you can do with a single read or grep.",
|
|
42
74
|
"",
|
|
43
|
-
"
|
|
44
|
-
"
|
|
75
|
+
"Output format (be brief — summarize, don't paste full diffs):",
|
|
76
|
+
"## Changes: list each file touched and what changed",
|
|
77
|
+
"## Verification: what you ran to confirm correctness",
|
|
45
78
|
].join("\n"),
|
|
46
79
|
},
|
|
47
80
|
researcher: {
|
|
48
81
|
role: "fast",
|
|
49
|
-
|
|
82
|
+
fallbackRole: "default",
|
|
83
|
+
description: "the ONLY role with WEB ACCESS — search docs, fetch pages, analyze GitHub repos. Tools: web_search, fetch_content, read, bash, delegate. Can clone repos & delegate to explorer.",
|
|
84
|
+
examples: [
|
|
85
|
+
"Find the React 19 migration guide",
|
|
86
|
+
"Check GitHub issue #1234 for context",
|
|
87
|
+
],
|
|
88
|
+
decisionTrigger: "Task searches web or GitHub?",
|
|
89
|
+
tools: ["web_search", "fetch_content", "read", "bash", "delegate"],
|
|
90
|
+
subagentRoles: ["explorer"],
|
|
50
91
|
systemPrompt: [
|
|
51
|
-
"
|
|
92
|
+
"Web researcher. Search with varied angles, prefer official docs over blogs.",
|
|
93
|
+
"If first results are insufficient, refine queries and search again.",
|
|
94
|
+
"",
|
|
95
|
+
"## GitHub repo analysis",
|
|
96
|
+
"When the task requires analyzing a GitHub repo:",
|
|
97
|
+
"1. git clone the repo into PI_SUBAGENT_TMPDIR (must exist)",
|
|
98
|
+
"2. Use `delegate` with role=explorer to investigate the cloned codebase — pass the repo path and the research question",
|
|
99
|
+
"3. Combine explorer findings with any web search results",
|
|
100
|
+
"",
|
|
101
|
+
"bash is for git clone and read-only commands only. Never modify files.",
|
|
52
102
|
"",
|
|
53
|
-
"
|
|
54
|
-
"
|
|
103
|
+
"Output format:",
|
|
104
|
+
"## Answer: direct answer to the question (2-3 sentences)",
|
|
105
|
+
"## Sources: list of URLs used",
|
|
106
|
+
"## Gaps: what could not be answered",
|
|
55
107
|
].join("\n"),
|
|
56
108
|
},
|
|
57
109
|
};
|
package/src/spawn.ts
CHANGED
|
@@ -10,28 +10,106 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
13
14
|
import type { SubagentMessage, SubagentResult } from "./types.ts";
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
+
/** Maximum task length before writing to a temp file (avoids CLI arg limits). */
|
|
17
|
+
const TASK_CHAR_LIMIT = 8000;
|
|
18
|
+
|
|
19
|
+
/** Maximum output characters returned to the main model. Larger outputs are truncated. */
|
|
20
|
+
const MAX_OUTPUT_CHARS = 50_000;
|
|
21
|
+
|
|
22
|
+
const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
function isRunnableScript(filePath: string): boolean {
|
|
25
|
+
try {
|
|
26
|
+
if (!fs.existsSync(filePath)) return false;
|
|
27
|
+
return /\.(?:mjs|cjs|js)$/i.test(filePath);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function findPiPackageRootFromEntry(entryPoint: string): string | undefined {
|
|
34
|
+
let dir = path.dirname(entryPoint);
|
|
35
|
+
while (dir !== path.dirname(dir)) {
|
|
36
|
+
const pkgPath = path.join(dir, "package.json");
|
|
37
|
+
if (fs.existsSync(pkgPath)) {
|
|
38
|
+
try {
|
|
39
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: unknown };
|
|
40
|
+
if (pkg.name === PI_CODING_AGENT_PACKAGE) return dir;
|
|
41
|
+
} catch {
|
|
42
|
+
/* ignore */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
dir = path.dirname(dir);
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveWindowsPiCliScript(args: string[]): { command: string; args: string[] } | undefined {
|
|
51
|
+
// Strategy 1: Use process.argv[1] if it's a runnable script
|
|
52
|
+
// (works when pi is run via `bun pi` or `bunx pi` — argv[1] is the real CLI path)
|
|
53
|
+
const argv1 = process.argv[1];
|
|
54
|
+
if (argv1) {
|
|
55
|
+
const argvPath = path.isAbsolute(argv1) ? argv1 : path.resolve(argv1);
|
|
56
|
+
if (isRunnableScript(argvPath)) {
|
|
57
|
+
return { command: process.execPath, args: [argvPath, ...args] };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Strategy 2: Resolve pi-coding-agent package via import.meta.resolve,
|
|
62
|
+
// then read the bin field from its package.json
|
|
63
|
+
try {
|
|
64
|
+
const resolved = fileURLToPath(import.meta.resolve(PI_CODING_AGENT_PACKAGE));
|
|
65
|
+
const root = findPiPackageRootFromEntry(resolved);
|
|
66
|
+
if (root) {
|
|
67
|
+
const pkgPath = path.join(root, "package.json");
|
|
68
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
|
|
69
|
+
bin?: string | Record<string, string>;
|
|
70
|
+
};
|
|
71
|
+
const binField = pkg.bin;
|
|
72
|
+
const binPath =
|
|
73
|
+
typeof binField === "string"
|
|
74
|
+
? binField
|
|
75
|
+
: binField?.pi ?? Object.values(binField ?? {})[0];
|
|
76
|
+
if (binPath) {
|
|
77
|
+
const candidate = path.resolve(root, binPath);
|
|
78
|
+
if (isRunnableScript(candidate)) {
|
|
79
|
+
return { command: process.execPath, args: [candidate, ...args] };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
/* fall through */
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Determine how to invoke pi.
|
|
16
92
|
*
|
|
17
|
-
*
|
|
18
|
-
* process.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
93
|
+
* On Windows, attempts to find the pi CLI script via:
|
|
94
|
+
* 1. process.argv[1] (when run via `bun pi` or `bunx pi`)
|
|
95
|
+
* 2. import.meta.resolve of @earendil-works/pi-coding-agent → bin field
|
|
96
|
+
* If found, spawns process.execPath (bun) with the script path.
|
|
97
|
+
* Falls back to `pi` from PATH if neither works.
|
|
98
|
+
*
|
|
99
|
+
* On non-Windows, always uses the `pi` CLI command from PATH.
|
|
100
|
+
*
|
|
101
|
+
* This avoids the standalone compiled pi.exe's process.execPath
|
|
102
|
+
* (virtual Bun path like B:/~BUN/root/pi.exe) ever being passed
|
|
103
|
+
* to the child process, while still working when `pi` is not in PATH.
|
|
21
104
|
*/
|
|
22
|
-
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
105
|
+
export function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
106
|
+
if (process.platform === "win32") {
|
|
107
|
+
const winResult = resolveWindowsPiCliScript(args);
|
|
108
|
+
if (winResult) return winResult;
|
|
109
|
+
}
|
|
23
110
|
return { command: "pi", args };
|
|
24
111
|
}
|
|
25
112
|
|
|
26
|
-
/** Write a system prompt to a temp file for --append-system-prompt. */
|
|
27
|
-
async function writeTempPromptFile(prefix: string, content: string): Promise<{ dir: string; filePath: string }> {
|
|
28
|
-
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
|
|
29
|
-
const safeName = prefix.replace(/[^\w.-]+/g, "_");
|
|
30
|
-
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
|
|
31
|
-
await fs.promises.writeFile(filePath, content, { encoding: "utf-8", mode: 0o600 });
|
|
32
|
-
return { dir: tmpDir, filePath };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
113
|
/**
|
|
36
114
|
* Spawn a pi child process with the given model and configuration.
|
|
37
115
|
* Fires onProgress on each JSON event for streaming TUI updates.
|
|
@@ -48,6 +126,7 @@ export async function spawnSubagent(
|
|
|
48
126
|
cwd?: string;
|
|
49
127
|
tools?: string[];
|
|
50
128
|
systemPrompt?: string;
|
|
129
|
+
subagentRoles?: string[];
|
|
51
130
|
timeoutMs?: number;
|
|
52
131
|
signal?: AbortSignal;
|
|
53
132
|
onProgress?: (update: Partial<SubagentResult>) => void;
|
|
@@ -61,6 +140,7 @@ export async function spawnSubagent(
|
|
|
61
140
|
output: "",
|
|
62
141
|
stderr: "",
|
|
63
142
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
143
|
+
activityLog: [],
|
|
64
144
|
};
|
|
65
145
|
|
|
66
146
|
let tmpDir: string | null = null;
|
|
@@ -74,14 +154,23 @@ export async function spawnSubagent(
|
|
|
74
154
|
args.push("--tools", options.tools.join(","));
|
|
75
155
|
}
|
|
76
156
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
tmpDir = tmp.dir;
|
|
80
|
-
tmpFile = tmp.filePath;
|
|
81
|
-
args.push("--append-system-prompt", tmpFile);
|
|
82
|
-
}
|
|
157
|
+
// Always create temp dir — used for prompt file, long task file, and as PI_SUBAGENT_TMPDIR for subagent work (e.g. git clone)
|
|
158
|
+
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
|
|
83
159
|
|
|
84
|
-
|
|
160
|
+
const promptContent = options.systemPrompt?.trim()
|
|
161
|
+
? options.systemPrompt + `\n\nPI_SUBAGENT_TMPDIR=${tmpDir}`
|
|
162
|
+
: `PI_SUBAGENT_TMPDIR=${tmpDir}`;
|
|
163
|
+
tmpFile = path.join(tmpDir, "prompt.md");
|
|
164
|
+
await fs.promises.writeFile(tmpFile, promptContent, { encoding: "utf-8", mode: 0o600 });
|
|
165
|
+
args.push("--append-system-prompt", tmpFile);
|
|
166
|
+
|
|
167
|
+
if (task.length > TASK_CHAR_LIMIT) {
|
|
168
|
+
const taskPath = path.join(tmpDir, "task.md");
|
|
169
|
+
await fs.promises.writeFile(taskPath, task, { encoding: "utf-8", mode: 0o600 });
|
|
170
|
+
args.push(`@${taskPath}`);
|
|
171
|
+
} else {
|
|
172
|
+
args.push(`Task: ${task}`);
|
|
173
|
+
}
|
|
85
174
|
|
|
86
175
|
// Spawn process
|
|
87
176
|
const invocation = getPiInvocation(args);
|
|
@@ -95,9 +184,12 @@ export async function spawnSubagent(
|
|
|
95
184
|
usage: { ...result.usage },
|
|
96
185
|
model: result.model,
|
|
97
186
|
stopReason: result.stopReason,
|
|
187
|
+
activityLog: result.activityLog.map((a) => ({ ...a })),
|
|
98
188
|
});
|
|
99
189
|
};
|
|
100
190
|
|
|
191
|
+
let thinkingCounter = 0;
|
|
192
|
+
|
|
101
193
|
const processLine = (line: string) => {
|
|
102
194
|
if (!line.trim()) return;
|
|
103
195
|
let event: any;
|
|
@@ -137,15 +229,63 @@ export async function spawnSubagent(
|
|
|
137
229
|
emitProgress();
|
|
138
230
|
}
|
|
139
231
|
|
|
140
|
-
|
|
141
|
-
|
|
232
|
+
// Activity log: track thinking blocks and tool calls in arrival order.
|
|
233
|
+
// Both update in place so the TUI reflects real-time state.
|
|
234
|
+
if (event.type === "tool_execution_start" && event.toolCallId) {
|
|
235
|
+
result.activityLog.push({
|
|
236
|
+
kind: "toolCall",
|
|
237
|
+
id: event.toolCallId,
|
|
238
|
+
status: "running",
|
|
239
|
+
toolName: event.toolName,
|
|
240
|
+
args: event.args ?? {},
|
|
241
|
+
});
|
|
142
242
|
emitProgress();
|
|
243
|
+
} else if (event.type === "tool_execution_end" && event.toolCallId) {
|
|
244
|
+
const entry = result.activityLog.find((a) => a.id === event.toolCallId);
|
|
245
|
+
if (entry) entry.status = event.isError ? "failed" : "done";
|
|
246
|
+
emitProgress();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Thinking-block lifecycle: pi wraps thinking_start/end inside
|
|
250
|
+
// message_update.assistantMessageEvent. These arrive BEFORE message_end,
|
|
251
|
+
// so we can't rely on messages[] to show real-time thinking state —
|
|
252
|
+
// register them in the activity log directly.
|
|
253
|
+
const aev = event.assistantMessageEvent;
|
|
254
|
+
if (event.type === "message_update" && aev) {
|
|
255
|
+
if (aev.type === "thinking_start") {
|
|
256
|
+
result.activityLog.push({
|
|
257
|
+
kind: "thinking",
|
|
258
|
+
id: `thinking-${thinkingCounter++}`,
|
|
259
|
+
status: "running",
|
|
260
|
+
});
|
|
261
|
+
emitProgress();
|
|
262
|
+
} else if (aev.type === "thinking_end") {
|
|
263
|
+
// Mark the most recent still-running thinking block as done.
|
|
264
|
+
for (let i = result.activityLog.length - 1; i >= 0; i--) {
|
|
265
|
+
if (result.activityLog[i].kind === "thinking" && result.activityLog[i].status === "running") {
|
|
266
|
+
result.activityLog[i].status = "done";
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
emitProgress();
|
|
271
|
+
}
|
|
143
272
|
}
|
|
144
273
|
};
|
|
145
274
|
|
|
275
|
+
// Build env with optional subagent allowlist and tmpdir for researcher role
|
|
276
|
+
const childEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
277
|
+
if (options.subagentRoles && options.subagentRoles.length > 0) {
|
|
278
|
+
childEnv.PI_SUBAGENT_ALLOWED = options.subagentRoles.join(",");
|
|
279
|
+
}
|
|
280
|
+
// Expose tmpdir as env var so subagent bash commands (e.g. git clone) can use it
|
|
281
|
+
childEnv.PI_SUBAGENT_TMPDIR = tmpDir;
|
|
282
|
+
|
|
283
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
284
|
+
|
|
146
285
|
const exitCode = await new Promise<number>((resolve) => {
|
|
147
286
|
const proc = spawn(invocation.command, invocation.args, {
|
|
148
287
|
cwd: options.cwd,
|
|
288
|
+
env: childEnv,
|
|
149
289
|
shell: false,
|
|
150
290
|
stdio: ["ignore", "pipe", "pipe"],
|
|
151
291
|
});
|
|
@@ -162,6 +302,7 @@ export async function spawnSubagent(
|
|
|
162
302
|
});
|
|
163
303
|
|
|
164
304
|
proc.on("close", (code) => {
|
|
305
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
165
306
|
if (buffer.trim()) processLine(buffer);
|
|
166
307
|
resolve(code ?? 0);
|
|
167
308
|
});
|
|
@@ -185,7 +326,7 @@ export async function spawnSubagent(
|
|
|
185
326
|
|
|
186
327
|
// Handle timeout
|
|
187
328
|
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
188
|
-
setTimeout(() => {
|
|
329
|
+
timeoutHandle = setTimeout(() => {
|
|
189
330
|
if (!proc.killed) {
|
|
190
331
|
proc.kill("SIGTERM");
|
|
191
332
|
setTimeout(() => {
|
|
@@ -198,10 +339,16 @@ export async function spawnSubagent(
|
|
|
198
339
|
|
|
199
340
|
result.exitCode = exitCode;
|
|
200
341
|
if (wasAborted) throw new Error("Subagent was aborted");
|
|
342
|
+
|
|
343
|
+
// Truncate large outputs: keep head (findings) + tail (summary), drop middle
|
|
344
|
+
if (result.output.length > MAX_OUTPUT_CHARS) {
|
|
345
|
+
const head = result.output.slice(0, 30_000);
|
|
346
|
+
const tail = result.output.slice(-(MAX_OUTPUT_CHARS - 30_050));
|
|
347
|
+
result.output = `[Output truncated — ${result.output.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
|
|
348
|
+
}
|
|
201
349
|
} finally {
|
|
202
|
-
// Cleanup temp
|
|
203
|
-
if (
|
|
204
|
-
if (tmpDir) try { fs.rmdirSync(tmpDir); } catch { /* ignore */ }
|
|
350
|
+
// Cleanup temp directory and all contents
|
|
351
|
+
if (tmpDir) try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
205
352
|
}
|
|
206
353
|
|
|
207
354
|
return result;
|
package/src/types.ts
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
export interface SubagentConfig {
|
|
7
7
|
timeoutMs: number;
|
|
8
8
|
summary: SubagentSummaryConfig;
|
|
9
|
+
/**
|
|
10
|
+
* Per-role overrides from settings.json. Keyed by role name.
|
|
11
|
+
* - Override built-in roles: provide fields to merge.
|
|
12
|
+
* - Disable built-in roles: set `disabled: true`.
|
|
13
|
+
*/
|
|
14
|
+
agentOverrides: Record<string, Partial<SubagentRole> & { disabled?: boolean }>;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
export interface SubagentSummaryConfig {
|
|
@@ -16,16 +22,41 @@ export interface SubagentSummaryConfig {
|
|
|
16
22
|
export const DEFAULT_CONFIG: SubagentConfig = {
|
|
17
23
|
timeoutMs: 300_000,
|
|
18
24
|
summary: { role: "utility", enabled: true },
|
|
25
|
+
agentOverrides: {},
|
|
19
26
|
};
|
|
20
27
|
|
|
21
28
|
/** A built-in subagent role definition. */
|
|
22
29
|
export interface SubagentRole {
|
|
23
30
|
/** pi-model-roles role name to use for this subagent */
|
|
24
31
|
role: string;
|
|
32
|
+
/** One-line description for the LLM prompt — what this role does and what tools it has */
|
|
33
|
+
description: string;
|
|
34
|
+
/** Example tasks to show in CONCRETE EXAMPLES section */
|
|
35
|
+
examples: string[];
|
|
36
|
+
/** Decision flow trigger phrase, e.g. "Task modifies files?" */
|
|
37
|
+
decisionTrigger: string;
|
|
25
38
|
/** System prompt for the subagent */
|
|
26
39
|
systemPrompt: string;
|
|
27
40
|
/** Tools available to this subagent */
|
|
28
41
|
tools: string[];
|
|
42
|
+
/** If this role has `delegate`, restrict which roles it may spawn. undefined = no restriction. */
|
|
43
|
+
subagentRoles?: string[];
|
|
44
|
+
/** Fallback pi-model-roles role name when this role's model is unavailable (provider error). Defaults to "default". */
|
|
45
|
+
fallbackRole?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Status of an individual tool call within a subagent run. */
|
|
49
|
+
export type ToolStatus = "running" | "done" | "failed";
|
|
50
|
+
|
|
51
|
+
/** A single entry in the real-time activity log (thinking block or tool call). */
|
|
52
|
+
export interface ActivityEntry {
|
|
53
|
+
kind: "thinking" | "toolCall";
|
|
54
|
+
/** Synthetic id (thinking-N) or the toolCallId from the event stream. */
|
|
55
|
+
id: string;
|
|
56
|
+
status: ToolStatus;
|
|
57
|
+
/** Tool name + args (toolCall only). */
|
|
58
|
+
toolName?: string;
|
|
59
|
+
args?: Record<string, any>;
|
|
29
60
|
}
|
|
30
61
|
|
|
31
62
|
/** Usage statistics from a subagent execution. */
|
|
@@ -87,6 +118,8 @@ export interface SubagentResult {
|
|
|
87
118
|
stopReason?: string;
|
|
88
119
|
/** Error message if failed */
|
|
89
120
|
errorMessage?: string;
|
|
121
|
+
/** Real-time activity log: thinking blocks and tool calls in arrival order. */
|
|
122
|
+
activityLog: ActivityEntry[];
|
|
90
123
|
}
|
|
91
124
|
|
|
92
125
|
/** TUI details structure passed via tool result details. */
|