@pify/swarm 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
@@ -27,6 +27,15 @@ match_keywords: rust, memory safety
27
27
 
28
28
  `@pify/subagent` = one child, one task. `@pify/swarm` = many independent items at once. `@pify/workflow` = deterministic scripted orchestration. Pick the smallest one that fits.
29
29
 
30
+ ## Mailbox (v0.3)
31
+
32
+ `swarm_run(items, { mailbox: true })` gives every agent two extra tools:
33
+
34
+ - `swarm_post(message)` — tell the siblings something that changes their work: a shared file you modified, a convention you had to pick, a blocker they will hit too.
35
+ - `swarm_inbox()` — read what the others posted since your last check.
36
+
37
+ Without it, parallel agents cannot see each other, so two of them cheerfully fix the same shared helper in two different ways. It is deliberately not a chat: no addressing, no waiting, no replies — an append-only log per run, and an agent never sees its own posts echoed back. A torn line from two simultaneous appends is skipped rather than failing the read.
38
+
30
39
  ## License
31
40
 
32
41
  MIT © [Pify maintainers](https://github.com/pifydev)
@@ -17,6 +17,7 @@ import {
17
17
  getAgentDir,
18
18
  SessionManager,
19
19
  type AgentSession,
20
+ type ToolDefinition,
20
21
  type ExtensionAPI,
21
22
  type ExtensionContext,
22
23
  } from "@earendil-works/pi-coding-agent";
@@ -24,6 +25,8 @@ import { Text } from "@earendil-works/pi-tui";
24
25
  import { Type } from "typebox";
25
26
 
26
27
  import { BUILTIN_AGENTS } from "../src/builtin.ts";
28
+ import { createIsolationWorktree, isolationNote } from "../src/isolate.ts";
29
+ import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "../src/mailbox.ts";
27
30
  import { parseAgentFile } from "../src/frontmatter.ts";
28
31
  import { buildReport, buildStatusLine } from "../src/report.ts";
29
32
  import { routeItem } from "../src/routing.ts";
@@ -99,7 +102,59 @@ export default function swarm(pi: ExtensionAPI) {
99
102
 
100
103
  // ── Child runner (subagent-proven pattern, one per item) ─────────────
101
104
 
102
- async function runItem(ctx: UiContext, def: AgentDef, item: ItemState, context: string): Promise<void> {
105
+ /**
106
+ * Mailbox tools for one child. Each agent posts under its own label and
107
+ * never sees its own posts echoed back; `seen` advances per agent so a
108
+ * second swarm_inbox only reports what arrived since the first.
109
+ */
110
+ function mailboxTools(dir: string, label: string): ToolDefinition[] {
111
+ let seen = 0;
112
+ return [
113
+ {
114
+ name: "swarm_post",
115
+ label: "Post to swarm",
116
+ description:
117
+ "Tell the other agents in this swarm something that changes their work: a shared file you " +
118
+ "modified, a convention you had to choose, a blocker they will hit too. Not for progress " +
119
+ "narration — only facts a sibling needs to avoid redoing or undoing your work.",
120
+ parameters: Type.Object({
121
+ message: Type.String({ description: "One fact the other agents need" }),
122
+ }),
123
+ async execute(_id: string, params: { message: string }) {
124
+ const posted = postMessage(dir, label, params.message, Date.now());
125
+ return {
126
+ content: [{ type: "text", text: `Posted #${posted.seq} to the swarm.` }],
127
+ details: { seq: posted.seq },
128
+ };
129
+ },
130
+ },
131
+ {
132
+ name: "swarm_inbox",
133
+ label: "Read swarm inbox",
134
+ description:
135
+ "Read what the other agents in this swarm have posted since you last checked. Call it " +
136
+ "before you start working and again before you finish.",
137
+ parameters: Type.Object({}),
138
+ async execute() {
139
+ const read = readInbox(dir, label, seen);
140
+ seen = read.nextSeq;
141
+ return {
142
+ content: [{ type: "text", text: formatInbox(read) }],
143
+ details: { count: read.messages.length },
144
+ };
145
+ },
146
+ },
147
+ ] as unknown as ToolDefinition[];
148
+ }
149
+
150
+ async function runItem(
151
+ ctx: UiContext,
152
+ def: AgentDef,
153
+ item: ItemState,
154
+ context: string,
155
+ workDir?: string,
156
+ mailbox?: string,
157
+ ): Promise<void> {
103
158
  item.status = "running";
104
159
  renderWidget();
105
160
  let session: AgentSession | null = null;
@@ -120,12 +175,13 @@ export default function swarm(pi: ExtensionAPI) {
120
175
  const promptOptions = promptHost.getSystemPromptOptions?.() ?? {};
121
176
 
122
177
  const created = await createAgentSession({
123
- sessionManager: SessionManager.inMemory(ctx.cwd),
178
+ sessionManager: SessionManager.inMemory(workDir ?? ctx.cwd),
124
179
  model,
125
180
  thinkingLevel: (def.thinking ?? pi.getThinkingLevel()) as never,
126
181
  tools: def.tools,
182
+ ...(mailbox ? { customTools: mailboxTools(mailbox, item.agent + "-" + item.index) } : {}),
127
183
  resourceLoader: new DefaultResourceLoader({
128
- cwd: ctx.cwd,
184
+ cwd: workDir ?? ctx.cwd,
129
185
  agentDir: getAgentDir(),
130
186
  noExtensions: true,
131
187
  noPromptTemplates: true,
@@ -135,6 +191,7 @@ export default function swarm(pi: ExtensionAPI) {
135
191
  ...(promptOptions.appendSystemPrompt ? [promptOptions.appendSystemPrompt] : []),
136
192
  def.systemPrompt,
137
193
  "You are one agent in a swarm, handling exactly one item. Your final assistant message is the deliverable — make it complete and self-contained.",
194
+ ...(mailbox ? [mailboxPrompt(item.agent + "-" + item.index)] : []),
138
195
  ],
139
196
  }),
140
197
  });
@@ -192,7 +249,16 @@ export default function swarm(pi: ExtensionAPI) {
192
249
  }
193
250
 
194
251
  /** Pool executor: at most DEFAULT_CONCURRENCY items in flight. */
195
- async function executeRun(ctx: UiContext, run: SwarmRun, context: string, fixed?: string): Promise<void> {
252
+ async function executeRun(
253
+ ctx: UiContext,
254
+ run: SwarmRun,
255
+ context: string,
256
+ fixed?: string,
257
+ isolate?: boolean,
258
+ useMailbox?: boolean,
259
+ ): Promise<void> {
260
+ // One shared log per run; only created when the caller asked for it.
261
+ const mailbox = useMailbox ? mailboxDir(getAgentDir(), run.runId) : undefined;
196
262
  const queue = [...run.items];
197
263
  const workers = Array.from({ length: Math.min(DEFAULT_CONCURRENCY, queue.length) }, async () => {
198
264
  for (;;) {
@@ -200,7 +266,18 @@ export default function swarm(pi: ExtensionAPI) {
200
266
  if (!item) return;
201
267
  const def = routeItem(item.item, defs, fixed);
202
268
  item.agent = def.name;
203
- await runItem(ctx, def, item, context);
269
+ if (isolate) {
270
+ try {
271
+ const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
272
+ await runItem(ctx, def, item, context, iso.path, mailbox);
273
+ if (item.result !== null) item.result = `${item.result}\n\n${isolationNote(iso)}`;
274
+ } catch (err) {
275
+ item.status = "error";
276
+ item.error = err instanceof Error ? err.message : String(err);
277
+ }
278
+ } else {
279
+ await runItem(ctx, def, item, context, undefined, mailbox);
280
+ }
204
281
  }
205
282
  });
206
283
  await Promise.all(workers);
@@ -220,16 +297,30 @@ export default function swarm(pi: ExtensionAPI) {
220
297
  "Each item auto-routes to an agent type via its match_patterns/match_keywords, falling back to the " +
221
298
  "read-only scout; set agent to force one type for all items. context is prepended to every item. " +
222
299
  "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.",
300
+ "Write each item as a self-contained brief — children see nothing else. For MUTATING items set " +
301
+ "isolation=worktree: each item gets its own git worktree and branch; reports say how to merge. " +
302
+ "mailbox=true adds swarm_post/swarm_inbox so agents can warn each other about shared files and " +
303
+ "conventions instead of silently conflicting.",
224
304
  parameters: Type.Object({
225
305
  items: Type.Array(Type.String(), { minItems: 1, maxItems: MAX_ITEMS }),
226
306
  context: Type.Optional(Type.String({ description: "Shared preamble for every item" })),
227
307
  agent: Type.Optional(Type.String({ description: "Force one agent type for all items" })),
308
+ isolation: Type.Optional(Type.String({ description: "Set to worktree to give each item its own git worktree (for mutating items)" })),
309
+ mailbox: Type.Optional(
310
+ Type.Boolean({ description: "Give the agents swarm_post/swarm_inbox to share facts mid-run" }),
311
+ ),
228
312
  background: Type.Optional(Type.Boolean()),
229
313
  }),
230
314
  async execute(
231
315
  _id,
232
- params: { items: string[]; context?: string; agent?: string; background?: boolean },
316
+ params: {
317
+ items: string[];
318
+ context?: string;
319
+ agent?: string;
320
+ background?: boolean;
321
+ isolation?: string;
322
+ mailbox?: boolean;
323
+ },
233
324
  _signal,
234
325
  _onUpdate,
235
326
  ctx,
@@ -267,7 +358,7 @@ export default function swarm(pi: ExtensionAPI) {
267
358
  renderWidget(uiCtx);
268
359
 
269
360
  if (run.background) {
270
- void executeRun(uiCtx, run, params.context ?? "", params.agent).then(() => {
361
+ void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true).then(() => {
271
362
  notify(uiCtx, `swarm ${run.runId} finished — collect with swarm_status`, "info");
272
363
  });
273
364
  return {
@@ -278,7 +369,7 @@ export default function swarm(pi: ExtensionAPI) {
278
369
  };
279
370
  }
280
371
 
281
- await executeRun(uiCtx, run, params.context ?? "", params.agent);
372
+ await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
282
373
  return {
283
374
  content: [{ type: "text", text: buildReport(run) }],
284
375
  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.3.0",
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
+ }
package/src/mailbox.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Run mailbox (v0.3, gjczone's shared inbox/outbox). Swarm agents work the
3
+ * same repository at the same time and cannot see each other, so two of them
4
+ * happily fix the same shared helper in two different ways. A mailbox is the
5
+ * cheapest fix: one append-only log per run, readable by every sibling.
6
+ *
7
+ * Deliberately not a chat. Agents post facts they discovered that change
8
+ * someone else's work, and read what others posted; there is no addressing,
9
+ * no waiting, and no reply. Anything richer needs steering, which is a
10
+ * different feature.
11
+ */
12
+
13
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ export interface MailMessage {
17
+ seq: number;
18
+ from: string;
19
+ text: string;
20
+ timestamp: number;
21
+ }
22
+
23
+ export const MAX_MESSAGE_CHARS = 1200;
24
+ export const MAX_INBOX_MESSAGES = 30;
25
+
26
+ /**
27
+ * One directory per run under the agent dir. Run ids are generated locally,
28
+ * but this builds a filesystem path, so it stays a single flat segment: no
29
+ * separators and no `..` can survive the sanitizer.
30
+ */
31
+ export function mailboxDir(agentDir: string, runId: string): string {
32
+ const safe =
33
+ runId
34
+ .replace(/[^A-Za-z0-9._-]/g, "-")
35
+ .replace(/\.{2,}/g, "-")
36
+ .replace(/^[.-]+/, "")
37
+ .slice(0, 80) || "run";
38
+ return join(agentDir, "swarm-mailbox", safe);
39
+ }
40
+
41
+ function logPath(dir: string): string {
42
+ return join(dir, "messages.jsonl");
43
+ }
44
+
45
+ export function readMailbox(dir: string): MailMessage[] {
46
+ let raw: string;
47
+ try {
48
+ raw = readFileSync(logPath(dir), "utf8");
49
+ } catch {
50
+ return [];
51
+ }
52
+ const messages: MailMessage[] = [];
53
+ for (const line of raw.split("\n")) {
54
+ if (!line.trim()) continue;
55
+ try {
56
+ const parsed = JSON.parse(line) as MailMessage;
57
+ if (typeof parsed.seq === "number" && typeof parsed.from === "string" && typeof parsed.text === "string") {
58
+ messages.push(parsed);
59
+ }
60
+ } catch {
61
+ // a torn line from a concurrent append — skip it, never fail the read
62
+ }
63
+ }
64
+ return messages;
65
+ }
66
+
67
+ /**
68
+ * Append one message. Concurrency is handled by the filesystem: a single
69
+ * append of one line is atomic enough for this, and a torn read is skipped
70
+ * rather than treated as an error.
71
+ */
72
+ export function postMessage(dir: string, from: string, text: string, now: number): MailMessage {
73
+ const trimmed = text.trim().slice(0, MAX_MESSAGE_CHARS);
74
+ if (!trimmed) throw new Error("A mailbox message cannot be empty.");
75
+ mkdirSync(dir, { recursive: true });
76
+ const seq = readMailbox(dir).length + 1;
77
+ const message: MailMessage = { seq, from, text: trimmed, timestamp: now };
78
+ appendFileSync(logPath(dir), `${JSON.stringify(message)}\n`);
79
+ return message;
80
+ }
81
+
82
+ export interface InboxRead {
83
+ messages: MailMessage[];
84
+ /** Pass back as `sinceSeq` to read only what arrives after this. */
85
+ nextSeq: number;
86
+ }
87
+
88
+ /** Messages from OTHER agents after `sinceSeq`. Own posts are never echoed. */
89
+ export function readInbox(dir: string, reader: string, sinceSeq = 0): InboxRead {
90
+ const all = readMailbox(dir);
91
+ const highest = all.reduce((max, m) => Math.max(max, m.seq), 0);
92
+ const messages = all
93
+ .filter((m) => m.seq > sinceSeq && m.from !== reader)
94
+ .slice(-MAX_INBOX_MESSAGES);
95
+ return { messages, nextSeq: highest };
96
+ }
97
+
98
+ export function formatInbox(read: InboxRead): string {
99
+ if (read.messages.length === 0) return "No new messages from the other agents.";
100
+ return read.messages.map((m) => `[#${m.seq} from ${m.from}] ${m.text}`).join("\n");
101
+ }
102
+
103
+ /** The instruction children get, naming their own label so posts are attributable. */
104
+ export function mailboxPrompt(label: string): string {
105
+ return [
106
+ `You are agent "${label}" in a parallel swarm working the same repository.`,
107
+ "Use swarm_post to tell the other agents something that changes their work:",
108
+ "a shared file you modified, a convention you had to pick, a blocker they will hit too.",
109
+ "Use swarm_inbox before you start and again before you finish, so you do not redo",
110
+ "or undo someone else's work. Do not post progress narration — only facts others need.",
111
+ ].join(" ");
112
+ }