@pify/subagent 0.1.0 → 0.2.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.
@@ -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,12 +119,12 @@ 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,
@@ -186,15 +203,18 @@ export default function subagent(pi: ExtensionAPI) {
186
203
  "(read-only exploration/research), worker (full tools, implements a task), or a custom type " +
187
204
  "from .pi/agents/. background=false (default) blocks and returns the child's report; " +
188
205
  "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.",
206
+ "Write the task as a complete, self-contained brief: the child sees none of this conversation. " +
207
+ "For MUTATING tasks set isolation=worktree: the child gets its own git worktree and branch, the " +
208
+ "main checkout stays untouched, and the report says how to merge or discard.",
190
209
  parameters: Type.Object({
191
210
  agent: Type.String({ description: "Agent type name" }),
192
211
  task: Type.String({ description: "Complete task brief for the child" }),
193
212
  background: Type.Optional(Type.Boolean({ description: "Run without blocking (default false)" })),
213
+ isolation: Type.Optional(Type.String({ description: "Set to worktree to run in an isolated git worktree (for mutating tasks)" })),
194
214
  }),
195
215
  async execute(
196
216
  _id,
197
- params: { agent: string; task: string; background?: boolean },
217
+ params: { agent: string; task: string; background?: boolean; isolation?: string },
198
218
  _signal,
199
219
  _onUpdate,
200
220
  ctx,
@@ -209,15 +229,16 @@ export default function subagent(pi: ExtensionAPI) {
209
229
 
210
230
  const uiCtx = ctx as UiContext;
211
231
  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
- );
232
+
233
+ // v0.2: worktree isolation for mutating children — its own branch and
234
+ // checkout under ~/.worktrees/, never touching the main tree.
235
+ let isolation: Isolation | null = null;
236
+ if (params.isolation === "worktree") {
237
+ isolation = createIsolationWorktree(uiCtx.cwd, nextId(def.name));
217
238
  }
218
239
 
219
240
  const run: RunState = {
220
- id: nextId(def.name),
241
+ id: isolation ? isolation.branch.replace(/^agent\//, "") : nextId(def.name),
221
242
  agent: def.name,
222
243
  task: params.task.trim(),
223
244
  background,
@@ -232,22 +253,35 @@ export default function subagent(pi: ExtensionAPI) {
232
253
  runs.set(run.id, run);
233
254
  renderWidget(uiCtx);
234
255
 
256
+ const runIt = async () => {
257
+ await acquireSlot();
258
+ try {
259
+ await runChild(uiCtx, def, run, isolation?.path);
260
+ } finally {
261
+ releaseSlot();
262
+ }
263
+ if (isolation && run.result !== null) {
264
+ run.result = `${run.result}\n\n${isolationNote(isolation)}`;
265
+ }
266
+ };
267
+
235
268
  if (background) {
236
- void runChild(uiCtx, def, run).then(() => {
269
+ // v0.2: beyond the concurrency cap runs queue instead of rejecting.
270
+ void runIt().then(() => {
237
271
  notify(uiCtx, `subagent ${run.id}: ${run.status}`, run.status === "done" ? "info" : "warning");
238
272
  });
239
273
  return {
240
274
  content: [
241
275
  { type: "text", text: `Started ${run.id} in the background. Collect with agent_result id="${run.id}".` },
242
276
  ],
243
- details: { id: run.id },
277
+ details: { id: run.id, worktree: isolation?.path ?? null },
244
278
  };
245
279
  }
246
280
 
247
- await runChild(uiCtx, def, run);
281
+ await runIt();
248
282
  return {
249
283
  content: [{ type: "text", text: formatRunResult(run) }],
250
- details: { id: run.id, status: run.status, tokens: run.tokens },
284
+ details: { id: run.id, status: run.status, tokens: run.tokens, worktree: isolation?.path ?? null },
251
285
  };
252
286
  },
253
287
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/subagent",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
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
+ }