@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.
@@ -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
- await runItem(ctx, def, item, context);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Coordinate multiple pi agents in parallel: swarm_run fan-out with per-item auto-routing, concurrency queue, aggregated reports",
5
5
  "keywords": [
6
6
  "pi-package",
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
+ }