@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.
- package/extensions/subagent.ts +49 -15
- package/package.json +16 -6
- package/src/isolate.ts +81 -0
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,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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": [
|
|
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/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
|
+
}
|