@henryqw/pi-subagent 0.1.1
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/LICENSE +21 -0
- package/README.md +90 -0
- package/extensions/subagent.ts +599 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henry Wang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @henryqw/pi-subagent
|
|
2
|
+
|
|
3
|
+
Delegate one bounded task to one isolated Pi process. Main chooses role and may override model and thinking level per task.
|
|
4
|
+
|
|
5
|
+
Based on Pi's authoritative [`examples/extensions/subagent`](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions/subagent): child processes use `pi --mode json -p --no-session`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@henryqw/pi-subagent
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configure roles
|
|
14
|
+
|
|
15
|
+
Use Pi's existing role Markdown format in package-owned `~/.pi/agent/config/pi-subagent/*.md`. No JSON config or profile layer.
|
|
16
|
+
|
|
17
|
+
```markdown
|
|
18
|
+
---
|
|
19
|
+
name: reviewer
|
|
20
|
+
description: Reviews changes for correctness and security
|
|
21
|
+
tools: [read, grep, find, ls, bash]
|
|
22
|
+
extensions:
|
|
23
|
+
- ~/.pi/agent/extensions/review-tools.ts
|
|
24
|
+
skills:
|
|
25
|
+
- code-review
|
|
26
|
+
- security
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
Review only requested change. Return ranked findings with file and line evidence.
|
|
30
|
+
Do not edit files.
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Fields:
|
|
34
|
+
|
|
35
|
+
| Field | Required | Meaning |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| `name` | yes | Role selected by Main |
|
|
38
|
+
| `description` | yes | Tells Main when to use role |
|
|
39
|
+
| `tools` | yes | Exact built-in and extension tool allowlist; use `[]` for none |
|
|
40
|
+
| `extensions` | no | Absolute/user-home paths or package sources passed to Pi `--extension` |
|
|
41
|
+
| `skills` | no | Effective Pi Skill names loaded for role |
|
|
42
|
+
| Markdown body | yes | Role system instructions |
|
|
43
|
+
|
|
44
|
+
String lists may also use comma-separated text, matching Pi's example role files. Repository-relative extension paths are rejected: child working directory is delegated project, so relative paths could load untrusted project code. Use absolute paths, `~/...`, or explicit package sources such as `npm:...`.
|
|
45
|
+
|
|
46
|
+
Skill entries use Pi Skill names, normally Skill directory names, not filesystem paths. At delegation time, package resolves names from Main's effective Pi Skill registry and passes matching files to child. Missing or unavailable Skills produce warning and are skipped; they do not block delegation. This preserves Main's trust and Skill collision decisions.
|
|
47
|
+
|
|
48
|
+
Pi's example `agents/` directory contains sample Role files, not another runtime mechanism. This package reuses that Markdown format but ships no presets: model choice and capabilities stay explicit in user config. No nested `agents/` directory is needed because `pi-subagent` config contains only Roles.
|
|
49
|
+
|
|
50
|
+
Reload Pi after adding or changing role files so tool description exposes current roles.
|
|
51
|
+
|
|
52
|
+
## Execution
|
|
53
|
+
|
|
54
|
+
Main calls `delegate_task` with:
|
|
55
|
+
|
|
56
|
+
- `role`: configured role name
|
|
57
|
+
- `task`: one bounded task
|
|
58
|
+
- `model`: optional exact `provider/model`; defaults to Main model
|
|
59
|
+
- `thinkingLevel`: optional; defaults to Main thinking level
|
|
60
|
+
|
|
61
|
+
Each call starts isolated child process. Ambient extensions and skills are disabled. Only role resources load. Child uses delegated working directory and normal Pi project context files, inheriting Main's project approval decision. Abort terminates child process group.
|
|
62
|
+
|
|
63
|
+
Model and thinking overrides must exist in Main model registry. Invalid role config, model, or thinking level fails before child starts.
|
|
64
|
+
|
|
65
|
+
Main-visible streaming updates, final output, and errors are capped at 50 KiB of UTF-8 text. Error collection stays bounded while child runs; malformed JSON events above 1 MiB fail delegation. Truncated output ends with exact omitted-byte count.
|
|
66
|
+
|
|
67
|
+
## Widget
|
|
68
|
+
|
|
69
|
+
TUI shows one aligned row per Subagent:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
⠼ scout[haiku:low] find auth flow 18.4k 8s
|
|
73
|
+
✓ reviewer[sonnet:high] inspect auth diff 22.1k 14s
|
|
74
|
+
✗ worker[sonnet:high] fix token expiry 31.8k 27s
|
|
75
|
+
■ scout[haiku:low] map routes 6.2k 5s
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Spinner means working; terminal icons mean success, failure, or abort. Token column sums `usage.totalTokens` across Subagent turns. Terminal rows auto-remove after one second; empty widget renders nothing. Role/route and task columns truncate to preserve right-aligned token and elapsed columns on narrow terminals.
|
|
79
|
+
|
|
80
|
+
## Scouting roles
|
|
81
|
+
|
|
82
|
+
Treat scouting result as index, not file dump. Put conclusions first, followed by exact file/line references, short snippets only, risks, and unexamined scope. Main can read cited files when more detail is needed. Split work when useful index cannot fit within output cap.
|
|
83
|
+
|
|
84
|
+
## Deliberate limits
|
|
85
|
+
|
|
86
|
+
- User roles only; no repo-controlled `.pi/agents` trust flow.
|
|
87
|
+
- One task per call; Main handles orchestration.
|
|
88
|
+
- No custom profile schema; role Markdown already groups instructions, tools, extensions, and skills.
|
|
89
|
+
- No persistent child sessions or interactive panes.
|
|
90
|
+
- No full-result artifact or retrieval protocol; split oversized work instead.
|
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
6
|
+
import { getSupportedThinkingLevels, StringEnum } from "@earendil-works/pi-ai";
|
|
7
|
+
import { type ExtensionAPI, type ExtensionContext, getAgentDir, parseFrontmatter, type Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { type Component, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
|
|
11
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
12
|
+
const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
13
|
+
const MAX_JSON_EVENT_BYTES = 1024 * 1024;
|
|
14
|
+
const WIDGET_KEY = "subagent-status";
|
|
15
|
+
const WIDGET_INTERVAL_MS = 80;
|
|
16
|
+
const TERMINAL_DISPLAY_MS = 1_000;
|
|
17
|
+
const MAX_WIDGET_ROWS = 8;
|
|
18
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
19
|
+
|
|
20
|
+
type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
21
|
+
type Role = {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
tools: string[];
|
|
25
|
+
extensions: string[];
|
|
26
|
+
skills: string[];
|
|
27
|
+
systemPrompt: string;
|
|
28
|
+
};
|
|
29
|
+
type ChildResult = {
|
|
30
|
+
exitCode: number;
|
|
31
|
+
output: string;
|
|
32
|
+
stderr: string;
|
|
33
|
+
stopReason?: string;
|
|
34
|
+
errorMessage?: string;
|
|
35
|
+
};
|
|
36
|
+
type WidgetStatus = "working" | "success" | "failure" | "aborted";
|
|
37
|
+
type WidgetItem = {
|
|
38
|
+
roleRoute: string;
|
|
39
|
+
task: string;
|
|
40
|
+
tokens: number;
|
|
41
|
+
startedAt: number;
|
|
42
|
+
status: WidgetStatus;
|
|
43
|
+
finishedAt?: number;
|
|
44
|
+
removeAt?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const cleanText = (value: unknown, field: string, file: string): string => {
|
|
48
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
|
|
49
|
+
throw new Error(`${file}: ${field} must be non-empty text.`);
|
|
50
|
+
}
|
|
51
|
+
return value.trim();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const stringList = (value: unknown, field: string, file: string, required = false): string[] => {
|
|
55
|
+
if (value === undefined) {
|
|
56
|
+
if (required) throw new Error(`${file}: ${field} is required.`);
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const values = typeof value === "string" ? value.split(",") : value;
|
|
60
|
+
if (!Array.isArray(values) || values.some((item) => typeof item !== "string" || !item.trim() || item.includes("\0"))) {
|
|
61
|
+
throw new Error(`${file}: ${field} must be an array of strings.`);
|
|
62
|
+
}
|
|
63
|
+
return values.map((item) => item.trim());
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const extensionList = (value: unknown, file: string): string[] => {
|
|
67
|
+
const extensions = stringList(value, "extensions", file);
|
|
68
|
+
for (const extension of extensions) {
|
|
69
|
+
const packageSource = /^(?:npm|git|github|https?|ssh):/.test(extension);
|
|
70
|
+
const userPath = isAbsolute(extension) || extension.startsWith("~/") || extension.startsWith("~\\") || extension.startsWith("file://");
|
|
71
|
+
if (!packageSource && !userPath) {
|
|
72
|
+
throw new Error(`${file}: extensions entries must be absolute paths or package sources.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return extensions;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export function loadRoles(agentDir = getAgentDir()): Role[] {
|
|
79
|
+
const dir = join(agentDir, "config", "pi-subagent");
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
83
|
+
} catch (error: unknown) {
|
|
84
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return [];
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const roles = entries
|
|
89
|
+
.filter((entry) => entry.name.endsWith(".md") && (entry.isFile() || entry.isSymbolicLink()))
|
|
90
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
91
|
+
.map((entry): Role => {
|
|
92
|
+
const file = join(dir, entry.name);
|
|
93
|
+
let parsed: ReturnType<typeof parseFrontmatter>;
|
|
94
|
+
try {
|
|
95
|
+
parsed = parseFrontmatter(readFileSync(file, "utf8"));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
98
|
+
}
|
|
99
|
+
const frontmatter = parsed.frontmatter;
|
|
100
|
+
return {
|
|
101
|
+
name: cleanText(frontmatter.name, "name", file),
|
|
102
|
+
description: cleanText(frontmatter.description, "description", file),
|
|
103
|
+
tools: stringList(frontmatter.tools, "tools", file, true),
|
|
104
|
+
extensions: extensionList(frontmatter.extensions, file),
|
|
105
|
+
skills: stringList(frontmatter.skills, "skills", file),
|
|
106
|
+
systemPrompt: cleanText(parsed.body, "system prompt", file),
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const names = new Set<string>();
|
|
111
|
+
for (const role of roles) {
|
|
112
|
+
if (names.has(role.name)) throw new Error(`Duplicate Subagent role: ${role.name}.`);
|
|
113
|
+
names.add(role.name);
|
|
114
|
+
}
|
|
115
|
+
return roles;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function piInvocation(args: string[]): { command: string; args: string[] } {
|
|
119
|
+
const currentScript = process.argv[1];
|
|
120
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
121
|
+
if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
|
|
122
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
123
|
+
}
|
|
124
|
+
const executable = basename(process.execPath).toLowerCase();
|
|
125
|
+
if (!/^(node|bun)(\.exe)?$/.test(executable)) return { command: process.execPath, args };
|
|
126
|
+
return { command: "pi", args };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function assistantText(message: unknown): string | undefined {
|
|
130
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) return;
|
|
131
|
+
const record = message as Record<string, unknown>;
|
|
132
|
+
if (record.role !== "assistant" || !Array.isArray(record.content)) return;
|
|
133
|
+
const text = record.content
|
|
134
|
+
.filter((part): part is { type: "text"; text: string } =>
|
|
135
|
+
Boolean(part && typeof part === "object" && !Array.isArray(part)
|
|
136
|
+
&& (part as Record<string, unknown>).type === "text"
|
|
137
|
+
&& typeof (part as Record<string, unknown>).text === "string"))
|
|
138
|
+
.map((part) => part.text)
|
|
139
|
+
.join("\n");
|
|
140
|
+
return text || undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function utf8Prefix(text: string, maxBytes: number): string {
|
|
144
|
+
let low = 0;
|
|
145
|
+
let high = Math.min(text.length, maxBytes);
|
|
146
|
+
while (low < high) {
|
|
147
|
+
const middle = Math.ceil((low + high) / 2);
|
|
148
|
+
if (Buffer.byteLength(text.slice(0, middle), "utf8") <= maxBytes) low = middle;
|
|
149
|
+
else high = middle - 1;
|
|
150
|
+
}
|
|
151
|
+
if (low > 0 && low < text.length && /[\uD800-\uDBFF]/.test(text[low - 1]) && /[\uDC00-\uDFFF]/.test(text[low])) low--;
|
|
152
|
+
return text.slice(0, low);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function cappedPrefix(text: string, totalBytes: number): string {
|
|
156
|
+
if (totalBytes <= MAX_OUTPUT_BYTES) return text;
|
|
157
|
+
const worstCaseMarker = `\n\n[Output truncated: ${totalBytes} bytes omitted]`;
|
|
158
|
+
const prefix = utf8Prefix(text, MAX_OUTPUT_BYTES - Buffer.byteLength(worstCaseMarker, "utf8"));
|
|
159
|
+
const omittedBytes = totalBytes - Buffer.byteLength(prefix, "utf8");
|
|
160
|
+
return `${prefix}\n\n[Output truncated: ${omittedBytes} bytes omitted]`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function capOutput(text: string): string {
|
|
164
|
+
return cappedPrefix(text, Buffer.byteLength(text, "utf8"));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
type BoundedText = { prefix: string; totalBytes: number };
|
|
168
|
+
|
|
169
|
+
function appendBounded(target: BoundedText, text: string): void {
|
|
170
|
+
target.totalBytes += Buffer.byteLength(text, "utf8");
|
|
171
|
+
const remaining = MAX_OUTPUT_BYTES - Buffer.byteLength(target.prefix, "utf8");
|
|
172
|
+
if (remaining > 0) target.prefix += utf8Prefix(text, remaining);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function boundedText(target: BoundedText): string {
|
|
176
|
+
return cappedPrefix(target.prefix, target.totalBytes);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function taskSummary(task: string): string {
|
|
180
|
+
return task.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().split(/\s+/).slice(0, 4).join(" ");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function formatTokens(tokens: number): string {
|
|
184
|
+
if (tokens < 1_000) return String(tokens);
|
|
185
|
+
if (tokens < 100_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
|
186
|
+
if (tokens < 1_000_000) return `${Math.round(tokens / 1_000)}k`;
|
|
187
|
+
return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function formatElapsed(startedAt: number, finishedAt = Date.now()): string {
|
|
191
|
+
const seconds = Math.max(0, Math.floor((finishedAt - startedAt) / 1_000));
|
|
192
|
+
const hours = Math.floor(seconds / 3_600);
|
|
193
|
+
const minutes = Math.floor(seconds % 3_600 / 60);
|
|
194
|
+
return hours ? `${hours}h ${minutes}m` : minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function usageTokens(value: unknown): number | undefined {
|
|
198
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
199
|
+
const total = (value as Record<string, unknown>).totalTokens;
|
|
200
|
+
return typeof total === "number" && Number.isFinite(total) && total >= 0 ? Math.round(total) : undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function statusGlyph(status: WidgetStatus, spinnerIndex: number, theme: Theme): string {
|
|
204
|
+
switch (status) {
|
|
205
|
+
case "working": return theme.fg("accent", SPINNER_FRAMES[spinnerIndex % SPINNER_FRAMES.length]!);
|
|
206
|
+
case "success": return theme.fg("success", "✓");
|
|
207
|
+
case "failure": return theme.fg("error", "✗");
|
|
208
|
+
case "aborted": return theme.fg("warning", "■");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function leftColumn(value: string, width: number): string {
|
|
213
|
+
return truncateToWidth(value, width, "…", true);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function rightColumn(value: string, width: number): string {
|
|
217
|
+
return " ".repeat(Math.max(0, width - visibleWidth(value))) + value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function renderWidgetRows(
|
|
221
|
+
items: WidgetItem[],
|
|
222
|
+
width: number,
|
|
223
|
+
now: number,
|
|
224
|
+
spinnerIndex: number,
|
|
225
|
+
theme: Theme,
|
|
226
|
+
): string[] {
|
|
227
|
+
const visible = items.slice(0, MAX_WIDGET_ROWS);
|
|
228
|
+
if (!visible.length) return [];
|
|
229
|
+
const tokens = visible.map((item) => formatTokens(item.tokens));
|
|
230
|
+
const elapsed = visible.map((item) => formatElapsed(item.startedAt, item.finishedAt ?? now));
|
|
231
|
+
const tokenWidth = Math.max(...tokens.map(visibleWidth));
|
|
232
|
+
const elapsedWidth = Math.max(...elapsed.map(visibleWidth));
|
|
233
|
+
const fixedWidth = 1 + 8 + tokenWidth + elapsedWidth;
|
|
234
|
+
if (width < fixedWidth) {
|
|
235
|
+
return visible.map((item, index) => truncateToWidth(
|
|
236
|
+
`${statusGlyph(item.status, spinnerIndex, theme)} ${tokens[index]} ${elapsed[index]}`,
|
|
237
|
+
width,
|
|
238
|
+
"",
|
|
239
|
+
));
|
|
240
|
+
}
|
|
241
|
+
const contentWidth = width - fixedWidth;
|
|
242
|
+
const naturalRoleWidth = Math.min(32, Math.max(...visible.map((item) => visibleWidth(item.roleRoute))));
|
|
243
|
+
const roleWidth = Math.min(naturalRoleWidth, contentWidth);
|
|
244
|
+
const taskWidth = contentWidth - roleWidth;
|
|
245
|
+
const lines = visible.map((item, index) => [
|
|
246
|
+
statusGlyph(item.status, spinnerIndex, theme),
|
|
247
|
+
theme.fg("accent", leftColumn(item.roleRoute, roleWidth)),
|
|
248
|
+
theme.fg("text", leftColumn(item.task, taskWidth)),
|
|
249
|
+
theme.fg("muted", rightColumn(tokens[index]!, tokenWidth)),
|
|
250
|
+
theme.fg("dim", rightColumn(elapsed[index]!, elapsedWidth)),
|
|
251
|
+
].join(" "));
|
|
252
|
+
if (items.length > visible.length) lines.push(theme.fg("muted", `… ${items.length - visible.length} more`));
|
|
253
|
+
return lines;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function runPi(
|
|
257
|
+
args: string[],
|
|
258
|
+
cwd: string,
|
|
259
|
+
signal: AbortSignal | undefined,
|
|
260
|
+
onUpdate: ((text: string) => void) | undefined,
|
|
261
|
+
onTokens: ((tokens: number) => void) | undefined,
|
|
262
|
+
): Promise<ChildResult> {
|
|
263
|
+
if (signal?.aborted) throw new Error("Subagent was aborted.");
|
|
264
|
+
return await new Promise<ChildResult>((resolve, reject) => {
|
|
265
|
+
const invocation = piInvocation(args);
|
|
266
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
267
|
+
cwd,
|
|
268
|
+
shell: false,
|
|
269
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
270
|
+
detached: process.platform !== "win32",
|
|
271
|
+
});
|
|
272
|
+
child.stdout.setEncoding("utf8");
|
|
273
|
+
child.stderr.setEncoding("utf8");
|
|
274
|
+
let lineParts: string[] = [];
|
|
275
|
+
let lineBytes = 0;
|
|
276
|
+
let output = "";
|
|
277
|
+
const stderr = { prefix: "", totalBytes: 0 };
|
|
278
|
+
const partial = { prefix: "", totalBytes: 0 };
|
|
279
|
+
let hasPartialText = false;
|
|
280
|
+
let stopReason: string | undefined;
|
|
281
|
+
let errorMessage: string | undefined;
|
|
282
|
+
let spawnError: Error | undefined;
|
|
283
|
+
let protocolError: Error | undefined;
|
|
284
|
+
let aborted = false;
|
|
285
|
+
let completedTokens = 0;
|
|
286
|
+
let currentTokens = 0;
|
|
287
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
288
|
+
|
|
289
|
+
const processLine = (line: string) => {
|
|
290
|
+
if (!line.trim()) return;
|
|
291
|
+
let event: unknown;
|
|
292
|
+
try {
|
|
293
|
+
event = JSON.parse(line);
|
|
294
|
+
} catch {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) return;
|
|
298
|
+
const record = event as Record<string, unknown>;
|
|
299
|
+
if (record.type === "message_start") {
|
|
300
|
+
partial.prefix = "";
|
|
301
|
+
partial.totalBytes = 0;
|
|
302
|
+
hasPartialText = false;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (record.type === "message_update") {
|
|
306
|
+
const tokens = usageTokens(record.usage);
|
|
307
|
+
if (tokens !== undefined) {
|
|
308
|
+
currentTokens = tokens;
|
|
309
|
+
onTokens?.(completedTokens + currentTokens);
|
|
310
|
+
}
|
|
311
|
+
const update = record.assistantMessageEvent;
|
|
312
|
+
if (update && typeof update === "object" && !Array.isArray(update)) {
|
|
313
|
+
const assistantEvent = update as Record<string, unknown>;
|
|
314
|
+
if (assistantEvent.type === "text_start" && hasPartialText) appendBounded(partial, "\n");
|
|
315
|
+
if (assistantEvent.type === "text_start") hasPartialText = true;
|
|
316
|
+
if (assistantEvent.type === "text_delta" && typeof assistantEvent.delta === "string") {
|
|
317
|
+
hasPartialText = true;
|
|
318
|
+
appendBounded(partial, assistantEvent.delta);
|
|
319
|
+
output = boundedText(partial);
|
|
320
|
+
onUpdate?.(output);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (record.type !== "message_end") return;
|
|
326
|
+
const text = assistantText(record.message);
|
|
327
|
+
if (text !== undefined) {
|
|
328
|
+
output = capOutput(text);
|
|
329
|
+
onUpdate?.(output);
|
|
330
|
+
}
|
|
331
|
+
if (record.message && typeof record.message === "object" && !Array.isArray(record.message)) {
|
|
332
|
+
const message = record.message as Record<string, unknown>;
|
|
333
|
+
if (message.role === "assistant") {
|
|
334
|
+
completedTokens += usageTokens(message.usage) ?? currentTokens;
|
|
335
|
+
currentTokens = 0;
|
|
336
|
+
onTokens?.(completedTokens);
|
|
337
|
+
}
|
|
338
|
+
if (typeof message.stopReason === "string") stopReason = message.stopReason;
|
|
339
|
+
if (typeof message.errorMessage === "string") errorMessage = message.errorMessage;
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const killTree = (force: boolean) => {
|
|
344
|
+
if (!child.pid) return;
|
|
345
|
+
if (process.platform === "win32") {
|
|
346
|
+
spawn("taskkill", [...(force ? ["/F"] : []), "/T", "/PID", String(child.pid)], {
|
|
347
|
+
stdio: "ignore",
|
|
348
|
+
windowsHide: true,
|
|
349
|
+
});
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
354
|
+
} catch {
|
|
355
|
+
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
child.stdout.on("data", (data: string) => {
|
|
360
|
+
if (protocolError) return;
|
|
361
|
+
let offset = 0;
|
|
362
|
+
while (offset < data.length) {
|
|
363
|
+
const newline = data.indexOf("\n", offset);
|
|
364
|
+
const end = newline === -1 ? data.length : newline;
|
|
365
|
+
const part = data.slice(offset, end);
|
|
366
|
+
lineBytes += Buffer.byteLength(part, "utf8");
|
|
367
|
+
if (lineBytes > MAX_JSON_EVENT_BYTES) {
|
|
368
|
+
protocolError = new Error(`Subagent JSON event exceeds ${MAX_JSON_EVENT_BYTES} bytes.`);
|
|
369
|
+
killTree(true);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (part) lineParts.push(part);
|
|
373
|
+
if (newline === -1) return;
|
|
374
|
+
processLine(lineParts.join(""));
|
|
375
|
+
lineParts = [];
|
|
376
|
+
lineBytes = 0;
|
|
377
|
+
offset = newline + 1;
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
child.stderr.on("data", (data: string) => appendBounded(stderr, data));
|
|
381
|
+
child.on("error", (error) => { spawnError = error; });
|
|
382
|
+
|
|
383
|
+
const abort = () => {
|
|
384
|
+
aborted = true;
|
|
385
|
+
killTree(false);
|
|
386
|
+
killTimer = setTimeout(() => killTree(true), 5_000);
|
|
387
|
+
killTimer.unref();
|
|
388
|
+
};
|
|
389
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
390
|
+
|
|
391
|
+
child.on("close", (code) => {
|
|
392
|
+
if (!protocolError && lineBytes) processLine(lineParts.join(""));
|
|
393
|
+
if (aborted) killTree(true);
|
|
394
|
+
if (killTimer) clearTimeout(killTimer);
|
|
395
|
+
signal?.removeEventListener("abort", abort);
|
|
396
|
+
if (aborted) reject(new Error("Subagent was aborted."));
|
|
397
|
+
else if (protocolError) reject(protocolError);
|
|
398
|
+
else if (spawnError) reject(spawnError);
|
|
399
|
+
else resolve({ exitCode: code ?? 1, output, stderr: boundedText(stderr), stopReason, errorMessage });
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const Parameters = Type.Object({
|
|
405
|
+
role: Type.String({ description: "Configured Subagent role name" }),
|
|
406
|
+
task: Type.String({ description: "One bounded task with needed context and expected result" }),
|
|
407
|
+
model: Type.Optional(Type.String({ description: "Exact provider/model; defaults to Main model" })),
|
|
408
|
+
thinkingLevel: Type.Optional(StringEnum(THINKING_LEVELS, {
|
|
409
|
+
description: "Thinking level; defaults to Main thinking level",
|
|
410
|
+
})),
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const roleSummary = (): string => {
|
|
414
|
+
try {
|
|
415
|
+
const roles = loadRoles();
|
|
416
|
+
return roles.length ? roles.map((role) => `${role.name}: ${role.description}`).join("; ") : "none configured";
|
|
417
|
+
} catch (error) {
|
|
418
|
+
return `configuration error: ${error instanceof Error ? error.message : String(error)}`;
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
function resolveSkillPaths(pi: ExtensionAPI, names: string[]): { paths: string[]; missing: string[] } {
|
|
423
|
+
const skills = new Map(pi.getCommands()
|
|
424
|
+
.filter((command) => command.source === "skill")
|
|
425
|
+
.map((command) => [command.name, command.sourceInfo.path]));
|
|
426
|
+
const paths: string[] = [];
|
|
427
|
+
const missing: string[] = [];
|
|
428
|
+
for (const name of names) {
|
|
429
|
+
const path = skills.get(`skill:${name}`);
|
|
430
|
+
if (path) paths.push(path);
|
|
431
|
+
else missing.push(name);
|
|
432
|
+
}
|
|
433
|
+
return { paths, missing };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export default function subagentExtension(pi: ExtensionAPI): void {
|
|
437
|
+
const widgetItems = new Map<string, WidgetItem>();
|
|
438
|
+
let widgetInstalled = false;
|
|
439
|
+
let widgetTimer: ReturnType<typeof setInterval> | undefined;
|
|
440
|
+
let spinnerIndex = 0;
|
|
441
|
+
let activeTui: TUI | undefined;
|
|
442
|
+
|
|
443
|
+
const stopWidgetTimer = () => {
|
|
444
|
+
if (!widgetTimer) return;
|
|
445
|
+
clearInterval(widgetTimer);
|
|
446
|
+
widgetTimer = undefined;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const requestWidgetRender = () => activeTui?.requestRender();
|
|
450
|
+
|
|
451
|
+
const startWidgetTimer = () => {
|
|
452
|
+
if (widgetTimer) return;
|
|
453
|
+
widgetTimer = setInterval(() => {
|
|
454
|
+
spinnerIndex = (spinnerIndex + 1) % SPINNER_FRAMES.length;
|
|
455
|
+
const now = Date.now();
|
|
456
|
+
for (const [id, item] of widgetItems) {
|
|
457
|
+
if (item.removeAt !== undefined && item.removeAt <= now) widgetItems.delete(id);
|
|
458
|
+
}
|
|
459
|
+
requestWidgetRender();
|
|
460
|
+
if (!widgetItems.size) stopWidgetTimer();
|
|
461
|
+
}, WIDGET_INTERVAL_MS);
|
|
462
|
+
widgetTimer.unref();
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const ensureWidget = (ctx: ExtensionContext) => {
|
|
466
|
+
if (!ctx.hasUI || widgetInstalled) return;
|
|
467
|
+
widgetInstalled = true;
|
|
468
|
+
ctx.ui.setWidget(WIDGET_KEY, (tui, theme): Component => {
|
|
469
|
+
activeTui = tui;
|
|
470
|
+
return {
|
|
471
|
+
invalidate() {},
|
|
472
|
+
render: (width) => renderWidgetRows([...widgetItems.values()], width, Date.now(), spinnerIndex, theme),
|
|
473
|
+
};
|
|
474
|
+
});
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const startWidgetItem = (
|
|
478
|
+
id: string,
|
|
479
|
+
role: string,
|
|
480
|
+
model: string,
|
|
481
|
+
thinkingLevel: string | undefined,
|
|
482
|
+
task: string,
|
|
483
|
+
ctx: ExtensionContext,
|
|
484
|
+
) => {
|
|
485
|
+
if (!ctx.hasUI) return;
|
|
486
|
+
ensureWidget(ctx);
|
|
487
|
+
widgetItems.set(id, {
|
|
488
|
+
roleRoute: `${role}[${model}:${thinkingLevel ?? "default"}]`,
|
|
489
|
+
task: taskSummary(task),
|
|
490
|
+
tokens: 0,
|
|
491
|
+
startedAt: Date.now(),
|
|
492
|
+
status: "working",
|
|
493
|
+
});
|
|
494
|
+
startWidgetTimer();
|
|
495
|
+
requestWidgetRender();
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const updateWidgetTokens = (id: string, tokens: number) => {
|
|
499
|
+
const item = widgetItems.get(id);
|
|
500
|
+
if (!item) return;
|
|
501
|
+
item.tokens = tokens;
|
|
502
|
+
requestWidgetRender();
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const finishWidgetItem = (id: string, status: Exclude<WidgetStatus, "working">) => {
|
|
506
|
+
const item = widgetItems.get(id);
|
|
507
|
+
if (!item) return;
|
|
508
|
+
item.status = status;
|
|
509
|
+
item.finishedAt = Date.now();
|
|
510
|
+
item.removeAt = item.finishedAt + TERMINAL_DISPLAY_MS;
|
|
511
|
+
startWidgetTimer();
|
|
512
|
+
requestWidgetRender();
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
pi.on("session_start", (_event, ctx) => ensureWidget(ctx));
|
|
516
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
517
|
+
stopWidgetTimer();
|
|
518
|
+
widgetItems.clear();
|
|
519
|
+
activeTui = undefined;
|
|
520
|
+
widgetInstalled = false;
|
|
521
|
+
if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
pi.registerTool({
|
|
525
|
+
name: "delegate_task",
|
|
526
|
+
label: "Subagent",
|
|
527
|
+
description: `Delegate one bounded task to one isolated Pi Subagent. Roles: ${roleSummary()}. Select model and thinking level only when task needs a different route. Request concise conclusions and file/line references; split broad scouting work.`,
|
|
528
|
+
parameters: Parameters,
|
|
529
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
530
|
+
const task = cleanText(params.task, "task", "delegate_task");
|
|
531
|
+
const roles = loadRoles();
|
|
532
|
+
const role = roles.find((candidate) => candidate.name === params.role);
|
|
533
|
+
if (!role) {
|
|
534
|
+
throw new Error(`Unknown Subagent role: ${params.role}. Available roles: ${roles.map(({ name }) => name).join(", ") || "none"}.`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const modelReference = params.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
|
|
538
|
+
if (!modelReference || modelReference.includes("\0")) throw new Error("Subagent model is missing.");
|
|
539
|
+
const model = ctx.modelRegistry.getAvailable().find(
|
|
540
|
+
(candidate) => `${candidate.provider}/${candidate.id}` === modelReference && candidate.input.includes("text"),
|
|
541
|
+
);
|
|
542
|
+
if (!model) throw new Error(`Subagent model is unavailable: ${modelReference}.`);
|
|
543
|
+
|
|
544
|
+
const thinkingLevel = params.thinkingLevel ?? ctx.thinkingLevel;
|
|
545
|
+
if (thinkingLevel && !getSupportedThinkingLevels(model).includes(thinkingLevel)) {
|
|
546
|
+
throw new Error(`Subagent thinking level ${thinkingLevel} is unavailable for ${modelReference}.`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const resolvedSkills = resolveSkillPaths(pi, role.skills);
|
|
550
|
+
if (resolvedSkills.missing.length) {
|
|
551
|
+
ctx.ui.notify(
|
|
552
|
+
`Subagent role ${role.name} skipped unavailable Pi skills: ${resolvedSkills.missing.join(", ")}.`,
|
|
553
|
+
"warning",
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const tempDir = await mkdtemp(join(tmpdir(), "pi-subagent-"));
|
|
558
|
+
const promptPath = join(tempDir, "system.md");
|
|
559
|
+
let widgetStatus: Exclude<WidgetStatus, "working"> = "failure";
|
|
560
|
+
try {
|
|
561
|
+
await writeFile(promptPath, role.systemPrompt, { encoding: "utf8", mode: 0o600 });
|
|
562
|
+
const args = ["--mode", "json", "-p", "--no-session", "--no-extensions", "--no-skills"];
|
|
563
|
+
for (const extension of role.extensions) args.push("--extension", extension);
|
|
564
|
+
for (const skill of resolvedSkills.paths) args.push("--skill", skill);
|
|
565
|
+
if (role.tools.length) args.push("--tools", role.tools.join(","));
|
|
566
|
+
else args.push("--no-tools");
|
|
567
|
+
args.push("--model", modelReference);
|
|
568
|
+
if (thinkingLevel) args.push("--thinking", thinkingLevel);
|
|
569
|
+
args.push(ctx.isProjectTrusted() ? "--approve" : "--no-approve");
|
|
570
|
+
args.push("--append-system-prompt", promptPath, `Task: ${task}`);
|
|
571
|
+
|
|
572
|
+
startWidgetItem(toolCallId, role.name, model.id, thinkingLevel, task, ctx);
|
|
573
|
+
const details = { role: role.name, model: modelReference, thinkingLevel };
|
|
574
|
+
const result = await runPi(
|
|
575
|
+
args,
|
|
576
|
+
ctx.cwd,
|
|
577
|
+
signal,
|
|
578
|
+
(text) => onUpdate?.({ content: [{ type: "text", text }], details }),
|
|
579
|
+
(tokens) => updateWidgetTokens(toolCallId, tokens),
|
|
580
|
+
);
|
|
581
|
+
const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
582
|
+
widgetStatus = result.stopReason === "aborted" ? "aborted" : failed ? "failure" : "success";
|
|
583
|
+
const text = capOutput(failed
|
|
584
|
+
? result.errorMessage || result.stderr.trim() || result.output || `Subagent exited with code ${result.exitCode}.`
|
|
585
|
+
: result.output || "(no output)");
|
|
586
|
+
return { content: [{ type: "text" as const, text }], details, ...(failed ? { isError: true } : {}) };
|
|
587
|
+
} catch (error) {
|
|
588
|
+
if (signal?.aborted) widgetStatus = "aborted";
|
|
589
|
+
throw error;
|
|
590
|
+
} finally {
|
|
591
|
+
try {
|
|
592
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
593
|
+
} finally {
|
|
594
|
+
finishWidgetItem(toolCallId, widgetStatus);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@henryqw/pi-subagent",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Delegate one task to an isolated Pi role with explicit extensions and skills.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"subagent",
|
|
9
|
+
"delegation"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22.19.0"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"files": [
|
|
17
|
+
"extensions",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/*.test.ts",
|
|
23
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/subagent.ts test/*.test.ts",
|
|
24
|
+
"pack:check": "npm pack --dry-run"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-ai": "^0.84.1",
|
|
28
|
+
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
29
|
+
"@earendil-works/pi-tui": "^0.84.1",
|
|
30
|
+
"typebox": "^1.3.7"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/HenryQW/pi-packages.git",
|
|
35
|
+
"directory": "packages/pi-subagent"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/HenryQW/pi-packages/issues"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"pi": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./extensions/subagent.ts"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
}
|