@d3ara1n/pi-subagent 0.1.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 +76 -0
- package/package.json +41 -0
- package/src/config.ts +63 -0
- package/src/index.ts +481 -0
- package/src/roles.ts +57 -0
- package/src/spawn.ts +208 -0
- package/src/types.ts +96 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @d3ara1n/pi-subagent
|
|
2
|
+
|
|
3
|
+
Role-based subagent orchestration for [pi](https://github.com/earendil-works/pi).
|
|
4
|
+
|
|
5
|
+
Provides a `delegate` tool that lets the main model delegate tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
1. Main model calls the `delegate` tool with a role and task description
|
|
10
|
+
2. The extension resolves the role to a model via pi-model-roles
|
|
11
|
+
3. Spawns an isolated pi child process with the configured model, tools, and system prompt
|
|
12
|
+
4. **Real-time TUI progress** shows tool calls, turns, and elapsed time as the subagent runs
|
|
13
|
+
5. After completion, an **AI-generated one-line summary** is produced for compact display
|
|
14
|
+
6. Returns the result to the main model with usage statistics (turns, tokens, cost)
|
|
15
|
+
|
|
16
|
+
## Built-in Roles
|
|
17
|
+
|
|
18
|
+
| Role | Model Role | Tools | Description |
|
|
19
|
+
|------|-----------|-------|-------------|
|
|
20
|
+
| `explorer` | fast | read, bash, find, grep, glob | Fast code search (read-only) |
|
|
21
|
+
| `reviewer` | heavy | read, bash, grep, glob | Deep code review (read-only) |
|
|
22
|
+
| `worker` | default | read, bash, edit, write, grep, glob | Implementation with file editing |
|
|
23
|
+
| `researcher` | fast | web_search, fetch_content, read | Web research and docs lookup |
|
|
24
|
+
|
|
25
|
+
## TUI Display
|
|
26
|
+
|
|
27
|
+
- **During execution**: Shows role, elapsed time, turn count, and live tool calls
|
|
28
|
+
- **Collapsed result**: `✓ explorer · 找到了登录/注册/token三块逻辑` + recent tool calls + usage stats
|
|
29
|
+
- **Expanded result** (Ctrl+O): Full task text, all tool calls, final output as rendered Markdown, and usage details
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
- **@d3ara1n/pi-model-roles** must be installed and configured
|
|
34
|
+
- **@earendil-works/pi-tui** — bundled with pi, no separate install needed
|
|
35
|
+
- Role definitions must exist in `modelRoles` settings
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pi extension add @d3ara1n/pi-subagent
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
Edit `~/.pi/agent/settings.json`:
|
|
46
|
+
|
|
47
|
+
```jsonc
|
|
48
|
+
{
|
|
49
|
+
"subagent": {
|
|
50
|
+
// Default timeout per subagent (5 minutes)
|
|
51
|
+
"timeoutMs": 300000,
|
|
52
|
+
|
|
53
|
+
// Summary generation — uses a lightweight model to create
|
|
54
|
+
// a one-line Chinese summary for the TUI display
|
|
55
|
+
"summary": {
|
|
56
|
+
"role": "utility", // pi-model-roles role for summarization
|
|
57
|
+
"enabled": true // set false to disable
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
All fields are optional. Defaults: `timeoutMs: 300000`, `summary.role: "utility"`, `summary.enabled: true`.
|
|
64
|
+
|
|
65
|
+
## Usage (by the main model)
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"role": "explorer",
|
|
70
|
+
"task": "Find all files that import the ModelRegistry and trace how they use it"
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@d3ara1n/pi-subagent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi"
|
|
9
|
+
],
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"@earendil-works/pi-ai": "*",
|
|
12
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
13
|
+
"@earendil-works/pi-tui": "*",
|
|
14
|
+
"@d3ara1n/pi-model-roles": "*"
|
|
15
|
+
},
|
|
16
|
+
"peerDependenciesMeta": {
|
|
17
|
+
"@earendil-works/pi-ai": {
|
|
18
|
+
"optional": true
|
|
19
|
+
},
|
|
20
|
+
"@earendil-works/pi-coding-agent": {
|
|
21
|
+
"optional": true
|
|
22
|
+
},
|
|
23
|
+
"@d3ara1n/pi-model-roles": {
|
|
24
|
+
"optional": true
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@d3ara1n/pi-model-roles": "^0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./src/index.ts"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/d3ara1n/pi-extensions",
|
|
38
|
+
"directory": "packages/pi-subagent"
|
|
39
|
+
},
|
|
40
|
+
"license": "MIT"
|
|
41
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read subagent configuration from settings files.
|
|
3
|
+
*
|
|
4
|
+
* Global (~/.pi/agent/settings.json) + project (.pi/settings.json),
|
|
5
|
+
* project overrides global.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import type { SubagentConfig } from "./types.ts";
|
|
12
|
+
import { DEFAULT_CONFIG } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
function getAgentDir(): string {
|
|
15
|
+
const envDir = process.env.PI_AGENT_DIR;
|
|
16
|
+
if (envDir) return envDir;
|
|
17
|
+
return path.join(os.homedir(), ".pi", "agent");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readSettingsFile(filePath: string): any {
|
|
21
|
+
try {
|
|
22
|
+
if (!fs.existsSync(filePath)) return {};
|
|
23
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
24
|
+
const stripped = content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
25
|
+
return JSON.parse(stripped);
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function merge(target: any, source: any): any {
|
|
32
|
+
if (!source || typeof source !== "object") return target;
|
|
33
|
+
if (!target || typeof target !== "object") return source;
|
|
34
|
+
const result = { ...target };
|
|
35
|
+
for (const key of Object.keys(source)) {
|
|
36
|
+
if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
|
|
37
|
+
result[key] = merge(result[key], source[key]);
|
|
38
|
+
} else {
|
|
39
|
+
result[key] = source[key];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function loadSubagentConfig(cwd?: string): SubagentConfig {
|
|
46
|
+
const globalSettings = readSettingsFile(path.join(getAgentDir(), "settings.json"));
|
|
47
|
+
const projectSettings = cwd
|
|
48
|
+
? readSettingsFile(path.join(cwd, ".pi", "settings.json"))
|
|
49
|
+
: {};
|
|
50
|
+
const settings = merge(globalSettings, projectSettings);
|
|
51
|
+
|
|
52
|
+
const raw = settings?.subagent;
|
|
53
|
+
if (!raw) return DEFAULT_CONFIG;
|
|
54
|
+
|
|
55
|
+
const rawSummary = raw?.summary;
|
|
56
|
+
return {
|
|
57
|
+
timeoutMs: raw.timeoutMs ?? DEFAULT_CONFIG.timeoutMs,
|
|
58
|
+
summary: {
|
|
59
|
+
role: rawSummary?.role ?? DEFAULT_CONFIG.summary.role,
|
|
60
|
+
enabled: rawSummary?.enabled ?? DEFAULT_CONFIG.summary.enabled,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-subagent — Role-based subagent orchestration with TUI rendering.
|
|
3
|
+
*
|
|
4
|
+
* Delegates tasks to specialized pi child processes with:
|
|
5
|
+
* - Real-time progress streaming via TUI (tool calls, turns, elapsed time)
|
|
6
|
+
* - AI-generated one-line summary for compact display (configurable role)
|
|
7
|
+
* - All messages collected for expanded view (Ctrl+O)
|
|
8
|
+
* - Accurate, concise output for the main model
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { complete } from "@earendil-works/pi-ai";
|
|
14
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
15
|
+
import { Type } from "typebox";
|
|
16
|
+
import type { ModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
17
|
+
import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
18
|
+
import type { SubagentConfig, SubagentDetails, SubagentResult } from "./types.ts";
|
|
19
|
+
import { DEFAULT_CONFIG } from "./types.ts";
|
|
20
|
+
import { loadSubagentConfig } from "./config.ts";
|
|
21
|
+
import { BUILTIN_ROLES } from "./roles.ts";
|
|
22
|
+
import { spawnSubagent } from "./spawn.ts";
|
|
23
|
+
import * as os from "node:os";
|
|
24
|
+
|
|
25
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function formatTokens(count: number): string {
|
|
28
|
+
if (count < 1000) return count.toString();
|
|
29
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
30
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
31
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatUsageStats(usage: SubagentResult["usage"], model?: string): string {
|
|
35
|
+
const parts: string[] = [];
|
|
36
|
+
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
37
|
+
if (usage.input) parts.push(`\u2191${formatTokens(usage.input)}`);
|
|
38
|
+
if (usage.output) parts.push(`\u2193${formatTokens(usage.output)}`);
|
|
39
|
+
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
40
|
+
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
41
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
42
|
+
if (model) parts.push(model);
|
|
43
|
+
return parts.join(" ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type DisplayItem =
|
|
47
|
+
| { type: "text"; text: string }
|
|
48
|
+
| { type: "toolCall"; name: string; args: Record<string, any> };
|
|
49
|
+
|
|
50
|
+
function getDisplayItems(messages: SubagentResult["messages"]): DisplayItem[] {
|
|
51
|
+
const items: DisplayItem[] = [];
|
|
52
|
+
for (const msg of messages) {
|
|
53
|
+
if (msg.role === "assistant") {
|
|
54
|
+
for (const part of msg.content) {
|
|
55
|
+
if (part.type === "text" && part.text) items.push({ type: "text", text: part.text });
|
|
56
|
+
else if (part.type === "toolCall" && part.name)
|
|
57
|
+
items.push({ type: "toolCall", name: part.name, args: part.arguments ?? {} });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return items;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function shortenPath(p: string): string {
|
|
65
|
+
const home = os.homedir();
|
|
66
|
+
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatToolCall(
|
|
70
|
+
toolName: string,
|
|
71
|
+
args: Record<string, unknown>,
|
|
72
|
+
fg: (color: string, text: string) => string,
|
|
73
|
+
): string {
|
|
74
|
+
switch (toolName) {
|
|
75
|
+
case "bash": {
|
|
76
|
+
const command = (args.command as string) || "...";
|
|
77
|
+
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
|
|
78
|
+
return fg("muted", "$ ") + fg("toolOutput", preview);
|
|
79
|
+
}
|
|
80
|
+
case "read": {
|
|
81
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
82
|
+
const filePath = shortenPath(rawPath);
|
|
83
|
+
const offset = args.offset as number | undefined;
|
|
84
|
+
const limit = args.limit as number | undefined;
|
|
85
|
+
let text = fg("accent", filePath);
|
|
86
|
+
if (offset !== undefined || limit !== undefined) {
|
|
87
|
+
const startLine = offset ?? 1;
|
|
88
|
+
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
89
|
+
text += fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
|
90
|
+
}
|
|
91
|
+
return fg("muted", "read ") + text;
|
|
92
|
+
}
|
|
93
|
+
case "write": {
|
|
94
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
95
|
+
const content = (args.content || "") as string;
|
|
96
|
+
const lines = content.split("\n").length;
|
|
97
|
+
let text = fg("muted", "write ") + fg("accent", shortenPath(rawPath));
|
|
98
|
+
if (lines > 1) text += fg("dim", ` (${lines} lines)`);
|
|
99
|
+
return text;
|
|
100
|
+
}
|
|
101
|
+
case "edit": {
|
|
102
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
103
|
+
return fg("muted", "edit ") + fg("accent", shortenPath(rawPath));
|
|
104
|
+
}
|
|
105
|
+
case "grep": {
|
|
106
|
+
const pattern = (args.pattern || "") as string;
|
|
107
|
+
const rawPath = (args.path || ".") as string;
|
|
108
|
+
return fg("muted", "grep ") + fg("accent", `/${pattern}/`) + fg("dim", ` in ${shortenPath(rawPath)}`);
|
|
109
|
+
}
|
|
110
|
+
case "find": {
|
|
111
|
+
const pattern = (args.pattern || "*") as string;
|
|
112
|
+
return fg("muted", "find ") + fg("accent", pattern);
|
|
113
|
+
}
|
|
114
|
+
case "glob": {
|
|
115
|
+
const pattern = (args.pattern || "*") as string;
|
|
116
|
+
return fg("muted", "glob ") + fg("accent", pattern);
|
|
117
|
+
}
|
|
118
|
+
default: {
|
|
119
|
+
const argsStr = JSON.stringify(args);
|
|
120
|
+
const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
|
|
121
|
+
return fg("accent", toolName) + fg("dim", ` ${preview}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderDisplayItems(
|
|
127
|
+
items: DisplayItem[],
|
|
128
|
+
limit: number | undefined,
|
|
129
|
+
fg: (color: string, text: string) => string,
|
|
130
|
+
): string {
|
|
131
|
+
const toShow = limit ? items.slice(-limit) : items;
|
|
132
|
+
const skipped = limit && items.length > limit ? items.length - limit : 0;
|
|
133
|
+
let text = "";
|
|
134
|
+
if (skipped > 0) text += fg("muted", `... ${skipped} earlier items\n`);
|
|
135
|
+
for (const item of toShow) {
|
|
136
|
+
if (item.type === "text") {
|
|
137
|
+
const preview = item.text.split("\n").slice(0, 3).join("\n");
|
|
138
|
+
text += `${fg("toolOutput", preview.length > 120 ? preview.slice(0, 120) + "..." : preview)}\n`;
|
|
139
|
+
} else {
|
|
140
|
+
text += `${fg("muted", "\u2192 ")}${formatToolCall(item.name, item.args, fg)}\n`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return text.trimEnd();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getFinalOutput(messages: SubagentResult["messages"]): string {
|
|
147
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
148
|
+
const msg = messages[i];
|
|
149
|
+
if (msg.role === "assistant") {
|
|
150
|
+
for (const part of msg.content) {
|
|
151
|
+
if (part.type === "text" && part.text) return part.text;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return "";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isFailedResult(r: SubagentResult): boolean {
|
|
159
|
+
return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Summary generation ─────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
async function generateSummary(
|
|
165
|
+
rolesApi: ModelRolesAPI,
|
|
166
|
+
outputText: string,
|
|
167
|
+
summaryConfig: SubagentConfig["summary"],
|
|
168
|
+
): Promise<string | undefined> {
|
|
169
|
+
if (!summaryConfig.enabled || !outputText.trim()) return undefined;
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const resolved = await rolesApi.resolveRoleAsync(summaryConfig.role);
|
|
173
|
+
if (!resolved.model) return undefined;
|
|
174
|
+
|
|
175
|
+
const result = await complete(
|
|
176
|
+
resolved.model,
|
|
177
|
+
{
|
|
178
|
+
systemPrompt:
|
|
179
|
+
"Summarize the following agent output in one concise Chinese sentence (max 60 characters). Focus on what was accomplished, not how. Output only the summary, no preamble.",
|
|
180
|
+
messages: [{ role: "user", content: outputText }],
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
maxTokens: 100,
|
|
184
|
+
apiKey: resolved.apiKey,
|
|
185
|
+
headers: resolved.headers,
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const text = result.content
|
|
190
|
+
?.filter((block: any) => block.type === "text")
|
|
191
|
+
?.map((block: any) => block.text)
|
|
192
|
+
?.join("")
|
|
193
|
+
?.trim();
|
|
194
|
+
|
|
195
|
+
return text || undefined;
|
|
196
|
+
} catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Extension entry ────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export default function subagentExtension(pi: ExtensionAPI) {
|
|
204
|
+
let config: SubagentConfig = DEFAULT_CONFIG;
|
|
205
|
+
|
|
206
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
207
|
+
config = loadSubagentConfig(ctx.cwd);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
pi.registerTool({
|
|
211
|
+
name: "delegate",
|
|
212
|
+
label: "Delegate to subagent",
|
|
213
|
+
description: [
|
|
214
|
+
"Delegate a task to a specialized subagent with isolated context.",
|
|
215
|
+
"Available roles:",
|
|
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"),
|
|
226
|
+
|
|
227
|
+
parameters: Type.Object({
|
|
228
|
+
role: Type.Union(
|
|
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
|
+
),
|
|
237
|
+
task: Type.String({ description: "Specific task for the subagent" }),
|
|
238
|
+
cwd: Type.Optional(Type.String({ description: "Working directory (defaults to current)" })),
|
|
239
|
+
}),
|
|
240
|
+
|
|
241
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
242
|
+
const roleDef = BUILTIN_ROLES[params.role];
|
|
243
|
+
if (!roleDef) {
|
|
244
|
+
return {
|
|
245
|
+
content: [
|
|
246
|
+
{
|
|
247
|
+
type: "text",
|
|
248
|
+
text: `Unknown subagent role: ${params.role}. Available: ${Object.keys(BUILTIN_ROLES).join(", ")}`,
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Resolve model from pi-model-roles
|
|
255
|
+
let rolesApi: ModelRolesAPI;
|
|
256
|
+
try {
|
|
257
|
+
rolesApi = getModelRolesAPI();
|
|
258
|
+
} catch {
|
|
259
|
+
return {
|
|
260
|
+
content: [{ type: "text", text: "pi-model-roles is not initialized. Cannot resolve model for subagent." }],
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const resolved = await rolesApi.resolveRoleAsync(roleDef.role);
|
|
265
|
+
if (!resolved.model) {
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: "text", text: `Role "${roleDef.role}" could not be resolved. Model not available.` }],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const modelRef = `${resolved.model.provider}/${resolved.model.id}`;
|
|
272
|
+
const startTime = Date.now();
|
|
273
|
+
|
|
274
|
+
// Emit initial placeholder for TUI
|
|
275
|
+
if (onUpdate) {
|
|
276
|
+
const placeholder: SubagentResult = {
|
|
277
|
+
role: params.role,
|
|
278
|
+
task: params.task,
|
|
279
|
+
exitCode: -1,
|
|
280
|
+
messages: [],
|
|
281
|
+
output: "",
|
|
282
|
+
stderr: "",
|
|
283
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
284
|
+
};
|
|
285
|
+
onUpdate({
|
|
286
|
+
content: [{ type: "text", text: `${params.role}: running...` }],
|
|
287
|
+
details: { mode: "single", results: [placeholder] },
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const result = await spawnSubagent(modelRef, params.task, {
|
|
293
|
+
cwd: params.cwd ?? ctx.cwd,
|
|
294
|
+
tools: roleDef.tools,
|
|
295
|
+
systemPrompt: roleDef.systemPrompt,
|
|
296
|
+
timeoutMs: config.timeoutMs,
|
|
297
|
+
signal,
|
|
298
|
+
onProgress: (partial) => {
|
|
299
|
+
if (!onUpdate) return;
|
|
300
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
301
|
+
const liveResult: SubagentResult = {
|
|
302
|
+
role: params.role,
|
|
303
|
+
task: params.task,
|
|
304
|
+
exitCode: -1,
|
|
305
|
+
messages: partial.messages ?? [],
|
|
306
|
+
output: partial.output ?? "",
|
|
307
|
+
stderr: "",
|
|
308
|
+
usage: partial.usage ?? {
|
|
309
|
+
input: 0,
|
|
310
|
+
output: 0,
|
|
311
|
+
cacheRead: 0,
|
|
312
|
+
cacheWrite: 0,
|
|
313
|
+
cost: 0,
|
|
314
|
+
contextTokens: 0,
|
|
315
|
+
turns: 0,
|
|
316
|
+
},
|
|
317
|
+
model: partial.model,
|
|
318
|
+
stopReason: partial.stopReason,
|
|
319
|
+
};
|
|
320
|
+
const statusText = `${params.role} ${elapsed}s ${liveResult.usage.turns} turn${liveResult.usage.turns !== 1 ? "s" : ""}`;
|
|
321
|
+
onUpdate({
|
|
322
|
+
content: [{ type: "text", text: statusText }],
|
|
323
|
+
details: { mode: "single", results: [liveResult] },
|
|
324
|
+
});
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Generate summary for TUI display
|
|
329
|
+
if (config.summary.enabled && result.output.trim()) {
|
|
330
|
+
result.summary = await generateSummary(rolesApi, result.output, config.summary);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (result.exitCode !== 0 || result.errorMessage) {
|
|
334
|
+
return {
|
|
335
|
+
content: [
|
|
336
|
+
{
|
|
337
|
+
type: "text",
|
|
338
|
+
text: `Subagent (${params.role}) failed: ${result.errorMessage || result.stderr || "unknown error"}\n\nPartial output:\n${result.output}`,
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
details: { mode: "single", results: [result] },
|
|
342
|
+
isError: true,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Build concise output for the main model with usage info
|
|
347
|
+
const usageParts: string[] = [];
|
|
348
|
+
if (result.usage.turns) usageParts.push(`${result.usage.turns} turn${result.usage.turns > 1 ? "s" : ""}`);
|
|
349
|
+
if (result.usage.input) usageParts.push(`\u2191${formatTokens(result.usage.input)}`);
|
|
350
|
+
if (result.usage.output) usageParts.push(`\u2193${formatTokens(result.usage.output)}`);
|
|
351
|
+
if (result.usage.cost) usageParts.push(`$${result.usage.cost.toFixed(4)}`);
|
|
352
|
+
if (result.model) usageParts.push(result.model);
|
|
353
|
+
const usageLine = usageParts.length > 0 ? `\n\n--- ${usageParts.join(" ")} ---` : "";
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
content: [{ type: "text", text: result.output + usageLine }],
|
|
357
|
+
details: { mode: "single", results: [result] },
|
|
358
|
+
};
|
|
359
|
+
} catch (err: any) {
|
|
360
|
+
return {
|
|
361
|
+
content: [{ type: "text", text: `Subagent (${params.role}) error: ${err.message || err}` }],
|
|
362
|
+
details: { mode: "single", results: [] },
|
|
363
|
+
isError: true,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
// ── renderCall: what the user sees when the tool is invoked ──────
|
|
369
|
+
|
|
370
|
+
renderCall(args, theme, _context) {
|
|
371
|
+
const roleName = (args as any).role || "...";
|
|
372
|
+
const task = (args as any).task || "";
|
|
373
|
+
const preview = task.length > 60 ? `${task.slice(0, 60)}...` : task;
|
|
374
|
+
const text =
|
|
375
|
+
theme.fg("toolTitle", theme.bold("subagent ")) +
|
|
376
|
+
theme.fg("accent", roleName) +
|
|
377
|
+
"\n " +
|
|
378
|
+
theme.fg("dim", preview);
|
|
379
|
+
return new Text(text, 0, 0);
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
// ── renderResult: TUI display when the tool finishes ─────────────
|
|
383
|
+
|
|
384
|
+
renderResult(result, { expanded }, theme, _context) {
|
|
385
|
+
const details = result.details as SubagentDetails | undefined;
|
|
386
|
+
if (!details || details.results.length === 0) {
|
|
387
|
+
const text = result.content[0];
|
|
388
|
+
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const r = details.results[0];
|
|
392
|
+
const isRunning = r.exitCode === -1;
|
|
393
|
+
const isError = !isRunning && isFailedResult(r);
|
|
394
|
+
let icon: string;
|
|
395
|
+
if (isRunning) {
|
|
396
|
+
icon = theme.fg("warning", "\u23F3"); // hourglass
|
|
397
|
+
} else if (isError) {
|
|
398
|
+
icon = theme.fg("error", "\u2717");
|
|
399
|
+
} else {
|
|
400
|
+
icon = theme.fg("success", "\u2713");
|
|
401
|
+
}
|
|
402
|
+
const displayItems = getDisplayItems(r.messages);
|
|
403
|
+
const finalOutput = getFinalOutput(r.messages);
|
|
404
|
+
const mdTheme = getMarkdownTheme();
|
|
405
|
+
|
|
406
|
+
if (expanded) {
|
|
407
|
+
const container = new Container();
|
|
408
|
+
|
|
409
|
+
// Header
|
|
410
|
+
let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.role))}`;
|
|
411
|
+
if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
|
|
412
|
+
container.addChild(new Text(header, 0, 0));
|
|
413
|
+
if (isError && r.errorMessage)
|
|
414
|
+
container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
|
|
415
|
+
|
|
416
|
+
if (!isRunning) {
|
|
417
|
+
container.addChild(new Spacer(1));
|
|
418
|
+
container.addChild(new Text(theme.fg("muted", "\u2500\u2500\u2500 Task \u2500\u2500\u2500"), 0, 0));
|
|
419
|
+
container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
container.addChild(new Spacer(1));
|
|
423
|
+
const toolCalls = displayItems.filter((item) => item.type === "toolCall");
|
|
424
|
+
if (toolCalls.length === 0) {
|
|
425
|
+
const runningLabel = isRunning ? "(waiting for first event...)" : "(none)";
|
|
426
|
+
container.addChild(new Text(theme.fg("muted", runningLabel), 0, 0));
|
|
427
|
+
} else {
|
|
428
|
+
for (const item of toolCalls) {
|
|
429
|
+
container.addChild(
|
|
430
|
+
new Text(
|
|
431
|
+
theme.fg("muted", "\u2192 ") +
|
|
432
|
+
formatToolCall(item.name, item.args, theme.fg.bind(theme)),
|
|
433
|
+
0,
|
|
434
|
+
0,
|
|
435
|
+
),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!isRunning && finalOutput) {
|
|
441
|
+
container.addChild(new Spacer(1));
|
|
442
|
+
container.addChild(new Text(theme.fg("muted", "\u2500\u2500\u2500 Output \u2500\u2500\u2500"), 0, 0));
|
|
443
|
+
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const usageStr = formatUsageStats(r.usage, r.model);
|
|
447
|
+
if (usageStr) {
|
|
448
|
+
container.addChild(new Spacer(1));
|
|
449
|
+
container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return container;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Collapsed view
|
|
456
|
+
let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.role))}`;
|
|
457
|
+
|
|
458
|
+
if (isRunning) {
|
|
459
|
+
// Running: show recent tool calls only
|
|
460
|
+
const toolCalls = displayItems.filter((item) => item.type === "toolCall");
|
|
461
|
+
if (toolCalls.length === 0) {
|
|
462
|
+
text += `\n${theme.fg("muted", "(running...)")}`;
|
|
463
|
+
} else {
|
|
464
|
+
const rendered = renderDisplayItems(toolCalls, 5, theme.fg.bind(theme));
|
|
465
|
+
if (rendered) text += `\n${rendered}`;
|
|
466
|
+
}
|
|
467
|
+
} else {
|
|
468
|
+
// Finished: summary + usage, no tool calls
|
|
469
|
+
if (r.summary) {
|
|
470
|
+
text += ` ${theme.fg("dim", "\u00b7")} ${theme.fg("text", r.summary)}`;
|
|
471
|
+
}
|
|
472
|
+
if (isError && r.errorMessage) {
|
|
473
|
+
text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
|
|
474
|
+
}
|
|
475
|
+
const usageStr = formatUsageStats(r.usage, r.model);
|
|
476
|
+
if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
|
|
477
|
+
}
|
|
478
|
+
return new Text(text, 0, 0);
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
}
|
package/src/roles.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in subagent role definitions.
|
|
3
|
+
*
|
|
4
|
+
* Each maps to a pi-model-roles role and has a tailored system prompt
|
|
5
|
+
* and tool set. Prompts are in English — concise, efficient, task-focused.
|
|
6
|
+
* Final output should be accurate and concise, stating conclusions directly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SubagentRole } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
12
|
+
explorer: {
|
|
13
|
+
role: "fast",
|
|
14
|
+
tools: ["read", "bash", "find", "grep", "glob"],
|
|
15
|
+
systemPrompt: [
|
|
16
|
+
"You are a fast code explorer. Investigate the codebase and answer the task query.",
|
|
17
|
+
"You must NOT edit any files.",
|
|
18
|
+
"",
|
|
19
|
+
"Output accurately and concisely. State findings directly with file paths and line numbers.",
|
|
20
|
+
"Keep the final output as short as possible while preserving all actionable information.",
|
|
21
|
+
].join("\n"),
|
|
22
|
+
},
|
|
23
|
+
reviewer: {
|
|
24
|
+
role: "heavy",
|
|
25
|
+
tools: ["read", "bash", "grep", "glob"],
|
|
26
|
+
systemPrompt: [
|
|
27
|
+
"You are a senior code reviewer. Inspect code for correctness, maintainability, and security issues.",
|
|
28
|
+
"You must NOT edit any files.",
|
|
29
|
+
"",
|
|
30
|
+
"Provide evidence-backed findings with file/line references.",
|
|
31
|
+
'Use bash only for read-only commands: git diff, git log, git show.',
|
|
32
|
+
"",
|
|
33
|
+
"Output accurately and concisely. Prioritize critical issues first.",
|
|
34
|
+
].join("\n"),
|
|
35
|
+
},
|
|
36
|
+
worker: {
|
|
37
|
+
role: "default",
|
|
38
|
+
tools: ["read", "bash", "edit", "write", "grep", "glob"],
|
|
39
|
+
systemPrompt: [
|
|
40
|
+
"You are an implementation worker. Follow the given plan precisely.",
|
|
41
|
+
"Make minimal, focused changes. Validate your work after each change.",
|
|
42
|
+
"",
|
|
43
|
+
"When finished, report what you changed and what validation you ran.",
|
|
44
|
+
"Output accurately and concisely — summarize changes, don't repeat full diffs.",
|
|
45
|
+
].join("\n"),
|
|
46
|
+
},
|
|
47
|
+
researcher: {
|
|
48
|
+
role: "fast",
|
|
49
|
+
tools: ["web_search", "fetch_content", "read"],
|
|
50
|
+
systemPrompt: [
|
|
51
|
+
"You are a web researcher. Find relevant documentation, examples, and best practices.",
|
|
52
|
+
"",
|
|
53
|
+
"Return concise summaries with source links.",
|
|
54
|
+
"Output accurately and concisely — state key findings first, then supporting details if needed.",
|
|
55
|
+
].join("\n"),
|
|
56
|
+
},
|
|
57
|
+
};
|
package/src/spawn.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn a pi child process and collect structured output with real-time progress.
|
|
3
|
+
*
|
|
4
|
+
* Uses pi's --mode json to get a JSON event stream.
|
|
5
|
+
* Collects all messages (assistant + tool results) for TUI rendering.
|
|
6
|
+
* Fires onProgress on each event for streaming updates.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import type { SubagentMessage, SubagentResult } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/** Determine how to invoke pi.
|
|
16
|
+
*
|
|
17
|
+
* Always uses the `pi` CLI command. On Windows with Bun-compiled pi,
|
|
18
|
+
* process.execPath returns a virtual path (B:/~BUN/root/pi.exe) that
|
|
19
|
+
* leaks into the child model's context and causes it to run stray
|
|
20
|
+
* diagnostic commands. Using the `pi` command from PATH avoids this.
|
|
21
|
+
*/
|
|
22
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
23
|
+
return { command: "pi", args };
|
|
24
|
+
}
|
|
25
|
+
|
|
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
|
+
/**
|
|
36
|
+
* Spawn a pi child process with the given model and configuration.
|
|
37
|
+
* Fires onProgress on each JSON event for streaming TUI updates.
|
|
38
|
+
*
|
|
39
|
+
* @param modelRef - Model identifier like "deepseek/deepseek-v4-flash"
|
|
40
|
+
* @param task - The task prompt
|
|
41
|
+
* @param options - Spawn options
|
|
42
|
+
* @returns SubagentResult with collected messages and usage stats
|
|
43
|
+
*/
|
|
44
|
+
export async function spawnSubagent(
|
|
45
|
+
modelRef: string,
|
|
46
|
+
task: string,
|
|
47
|
+
options: {
|
|
48
|
+
cwd?: string;
|
|
49
|
+
tools?: string[];
|
|
50
|
+
systemPrompt?: string;
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
onProgress?: (update: Partial<SubagentResult>) => void;
|
|
54
|
+
},
|
|
55
|
+
): Promise<SubagentResult> {
|
|
56
|
+
const result: SubagentResult = {
|
|
57
|
+
role: "",
|
|
58
|
+
task,
|
|
59
|
+
exitCode: 0,
|
|
60
|
+
messages: [],
|
|
61
|
+
output: "",
|
|
62
|
+
stderr: "",
|
|
63
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
let tmpDir: string | null = null;
|
|
67
|
+
let tmpFile: string | null = null;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// Build CLI args
|
|
71
|
+
const args: string[] = ["--mode", "json", "--no-session", "--model", modelRef];
|
|
72
|
+
|
|
73
|
+
if (options.tools && options.tools.length > 0) {
|
|
74
|
+
args.push("--tools", options.tools.join(","));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (options.systemPrompt?.trim()) {
|
|
78
|
+
const tmp = await writeTempPromptFile("delegate", options.systemPrompt);
|
|
79
|
+
tmpDir = tmp.dir;
|
|
80
|
+
tmpFile = tmp.filePath;
|
|
81
|
+
args.push("--append-system-prompt", tmpFile);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
args.push(`Task: ${task}`);
|
|
85
|
+
|
|
86
|
+
// Spawn process
|
|
87
|
+
const invocation = getPiInvocation(args);
|
|
88
|
+
let wasAborted = false;
|
|
89
|
+
let buffer = "";
|
|
90
|
+
|
|
91
|
+
const emitProgress = () => {
|
|
92
|
+
options.onProgress?.({
|
|
93
|
+
output: result.output,
|
|
94
|
+
messages: [...result.messages],
|
|
95
|
+
usage: { ...result.usage },
|
|
96
|
+
model: result.model,
|
|
97
|
+
stopReason: result.stopReason,
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const processLine = (line: string) => {
|
|
102
|
+
if (!line.trim()) return;
|
|
103
|
+
let event: any;
|
|
104
|
+
try {
|
|
105
|
+
event = JSON.parse(line);
|
|
106
|
+
} catch {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (event.type === "message_end" && event.message) {
|
|
111
|
+
const msg = event.message as SubagentMessage;
|
|
112
|
+
result.messages.push(msg);
|
|
113
|
+
|
|
114
|
+
if (msg.role === "assistant") {
|
|
115
|
+
result.usage.turns++;
|
|
116
|
+
const usage = msg.usage;
|
|
117
|
+
if (usage) {
|
|
118
|
+
result.usage.input += usage.input || 0;
|
|
119
|
+
result.usage.output += usage.output || 0;
|
|
120
|
+
result.usage.cacheRead += usage.cacheRead || 0;
|
|
121
|
+
result.usage.cacheWrite += usage.cacheWrite || 0;
|
|
122
|
+
result.usage.cost += usage.cost?.total || 0;
|
|
123
|
+
result.usage.contextTokens = usage.totalTokens || 0;
|
|
124
|
+
}
|
|
125
|
+
if (!result.model && msg.model) result.model = msg.model;
|
|
126
|
+
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
127
|
+
if (msg.errorMessage) result.errorMessage = msg.errorMessage;
|
|
128
|
+
|
|
129
|
+
// Track last assistant text
|
|
130
|
+
for (const part of msg.content) {
|
|
131
|
+
if (part.type === "text" && part.text) {
|
|
132
|
+
result.output = part.text;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
emitProgress();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (event.type === "tool_result_end" && event.message) {
|
|
141
|
+
result.messages.push(event.message as SubagentMessage);
|
|
142
|
+
emitProgress();
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
147
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
148
|
+
cwd: options.cwd,
|
|
149
|
+
shell: false,
|
|
150
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
proc.stdout.on("data", (data: Buffer) => {
|
|
154
|
+
buffer += data.toString();
|
|
155
|
+
const lines = buffer.split("\n");
|
|
156
|
+
buffer = lines.pop() || "";
|
|
157
|
+
for (const line of lines) processLine(line);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
proc.stderr.on("data", (data: Buffer) => {
|
|
161
|
+
result.stderr += data.toString();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
proc.on("close", (code) => {
|
|
165
|
+
if (buffer.trim()) processLine(buffer);
|
|
166
|
+
resolve(code ?? 0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
proc.on("error", () => {
|
|
170
|
+
resolve(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Handle abort signal
|
|
174
|
+
if (options.signal) {
|
|
175
|
+
const killProc = () => {
|
|
176
|
+
wasAborted = true;
|
|
177
|
+
proc.kill("SIGTERM");
|
|
178
|
+
setTimeout(() => {
|
|
179
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
180
|
+
}, 5000);
|
|
181
|
+
};
|
|
182
|
+
if (options.signal.aborted) killProc();
|
|
183
|
+
else options.signal.addEventListener("abort", killProc, { once: true });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Handle timeout
|
|
187
|
+
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
188
|
+
setTimeout(() => {
|
|
189
|
+
if (!proc.killed) {
|
|
190
|
+
proc.kill("SIGTERM");
|
|
191
|
+
setTimeout(() => {
|
|
192
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
193
|
+
}, 5000);
|
|
194
|
+
}
|
|
195
|
+
}, options.timeoutMs);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
result.exitCode = exitCode;
|
|
200
|
+
if (wasAborted) throw new Error("Subagent was aborted");
|
|
201
|
+
} finally {
|
|
202
|
+
// Cleanup temp files
|
|
203
|
+
if (tmpFile) try { fs.unlinkSync(tmpFile); } catch { /* ignore */ }
|
|
204
|
+
if (tmpDir) try { fs.rmdirSync(tmpDir); } catch { /* ignore */ }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return result;
|
|
208
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent configuration and types.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Configuration for the subagent extension. */
|
|
6
|
+
export interface SubagentConfig {
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
summary: SubagentSummaryConfig;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SubagentSummaryConfig {
|
|
12
|
+
role: string;
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_CONFIG: SubagentConfig = {
|
|
17
|
+
timeoutMs: 300_000,
|
|
18
|
+
summary: { role: "utility", enabled: true },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** A built-in subagent role definition. */
|
|
22
|
+
export interface SubagentRole {
|
|
23
|
+
/** pi-model-roles role name to use for this subagent */
|
|
24
|
+
role: string;
|
|
25
|
+
/** System prompt for the subagent */
|
|
26
|
+
systemPrompt: string;
|
|
27
|
+
/** Tools available to this subagent */
|
|
28
|
+
tools: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Usage statistics from a subagent execution. */
|
|
32
|
+
export interface SubagentUsage {
|
|
33
|
+
input: number;
|
|
34
|
+
output: number;
|
|
35
|
+
cacheRead: number;
|
|
36
|
+
cacheWrite: number;
|
|
37
|
+
cost: number;
|
|
38
|
+
contextTokens: number;
|
|
39
|
+
turns: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A message from the subagent's JSON event stream. */
|
|
43
|
+
export interface SubagentMessage {
|
|
44
|
+
role: string;
|
|
45
|
+
content: Array<{
|
|
46
|
+
type: string;
|
|
47
|
+
text?: string;
|
|
48
|
+
name?: string;
|
|
49
|
+
arguments?: Record<string, any>;
|
|
50
|
+
id?: string;
|
|
51
|
+
}>;
|
|
52
|
+
usage?: {
|
|
53
|
+
input?: number;
|
|
54
|
+
output?: number;
|
|
55
|
+
cacheRead?: number;
|
|
56
|
+
cacheWrite?: number;
|
|
57
|
+
cost?: { total?: number };
|
|
58
|
+
totalTokens?: number;
|
|
59
|
+
};
|
|
60
|
+
model?: string;
|
|
61
|
+
stopReason?: string;
|
|
62
|
+
errorMessage?: string;
|
|
63
|
+
toolCallId?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Result from a single subagent execution. */
|
|
67
|
+
export interface SubagentResult {
|
|
68
|
+
/** Which subagent role was used */
|
|
69
|
+
role: string;
|
|
70
|
+
/** The task that was assigned */
|
|
71
|
+
task: string;
|
|
72
|
+
/** Process exit code (-1 = still running for streaming) */
|
|
73
|
+
exitCode: number;
|
|
74
|
+
/** All messages from the event stream (assistant + tool results) */
|
|
75
|
+
messages: SubagentMessage[];
|
|
76
|
+
/** Last assistant text output */
|
|
77
|
+
output: string;
|
|
78
|
+
/** AI-generated one-line summary for TUI display */
|
|
79
|
+
summary?: string;
|
|
80
|
+
/** stderr output */
|
|
81
|
+
stderr: string;
|
|
82
|
+
/** Token usage stats */
|
|
83
|
+
usage: SubagentUsage;
|
|
84
|
+
/** Model identifier used */
|
|
85
|
+
model?: string;
|
|
86
|
+
/** Stop reason from last message */
|
|
87
|
+
stopReason?: string;
|
|
88
|
+
/** Error message if failed */
|
|
89
|
+
errorMessage?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** TUI details structure passed via tool result details. */
|
|
93
|
+
export interface SubagentDetails {
|
|
94
|
+
mode: "single";
|
|
95
|
+
results: SubagentResult[];
|
|
96
|
+
}
|