@leo-alvarenga/pi-mini-subagents 0.2.1 → 0.2.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo-alvarenga/pi-mini-subagents",
3
- "version": "0.2.1",
3
+ "version": "0.2.5",
4
4
  "description": "Transient subagents for Pi: mini_subagent tool (single + parallel), /subagents command, and a live TUI panel",
5
5
  "license": "MIT",
6
6
  "repository": "github.com/leo-alvarenga/pi-mono",
@@ -15,10 +15,10 @@
15
15
  ],
16
16
  "files": [
17
17
  "src",
18
+ "!src/test",
18
19
  "skills",
19
20
  "README.md",
20
- "LICENSE",
21
- "!src/*.test.ts"
21
+ "LICENSE"
22
22
  ],
23
23
  "peerDependencies": {
24
24
  "@earendil-works/pi-ai": "*",
@@ -26,6 +26,9 @@
26
26
  "@earendil-works/pi-tui": "*",
27
27
  "typebox": "*"
28
28
  },
29
+ "dependencies": {
30
+ "@leo-alvarenga/pi-ext-core": "^0.3.4"
31
+ },
29
32
  "pi": {
30
33
  "extensions": [
31
34
  "./src/index.ts"
@@ -36,11 +39,12 @@
36
39
  },
37
40
  "devDependencies": {
38
41
  "@types/node": "^22.0.0",
39
- "tsx": "^4.19.0",
40
- "typescript": "^7.0.2"
42
+ "typescript": "^7.0.2",
43
+ "vitest": "^3.2.4"
41
44
  },
42
45
  "scripts": {
43
46
  "build": "tsc --noEmit",
44
- "typecheck": "tsc --noEmit"
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "vitest run"
45
49
  }
46
50
  }
package/src/constants.ts CHANGED
@@ -1,13 +1,5 @@
1
1
  import type { KeyId } from "@earendil-works/pi-tui";
2
- import type { ThemeColor } from "@earendil-works/pi-coding-agent";
3
-
4
- import type { SubagentStatus } from "./types";
5
-
6
- export type SubagentStatusUi = {
7
- icon: string;
8
- fg: ThemeColor;
9
- bold?: boolean;
10
- };
2
+ import type { StatusUi, SubagentStatus } from "@leo-alvarenga/pi-ext-core";
11
3
 
