@bacnh85/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/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@bacnh85/pi-subagent",
3
+ "version": "0.1.0",
4
+ "description": "Minimal-overhead sub-agent extension for pi. Delegate tasks to specialized agents with isolated context using the pi SDK in-process.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "homepage": "https://github.com/bacnh85/skills#readme",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/bacnh85/skills.git",
14
+ "directory": "extensions/pi-subagent"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/bacnh85/skills/issues"
18
+ },
19
+ "keywords": [
20
+ "pi-package",
21
+ "pi-extension",
22
+ "subagent",
23
+ "sub-agent",
24
+ "delegation",
25
+ "parallel"
26
+ ],
27
+ "files": [
28
+ "README.md",
29
+ "index.ts",
30
+ "agents.ts",
31
+ "runner.ts",
32
+ "render.ts",
33
+ "agents/"
34
+ ],
35
+ "pi": {
36
+ "extensions": [
37
+ "./index.ts"
38
+ ]
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-coding-agent": "*",
42
+ "@earendil-works/pi-ai": "*",
43
+ "@earendil-works/pi-agent-core": "*",
44
+ "@earendil-works/pi-tui": "*"
45
+ }
46
+ }
package/render.ts ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * TUI rendering for pi-sugagents.
3
+ *
4
+ * Renders sub-agent results in collapsed and expanded views.
5
+ * Collapsed: status icon, agent name, last few items, usage stats.
6
+ * Expanded (Ctrl+O): full task text, all tool calls, final markdown output.
7
+ */
8
+
9
+ import * as os from "node:os";
10
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
11
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
12
+ import type { Message } from "@earendil-works/pi-ai";
13
+ import { type SubAgentResult, isFailedResult, getResultOutput } from "./runner.ts";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Display helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function formatTokens(count: number): string {
20
+ if (count < 1000) return count.toString();
21
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
22
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
23
+ return `${(count / 1000000).toFixed(1)}M`;
24
+ }
25
+
26
+ export function formatUsageStats(
27
+ usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; contextTokens?: number; turns?: number },
28
+ model?: string,
29
+ ): string {
30
+ const parts: string[] = [];
31
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
32
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
33
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
34
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
35
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
36
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
37
+ if (usage.contextTokens && usage.contextTokens > 0) {
38
+ parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
39
+ }
40
+ if (model) parts.push(model);
41
+ return parts.join(" ");
42
+ }
43
+
44
+ function formatToolCall(
45
+ toolName: string,
46
+ args: Record<string, unknown>,
47
+ themeFg: (color: string, text: string) => string,
48
+ ): string {
49
+ const shortenPath = (p: string) => {
50
+ const home = os.homedir();
51
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
52
+ };
53
+
54
+ switch (toolName) {
55
+ case "bash": {
56
+ const command = (args.command as string) || "...";
57
+ const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
58
+ return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
59
+ }
60
+ case "read": {
61
+ const rawPath = (args.file_path || args.path || "...") as string;
62
+ const filePath = shortenPath(rawPath);
63
+ const offset = args.offset as number | undefined;
64
+ const limit = args.limit as number | undefined;
65
+ let text = themeFg("accent", filePath);
66
+ if (offset !== undefined || limit !== undefined) {
67
+ const startLine = offset ?? 1;
68
+ const endLine = limit !== undefined ? startLine + limit - 1 : "";
69
+ text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
70
+ }
71
+ return themeFg("muted", "read ") + text;
72
+ }
73
+ case "write": {
74
+ const rawPath = (args.file_path || args.path || "...") as string;
75
+ const content = (args.content || "") as string;
76
+ const lines = content.split("\n").length;
77
+ let text = themeFg("muted", "write ") + themeFg("accent", shortenPath(rawPath));
78
+ if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
79
+ return text;
80
+ }
81
+ case "edit": {
82
+ const rawPath = (args.file_path || args.path || "...") as string;
83
+ return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
84
+ }
85
+ case "ls": {
86
+ const rawPath = (args.path || ".") as string;
87
+ return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
88
+ }
89
+ case "find": {
90
+ const pattern = (args.pattern || "*") as string;
91
+ const rawPath = (args.path || ".") as string;
92
+ return (
93
+ themeFg("muted", "find ") +
94
+ themeFg("accent", pattern) +
95
+ themeFg("dim", ` in ${shortenPath(rawPath)}`)
96
+ );
97
+ }
98
+ case "grep": {
99
+ const pattern = (args.pattern || "") as string;
100
+ const rawPath = (args.path || ".") as string;
101
+ return (
102
+ themeFg("muted", "grep ") +
103
+ themeFg("accent", `/${pattern}/`) +
104
+ themeFg("dim", ` in ${shortenPath(rawPath)}`)
105
+ );
106
+ }
107
+ default: {
108
+ const argsStr = JSON.stringify(args);
109
+ const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
110
+ return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
111
+ }
112
+ }
113
+ }
114
+
115
+ type DisplayItem =
116
+ | { type: "text"; text: string }
117
+ | { type: "toolCall"; name: string; args: Record<string, unknown> };
118
+
119
+ function getDisplayItems(messages: Message[]): DisplayItem[] {
120
+ const items: DisplayItem[] = [];
121
+ for (const msg of messages) {
122
+ if (msg.role === "assistant") {
123
+ for (const part of msg.content) {
124
+ if (part.type === "text") {
125
+ items.push({ type: "text", text: part.text });
126
+ } else if (part.type === "toolCall") {
127
+ items.push({
128
+ type: "toolCall",
129
+ name: part.name,
130
+ args: part.arguments as Record<string, unknown>,
131
+ });
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return items;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Collapsed renderer
141
+ // ---------------------------------------------------------------------------
142
+
143
+ const COLLAPSED_ITEM_COUNT = 10;
144
+
145
+ function renderDisplayItems(
146
+ items: DisplayItem[],
147
+ theme: { fg: (c: string, t: string) => string },
148
+ limit?: number,
149
+ ): string {
150
+ const toShow = limit ? items.slice(-limit) : items;
151
+ const skipped = limit && items.length > limit ? items.length - limit : 0;
152
+ let text = "";
153
+ if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
154
+ for (const item of toShow) {
155
+ if (item.type === "text") {
156
+ const preview = item.text.split("\n").slice(0, 3).join("\n");
157
+ text += `${theme.fg("toolOutput", preview)}\n`;
158
+ } else {
159
+ text += `${theme.fg("muted", "→ ")}${formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
160
+ }
161
+ }
162
+ return text.trimEnd();
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Single agent result
167
+ // ---------------------------------------------------------------------------
168
+
169
+ export function renderSingleResult(
170
+ result: SubAgentResult,
171
+ expanded: boolean,
172
+ theme: { fg: (c: string, t: string) => string; bold: (t: string) => string },
173
+ ): Container | Text {
174
+ const isError = isFailedResult(result);
175
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
176
+ const displayItems = getDisplayItems(result.messages);
177
+ const finalOutput = getResultOutput(result);
178
+
179
+ if (expanded) {
180
+ const mdTheme = getMarkdownTheme();
181
+ const container = new Container();
182
+ let header = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
183
+ if (isError && result.stopReason) header += ` ${theme.fg("error", `[${result.stopReason}]`)}`;
184
+ container.addChild(new Text(header, 0, 0));
185
+ if (isError && result.errorMessage) {
186
+ container.addChild(new Text(theme.fg("error", `Error: ${result.errorMessage}`), 0, 0));
187
+ }
188
+ container.addChild(new Spacer(1));
189
+ container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
190
+ container.addChild(new Text(theme.fg("dim", result.task), 0, 0));
191
+ container.addChild(new Spacer(1));
192
+ container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
193
+ if (displayItems.length === 0 && !finalOutput) {
194
+ container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
195
+ } else {
196
+ for (const item of displayItems) {
197
+ if (item.type === "toolCall") {
198
+ container.addChild(
199
+ new Text(
200
+ theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
201
+ 0, 0,
202
+ ),
203
+ );
204
+ }
205
+ }
206
+ if (finalOutput) {
207
+ container.addChild(new Spacer(1));
208
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
209
+ }
210
+ }
211
+ const usageStr = formatUsageStats(result.usage, result.model);
212
+ if (usageStr) {
213
+ container.addChild(new Spacer(1));
214
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
215
+ }
216
+ return container;
217
+ }
218
+
219
+ // Collapsed
220
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
221
+ if (isError && result.stopReason) text += ` ${theme.fg("error", `[${result.stopReason}]`)}`;
222
+ if (isError && result.errorMessage) text += `\n${theme.fg("error", `Error: ${result.errorMessage}`)}`;
223
+ else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
224
+ else {
225
+ text += `\n${renderDisplayItems(displayItems, theme, COLLAPSED_ITEM_COUNT)}`;
226
+ if (displayItems.length > COLLAPSED_ITEM_COUNT) {
227
+ text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
228
+ }
229
+ }
230
+ const usageStr = formatUsageStats(result.usage, result.model);
231
+ if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
232
+ return new Text(text, 0, 0);
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Aggregate helpers
237
+ // ---------------------------------------------------------------------------
238
+
239
+ export function aggregateUsage(results: SubAgentResult[]) {
240
+ const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
241
+ for (const r of results) {
242
+ total.input += r.usage.input;
243
+ total.output += r.usage.output;
244
+ total.cacheRead += r.usage.cacheRead;
245
+ total.cacheWrite += r.usage.cacheWrite;
246
+ total.cost += r.usage.cost;
247
+ total.turns += r.usage.turns;
248
+ }
249
+ return total;
250
+ }
package/runner.ts ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * SDK-based sub-agent runner for pi-sugagents.
3
+ *
4
+ * Creates an in-process AgentSession via the pi SDK instead of spawning a
5
+ * separate `pi` process. This eliminates cold-start overhead and allows
6
+ * fine-grained control over token budget:
7
+ *
8
+ * - Only the agent's system prompt is used (no pi defaults).
9
+ * - No AGENTS.md, no extensions, no skills, no prompt templates loaded.
10
+ * - Thinking disabled, compaction disabled, retry disabled.
11
+ * - In-memory session (no disk I/O).
12
+ * - Shared auth/model infrastructure (no re-connection).
13
+ *
14
+ * Estimated token savings vs process-spawn: ~4-11K tokens per invocation.
15
+ */
16
+
17
+ import type { Message, Model } from "@earendil-works/pi-ai";
18
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
+ import {
20
+ AuthStorage,
21
+ createAgentSession,
22
+ createExtensionRuntime,
23
+ ModelRegistry,
24
+ type ResourceLoader,
25
+ SessionManager,
26
+ SettingsManager,
27
+ } from "@earendil-works/pi-coding-agent";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Types
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export interface UsageStats {
34
+ input: number;
35
+ output: number;
36
+ cacheRead: number;
37
+ cacheWrite: number;
38
+ cost: number;
39
+ contextTokens: number;
40
+ turns: number;
41
+ }
42
+
43
+ export interface SubAgentResult {
44
+ agent: string;
45
+ task: string;
46
+ exitCode: number;
47
+ messages: Message[];
48
+ stderr: string;
49
+ usage: UsageStats;
50
+ model?: string;
51
+ stopReason?: string;
52
+ errorMessage?: string;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Public API
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export async function runSubAgent(options: {
60
+ cwd: string;
61
+ systemPrompt: string;
62
+ task: string;
63
+ tools: string[];
64
+ model: Model;
65
+ authStorage: AuthStorage;
66
+ modelRegistry: ModelRegistry;
67
+ signal?: AbortSignal;
68
+ agentName?: string;
69
+ }): Promise<SubAgentResult> {
70
+ const {
71
+ cwd,
72
+ systemPrompt,
73
+ task,
74
+ tools,
75
+ model,
76
+ authStorage,
77
+ modelRegistry,
78
+ signal,
79
+ agentName = "subagent",
80
+ } = options;
81
+
82
+ const result: SubAgentResult = {
83
+ agent: agentName,
84
+ task,
85
+ exitCode: 0,
86
+ messages: [],
87
+ stderr: "",
88
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
89
+ model: `${model.provider}/${model.id}`,
90
+ };
91
+
92
+ // Build a minimal resource loader. The sub-agent sees ONLY the agent's
93
+ // system prompt — no pi defaults, no AGENTS.md, no extensions, no skills.
94
+ const resourceLoader: ResourceLoader = {
95
+ getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
96
+ getSkills: () => ({ skills: [], diagnostics: [] }),
97
+ getPrompts: () => ({ prompts: [], diagnostics: [] }),
98
+ getThemes: () => ({ themes: [], diagnostics: [] }),
99
+ getAgentsFiles: () => ({ agentsFiles: [] }),
100
+ getSystemPrompt: () => systemPrompt,
101
+ getAppendSystemPrompt: () => [],
102
+ extendResources: () => {},
103
+ reload: async () => {},
104
+ };
105
+
106
+ const settingsManager = SettingsManager.inMemory({
107
+ compaction: { enabled: false },
108
+ retry: { enabled: false },
109
+ });
110
+
111
+ try {
112
+ const { session } = await createAgentSession({
113
+ cwd,
114
+ model,
115
+ thinkingLevel: "off", // no reasoning token overhead
116
+ authStorage,
117
+ modelRegistry,
118
+ resourceLoader,
119
+ tools,
120
+ sessionManager: SessionManager.inMemory(cwd),
121
+ settingsManager,
122
+ });
123
+
124
+ try {
125
+ // Wire abort signal
126
+ if (signal) {
127
+ const onAbort = () => session.abort();
128
+ if (signal.aborted) {
129
+ // Already aborted — shortcut
130
+ result.exitCode = 1;
131
+ result.stopReason = "aborted";
132
+ result.errorMessage = "Sub-agent aborted before start";
133
+ onAbort();
134
+ return result;
135
+ }
136
+ signal.addEventListener("abort", onAbort, { once: true });
137
+
138
+ // Cleanup listener on completion
139
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
140
+ // We'll clean up in finally via a flag
141
+ }
142
+
143
+ // Collect all messages and usage stats from events
144
+ const eventPromise = new Promise<void>((resolve, reject) => {
145
+ const unsubscribe = session.subscribe((event) => {
146
+ try {
147
+ switch (event.type) {
148
+ case "message_end": {
149
+ const msg = event.message as AgentMessage;
150
+ if (msg.role === "assistant") {
151
+ result.usage.turns++;
152
+ if (msg.usage) {
153
+ result.usage.input += msg.usage.input || 0;
154
+ result.usage.output += msg.usage.output || 0;
155
+ result.usage.cacheRead += msg.usage.cacheRead || 0;
156
+ result.usage.cacheWrite += msg.usage.cacheWrite || 0;
157
+ result.usage.cost += msg.usage.cost?.total || 0;
158
+ result.usage.contextTokens = msg.usage.totalTokens || 0;
159
+ }
160
+ if (!result.model && msg.model) {
161
+ result.model = `${msg.provider || "?"}/${msg.model}`;
162
+ }
163
+ if (msg.stopReason) result.stopReason = msg.stopReason;
164
+ if (msg.errorMessage) result.errorMessage = msg.errorMessage;
165
+ }
166
+ // Collect all messages for extraction
167
+ result.messages.push(msg as unknown as Message);
168
+ break;
169
+ }
170
+ case "agent_end": {
171
+ // agent_end carries all messages; use them if we haven't collected
172
+ if (result.messages.length === 0 && event.messages) {
173
+ result.messages = event.messages as unknown as Message[];
174
+ }
175
+ unsubscribe();
176
+ resolve();
177
+ break;
178
+ }
179
+ }
180
+ } catch (err) {
181
+ unsubscribe();
182
+ reject(err);
183
+ }
184
+ });
185
+ });
186
+
187
+ await session.prompt(task);
188
+ await eventPromise;
189
+
190
+ result.exitCode = 0;
191
+ return result;
192
+ } finally {
193
+ try {
194
+ session.dispose();
195
+ } catch {
196
+ // Best-effort cleanup
197
+ }
198
+ }
199
+ } catch (err) {
200
+ const message = err instanceof Error ? err.message : String(err);
201
+ result.exitCode = 1;
202
+ result.errorMessage = message;
203
+ if (!result.stopReason) result.stopReason = "error";
204
+ return result;
205
+ }
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Helpers
210
+ // ---------------------------------------------------------------------------
211
+
212
+ export function getFinalOutput(messages: Message[]): string {
213
+ for (let i = messages.length - 1; i >= 0; i--) {
214
+ const msg = messages[i];
215
+ if (msg.role === "assistant") {
216
+ for (const part of msg.content) {
217
+ if (part.type === "text") return part.text;
218
+ }
219
+ }
220
+ }
221
+ return "";
222
+ }
223
+
224
+ export function isFailedResult(result: SubAgentResult): boolean {
225
+ return (
226
+ result.exitCode !== 0 ||
227
+ result.stopReason === "error" ||
228
+ result.stopReason === "aborted"
229
+ );
230
+ }
231
+
232
+ export function getResultOutput(result: SubAgentResult): string {
233
+ if (isFailedResult(result)) {
234
+ return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
235
+ }
236
+ return getFinalOutput(result.messages) || "(no output)";
237
+ }
238
+
239
+ /** Concurrency-limited map. Runs up to `concurrency` async operations at a time. */
240
+ export async function mapWithConcurrencyLimit<TIn, TOut>(
241
+ items: TIn[],
242
+ concurrency: number,
243
+ fn: (item: TIn, index: number) => Promise<TOut>,
244
+ ): Promise<TOut[]> {
245
+ if (items.length === 0) return [];
246
+ const limit = Math.max(1, Math.min(concurrency, items.length));
247
+ const results: TOut[] = new Array(items.length);
248
+ let nextIndex = 0;
249
+ const workers = new Array(limit).fill(null).map(async () => {
250
+ while (true) {
251
+ const current = nextIndex++;
252
+ if (current >= items.length) return;
253
+ results[current] = await fn(items[current], current);
254
+ }
255
+ });
256
+ await Promise.all(workers);
257
+ return results;
258
+ }