@pify/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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pifydev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # @pify/subagent
2
+
3
+ Spawn scoped subagents from within a [pi](https://github.com/earendil-works/pi) session. One tool call, one focused child agent — with its own tool allowlist, model, thinking level, and turn cap.
4
+
5
+ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install subagent`](https://github.com/pifydev/cli) or `pi install npm:@pify/subagent`.
6
+
7
+ ## What it does
8
+
9
+ - **`agent_run`** — delegate a task to a child pi session (in-process, isolated in-memory transcript). Foreground blocks and returns the child's report; `background: true` returns an id immediately (up to 4 concurrent) with a live widget showing spinners, token counts, and elapsed time.
10
+ - **`agent_result`** — collect a background run's report; completed results survive `/reload`.
11
+ - **Three builtin agent types**: `reviewer` (read-only, thinking high — findings with evidence), `scout` (read-only exploration — paths + excerpts), `worker` (full tools — scoped implementation, verifies before finishing).
12
+ - **Custom agent types**, Claude Code-compatible: drop `.pi/agents/<name>.md` (project) or `<agentDir>/agents/<name>.md` (global) with frontmatter — `description`, `tools`, `model` (`provider/id`), `thinking`, `max_turns` — and a system-prompt body. Project overrides global overrides builtin; a def without `tools:` defaults to read-only.
13
+ - **Guardrails**: tool allowlists are enforced at session creation; children are aborted at their turn cap; children cannot spawn children.
14
+ - `/agents` lists types and this session's runs.
15
+
16
+ ## Where this sits in the suite
17
+
18
+ `@pify/subagent` is deliberately the primitive: one child, one task, one report. Multi-agent coordination belongs to `@pify/swarm`; deterministic scripted orchestration to `@pify/workflow`.
19
+
20
+ ## Custom agent example
21
+
22
+ ```markdown
23
+ ---
24
+ description: Security auditor for diffs
25
+ tools: read, grep, find, ls
26
+ model: anthropic/claude-haiku-4-5-20251001
27
+ thinking: low
28
+ max_turns: 15
29
+ ---
30
+
31
+ You are a security auditor. Scan for hardcoded secrets, injection flaws,
32
+ and overly broad permissions. Report file:line with remediation notes.
33
+ ```
34
+
35
+ ## License
36
+
37
+ MIT © [Pify maintainers](https://github.com/pifydev)
@@ -0,0 +1,317 @@
1
+ /**
2
+ * @pify/subagent — spawn scoped subagents from within a pi session.
3
+ *
4
+ * The foundation layer of the Pify agent stack: one tool call spawns one
5
+ * child pi session (in-process, createAgentSession) with an agent type's
6
+ * tool allowlist, model/thinking overrides, and turn cap. Foreground runs
7
+ * block and return the child's report; background runs stream into a small
8
+ * widget and are collected with agent_result. Agent types are Claude
9
+ * Code-compatible markdown files (project > global > builtin); three
10
+ * builtins ship: reviewer, scout, worker.
11
+ *
12
+ * Coordination of many agents belongs to @pify/swarm; scripted orchestration
13
+ * to @pify/workflow — this package deliberately stays the primitive.
14
+ *
15
+ * Design synthesis: in-process runner + Claude Code tool shapes
16
+ * (tintinweb/pi-subagents), agent archetypes + md definitions
17
+ * (nicobailon/pi-subagents), sub-session mechanics proven in @pify/btw.
18
+ */
19
+ import {
20
+ createAgentSession,
21
+ DefaultResourceLoader,
22
+ getAgentDir,
23
+ SessionManager,
24
+ type AgentSession,
25
+ type ExtensionAPI,
26
+ type ExtensionContext,
27
+ } from "@earendil-works/pi-coding-agent";
28
+ import { Text } from "@earendil-works/pi-tui";
29
+ import { Type } from "typebox";
30
+
31
+ import { loadAgentDefs } from "../src/defs.ts";
32
+ import { CHILD_FRAMING, buildTaskPrompt, describeDefs, formatRunResult } from "../src/prompts.ts";
33
+ import { buildWidgetLines } from "../src/widget.ts";
34
+ import {
35
+ MAX_CONCURRENT_BACKGROUND,
36
+ isRecord,
37
+ type AgentDef,
38
+ type RunState,
39
+ } from "../src/types.ts";
40
+
41
+ const RESULT_ENTRY = "subagent-result";
42
+
43
+ type UiContext = ExtensionContext;
44
+
45
+ export default function subagent(pi: ExtensionAPI) {
46
+ let defs = new Map<string, AgentDef>();
47
+ const runs = new Map<string, RunState>();
48
+ const counters = new Map<string, number>();
49
+ let lastUiCtx: UiContext | null = null;
50
+
51
+ // ── UI ───────────────────────────────────────────────────────────────
52
+
53
+ function renderWidget(ctx: UiContext | null = lastUiCtx): void {
54
+ if (!ctx || !ctx.hasUI) return;
55
+ lastUiCtx = ctx;
56
+ const now = Date.now();
57
+ const anyVisible = [...runs.values()].some(
58
+ (r) => r.status === "running" || (r.finishedAt ?? 0) > now - 15_000,
59
+ );
60
+ if (!anyVisible) {
61
+ ctx.ui.setWidget("subagent", undefined);
62
+ return;
63
+ }
64
+ ctx.ui.setWidget(
65
+ "subagent",
66
+ (_tui: unknown, theme: { fg(c: string, s: string): string; bold(s: string): string }) =>
67
+ new Text(buildWidgetLines([...runs.values()], theme, Date.now()).join("\n"), 0, 0),
68
+ { placement: "aboveEditor" },
69
+ );
70
+ }
71
+
72
+ function notify(ctx: UiContext, message: string, level: "info" | "warning" | "error"): void {
73
+ if (ctx.hasUI) ctx.ui.notify(message, level);
74
+ }
75
+
76
+ // ── Child runner ─────────────────────────────────────────────────────
77
+
78
+ function nextId(agent: string): string {
79
+ const n = (counters.get(agent) ?? 0) + 1;
80
+ counters.set(agent, n);
81
+ return `${agent}-${n}`;
82
+ }
83
+
84
+ async function runChild(ctx: UiContext, def: AgentDef, run: RunState): Promise<void> {
85
+ let session: AgentSession | null = null;
86
+ let unsubscribe: (() => void) | null = null;
87
+ try {
88
+ let model = ctx.model ?? null;
89
+ if (def.model) {
90
+ const [provider, ...rest] = def.model.split("/");
91
+ const found =
92
+ provider && rest.length > 0 ? ctx.modelRegistry.find(provider, rest.join("/")) : undefined;
93
+ if (found) model = found;
94
+ else notify(ctx, `subagent ${run.id}: model ${def.model} not found — using session model`, "warning");
95
+ }
96
+ if (!model) throw new Error("No model available");
97
+
98
+ // getSystemPromptOptions lives on the command context; tool contexts may
99
+ // carry it at runtime — probe structurally, fall back to the defaults.
100
+ const promptHost = ctx as unknown as {
101
+ getSystemPromptOptions?: () => { customPrompt?: string; appendSystemPrompt?: string };
102
+ };
103
+ const promptOptions = promptHost.getSystemPromptOptions?.() ?? {};
104
+ const created = await createAgentSession({
105
+ sessionManager: SessionManager.inMemory(ctx.cwd),
106
+ model,
107
+ thinkingLevel: (def.thinking ?? pi.getThinkingLevel()) as never,
108
+ tools: def.tools,
109
+ resourceLoader: new DefaultResourceLoader({
110
+ cwd: ctx.cwd,
111
+ agentDir: getAgentDir(),
112
+ noExtensions: true,
113
+ noPromptTemplates: true,
114
+ noThemes: true,
115
+ systemPrompt: promptOptions.customPrompt,
116
+ appendSystemPrompt: [
117
+ ...(promptOptions.appendSystemPrompt ? [promptOptions.appendSystemPrompt] : []),
118
+ def.systemPrompt,
119
+ CHILD_FRAMING,
120
+ ],
121
+ }),
122
+ });
123
+ session = created.session;
124
+
125
+ unsubscribe = session.subscribe((event) => {
126
+ if (event.type === "message_end" && (event as { message?: { role?: string } }).message?.role === "assistant") {
127
+ run.turns++;
128
+ const usage = (event as { message?: { usage?: { totalTokens?: number } } }).message?.usage;
129
+ if (usage && typeof usage.totalTokens === "number") run.tokens += usage.totalTokens;
130
+ renderWidget();
131
+ if (run.turns >= def.maxTurns) {
132
+ void session?.abort().catch(() => {});
133
+ }
134
+ }
135
+ });
136
+
137
+ await session.prompt(buildTaskPrompt(run.task), { source: "extension" } as never);
138
+
139
+ const messages = session.messages as Array<{
140
+ role?: string;
141
+ stopReason?: unknown;
142
+ content?: Array<{ type?: string; text?: string }>;
143
+ }>;
144
+ const last = [...messages].reverse().find((m) => m.role === "assistant");
145
+ const text = (last?.content ?? [])
146
+ .filter((c) => c.type === "text" && typeof c.text === "string")
147
+ .map((c) => c.text)
148
+ .join("\n")
149
+ .trim();
150
+
151
+ run.result = text || null;
152
+ run.status =
153
+ last?.stopReason === "aborted" ? "aborted" : last?.stopReason === "error" ? "error" : "done";
154
+ if (run.status === "error") run.error = text || "child session error";
155
+ } catch (err) {
156
+ run.status = "error";
157
+ run.error = err instanceof Error ? err.message : String(err);
158
+ } finally {
159
+ run.finishedAt = Date.now();
160
+ if (unsubscribe) {
161
+ try {
162
+ unsubscribe();
163
+ } catch {
164
+ // gone
165
+ }
166
+ }
167
+ if (session) {
168
+ try {
169
+ session.dispose();
170
+ } catch {
171
+ // double-dispose fine
172
+ }
173
+ }
174
+ pi.appendEntry(RESULT_ENTRY, run);
175
+ renderWidget();
176
+ }
177
+ }
178
+
179
+ // ── Tools ────────────────────────────────────────────────────────────
180
+
181
+ pi.registerTool({
182
+ name: "agent_run",
183
+ label: "Run subagent",
184
+ description:
185
+ "Delegate one scoped task to a child agent. agent: reviewer (read-only review), scout " +
186
+ "(read-only exploration/research), worker (full tools, implements a task), or a custom type " +
187
+ "from .pi/agents/. background=false (default) blocks and returns the child's report; " +
188
+ "background=true returns an id immediately — collect it later with agent_result. " +
189
+ "Write the task as a complete, self-contained brief: the child sees none of this conversation.",
190
+ parameters: Type.Object({
191
+ agent: Type.String({ description: "Agent type name" }),
192
+ task: Type.String({ description: "Complete task brief for the child" }),
193
+ background: Type.Optional(Type.Boolean({ description: "Run without blocking (default false)" })),
194
+ }),
195
+ async execute(
196
+ _id,
197
+ params: { agent: string; task: string; background?: boolean },
198
+ _signal,
199
+ _onUpdate,
200
+ ctx,
201
+ ) {
202
+ const def = defs.get(params.agent.trim().toLowerCase());
203
+ if (!def) {
204
+ throw new Error(
205
+ `Unknown agent type "${params.agent}". Available: ${[...defs.keys()].sort().join(", ")}`,
206
+ );
207
+ }
208
+ if (!params.task.trim()) throw new Error("agent_run requires a non-empty task.");
209
+
210
+ const uiCtx = ctx as UiContext;
211
+ const background = params.background === true;
212
+ const active = [...runs.values()].filter((r) => r.status === "running").length;
213
+ if (background && active >= MAX_CONCURRENT_BACKGROUND) {
214
+ throw new Error(
215
+ `Too many background agents running (${active}/${MAX_CONCURRENT_BACKGROUND}). Collect results first or run foreground.`,
216
+ );
217
+ }
218
+
219
+ const run: RunState = {
220
+ id: nextId(def.name),
221
+ agent: def.name,
222
+ task: params.task.trim(),
223
+ background,
224
+ status: "running",
225
+ startedAt: Date.now(),
226
+ finishedAt: null,
227
+ tokens: 0,
228
+ turns: 0,
229
+ result: null,
230
+ error: null,
231
+ };
232
+ runs.set(run.id, run);
233
+ renderWidget(uiCtx);
234
+
235
+ if (background) {
236
+ void runChild(uiCtx, def, run).then(() => {
237
+ notify(uiCtx, `subagent ${run.id}: ${run.status}`, run.status === "done" ? "info" : "warning");
238
+ });
239
+ return {
240
+ content: [
241
+ { type: "text", text: `Started ${run.id} in the background. Collect with agent_result id="${run.id}".` },
242
+ ],
243
+ details: { id: run.id },
244
+ };
245
+ }
246
+
247
+ await runChild(uiCtx, def, run);
248
+ return {
249
+ content: [{ type: "text", text: formatRunResult(run) }],
250
+ details: { id: run.id, status: run.status, tokens: run.tokens },
251
+ };
252
+ },
253
+ });
254
+
255
+ pi.registerTool({
256
+ name: "agent_result",
257
+ label: "Subagent result",
258
+ description: "Fetch the report of a background subagent by id (from agent_run).",
259
+ parameters: Type.Object({
260
+ id: Type.String({ description: "Run id, e.g. reviewer-1" }),
261
+ }),
262
+ async execute(_id, params: { id: string }) {
263
+ const run = runs.get(params.id.trim());
264
+ if (!run) {
265
+ const known = [...runs.keys()].sort().join(", ") || "(none this session)";
266
+ throw new Error(`No run "${params.id}". Known runs: ${known}`);
267
+ }
268
+ return {
269
+ content: [{ type: "text", text: formatRunResult(run) }],
270
+ details: { id: run.id, status: run.status },
271
+ };
272
+ },
273
+ });
274
+
275
+ // ── Lifecycle ────────────────────────────────────────────────────────
276
+
277
+ pi.on("session_start", async (_event, ctx) => {
278
+ defs = loadAgentDefs(ctx.cwd, getAgentDir());
279
+ // Completed runs from earlier in this session's branch are replayable so
280
+ // agent_result keeps working after /reload. Running ones did not survive.
281
+ runs.clear();
282
+ for (const entry of ctx.sessionManager.getBranch()) {
283
+ const e = entry as { type?: string; customType?: string; data?: unknown };
284
+ if (e.type !== "custom" || e.customType !== RESULT_ENTRY || !isRecord(e.data)) continue;
285
+ const data = e.data as unknown as RunState;
286
+ if (typeof data.id === "string" && data.status !== "running") runs.set(data.id, data);
287
+ }
288
+ renderWidget(ctx);
289
+ });
290
+
291
+ pi.on("session_shutdown", async (_event, ctx) => {
292
+ for (const run of runs.values()) {
293
+ if (run.status === "running") {
294
+ run.status = "aborted";
295
+ run.finishedAt = Date.now();
296
+ }
297
+ }
298
+ if (ctx.hasUI) ctx.ui.setWidget("subagent", undefined);
299
+ });
300
+
301
+ // ── Command ──────────────────────────────────────────────────────────
302
+
303
+ pi.registerCommand("agents", {
304
+ description: "List subagent types and this session's runs",
305
+ handler: async (_args, ctx) => {
306
+ if (!ctx.hasUI) return;
307
+ const runLines =
308
+ [...runs.values()]
309
+ .map((r) => `${r.id}: ${r.status} (${r.turns} turns, ${r.tokens} tok)`)
310
+ .join("\n") || "(no runs yet)";
311
+ ctx.ui.notify(
312
+ `Agent types\n${describeDefs([...defs.values()])}\n\nRuns\n${runLines}\n\nCustom types: .pi/agents/<name>.md`,
313
+ "info",
314
+ );
315
+ },
316
+ });
317
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@pify/subagent",
3
+ "version": "0.1.0",
4
+ "description": "Spawn scoped subagents from within a pi session: agent_run/agent_result tools, Claude Code-compatible agent types, turn caps and tool allowlists",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi",
9
+ "pify",
10
+ "subagent",
11
+ "agents"
12
+ ],
13
+ "homepage": "https://github.com/pifydev/subagent#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/pifydev/subagent/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/pifydev/subagent.git"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Pify maintainers",
23
+ "type": "module",
24
+ "engines": {
25
+ "node": ">=22.19.0"
26
+ },
27
+ "files": [
28
+ "extensions",
29
+ "src",
30
+ "skills",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "pi": {
35
+ "extensions": ["./extensions/subagent.ts"],
36
+ "skills": ["./skills"]
37
+ },
38
+ "scripts": {
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "bun test",
41
+ "prepublishOnly": "npm run typecheck && npm test"
42
+ },
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-tui": "*",
46
+ "typebox": "*"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "@earendil-works/pi-coding-agent": { "optional": true },
50
+ "@earendil-works/pi-tui": { "optional": true },
51
+ "typebox": { "optional": true }
52
+ },
53
+ "devDependencies": {
54
+ "@earendil-works/pi-coding-agent": "^0.84.4",
55
+ "@earendil-works/pi-tui": "^0.84.4",
56
+ "@types/node": "^22.10.2",
57
+ "typebox": "^1.1.38",
58
+ "typescript": "^5.7.2"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ }
63
+ }
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: subagent
3
+ description: Use when a task benefits from delegation to a focused child agent - code review by fresh eyes, parallel read-only research, or a scoped implementation task - explains agent_run/agent_result and how to write good task briefs
4
+ ---
5
+
6
+ # Subagents
7
+
8
+ This project has the `@pify/subagent` extension installed: `agent_run` spawns
9
+ one child pi session per call, `agent_result` collects background runs.
10
+
11
+ ## When to delegate
12
+
13
+ - **reviewer** — a diff, plan, or module needs fresh eyes; read-only, reports
14
+ findings with evidence. Great right after you finish a change.
15
+ - **scout** — you need facts from elsewhere in the codebase without spending
16
+ your own context reading files; read-only, returns paths + excerpts.
17
+ - **worker** — a well-scoped implementation task can proceed independently;
18
+ full tools. Only delegate what you can specify completely.
19
+
20
+ Do not delegate trivial lookups (read the file yourself) or tasks whose
21
+ requirements you cannot state precisely.
22
+
23
+ ## Writing the task brief
24
+
25
+ The child sees NONE of this conversation. The brief must be self-contained:
26
+ - the goal and its boundaries (what NOT to touch),
27
+ - relevant file paths you already know,
28
+ - the expected deliverable shape ("report findings as file:line + why").
29
+
30
+ ## Foreground vs background
31
+
32
+ - Default (foreground) blocks and returns the report — use when you need the
33
+ answer to continue.
34
+ - `background: true` returns an id immediately — use for work that can run
35
+ while you continue; collect with `agent_result` before relying on it. At
36
+ most 4 background runs at once.
37
+
38
+ Custom agent types: `.pi/agents/<name>.md` (description/tools/model/thinking/
39
+ max_turns frontmatter + system-prompt body) — project overrides global
40
+ overrides builtin.
package/src/builtin.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Built-in agent types (adapted from nicobailon/pi-subagents' agent set,
3
+ * trimmed to three archetypes). Overridable: a project or global .md file
4
+ * with the same name wins.
5
+ */
6
+
7
+ export const BUILTIN_AGENTS: Record<string, string> = {
8
+ reviewer: `---
9
+ description: Read-only review specialist for diffs, plans, and code health
10
+ tools: read, grep, find, ls
11
+ thinking: high
12
+ max_turns: 25
13
+ ---
14
+
15
+ You are a disciplined review subagent. Inspect, evaluate, and report findings
16
+ with evidence — never guess; verify from the code itself. You cannot modify
17
+ anything: your deliverable is the report.
18
+
19
+ For each finding give: file:line, what is wrong, why it matters, and a
20
+ concrete suggestion. Rank findings by severity. If the code is fine, say so
21
+ plainly — do not invent issues. End with a one-paragraph verdict.`,
22
+
23
+ scout: `---
24
+ description: Fast read-only exploration and research across the codebase
25
+ tools: read, grep, find, ls
26
+ thinking: low
27
+ max_turns: 25
28
+ ---
29
+
30
+ You are a scout subagent: locate, map, and summarize — quickly. Answer the
31
+ question with file paths and line references, quoting only the smallest
32
+ relevant excerpts. Prefer breadth over depth unless asked otherwise. If
33
+ something cannot be found, report exactly what you searched so the caller
34
+ can redirect you. Your final message is the entire deliverable.`,
35
+
36
+ worker: `---
37
+ description: Implementation agent with full tool access for a scoped task
38
+ tools: read, bash, edit, write, grep, find, ls
39
+ thinking: medium
40
+ max_turns: 60
41
+ ---
42
+
43
+ You are a worker subagent implementing one scoped task. Stay strictly within
44
+ the task's boundaries: no drive-by refactors, no scope creep. Follow the
45
+ project's existing conventions. Verify your work (build, tests, or a smoke
46
+ check) before finishing. Your final message must state exactly what changed,
47
+ what you verified, and anything you deliberately left undone.`,
48
+ };
package/src/defs.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { BUILTIN_AGENTS } from "./builtin.ts";
4
+ import { parseAgentFile } from "./frontmatter.ts";
5
+ import type { AgentDef } from "./types.ts";
6
+
7
+ function loadDir(dir: string, source: "global" | "project"): AgentDef[] {
8
+ let files: string[];
9
+ try {
10
+ files = readdirSync(dir).filter((f) => f.endsWith(".md"));
11
+ } catch {
12
+ return [];
13
+ }
14
+ const defs: AgentDef[] = [];
15
+ for (const file of files) {
16
+ try {
17
+ const def = parseAgentFile(
18
+ basename(file, ".md"),
19
+ readFileSync(join(dir, file), "utf8"),
20
+ source,
21
+ );
22
+ if (def) defs.push(def);
23
+ } catch {
24
+ // unreadable file — skip
25
+ }
26
+ }
27
+ return defs;
28
+ }
29
+
30
+ /**
31
+ * Load all agent definitions. Precedence per name:
32
+ * project (.pi/agents/) > global (<agentDir>/agents/) > builtin.
33
+ */
34
+ export function loadAgentDefs(cwd: string, agentDir: string): Map<string, AgentDef> {
35
+ const defs = new Map<string, AgentDef>();
36
+ for (const [name, content] of Object.entries(BUILTIN_AGENTS)) {
37
+ const def = parseAgentFile(name, content, "builtin");
38
+ if (def) defs.set(def.name, def);
39
+ }
40
+ for (const def of loadDir(join(agentDir, "agents"), "global")) defs.set(def.name, def);
41
+ for (const def of loadDir(join(cwd, ".pi", "agents"), "project")) defs.set(def.name, def);
42
+ return defs;
43
+ }
@@ -0,0 +1,71 @@
1
+ import {
2
+ DEFAULT_MAX_TURNS,
3
+ THINKING_LEVELS,
4
+ VALID_TOOLS,
5
+ type AgentDef,
6
+ type ThinkingLevelName,
7
+ type ValidTool,
8
+ } from "./types.ts";
9
+
10
+ /**
11
+ * Parse a Claude Code-compatible agent definition file:
12
+ * `---` frontmatter with description / tools / model / thinking / max_turns,
13
+ * body = system prompt. Line-based parser — no YAML dependency; unknown keys
14
+ * are ignored, invalid values fall back to safe defaults.
15
+ */
16
+ export function parseAgentFile(
17
+ name: string,
18
+ content: string,
19
+ source: AgentDef["source"],
20
+ ): AgentDef | null {
21
+ const normalized = content.replace(/\r\n/g, "\n");
22
+ const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(normalized);
23
+ if (!match) return null;
24
+
25
+ const fields = new Map<string, string>();
26
+ for (const line of match[1]!.split("\n")) {
27
+ const kv = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line.trim());
28
+ if (kv) fields.set(kv[1]!.toLowerCase(), kv[2]!.trim());
29
+ }
30
+
31
+ const description = fields.get("description") ?? "";
32
+ if (!description) return null;
33
+
34
+ const tools = parseTools(fields.get("tools"));
35
+ const thinkingRaw = fields.get("thinking")?.toLowerCase();
36
+ const thinking = (THINKING_LEVELS as readonly string[]).includes(thinkingRaw ?? "")
37
+ ? (thinkingRaw as ThinkingLevelName)
38
+ : null;
39
+
40
+ const maxTurnsRaw = Number.parseInt(fields.get("max_turns") ?? "", 10);
41
+ const maxTurns =
42
+ Number.isFinite(maxTurnsRaw) && maxTurnsRaw > 0 && maxTurnsRaw <= 200
43
+ ? maxTurnsRaw
44
+ : DEFAULT_MAX_TURNS;
45
+
46
+ const model = fields.get("model") || null;
47
+
48
+ return {
49
+ name: name.toLowerCase(),
50
+ description,
51
+ tools,
52
+ model,
53
+ thinking,
54
+ maxTurns,
55
+ systemPrompt: match[2]!.trim(),
56
+ source,
57
+ };
58
+ }
59
+
60
+ /** Read-only default keeps a def missing `tools:` from mutating anything. */
61
+ function parseTools(raw: string | undefined): ValidTool[] {
62
+ if (!raw) return ["read", "grep", "find", "ls"];
63
+ const requested = raw
64
+ .split(",")
65
+ .map((t) => t.trim().toLowerCase())
66
+ .filter(Boolean);
67
+ const valid = requested.filter((t): t is ValidTool =>
68
+ (VALID_TOOLS as readonly string[]).includes(t),
69
+ );
70
+ return valid.length > 0 ? valid : ["read", "grep", "find", "ls"];
71
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { AgentDef, RunState } from "./types.ts";
2
+
3
+ /** Framing appended to every child's system prompt after the def body. */
4
+ export const CHILD_FRAMING = [
5
+ "You are a subagent running a single delegated task inside another agent's session.",
6
+ "Your final assistant message IS the deliverable returned to the caller —",
7
+ "make it a complete, self-contained report; do not ask follow-up questions.",
8
+ ].join(" ");
9
+
10
+ /** The task prompt sent to the child session. */
11
+ export function buildTaskPrompt(task: string): string {
12
+ return task.trim();
13
+ }
14
+
15
+ /** Tool-result text returned to the parent model for a finished run. */
16
+ export function formatRunResult(run: RunState): string {
17
+ const header = `[${run.agent} · ${run.id} · ${run.status} · ${run.turns} turns]`;
18
+ if (run.status === "done" && run.result) return `${header}\n${run.result}`;
19
+ if (run.status === "error") return `${header}\nError: ${run.error ?? "unknown failure"}`;
20
+ if (run.status === "aborted") {
21
+ return `${header}\nAborted (turn limit or user stop). Partial output:\n${run.result ?? "(none)"}`;
22
+ }
23
+ return `${header}\nStill running — call agent_result with id "${run.id}" later.`;
24
+ }
25
+
26
+ /** Summary for /agents. */
27
+ export function describeDefs(defs: AgentDef[]): string {
28
+ return defs
29
+ .map((d) => `${d.name} (${d.source}) — ${d.description} [${d.tools.join(", ")}]`)
30
+ .join("\n");
31
+ }
package/src/types.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Local structural types for @pify/subagent.
3
+ * No imports from pi packages: src/ typechecks and runs standalone.
4
+ */
5
+
6
+ export const VALID_TOOLS = [
7
+ "read",
8
+ "bash",
9
+ "powershell",
10
+ "edit",
11
+ "write",
12
+ "grep",
13
+ "find",
14
+ "ls",
15
+ ] as const;
16
+ export type ValidTool = (typeof VALID_TOOLS)[number];
17
+
18
+ export type ThinkingLevelName = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
19
+ export const THINKING_LEVELS: readonly ThinkingLevelName[] = [
20
+ "off",
21
+ "minimal",
22
+ "low",
23
+ "medium",
24
+ "high",
25
+ "xhigh",
26
+ "max",
27
+ ];
28
+
29
+ /** A parsed agent definition (from builtin constants or *.md files). */
30
+ export interface AgentDef {
31
+ name: string;
32
+ description: string;
33
+ tools: ValidTool[];
34
+ /** "provider/model-id" or null to inherit the session model. */
35
+ model: string | null;
36
+ thinking: ThinkingLevelName | null;
37
+ /** Assistant round-trips before the child is aborted. */
38
+ maxTurns: number;
39
+ /** Markdown body appended to the child's system prompt. */
40
+ systemPrompt: string;
41
+ source: "builtin" | "global" | "project";
42
+ }
43
+
44
+ export const DEFAULT_MAX_TURNS = 30;
45
+ export const MAX_CONCURRENT_BACKGROUND = 4;
46
+
47
+ export type RunStatus = "running" | "done" | "error" | "aborted";
48
+
49
+ export interface RunState {
50
+ id: string;
51
+ agent: string;
52
+ task: string;
53
+ background: boolean;
54
+ status: RunStatus;
55
+ startedAt: number;
56
+ finishedAt: number | null;
57
+ tokens: number;
58
+ turns: number;
59
+ result: string | null;
60
+ error: string | null;
61
+ }
62
+
63
+ export interface ThemeLike {
64
+ fg(color: string, text: string): string;
65
+ bold(text: string): string;
66
+ }
67
+
68
+ export interface BranchEntryLike {
69
+ type?: string;
70
+ customType?: string;
71
+ data?: unknown;
72
+ [key: string]: unknown;
73
+ }
74
+
75
+ export function isRecord(value: unknown): value is Record<string, unknown> {
76
+ return typeof value === "object" && value !== null && !Array.isArray(value);
77
+ }
package/src/widget.ts ADDED
@@ -0,0 +1,60 @@
1
+ import type { RunState, ThemeLike } from "./types.ts";
2
+
3
+ const WIDTH = 54;
4
+
5
+ function tokens(n: number): string {
6
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
7
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
8
+ return String(n);
9
+ }
10
+
11
+ function elapsed(run: RunState, now: number): string {
12
+ const s = Math.max(0, Math.round(((run.finishedAt ?? now) - run.startedAt) / 1000));
13
+ if (s >= 60) return `${Math.floor(s / 60)}m ${s % 60}s`;
14
+ return `${s}s`;
15
+ }
16
+
17
+ function icon(status: RunState["status"]): string {
18
+ switch (status) {
19
+ case "running":
20
+ return "⟳";
21
+ case "done":
22
+ return "✓";
23
+ case "error":
24
+ return "✗";
25
+ case "aborted":
26
+ return "◼";
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Widget above the editor listing active + recently finished runs.
32
+ * Empty when there is nothing to show.
33
+ */
34
+ export function buildWidgetLines(runs: RunState[], theme: ThemeLike, now: number): string[] {
35
+ const visible = runs.filter((r) => r.status === "running" || (r.finishedAt ?? 0) > now - 15_000);
36
+ if (visible.length === 0) return [];
37
+
38
+ const dim = (s: string) => theme.fg("dim", s);
39
+ const lines: string[] = [];
40
+ const title = " 🤖 subagents ";
41
+ const hint = " /agents ";
42
+ const pad = Math.max(1, WIDTH - title.length - hint.length);
43
+ lines.push(dim(`╭${title}${"─".repeat(pad)}${hint}╮`));
44
+
45
+ for (const run of visible) {
46
+ const paint =
47
+ run.status === "running"
48
+ ? (s: string) => theme.fg("warning", s)
49
+ : run.status === "done"
50
+ ? (s: string) => theme.fg("success", s)
51
+ : (s: string) => theme.fg("error", s);
52
+ const head = paint(`${icon(run.status)} ${run.id}`);
53
+ const stats = dim(` · ${tokens(run.tokens)} tok · ${elapsed(run, now)}`);
54
+ const task = run.task.length > 30 ? `${run.task.slice(0, 30)}…` : run.task;
55
+ lines.push(`${dim("│ ")}${head}${stats} ${dim(task)}`);
56
+ }
57
+
58
+ lines.push(dim(`╰${"─".repeat(WIDTH)}╯`));
59
+ return lines;
60
+ }