12
4
  export const STATUSES = [
13
5
  "running",
@@ -16,7 +8,7 @@ export const STATUSES = [
16
8
  "needs_input",
17
9
  ] as const;
18
10
 
19
- export const STATUS_STYLES: Record<SubagentStatus, SubagentStatusUi> = {
11
+ export const STATUS_STYLES: Record<SubagentStatus, StatusUi> = {
20
12
  running: { icon: "󱥸 ", fg: "accent", bold: true },
21
13
  completed: { icon: "✓ ", fg: "success" },
22
14
  failed: { icon: "✗ ", fg: "error", bold: true },
@@ -41,6 +33,14 @@ export const WRITE_TOOLS = [
41
33
  /** Marker the subagent emits when it cannot proceed without outside input. */
42
34
  export const NEEDS_INPUT_MARKER = "NEEDS_INPUT:";
43
35
 
36
+ /** Prompt suffix block that instructs the subagent to use the marker. */
37
+ export const NEEDS_INPUT_SUFFIX = `If the task cannot be completed without information you cannot obtain yourself, end your final message with the exact block below and stop — do not guess:
38
+
39
+ ${NEEDS_INPUT_MARKER}
40
+ - <question>
41
+
42
+ Report your findings clearly and concisely.`;
43
+
44
44
  /** Max parallel tasks per call. */
45
45
  export const MAX_PARALLEL_TASKS = 8;
46
46
  /** Max concurrent subagent processes. */
@@ -64,8 +64,3 @@ export const REPORT_ENTRY = "subagents.report";
64
64
 
65
65
  /** Chord that toggles the panel. */
66
66
  export const PANEL_TOGGLE_CHORD: KeyId = "alt+s";
67
-
68
- export const PANEL_STATE_ICON = {
69
- collapsed: "󰅂",
70
- expanded: "󰅀",
71
- };
package/src/index.ts CHANGED
@@ -1,31 +1,116 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ capitalize,
4
+ createSubagentRuntime,
5
+ formatTokens,
6
+ type SubagentRecord,
7
+ } from "@leo-alvarenga/pi-ext-core";
2
8
 
3
- import { registerSubagentsCommand } from "./command";
4
- import { STATE_ENTRY, WIDGET_KEY } from "./constants";
5
- import { killAllRunning } from "./spawn";
6
- import { SubagentStore } from "./state";
7
- import { registerMiniSubagentTool } from "./tool";
8
- import { registerSubagentWidget, refreshWidget } from "./widget";
9
+ import {
10
+ MAX_CONCURRENCY,
11
+ MAX_PANEL_ROWS,
12
+ MAX_PARALLEL_TASKS,
13
+ MAX_STORED_OUTPUT,
14
+ NEEDS_INPUT_MARKER,
15
+ NEEDS_INPUT_SUFFIX,
16
+ PANEL_TOGGLE_CHORD,
17
+ PER_TASK_OUTPUT_CAP,
18
+ READ_ONLY_TOOLS,
19
+ REPORT_ENTRY,
20
+ STATE_ENTRY,
21
+ STATUS_STYLES,
22
+ WIDGET_KEY,
23
+ WRITE_TOOLS,
24
+ } from "./constants";
25
+
26
+ function getStyledSubagent(r: SubagentRecord, th: Theme): string {
27
+ const task = r.task.replace(/\s*[\r\n]+\s*/g, " ");
28
+ const style = STATUS_STYLES[r.status] ?? STATUS_STYLES.completed;
29
+ let line = `${th.fg(style.fg, style.icon)} ${th.fg("accent", `#${r.id}`)} ${th.fg(style.fg, task)}`;
30
+ if (r.status === "completed" && r.tokens) {
31
+ line += th.fg("dim", ` · ${formatTokens(r.tokens)}`);
32
+ }
33
+ if (r.status === "failed") line += th.fg("dim", " · failed");
34
+ if (r.status === "needs_input") line += th.fg("dim", " · needs input");
35
+ if (r.allowWrite) line += th.fg("warning", " ✎");
36
+ return line;
37
+ }
9
38
 
10
39
  export default function (pi: ExtensionAPI): void {
11
40
  // Child subagent processes inherit PI_SUBAGENT=1; they must not be able to
12
41
  // spawn sub-subagents, so the tool/widget/command never register there.
13
42
  if (process.env.PI_SUBAGENT) return;
14
43
 
15
- const store = new SubagentStore(
16
- (snapshot) => pi.appendEntry(STATE_ENTRY, snapshot),
17
- (ctx) => refreshWidget(ctx, store),
18
- );
19
-
20
- registerMiniSubagentTool(pi, store);
21
- registerSubagentsCommand(pi, store);
22
- registerSubagentWidget(pi, store);
23
-
24
- pi.on("session_start", (_event, ctx) => store.replay(ctx));
25
- pi.on("session_tree", (_event, ctx) => store.replay(ctx));
26
- pi.on("session_before_compact", (_event, ctx) => store.persistSnapshot(ctx));
27
- pi.on("session_shutdown", (_event, ctx) => {
28
- killAllRunning();
29
- if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
44
+ createSubagentRuntime(pi, {
45
+ toolName: "mini_subagent",
46
+ toolLabel: "Mini Subagent",
47
+ spawnFlagEnv: "PI_SUBAGENT",
48
+ toolDescription:
49
+ "Delegate a task to a transient headless subagent (a separate pi process) and get its findings back. " +
50
+ "Modes: single (task) or parallel (tasks array, max 8). Subagents are read-only by default; set allowWrite to let one edit files. " +
51
+ "If a subagent reports it needs input (NEEDS_INPUT), answer the questions and call again with `answers`.",
52
+ promptSnippet:
53
+ "Delegate a task to a transient read-only subagent (single or parallel)",
54
+ promptGuidelines: [
55
+ "Subagents are read-only unless you set allowWrite: true.",
56
+ "If a result asks for input, answer the questions (ask the user if needed) and re-call with `answers` — do not guess.",
57
+ ],
58
+ promptInstructions: {
59
+ always: "You are a transient subagent. Complete the task, then stop",
60
+ readOnly:
61
+ "You may only READ and EXPLORE. Do not modify files or run mutating commands.",
62
+ writeAllowed:
63
+ "You may edit files ONLY if strictly necessary, preferring hash-anchored operations (replace/insert) over rewriting.",
64
+ },
65
+ needsInput: {
66
+ marker: NEEDS_INPUT_MARKER,
67
+ suffix: NEEDS_INPUT_SUFFIX,
68
+ },
69
+ allowlists: {
70
+ readOnly: READ_ONLY_TOOLS,
71
+ writeable: WRITE_TOOLS,
72
+ },
73
+
74
+ limits: {
75
+ maxPanelRows: MAX_PANEL_ROWS,
76
+ maxConcurrency: MAX_CONCURRENCY,
77
+ maxStoredOutput: MAX_STORED_OUTPUT,
78
+ maxParallelTasks: MAX_PARALLEL_TASKS,
79
+ perTaskOutputCap: PER_TASK_OUTPUT_CAP,
80
+ },
81
+
82
+ state: { entryType: STATE_ENTRY },
83
+ report: { entryType: REPORT_ENTRY },
84
+
85
+ panel: {
86
+ widgetKey: WIDGET_KEY,
87
+ toggleChord: PANEL_TOGGLE_CHORD,
88
+ title: "Subagents",
89
+ emptyText: "No subagents yet. Ask the agent to delegate a task!",
90
+ },
91
+
92
+ rowLine: (r, theme) => getStyledSubagent(r, theme),
93
+ reportSections: (s) => {
94
+ const byStatus = s.records.reduce<Record<string, SubagentRecord[]>>(
95
+ (acc, curr) => {
96
+ const label = capitalize(curr.status.toLowerCase()).replaceAll(
97
+ "_",
98
+ " ",
99
+ );
100
+
101
+ if (!acc[label]?.length) acc[label] = [];
102
+
103
+ acc[label].push(curr);
104
+
105
+ return acc;
106
+ },
107
+ {},
108
+ );
109
+
110
+ return Object.entries(byStatus).map(([label, records]) => ({
111
+ label,
112
+ records,
113
+ }));
114
+ },
30
115
  });
31
116
  }
package/src/command.ts DELETED
@@ -1,71 +0,0 @@
1
- import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
- import { Text } from "@earendil-works/pi-tui";
3
-
4
- import { REPORT_ENTRY } from "./constants";
5
- import type { SubagentStore } from "./state";
6
- import type { SubagentRecord } from "./types";
7
- import { getStyledSubagent } from "./utils";
8
-
9
- function renderReport(records: SubagentRecord[], theme: Theme): string {
10
- if (records.length === 0) return ` ${theme.fg("dim", "No subagents.")}`;
11
-
12
- const lines: string[] = [];
13
- const section = (label: string, items: SubagentRecord[]): void => {
14
- if (items.length === 0) return;
15
-
16
- lines.push(` ${theme.fg("muted", `${label} (${items.length})`)}`);
17
-
18
- for (const r of items) {
19
- lines.push(getStyledSubagent(r, theme));
20
- }
21
-
22
- lines.push("");
23
- };
24
-
25
- section(
26
- "Running",
27
- records.filter((r) => r.status === "running"),
28
- );
29
-
30
- section(
31
- "Needs input",
32
- records.filter((r) => r.status === "needs_input"),
33
- );
34
-
35
- section(
36
- "Completed",
37
- records.filter((r) => r.status === "completed"),
38
- );
39
-
40
- section(
41
- "Failed",
42
- records.filter((r) => r.status === "failed"),
43
- );
44
-
45
- return lines.join("\n");
46
- }
47
-
48
- export function registerSubagentsCommand(
49
- pi: ExtensionAPI,
50
- store: SubagentStore,
51
- ): void {
52
- pi.registerCommand("subagents", {
53
- description: "Show all subagents grouped by status",
54
- handler: async (_args, ctx) => {
55
- if (!ctx.hasUI) {
56
- ctx.ui.notify("/subagents requires interactive mode", "error");
57
- return;
58
- }
59
-
60
- pi.appendEntry(REPORT_ENTRY, {
61
- records: [...store.getState(ctx).records],
62
- });
63
- },
64
- });
65
-
66
- pi.registerEntryRenderer<{ records: SubagentRecord[] }>(
67
- REPORT_ENTRY,
68
- (entry, _options, theme) =>
69
- new Text(renderReport(entry.data?.records ?? [], theme), 0, 0),
70
- );
71
- }
package/src/core.ts DELETED
@@ -1,104 +0,0 @@
1
- import {
2
- MAX_STORED_OUTPUT,
3
- NEEDS_INPUT_MARKER,
4
- READ_ONLY_TOOLS,
5
- WRITE_TOOLS,
6
- } from "./constants";
7
- import type { SubagentStatus } from "./types";
8
-
9
- const ALWAYS = "You are a transient subagent. Complete the task, then stop";
10
- const READ_ONLY = `You may only READ and EXPLORE. Do not modify files or run mutating commands.`;
11
- const WRITE_OK = `You may edit files ONLY if strictly necessary, preferring hash-anchored operations (replace/insert) over rewriting.`;
12
-
13
- const QUESTIONS_SUFFIX = `If the task cannot be completed without information you cannot obtain yourself, end your final message with the exact block below and stop — do not guess:
14
-
15
- ${NEEDS_INPUT_MARKER}
16
- - <question>
17
-
18
- Report your findings clearly and concisely.`;
19
-
20
- /** Dynamic minimal prompt: read-only always; write permission added only when allowed. */
21
- export function buildSystemPrompt(allowWrite: boolean): string {
22
- let prompt = ALWAYS;
23
-
24
- if (!allowWrite) prompt += `\n\n${READ_ONLY}`;
25
- else prompt += `\n\n${WRITE_OK}`;
26
-
27
- return `${prompt}\n\n${QUESTIONS_SUFFIX}`;
28
- }
29
-
30
- /** Tool allowlist for the spawned child process. */
31
- export function buildAllowlist(allowWrite: boolean): string[] {
32
- return allowWrite ? WRITE_TOOLS : READ_ONLY_TOOLS;
33
- }
34
-
35
- /**
36
- * Extract the questions the subagent needs answered, if it emitted the
37
- * NEEDS_INPUT block. Returns `[]` when the marker is present but no bullet
38
- * lines follow, and `undefined` when the marker is absent.
39
- */
40
- export function parseNeedsInput(text: string): string[] | undefined {
41
- const lines = text.split("\n");
42
- const idx = lines.findIndex((l) => l.trim() === NEEDS_INPUT_MARKER);
43
- if (idx === -1) return undefined;
44
-
45
- const questions: string[] = [];
46
-
47
- for (const line of lines.slice(idx + 1)) {
48
- const trimmed = line.trim();
49
-
50
- if (trimmed.startsWith("- ")) {
51
- const q = trimmed.slice(2).trim();
52
- if (q) questions.push(q);
53
- } else if (trimmed === "") {
54
- continue;
55
- } else {
56
- break;
57
- }
58
- }
59
-
60
- return questions;
61
- }
62
-
63
- /** Classify a finished subagent run into a record status. */
64
- export function classifyResult(opts: {
65
- exitCode: number;
66
- stopReason?: string;
67
- needsInput: boolean;
68
- }): SubagentStatus {
69
- if (opts.needsInput) return "needs_input";
70
-
71
- if (
72
- opts.exitCode !== 0 ||
73
- opts.stopReason === "error" ||
74
- opts.stopReason === "aborted"
75
- ) {
76
- return "failed";
77
- }
78
-
79
- return "completed";
80
- }
81
-
82
- /** Truncate a string to a char budget, appending an ellipsis marker. */
83
- export function truncateChars(text: string, max: number): string {
84
- if (text.length <= max) return text;
85
-
86
- return `${text.slice(0, max)}…`;
87
- }
88
-
89
- /** Truncate to a UTF-8 byte budget (for parallel per-task caps). */
90
- export function truncateBytes(text: string, maxBytes: number): string {
91
- if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
92
-
93
- let out = text.slice(0, maxBytes);
94
- while (Buffer.byteLength(out, "utf8") > maxBytes) {
95
- out = out.slice(0, -1);
96
- }
97
-
98
- return `${out}\n\n[Output truncated: ${Buffer.byteLength(text, "utf8") - Buffer.byteLength(out, "utf8")} bytes omitted.]`;
99
- }
100
-
101
- /** Final output kept in the stored record (TUI summary only). */
102
- export function summarizeOutput(text: string): string {
103
- return truncateChars(text, MAX_STORED_OUTPUT);
104
- }
package/src/spawn.ts DELETED
@@ -1,223 +0,0 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
2
- import * as fs from "node:fs";
3
- import * as os from "node:os";
4
- import * as path from "node:path";
5
-
6
- import { buildAllowlist, buildSystemPrompt } from "./core";
7
-
8
- export interface SubagentUsage {
9
- input: number;
10
- output: number;
11
- cacheRead: number;
12
- cacheWrite: number;
13
- cost: number;
14
- contextTokens: number;
15
- turns: number;
16
- }
17
-
18
- export interface SubagentRunResult {
19
- /** Final assistant text from the subagent. */
20
- output: string;
21
- usage: SubagentUsage;
22
- model?: string;
23
- stopReason?: string;
24
- errorMessage?: string;
25
- exitCode: number;
26
- stderr: string;
27
- aborted: boolean;
28
- }
29
-
30
- const activeProcesses = new Set<ChildProcess>();
31
-
32
- /** Signal every still-running subagent process (session shutdown cleanup). */
33
- export function killAllRunning(): void {
34
- for (const proc of activeProcesses) {
35
- try {
36
- proc.kill("SIGTERM");
37
- } catch {
38
- /* already gone */
39
- }
40
- }
41
- }
42
-
43
- /** Resolve the command that re-invokes pi (handles bun virtual scripts). */
44
- function getPiInvocation(args: string[]): { command: string; args: string[] } {
45
- const currentScript = process.argv[1];
46
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
47
- if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
48
- return { command: process.execPath, args: [currentScript, ...args] };
49
- }
50
- const execName = path.basename(process.execPath).toLowerCase();
51
- const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
52
- if (!isGenericRuntime) return { command: process.execPath, args };
53
- return { command: "pi", args };
54
- }
55
-
56
- async function writePromptTempFile(
57
- prompt: string,
58
- ): Promise<{ dir: string; filePath: string }> {
59
- const dir = await fs.promises.mkdtemp(
60
- path.join(os.tmpdir(), "pi-mini-subagent-"),
61
- );
62
- const filePath = path.join(dir, "prompt.md");
63
- await fs.promises.writeFile(filePath, prompt, {
64
- encoding: "utf-8",
65
- mode: 0o600,
66
- });
67
- return { dir, filePath };
68
- }
69
-
70
- /** Extract the last text part of an assistant message. */
71
- function extractText(msg: any): string {
72
- const content = Array.isArray(msg?.content) ? msg.content : [];
73
- for (let i = content.length - 1; i >= 0; i--) {
74
- const part = content[i];
75
- if (part?.type === "text" && typeof part.text === "string")
76
- return part.text;
77
- }
78
- return "";
79
- }
80
-
81
- export interface RunSubagentOptions {
82
- cwd: string;
83
- task: string;
84
- allowWrite: boolean;
85
- answers?: string;
86
- signal?: AbortSignal;
87
- }
88
-
89
- /**
90
- * Spawn a transient headless pi subprocess, stream its JSON events, and
91
- * resolve with the final output once it exits.
92
- */
93
- export async function runSubagent(
94
- opts: RunSubagentOptions,
95
- ): Promise<SubagentRunResult> {
96
- const prompt = buildSystemPrompt(opts.allowWrite);
97
- const { dir: tmpDir, filePath: tmpPath } = await writePromptTempFile(prompt);
98
-
99
- const taskText = opts.answers
100
- ? `Task: ${opts.task}\n\nAnswers from the user:\n${opts.answers}`
101
- : `Task: ${opts.task}`;
102
-
103
- const args = [
104
- "--mode",
105
- "json",
106
- "-p",
107
- "--no-session",
108
- "--tools",
109
- buildAllowlist(opts.allowWrite).join(","),
110
- "--append-system-prompt",
111
- tmpPath,
112
- taskText,
113
- ];
114
-
115
- const result: SubagentRunResult = {
116
- output: "",
117
- usage: {
118
- input: 0,
119
- output: 0,
120
- cacheRead: 0,
121
- cacheWrite: 0,
122
- cost: 0,
123
- contextTokens: 0,
124
- turns: 0,
125
- },
126
- exitCode: 0,
127
- stderr: "",
128
- aborted: false,
129
- };
130
-
131
- try {
132
- const exitCode = await new Promise<number>((resolve) => {
133
- const invocation = getPiInvocation(args);
134
- const proc = spawn(invocation.command, invocation.args, {
135
- cwd: opts.cwd,
136
- shell: false,
137
- stdio: ["ignore", "pipe", "pipe"],
138
- env: { ...process.env, PI_SUBAGENT: "1" },
139
- });
140
- activeProcesses.add(proc);
141
-
142
- let buffer = "";
143
- const processLine = (line: string) => {
144
- if (!line.trim()) return;
145
- let event: any;
146
- try {
147
- event = JSON.parse(line);
148
- } catch {
149
- return;
150
- }
151
-
152
- if (event.type === "message_end" && event.message) {
153
- const msg = event.message;
154
- if (msg.role === "assistant") {
155
- result.usage.turns++;
156
- const usage = msg.usage;
157
- if (usage) {
158
- result.usage.input += usage.input || 0;
159
- result.usage.output += usage.output || 0;
160
- result.usage.cacheRead += usage.cacheRead || 0;
161
- result.usage.cacheWrite += usage.cacheWrite || 0;
162
- result.usage.cost += usage.cost?.total || 0;
163
- result.usage.contextTokens = usage.totalTokens || 0;
164
- }
165
- if (!result.model && msg.model) result.model = msg.model;
166
- if (msg.stopReason) result.stopReason = msg.stopReason;
167
- if (msg.errorMessage) result.errorMessage = msg.errorMessage;
168
- result.output = extractText(msg);
169
- }
170
- }
171
- };
172
-
173
- proc.stdout.on("data", (data) => {
174
- buffer += data.toString();
175
- const lines = buffer.split("\n");
176
- buffer = lines.pop() || "";
177
- for (const line of lines) processLine(line);
178
- });
179
-
180
- proc.stderr.on("data", (data) => {
181
- result.stderr += data.toString();
182
- });
183
-
184
- proc.on("close", (code) => {
185
- activeProcesses.delete(proc);
186
- if (buffer.trim()) processLine(buffer);
187
- resolve(code ?? 0);
188
- });
189
-
190
- proc.on("error", () => {
191
- activeProcesses.delete(proc);
192
- resolve(1);
193
- });
194
-
195
- if (opts.signal) {
196
- const kill = () => {
197
- result.aborted = true;
198
- proc.kill("SIGTERM");
199
- setTimeout(() => {
200
- try {
201
- proc.kill("SIGKILL");
202
- } catch {
203
- /* already dead */
204
- }
205
- }, 5000);
206
- };
207
- if (opts.signal.aborted) kill();
208
- else opts.signal.addEventListener("abort", kill, { once: true });
209
- }
210
- });
211
-
212
- result.exitCode = exitCode;
213
- if (result.aborted) result.stopReason = "aborted";
214
- return result;
215
- } finally {
216
- try {
217
- fs.unlinkSync(tmpPath);
218
- fs.rmdirSync(tmpDir);
219
- } catch {
220
- /* ignore */
221
- }
222
- }
223
- }
package/src/state.ts DELETED
@@ -1,97 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
-
3
- import { STATE_ENTRY } from "./constants";
4
- import type { SubagentRecord, SubagentState } from "./types";
5
-
6
- /**
7
- * Session-scoped subagent state.
8
- *
9
- * Keyed by session id so forked/parallel sessions never overwrite each other,
10
- * and replayed from the session branch on session events so it survives
11
- * /reload and compaction without the extension writing its own files.
12
- * Running records are dropped on replay — their processes do not survive a reload.
13
- */
14
- export class SubagentStore {
15
- private readonly stateBySession = new Map<string, SubagentState>();
16
-
17
- constructor(
18
- private readonly persist: (snapshot: SubagentState) => void,
19
- private readonly emitChange: (ctx: ExtensionContext) => void,
20
- ) {}
21
-
22
- private sid(ctx: ExtensionContext): string {
23
- return ctx.sessionManager.getSessionId();
24
- }
25
-
26
- getState(ctx: ExtensionContext): SubagentState {
27
- const sid = this.sid(ctx);
28
- let s = this.stateBySession.get(sid);
29
- if (!s) this.stateBySession.set(sid, (s = { records: [], nextId: 1 }));
30
- return s;
31
- }
32
-
33
- /** Commit a snapshot: update memory, persist, refresh the panel. */
34
- commit(ctx: ExtensionContext, next: SubagentState): void {
35
- this.stateBySession.set(this.sid(ctx), next);
36
- this.persist(next);
37
- this.emitChange(ctx);
38
- }
39
-
40
- /** Create a `running` record and commit it. */
41
- start(
42
- ctx: ExtensionContext,
43
- task: string,
44
- allowWrite: boolean,
45
- cwd?: string,
46
- ): SubagentRecord {
47
- const s = this.getState(ctx);
48
- const record: SubagentRecord = {
49
- id: s.nextId,
50
- task,
51
- status: "running",
52
- allowWrite,
53
- cwd,
54
- startedAt: Date.now(),
55
- };
56
- this.commit(ctx, { records: [...s.records, record], nextId: s.nextId + 1 });
57
- return record;
58
- }
59
-
60
- /** Patch an existing record in place and commit. */
61
- finish(
62
- ctx: ExtensionContext,
63
- id: number,
64
- patch: Partial<SubagentRecord>,
65
- ): void {
66
- const s = this.getState(ctx);
67
- this.commit(ctx, {
68
- records: s.records.map((r) => (r.id === id ? { ...r, ...patch } : r)),
69
- nextId: s.nextId,
70
- });
71
- }
72
-
73
- /** Rebuild the current session's state from the branch (no disk writes). */
74
- replay(ctx: ExtensionContext): void {
75
- const s = this.getState(ctx);
76
- s.records = [];
77
- s.nextId = 1;
78
-
79
- for (const entry of ctx.sessionManager.getBranch()) {
80
- if (entry.type === "custom" && entry.customType === STATE_ENTRY) {
81
- const d = entry.data as SubagentState | undefined;
82
- if (d) {
83
- s.records = d.records.filter((r) => r.status !== "running");
84
- s.nextId = d.nextId;
85
- }
86
- }
87
- }
88
-
89
- this.emitChange(ctx);
90
- }
91
-
92
- /** Persist the latest snapshot right before compaction so it lands after the cut point. */
93
- persistSnapshot(ctx: ExtensionContext): void {
94
- const s = this.getState(ctx);
95
- if (s.records.length > 0) this.persist(s);
96
- }
97
- }
package/src/tool.ts DELETED
@@ -1,321 +0,0 @@
1
- import { Type } from "typebox";
2
- import type {
3
- AgentToolResult,
4
- ExtensionAPI,
5
- } from "@earendil-works/pi-coding-agent";
6
- import { Text } from "@earendil-works/pi-tui";
7
-
8
- import {
9
- MAX_CONCURRENCY,
10
- MAX_PARALLEL_TASKS,
11
- PER_TASK_OUTPUT_CAP,
12
- } from "./constants";
13
- import {
14
- classifyResult,
15
- parseNeedsInput,
16
- summarizeOutput,
17
- truncateBytes,
18
- } from "./core";
19
- import { runSubagent } from "./spawn";
20
- import type { SubagentStore } from "./state";
21
- import type { SubagentDetails, SubagentRecord } from "./types";
22
-
23
- const TaskItem = Type.Object({
24
- task: Type.String({ description: "Task to delegate to a subagent" }),
25
- allowWrite: Type.Optional(
26
- Type.Boolean({
27
- description: "Allow the subagent to edit files. Default: false.",
28
- }),
29
- ),
30
- answers: Type.Optional(
31
- Type.String({
32
- description: "Answers to a prior NEEDS_INPUT, for re-spawn of this task",
33
- }),
34
- ),
35
- cwd: Type.Optional(
36
- Type.String({ description: "Working directory for this subagent" }),
37
- ),
38
- });
39
-
40
- const MiniSubagentParams = Type.Object({
41
- task: Type.Optional(
42
- Type.String({ description: "Task to delegate (single mode)" }),
43
- ),
44
- tasks: Type.Optional(
45
- Type.Array(TaskItem, {
46
- description: "Tasks to delegate in parallel (max 8)",
47
- }),
48
- ),
49
- allowWrite: Type.Optional(
50
- Type.Boolean({
51
- description: "Allow write for single mode. Default: false.",
52
- }),
53
- ),
54
- answers: Type.Optional(
55
- Type.String({
56
- description: "Answers to a prior NEEDS_INPUT (single mode re-spawn)",
57
- }),
58
- ),
59
- cwd: Type.Optional(
60
- Type.String({ description: "Working directory (single mode)" }),
61
- ),
62
- });
63
-
64
- async function mapWithConcurrencyLimit<TIn, TOut>(
65
- items: TIn[],
66
- concurrency: number,
67
- fn: (item: TIn, index: number) => Promise<TOut>,
68
- ): Promise<TOut[]> {
69
- if (items.length === 0) return [];
70
-
71
- const limit = Math.max(1, Math.min(concurrency, items.length));
72
- const results = new Array<TOut>(items.length);
73
-
74
- let next = 0;
75
- const workers = new Array(limit).fill(null).map(async () => {
76
- while (true) {
77
- const i = next++;
78
- if (i >= items.length) return;
79
- results[i] = await fn(items[i], i);
80
- }
81
- });
82
-
83
- await Promise.all(workers);
84
- return results;
85
- }
86
-
87
- function needsInputContent(r: SubagentRecord): string {
88
- const qs = (r.questions ?? []).map((q) => `- ${q}`).join("\n");
89
-
90
- return `The subagent needs input to complete this task.\n\nQuestions:\n${qs || "- (unparsed)"}\n\nAsk the user (or answer from context), then call mini_subagent again with the same \`task\` and your answers in \`answers\`.`;
91
- }
92
-
93
- function singleContent(r: SubagentRecord): string {
94
- if (r.status === "needs_input") return needsInputContent(r);
95
-
96
- if (r.status === "failed") {
97
- return `Subagent failed: ${r.error ?? r.output ?? "(no output)"}`;
98
- }
99
-
100
- return r.output ?? "(no output)";
101
- }
102
-
103
- export function registerMiniSubagentTool(
104
- pi: ExtensionAPI,
105
- store: SubagentStore,
106
- ): void {
107
- pi.registerTool({
108
- name: "mini_subagent",
109
- label: "Mini Subagent",
110
- parameters: MiniSubagentParams,
111
- description:
112
- "Delegate a task to a transient headless subagent (a separate pi process) and get its findings back. " +
113
- "Modes: single (task) or parallel (tasks array, max 8). Subagents are read-only by default; set allowWrite to let one edit files. " +
114
- "If a subagent reports it needs input (NEEDS_INPUT), answer the questions and call again with `answers`.",
115
- promptSnippet:
116
- "Delegate a task to a transient read-only subagent (single or parallel)",
117
- promptGuidelines: [
118
- "Subagents are read-only unless you set allowWrite: true.",
119
- "If a result asks for input, answer the questions (ask the user if needed) and re-call with `answers` — do not guess.",
120
- ],
121
-
122
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
123
- const hasTasks = (params.tasks?.length ?? 0) > 0;
124
- const hasSingle = Boolean(params.task);
125
- const modeCount = Number(hasTasks) + Number(hasSingle);
126
-
127
- const errorResult = (text: string): AgentToolResult<SubagentDetails> => ({
128
- content: [{ type: "text", text }],
129
- details: { mode: hasTasks ? "parallel" : "single", records: [] },
130
- });
131
-
132
- if (modeCount !== 1) {
133
- return errorResult(
134
- "Provide exactly one of `task` (single) or `tasks` (parallel).",
135
- );
136
- }
137
-
138
- // single mode
139
- if (hasSingle) {
140
- const record = store.start(
141
- ctx,
142
- params.task!,
143
- params.allowWrite ?? false,
144
- params.cwd,
145
- );
146
-
147
- const run = await runSubagent({
148
- cwd: params.cwd ?? ctx.cwd,
149
- task: params.task!,
150
- allowWrite: params.allowWrite ?? false,
151
- answers: params.answers,
152
- signal,
153
- });
154
-
155
- const needsInput = parseNeedsInput(run.output);
156
-
157
- const status = classifyResult({
158
- exitCode: run.exitCode,
159
- stopReason: run.stopReason,
160
- needsInput: needsInput !== undefined,
161
- });
162
-
163
- const patch: Partial<SubagentRecord> = {
164
- status,
165
- output: summarizeOutput(run.output),
166
- tokens: run.usage.contextTokens,
167
- questions: needsInput,
168
- error:
169
- status === "failed"
170
- ? run.errorMessage || run.stderr.slice(0, 500) || undefined
171
- : undefined,
172
- finishedAt: Date.now(),
173
- };
174
-
175
- store.finish(ctx, record.id, patch);
176
- const final = { ...record, ...patch };
177
-
178
- return {
179
- content: [{ type: "text", text: singleContent(final) }],
180
- details: {
181
- mode: "single",
182
- records: [final],
183
- } satisfies SubagentDetails,
184
- };
185
- }
186
-
187
- // parallel mode
188
- const tasks = params.tasks!;
189
- if (tasks.length > MAX_PARALLEL_TASKS) {
190
- return errorResult(
191
- `Too many parallel tasks (${tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
192
- );
193
- }
194
-
195
- const results: SubagentRecord[] = tasks.map((t) =>
196
- store.start(ctx, t.task, t.allowWrite ?? false, t.cwd),
197
- );
198
- const makeDetails = (): SubagentDetails => ({
199
- mode: "parallel",
200
- records: [...results],
201
- });
202
- const emit = () => {
203
- const running = results.filter((r) => r.status === "running").length;
204
- const done = results.length - running;
205
- onUpdate?.({
206
- content: [
207
- {
208
- type: "text",
209
- text: `Parallel: ${done}/${results.length} done, ${running} running…`,
210
- },
211
- ],
212
- details: makeDetails(),
213
- });
214
- };
215
-
216
- await mapWithConcurrencyLimit(
217
- tasks,
218
- MAX_CONCURRENCY,
219
- async (t, index) => {
220
- const run = await runSubagent({
221
- cwd: t.cwd ?? ctx.cwd,
222
- task: t.task,
223
- allowWrite: t.allowWrite ?? false,
224
- answers: t.answers,
225
- signal,
226
- });
227
- const needsInput = parseNeedsInput(run.output);
228
- const status = classifyResult({
229
- exitCode: run.exitCode,
230
- stopReason: run.stopReason,
231
- needsInput: needsInput !== undefined,
232
- });
233
- const patch: Partial<SubagentRecord> = {
234
- status,
235
- output: summarizeOutput(run.output),
236
- tokens: run.usage.contextTokens,
237
- questions: needsInput,
238
- error:
239
- status === "failed"
240
- ? run.errorMessage || run.stderr.slice(0, 500) || undefined
241
- : undefined,
242
- finishedAt: Date.now(),
243
- };
244
- results[index] = { ...results[index], ...patch };
245
- store.finish(ctx, results[index].id, patch);
246
- emit();
247
- },
248
- );
249
-
250
- const successCount = results.filter(
251
- (r) => r.status === "completed",
252
- ).length;
253
- const sections = results.map((r) => {
254
- let body: string;
255
- if (r.status === "completed")
256
- body = truncateBytes(r.output ?? "", PER_TASK_OUTPUT_CAP);
257
- else if (r.status === "needs_input")
258
- body = `Needs input:\n${(r.questions ?? []).map((q) => `- ${q}`).join("\n")}`;
259
- else body = r.error ?? "(no output)";
260
- return `### #${r.id} ${r.status}\n${body}`;
261
- });
262
-
263
- let content = `Parallel: ${successCount}/${results.length} succeeded\n\n${sections.join("\n\n---\n\n")}`;
264
- const blocked = results.filter((r) => r.status === "needs_input");
265
- if (blocked.length > 0) {
266
- content += `\n\nSome subagents need input. Answer their questions, then call mini_subagent again with \`tasks\` for just those tasks (each with its own \`answers\`):\n`;
267
- content += blocked
268
- .map(
269
- (r) =>
270
- `- #${r.id}: ${(r.questions ?? []).join(" / ") || "(unparsed)"}`,
271
- )
272
- .join("\n");
273
- }
274
-
275
- return {
276
- content: [{ type: "text", text: content }],
277
- details: makeDetails(),
278
- };
279
- },
280
-
281
- renderCall(args, theme) {
282
- if (args.tasks && args.tasks.length > 0) {
283
- let text =
284
- theme.fg("toolTitle", theme.bold("mini_subagent ")) +
285
- theme.fg("accent", `parallel (${args.tasks.length} tasks)`);
286
- for (const t of args.tasks.slice(0, 3)) {
287
- const preview =
288
- t.task.length > 40 ? `${t.task.slice(0, 40)}…` : t.task;
289
- text += `\n ${theme.fg("dim", preview)}${t.allowWrite ? theme.fg("warning", " ✎") : ""}`;
290
- }
291
- if (args.tasks.length > 3)
292
- text += `\n ${theme.fg("muted", `… +${args.tasks.length - 3} more`)}`;
293
- return new Text(text, 0, 0);
294
- }
295
-
296
- const preview = args.task
297
- ? args.task.length > 60
298
- ? `${args.task.slice(0, 60)}…`
299
- : args.task
300
- : "...";
301
- let text =
302
- theme.fg("toolTitle", theme.bold("mini_subagent ")) +
303
- theme.fg("dim", preview);
304
- if (args.allowWrite) text += theme.fg("warning", " ✎");
305
- return new Text(text, 0, 0);
306
- },
307
-
308
- renderResult(result, _options, theme) {
309
- const details = result.details as SubagentDetails | undefined;
310
- const text =
311
- result.content[0]?.type === "text" ? result.content[0].text : "";
312
- const statuses = details?.records.map((r) => r.status) ?? [];
313
- const color = statuses.includes("failed")
314
- ? "error"
315
- : statuses.includes("needs_input")
316
- ? "warning"
317
- : "muted";
318
- return new Text(theme.fg(color, text), 0, 0);
319
- },
320
- });
321
- }
package/src/types.ts DELETED
@@ -1,28 +0,0 @@
1
- export type SubagentStatus = "running" | "completed" | "failed" | "needs_input";
2
-
3
- export interface SubagentRecord {
4
- id: number;
5
- task: string;
6
- cwd?: string;
7
- startedAt: number;
8
- allowWrite: boolean;
9
- finishedAt?: number;
10
- status: SubagentStatus;
11
-
12
- /** Final output summary (truncated for storage). */
13
- output?: string;
14
- tokens?: number;
15
- error?: string;
16
- questions?: string[];
17
- }
18
-
19
- export interface SubagentState {
20
- records: SubagentRecord[];
21
- nextId: number;
22
- }
23
-
24
- /** Shape stored in the tool result `details` for rendering. */
25
- export interface SubagentDetails {
26
- mode: "single" | "parallel";
27
- records: SubagentRecord[];
28
- }
package/src/utils.ts DELETED
@@ -1,101 +0,0 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth } from "@earendil-works/pi-tui";
3
-
4
- import { MAX_PANEL_ROWS, PANEL_STATE_ICON, STATUS_STYLES } from "./constants";
5
- import type { SubagentRecord } from "./types";
6
-
7
- function formatTokens(n: number): string {
8
- if (n < 1000) return `${n} tokens`;
9
- if (n < 10000) return `${(n / 1000).toFixed(1)}k tokens`;
10
-
11
- return `${Math.round(n / 1000)}k tokens`;
12
- }
13
-
14
- export function getStyledSubagent(r: SubagentRecord, th: Theme): string {
15
- const task = r.task.replace(/\s*[\r\n]+\s*/g, " ");
16
- const style = STATUS_STYLES[r.status] ?? STATUS_STYLES.completed;
17
-
18
- let line = `${th.fg(style.fg, style.icon)} ${th.fg("accent", `#${r.id}`)} ${th.fg(style.fg, task)}`;
19
-
20
- if (r.status === "completed" && r.tokens) {
21
- line += th.fg("dim", ` · ${formatTokens(r.tokens)}`);
22
- }
23
-
24
- if (r.status === "failed") line += th.fg("dim", " · failed");
25
- if (r.status === "needs_input") line += th.fg("dim", " · needs input");
26
- if (r.allowWrite) line += th.fg("warning", " ✎");
27
-
28
- return line;
29
- }
30
-
31
- export function getStyledSubagentHeader(
32
- records: SubagentRecord[],
33
- th: Theme,
34
- isCollapsed?: boolean,
35
- ): string {
36
- const running = records.filter((r) => r.status === "running").length;
37
-
38
- const done = records.length - running;
39
- const collapseState = isCollapsed ? "collapsed" : "expanded";
40
-
41
- return th.fg(
42
- "accent",
43
- `${PANEL_STATE_ICON[collapseState]}  Subagents | ${running} running / ${done} done`,
44
- );
45
- }
46
-
47
- export function getStyledSubagentList(
48
- records: SubagentRecord[],
49
- th: Theme,
50
- width: number,
51
- isCollapsed?: boolean,
52
- ): string[] {
53
- const indent = (str: string, level = 1) => " ".repeat(Math.abs(level)) + str;
54
-
55
- const lines: string[] = [
56
- truncateToWidth(
57
- indent(getStyledSubagentHeader(records, th, isCollapsed)),
58
- width,
59
- ),
60
- ];
61
-
62
- if (!isCollapsed) {
63
- lines.push("");
64
-
65
- if (records.length === 0) {
66
- lines.push(
67
- truncateToWidth(
68
- indent(
69
- th.fg("dim", "No subagents yet. Ask the agent to delegate a task!"),
70
- 2,
71
- ),
72
- width,
73
- ),
74
- );
75
- } else {
76
- const running = records.filter((r) => r.status === "running");
77
- const finished = records.filter((r) => r.status !== "running");
78
-
79
- const visible = [...running, ...finished].slice(0, MAX_PANEL_ROWS);
80
-
81
- for (const r of visible) {
82
- lines.push(truncateToWidth(indent(getStyledSubagent(r, th), 2), width));
83
- }
84
-
85
- if (records.length > visible.length) {
86
- lines.push(
87
- truncateToWidth(
88
- indent(
89
- th.fg("dim", `… +${records.length - visible.length} more`),
90
- 3,
91
- ),
92
- width,
93
- ),
94
- );
95
- }
96
- }
97
- }
98
-
99
- lines.push("");
100
- return lines;
101
- }
package/src/widget.ts DELETED
@@ -1,77 +0,0 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionContext,
4
- Theme,
5
- } from "@earendil-works/pi-coding-agent";
6
-
7
- import { PANEL_TOGGLE_CHORD, WIDGET_KEY } from "./constants";
8
- import type { SubagentStore } from "./state";
9
- import type { SubagentState } from "./types";
10
- import { getStyledSubagentList } from "./utils";
11
-
12
- let collapsed = true;
13
-
14
- class SubagentWidget {
15
- private cachedWidth: number | undefined;
16
- private cachedLines: string[] | undefined;
17
-
18
- constructor(
19
- private readonly theme: Theme,
20
- private readonly snapshot: () => SubagentState,
21
- private readonly isCollapsed: () => boolean,
22
- ) {}
23
-
24
- render(width: number): string[] {
25
- if (this.snapshot().records.length === 0) return [];
26
-
27
- if (this.cachedLines !== undefined && this.cachedWidth === width) {
28
- return this.cachedLines;
29
- }
30
-
31
- const lines = getStyledSubagentList(
32
- this.snapshot().records,
33
- this.theme,
34
- width,
35
- this.isCollapsed(),
36
- );
37
-
38
- this.cachedWidth = width;
39
- this.cachedLines = lines;
40
- return lines;
41
- }
42
-
43
- invalidate(): void {
44
- this.cachedWidth = undefined;
45
- this.cachedLines = undefined;
46
- }
47
- }
48
-
49
- export function refreshWidget(
50
- ctx: ExtensionContext,
51
- store: SubagentStore,
52
- ): void {
53
- if (!ctx.hasUI) return;
54
-
55
- ctx.ui.setWidget(
56
- WIDGET_KEY,
57
- (_tui, theme) =>
58
- new SubagentWidget(
59
- theme,
60
- () => store.getState(ctx),
61
- () => collapsed,
62
- ),
63
- );
64
- }
65
-
66
- export function registerSubagentWidget(
67
- pi: ExtensionAPI,
68
- store: SubagentStore,
69
- ): void {
70
- pi.registerShortcut(PANEL_TOGGLE_CHORD, {
71
- description: "Toggle subagents panel",
72
- handler: (ctx) => {
73
- collapsed = !collapsed;
74
- refreshWidget(ctx, store);
75
- },
76
- });
77
- }