@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 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)
@@ -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
- systemPrompt: promptOptions.customPrompt,
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
- ...(promptOptions.appendSystemPrompt ? [promptOptions.appendSystemPrompt] : []),
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
- 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
- );
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
- void runChild(uiCtx, def, run).then(() => {
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 runChild(uiCtx, def, run);
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.1.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": ["./extensions/subagent.ts"],
36
- "skills": ["./skills"]
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": { "optional": true },
50
- "@earendil-works/pi-tui": { "optional": true },
51
- "typebox": { "optional": true }
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",
@@ -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