@pify/swarm 0.1.0 → 0.2.1
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/swarm.ts +23 -9
- package/package.json +1 -1
- package/src/isolate.ts +81 -0
package/extensions/swarm.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
24
24
|
import { Type } from "typebox";
|
|
25
25
|
|
|
26
26
|
import { BUILTIN_AGENTS } from "../src/builtin.ts";
|
|
27
|
+
import { createIsolationWorktree, isolationNote } from "../src/isolate.ts";
|
|
27
28
|
import { parseAgentFile } from "../src/frontmatter.ts";
|
|
28
29
|
import { buildReport, buildStatusLine } from "../src/report.ts";
|
|
29
30
|
import { routeItem } from "../src/routing.ts";
|
|
@@ -99,7 +100,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
99
100
|
|
|
100
101
|
// ── Child runner (subagent-proven pattern, one per item) ─────────────
|
|
101
102
|
|
|
102
|
-
async function runItem(ctx: UiContext, def: AgentDef, item: ItemState, context: string): Promise<void> {
|
|
103
|
+
async function runItem(ctx: UiContext, def: AgentDef, item: ItemState, context: string, workDir?: string): Promise<void> {
|
|
103
104
|
item.status = "running";
|
|
104
105
|
renderWidget();
|
|
105
106
|
let session: AgentSession | null = null;
|
|
@@ -120,12 +121,12 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
120
121
|
const promptOptions = promptHost.getSystemPromptOptions?.() ?? {};
|
|
121
122
|
|
|
122
123
|
const created = await createAgentSession({
|
|
123
|
-
sessionManager: SessionManager.inMemory(ctx.cwd),
|
|
124
|
+
sessionManager: SessionManager.inMemory(workDir ?? ctx.cwd),
|
|
124
125
|
model,
|
|
125
126
|
thinkingLevel: (def.thinking ?? pi.getThinkingLevel()) as never,
|
|
126
127
|
tools: def.tools,
|
|
127
128
|
resourceLoader: new DefaultResourceLoader({
|
|
128
|
-
cwd: ctx.cwd,
|
|
129
|
+
cwd: workDir ?? ctx.cwd,
|
|
129
130
|
agentDir: getAgentDir(),
|
|
130
131
|
noExtensions: true,
|
|
131
132
|
noPromptTemplates: true,
|
|
@@ -192,7 +193,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
/** Pool executor: at most DEFAULT_CONCURRENCY items in flight. */
|
|
195
|
-
async function executeRun(ctx: UiContext, run: SwarmRun, context: string, fixed?: string): Promise<void> {
|
|
196
|
+
async function executeRun(ctx: UiContext, run: SwarmRun, context: string, fixed?: string, isolate?: boolean): Promise<void> {
|
|
196
197
|
const queue = [...run.items];
|
|
197
198
|
const workers = Array.from({ length: Math.min(DEFAULT_CONCURRENCY, queue.length) }, async () => {
|
|
198
199
|
for (;;) {
|
|
@@ -200,7 +201,18 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
200
201
|
if (!item) return;
|
|
201
202
|
const def = routeItem(item.item, defs, fixed);
|
|
202
203
|
item.agent = def.name;
|
|
203
|
-
|
|
204
|
+
if (isolate) {
|
|
205
|
+
try {
|
|
206
|
+
const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
|
|
207
|
+
await runItem(ctx, def, item, context, iso.path);
|
|
208
|
+
if (item.result !== null) item.result = `${item.result}\n\n${isolationNote(iso)}`;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
item.status = "error";
|
|
211
|
+
item.error = err instanceof Error ? err.message : String(err);
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
await runItem(ctx, def, item, context);
|
|
215
|
+
}
|
|
204
216
|
}
|
|
205
217
|
});
|
|
206
218
|
await Promise.all(workers);
|
|
@@ -220,16 +232,18 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
220
232
|
"Each item auto-routes to an agent type via its match_patterns/match_keywords, falling back to the " +
|
|
221
233
|
"read-only scout; set agent to force one type for all items. context is prepended to every item. " +
|
|
222
234
|
"Blocking by default (returns the aggregated report); background=true returns a runId for swarm_status. " +
|
|
223
|
-
"Write each item as a self-contained brief — children see nothing else."
|
|
235
|
+
"Write each item as a self-contained brief — children see nothing else. For MUTATING items set " +
|
|
236
|
+
"isolation=worktree: each item gets its own git worktree and branch; reports say how to merge.",
|
|
224
237
|
parameters: Type.Object({
|
|
225
238
|
items: Type.Array(Type.String(), { minItems: 1, maxItems: MAX_ITEMS }),
|
|
226
239
|
context: Type.Optional(Type.String({ description: "Shared preamble for every item" })),
|
|
227
240
|
agent: Type.Optional(Type.String({ description: "Force one agent type for all items" })),
|
|
241
|
+
isolation: Type.Optional(Type.String({ description: "Set to worktree to give each item its own git worktree (for mutating items)" })),
|
|
228
242
|
background: Type.Optional(Type.Boolean()),
|
|
229
243
|
}),
|
|
230
244
|
async execute(
|
|
231
245
|
_id,
|
|
232
|
-
params: { items: string[]; context?: string; agent?: string; background?: boolean },
|
|
246
|
+
params: { items: string[]; context?: string; agent?: string; background?: boolean; isolation?: string },
|
|
233
247
|
_signal,
|
|
234
248
|
_onUpdate,
|
|
235
249
|
ctx,
|
|
@@ -267,7 +281,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
267
281
|
renderWidget(uiCtx);
|
|
268
282
|
|
|
269
283
|
if (run.background) {
|
|
270
|
-
void executeRun(uiCtx, run, params.context ?? "", params.agent).then(() => {
|
|
284
|
+
void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree").then(() => {
|
|
271
285
|
notify(uiCtx, `swarm ${run.runId} finished — collect with swarm_status`, "info");
|
|
272
286
|
});
|
|
273
287
|
return {
|
|
@@ -278,7 +292,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
278
292
|
};
|
|
279
293
|
}
|
|
280
294
|
|
|
281
|
-
await executeRun(uiCtx, run, params.context ?? "", params.agent);
|
|
295
|
+
await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree");
|
|
282
296
|
return {
|
|
283
297
|
content: [{ type: "text", text: buildReport(run) }],
|
|
284
298
|
details: { runId: run.runId },
|
package/package.json
CHANGED
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
|
+
}
|