@pify/subagent 0.1.0 → 0.3.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 +5 -1
- package/extensions/subagent.ts +58 -17
- package/package.json +16 -6
- package/src/frontmatter.ts +11 -0
- package/src/isolate.ts +81 -0
- package/src/types.ts +8 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify instal
|
|
|
9
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
10
|
- **`agent_result`** — collect a background run's report; completed results survive `/reload`.
|
|
11
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.
|
|
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 (v0.3) `system_prompt_mode` / `inherit_skills` — and a system-prompt body. Project overrides global overrides builtin; a def without `tools:` defaults to read-only.
|
|
13
13
|
- **Guardrails**: tool allowlists are enforced at session creation; children are aborted at their turn cap; children cannot spawn children.
|
|
14
14
|
- `/agents` lists types and this session's runs.
|
|
15
15
|
|
|
@@ -26,12 +26,16 @@ tools: read, grep, find, ls
|
|
|
26
26
|
model: anthropic/claude-haiku-4-5-20251001
|
|
27
27
|
thinking: low
|
|
28
28
|
max_turns: 15
|
|
29
|
+
system_prompt_mode: replace
|
|
30
|
+
inherit_skills: false
|
|
29
31
|
---
|
|
30
32
|
|
|
31
33
|
You are a security auditor. Scan for hardcoded secrets, injection flaws,
|
|
32
34
|
and overly broad permissions. Report file:line with remediation notes.
|
|
33
35
|
```
|
|
34
36
|
|
|
37
|
+
`system_prompt_mode: replace` (default `append`) drops the session's own system prompt, so a specialist is not also told to be this project's coding assistant. `inherit_skills: false` (default `true`) keeps a narrow child out of the project's whole skill surface. Both are unset in the builtins, which behave exactly as before.
|
|
38
|
+
|
|
35
39
|
## License
|
|
36
40
|
|
|
37
41
|
MIT © [Pify maintainers](https://github.com/pifydev)
|
package/extensions/subagent.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
29
29
|
import { Type } from "typebox";
|
|
30
30
|
|
|
31
31
|
import { loadAgentDefs } from "../src/defs.ts";
|
|
32
|
+
import { createIsolationWorktree, isolationNote, type Isolation } from "../src/isolate.ts";
|
|
32
33
|
import { CHILD_FRAMING, buildTaskPrompt, describeDefs, formatRunResult } from "../src/prompts.ts";
|
|
33
34
|
import { buildWidgetLines } from "../src/widget.ts";
|
|
34
35
|
import {
|
|
@@ -44,6 +45,22 @@ type UiContext = ExtensionContext;
|
|
|
44
45
|
|
|
45
46
|
export default function subagent(pi: ExtensionAPI) {
|
|
46
47
|
let defs = new Map<string, AgentDef>();
|
|
48
|
+
// v0.2 queue: children beyond the cap wait for a slot instead of failing.
|
|
49
|
+
let slotsInUse = 0;
|
|
50
|
+
const slotWaiters: Array<() => void> = [];
|
|
51
|
+
async function acquireSlot(): Promise<void> {
|
|
52
|
+
if (slotsInUse < MAX_CONCURRENT_BACKGROUND) {
|
|
53
|
+
slotsInUse++;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
await new Promise<void>((resolve) => slotWaiters.push(resolve));
|
|
57
|
+
slotsInUse++;
|
|
58
|
+
}
|
|
59
|
+
function releaseSlot(): void {
|
|
60
|
+
slotsInUse--;
|
|
61
|
+
const next = slotWaiters.shift();
|
|
62
|
+
if (next) next();
|
|
63
|
+
}
|
|
47
64
|
const runs = new Map<string, RunState>();
|
|
48
65
|
const counters = new Map<string, number>();
|
|
49
66
|
let lastUiCtx: UiContext | null = null;
|
|
@@ -81,7 +98,7 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
81
98
|
return `${agent}-${n}`;
|
|
82
99
|
}
|
|
83
100
|
|
|
84
|
-
async function runChild(ctx: UiContext, def: AgentDef, run: RunState): Promise<void> {
|
|
101
|
+
async function runChild(ctx: UiContext, def: AgentDef, run: RunState, workDir?: string): Promise<void> {
|
|
85
102
|
let session: AgentSession | null = null;
|
|
86
103
|
let unsubscribe: (() => void) | null = null;
|
|
87
104
|
try {
|
|
@@ -102,19 +119,26 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
102
119
|
};
|
|
103
120
|
const promptOptions = promptHost.getSystemPromptOptions?.() ?? {};
|
|
104
121
|
const created = await createAgentSession({
|
|
105
|
-
sessionManager: SessionManager.inMemory(ctx.cwd),
|
|
122
|
+
sessionManager: SessionManager.inMemory(workDir ?? ctx.cwd),
|
|
106
123
|
model,
|
|
107
124
|
thinkingLevel: (def.thinking ?? pi.getThinkingLevel()) as never,
|
|
108
125
|
tools: def.tools,
|
|
109
126
|
resourceLoader: new DefaultResourceLoader({
|
|
110
|
-
cwd: ctx.cwd,
|
|
127
|
+
cwd: workDir ?? ctx.cwd,
|
|
111
128
|
agentDir: getAgentDir(),
|
|
112
129
|
noExtensions: true,
|
|
113
130
|
noPromptTemplates: true,
|
|
114
131
|
noThemes: true,
|
|
115
|
-
|
|
132
|
+
// system_prompt_mode: replace drops the parent's prompt so a
|
|
133
|
+
// specialist is not also told to be this project's coding
|
|
134
|
+
// assistant; inherit_skills: false keeps a focused child out of
|
|
135
|
+
// the project's whole skill surface.
|
|
136
|
+
noSkills: !def.inheritSkills,
|
|
137
|
+
...(def.systemPromptMode === "replace" ? {} : { systemPrompt: promptOptions.customPrompt }),
|
|
116
138
|
appendSystemPrompt: [
|
|
117
|
-
...(
|
|
139
|
+
...(def.systemPromptMode === "replace" || !promptOptions.appendSystemPrompt
|
|
140
|
+
? []
|
|
141
|
+
: [promptOptions.appendSystemPrompt]),
|
|
118
142
|
def.systemPrompt,
|
|
119
143
|
CHILD_FRAMING,
|
|
120
144
|
],
|
|
@@ -186,15 +210,18 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
186
210
|
"(read-only exploration/research), worker (full tools, implements a task), or a custom type " +
|
|
187
211
|
"from .pi/agents/. background=false (default) blocks and returns the child's report; " +
|
|
188
212
|
"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."
|
|
213
|
+
"Write the task as a complete, self-contained brief: the child sees none of this conversation. " +
|
|
214
|
+
"For MUTATING tasks set isolation=worktree: the child gets its own git worktree and branch, the " +
|
|
215
|
+
"main checkout stays untouched, and the report says how to merge or discard.",
|
|
190
216
|
parameters: Type.Object({
|
|
191
217
|
agent: Type.String({ description: "Agent type name" }),
|
|
192
218
|
task: Type.String({ description: "Complete task brief for the child" }),
|
|
193
219
|
background: Type.Optional(Type.Boolean({ description: "Run without blocking (default false)" })),
|
|
220
|
+
isolation: Type.Optional(Type.String({ description: "Set to worktree to run in an isolated git worktree (for mutating tasks)" })),
|
|
194
221
|
}),
|
|
195
222
|
async execute(
|
|
196
223
|
_id,
|
|
197
|
-
params: { agent: string; task: string; background?: boolean },
|
|
224
|
+
params: { agent: string; task: string; background?: boolean; isolation?: string },
|
|
198
225
|
_signal,
|
|
199
226
|
_onUpdate,
|
|
200
227
|
ctx,
|
|
@@ -209,15 +236,16 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
209
236
|
|
|
210
237
|
const uiCtx = ctx as UiContext;
|
|
211
238
|
const background = params.background === true;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
239
|
+
|
|
240
|
+
// v0.2: worktree isolation for mutating children — its own branch and
|
|
241
|
+
// checkout under ~/.worktrees/, never touching the main tree.
|
|
242
|
+
let isolation: Isolation | null = null;
|
|
243
|
+
if (params.isolation === "worktree") {
|
|
244
|
+
isolation = createIsolationWorktree(uiCtx.cwd, nextId(def.name));
|
|
217
245
|
}
|
|
218
246
|
|
|
219
247
|
const run: RunState = {
|
|
220
|
-
id: nextId(def.name),
|
|
248
|
+
id: isolation ? isolation.branch.replace(/^agent\//, "") : nextId(def.name),
|
|
221
249
|
agent: def.name,
|
|
222
250
|
task: params.task.trim(),
|
|
223
251
|
background,
|
|
@@ -232,22 +260,35 @@ export default function subagent(pi: ExtensionAPI) {
|
|
|
232
260
|
runs.set(run.id, run);
|
|
233
261
|
renderWidget(uiCtx);
|
|
234
262
|
|
|
263
|
+
const runIt = async () => {
|
|
264
|
+
await acquireSlot();
|
|
265
|
+
try {
|
|
266
|
+
await runChild(uiCtx, def, run, isolation?.path);
|
|
267
|
+
} finally {
|
|
268
|
+
releaseSlot();
|
|
269
|
+
}
|
|
270
|
+
if (isolation && run.result !== null) {
|
|
271
|
+
run.result = `${run.result}\n\n${isolationNote(isolation)}`;
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
235
275
|
if (background) {
|
|
236
|
-
|
|
276
|
+
// v0.2: beyond the concurrency cap runs queue instead of rejecting.
|
|
277
|
+
void runIt().then(() => {
|
|
237
278
|
notify(uiCtx, `subagent ${run.id}: ${run.status}`, run.status === "done" ? "info" : "warning");
|
|
238
279
|
});
|
|
239
280
|
return {
|
|
240
281
|
content: [
|
|
241
282
|
{ type: "text", text: `Started ${run.id} in the background. Collect with agent_result id="${run.id}".` },
|
|
242
283
|
],
|
|
243
|
-
details: { id: run.id },
|
|
284
|
+
details: { id: run.id, worktree: isolation?.path ?? null },
|
|
244
285
|
};
|
|
245
286
|
}
|
|
246
287
|
|
|
247
|
-
await
|
|
288
|
+
await runIt();
|
|
248
289
|
return {
|
|
249
290
|
content: [{ type: "text", text: formatRunResult(run) }],
|
|
250
|
-
details: { id: run.id, status: run.status, tokens: run.tokens },
|
|
291
|
+
details: { id: run.id, status: run.status, tokens: run.tokens, worktree: isolation?.path ?? null },
|
|
251
292
|
};
|
|
252
293
|
},
|
|
253
294
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pify/subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -32,8 +32,12 @@
|
|
|
32
32
|
"LICENSE"
|
|
33
33
|
],
|
|
34
34
|
"pi": {
|
|
35
|
-
"extensions": [
|
|
36
|
-
|
|
35
|
+
"extensions": [
|
|
36
|
+
"./extensions/subagent.ts"
|
|
37
|
+
],
|
|
38
|
+
"skills": [
|
|
39
|
+
"./skills"
|
|
40
|
+
]
|
|
37
41
|
},
|
|
38
42
|
"scripts": {
|
|
39
43
|
"typecheck": "tsc --noEmit",
|
|
@@ -46,9 +50,15 @@
|
|
|
46
50
|
"typebox": "*"
|
|
47
51
|
},
|
|
48
52
|
"peerDependenciesMeta": {
|
|
49
|
-
"@earendil-works/pi-coding-agent": {
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
"@earendil-works/pi-coding-agent": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"@earendil-works/pi-tui": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"typebox": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
52
62
|
},
|
|
53
63
|
"devDependencies": {
|
|
54
64
|
"@earendil-works/pi-coding-agent": "^0.84.4",
|
package/src/frontmatter.ts
CHANGED
|
@@ -44,6 +44,9 @@ export function parseAgentFile(
|
|
|
44
44
|
: DEFAULT_MAX_TURNS;
|
|
45
45
|
|
|
46
46
|
const model = fields.get("model") || null;
|
|
47
|
+
const promptMode = fields.get("system_prompt_mode")?.toLowerCase();
|
|
48
|
+
const systemPromptMode = promptMode === "replace" ? "replace" : "append";
|
|
49
|
+
const inheritSkills = !isFalse(fields.get("inherit_skills"));
|
|
47
50
|
|
|
48
51
|
return {
|
|
49
52
|
name: name.toLowerCase(),
|
|
@@ -53,10 +56,18 @@ export function parseAgentFile(
|
|
|
53
56
|
thinking,
|
|
54
57
|
maxTurns,
|
|
55
58
|
systemPrompt: match[2]!.trim(),
|
|
59
|
+
systemPromptMode,
|
|
60
|
+
inheritSkills,
|
|
56
61
|
source,
|
|
57
62
|
};
|
|
58
63
|
}
|
|
59
64
|
|
|
65
|
+
/** Frontmatter booleans, written the handful of ways people write them. */
|
|
66
|
+
function isFalse(raw: string | undefined): boolean {
|
|
67
|
+
if (raw === undefined) return false;
|
|
68
|
+
return ["false", "no", "off", "0"].includes(raw.trim().toLowerCase());
|
|
69
|
+
}
|
|
70
|
+
|
|
60
71
|
/** Read-only default keeps a def missing `tools:` from mutating anything. */
|
|
61
72
|
function parseTools(raw: string | undefined): ValidTool[] {
|
|
62
73
|
if (!raw) return ["read", "grep", "find", "ls"];
|
package/src/isolate.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Worktree isolation for child agents (v0.2 integration with the suite's
|
|
8
|
+
* worktree conventions): a mutating child gets its own git worktree on an
|
|
9
|
+
* agent/<slug> branch under ~/.worktrees/<repo>/, so parallel edits can
|
|
10
|
+
* never collide with the main checkout. All git calls are execFile argv —
|
|
11
|
+
* no shell, no interpolation. The worktree is NOT auto-removed: the result
|
|
12
|
+
* reports it so the user merges (worktree_merge from @pify/worktree, or
|
|
13
|
+
* plain git) or discards deliberately.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface Isolation {
|
|
17
|
+
path: string;
|
|
18
|
+
branch: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function git(cwd: string, args: string[]): string {
|
|
22
|
+
return execFileSync("git", args, {
|
|
23
|
+
cwd,
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
timeout: 30_000,
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
28
|
+
}).trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sanitizeSlug(raw: string): string {
|
|
32
|
+
const slug = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
33
|
+
return slug || "run";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createIsolationWorktree(cwd: string, rawSlug: string): Isolation {
|
|
37
|
+
let toplevel: string;
|
|
38
|
+
try {
|
|
39
|
+
toplevel = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error("Worktree isolation requires a git repository.");
|
|
42
|
+
}
|
|
43
|
+
const repo = basename(toplevel);
|
|
44
|
+
const slug = sanitizeSlug(rawSlug);
|
|
45
|
+
|
|
46
|
+
let branch = `agent/${slug}`;
|
|
47
|
+
let path = join(homedir(), ".worktrees", repo, slug);
|
|
48
|
+
let counter = 2;
|
|
49
|
+
while (existsSync(path) || branchExists(cwd, branch)) {
|
|
50
|
+
branch = `agent/${slug}-${counter}`;
|
|
51
|
+
path = join(homedir(), ".worktrees", repo, `${slug}-${counter}`);
|
|
52
|
+
counter++;
|
|
53
|
+
if (counter > 50) throw new Error("Could not find a free worktree slot.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
git(cwd, ["worktree", "add", "-b", branch, path, "HEAD"]);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const e = err as { stderr?: string; message?: string };
|
|
60
|
+
throw new Error(`git worktree add failed: ${(e.stderr ?? e.message ?? "unknown").toString().trim()}`);
|
|
61
|
+
}
|
|
62
|
+
return { path, branch };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function branchExists(cwd: string, branch: string): boolean {
|
|
66
|
+
try {
|
|
67
|
+
git(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Note appended to a child's report when it ran isolated. */
|
|
75
|
+
export function isolationNote(isolation: Isolation): string {
|
|
76
|
+
return [
|
|
77
|
+
`Ran isolated in worktree ${isolation.path} (branch ${isolation.branch}).`,
|
|
78
|
+
`The main checkout is untouched. Merge with @pify/worktree's worktree_merge branch="${isolation.branch}",`,
|
|
79
|
+
`or inspect: cd "${isolation.path}" && git log --stat`,
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -38,6 +38,14 @@ export interface AgentDef {
|
|
|
38
38
|
maxTurns: number;
|
|
39
39
|
/** Markdown body appended to the child's system prompt. */
|
|
40
40
|
systemPrompt: string;
|
|
41
|
+
/**
|
|
42
|
+
* "append" (default) puts the body after the session's own system prompt;
|
|
43
|
+
* "replace" drops the parent's prompt so a specialist is not also told to
|
|
44
|
+
* be this project's coding assistant.
|
|
45
|
+
*/
|
|
46
|
+
systemPromptMode: "append" | "replace";
|
|
47
|
+
/** Whether the child loads the project's skills (default true). */
|
|
48
|
+
inheritSkills: boolean;
|
|
41
49
|
source: "builtin" | "global" | "project";
|
|
42
50
|
}
|
|
43
51
|
|