@pify/swarm 0.2.1 → 0.4.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,17 @@ 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)
42
+
43
+ **Isolated runs clean up after themselves** (v0.4): a worktree whose child changed nothing is removed along with its branch — otherwise a read-only step left one of each behind, per run. Anything uncommitted, or any commit the child made, is kept and reported.
@@ -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,7 +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";
27
- import { createIsolationWorktree, isolationNote } from "../src/isolate.ts";
28
+ import { createIsolationWorktree, isolationNote, removeIfUnchanged } from "../src/isolate.ts";
29
+ import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "../src/mailbox.ts";
28
30
  import { parseAgentFile } from "../src/frontmatter.ts";
29
31
  import { buildReport, buildStatusLine } from "../src/report.ts";
30
32
  import { routeItem } from "../src/routing.ts";
@@ -41,6 +43,8 @@ import { readFileSync, readdirSync } from "node:fs";
41
43
  import { basename, join } from "node:path";
42
44
 
43
45
  const RUN_ENTRY = "swarm-run";
46
+ const CLEAN_WORKTREE_NOTE =
47
+ "Ran isolated in a temporary worktree; it changed nothing, so the worktree was removed.";
44
48
 
45
49
  type UiContext = ExtensionContext;
46
50
 
@@ -100,7 +104,59 @@ export default function swarm(pi: ExtensionAPI) {
100
104
 
101
105
  // ── Child runner (subagent-proven pattern, one per item) ─────────────
102
106
 
103
- async function runItem(ctx: UiContext, def: AgentDef, item: ItemState, context: string, workDir?: string): Promise<void> {
107
+ /**
108
+ * Mailbox tools for one child. Each agent posts under its own label and
109
+ * never sees its own posts echoed back; `seen` advances per agent so a
110
+ * second swarm_inbox only reports what arrived since the first.
111
+ */
112
+ function mailboxTools(dir: string, label: string): ToolDefinition[] {
113
+ let seen = 0;
114
+ return [
115
+ {
116
+ name: "swarm_post",
117
+ label: "Post to swarm",
118
+ description:
119
+ "Tell the other agents in this swarm something that changes their work: a shared file you " +
120
+ "modified, a convention you had to choose, a blocker they will hit too. Not for progress " +
121
+ "narration — only facts a sibling needs to avoid redoing or undoing your work.",
122
+ parameters: Type.Object({
123
+ message: Type.String({ description: "One fact the other agents need" }),
124
+ }),
125
+ async execute(_id: string, params: { message: string }) {
126
+ const posted = postMessage(dir, label, params.message, Date.now());
127
+ return {
128
+ content: [{ type: "text", text: `Posted #${posted.seq} to the swarm.` }],
129
+ details: { seq: posted.seq },
130
+ };
131
+ },
132
+ },
133
+ {
134
+ name: "swarm_inbox",
135
+ label: "Read swarm inbox",
136
+ description:
137
+ "Read what the other agents in this swarm have posted since you last checked. Call it " +
138
+ "before you start working and again before you finish.",
139
+ parameters: Type.Object({}),
140
+ async execute() {
141
+ const read = readInbox(dir, label, seen);
142
+ seen = read.nextSeq;
143
+ return {
144
+ content: [{ type: "text", text: formatInbox(read) }],
145
+ details: { count: read.messages.length },
146
+ };
147
+ },
148
+ },
149
+ ] as unknown as ToolDefinition[];
150
+ }
151
+
152
+ async function runItem(
153
+ ctx: UiContext,
154
+ def: AgentDef,
155
+ item: ItemState,
156
+ context: string,
157
+ workDir?: string,
158
+ mailbox?: string,
159
+ ): Promise<void> {
104
160
  item.status = "running";
105
161
  renderWidget();
106
162
  let session: AgentSession | null = null;
@@ -125,6 +181,7 @@ export default function swarm(pi: ExtensionAPI) {
125
181
  model,
126
182
  thinkingLevel: (def.thinking ?? pi.getThinkingLevel()) as never,
127
183
  tools: def.tools,
184
+ ...(mailbox ? { customTools: mailboxTools(mailbox, item.agent + "-" + item.index) } : {}),
128
185
  resourceLoader: new DefaultResourceLoader({
129
186
  cwd: workDir ?? ctx.cwd,
130
187
  agentDir: getAgentDir(),
@@ -136,6 +193,7 @@ export default function swarm(pi: ExtensionAPI) {
136
193
  ...(promptOptions.appendSystemPrompt ? [promptOptions.appendSystemPrompt] : []),
137
194
  def.systemPrompt,
138
195
  "You are one agent in a swarm, handling exactly one item. Your final assistant message is the deliverable — make it complete and self-contained.",
196
+ ...(mailbox ? [mailboxPrompt(item.agent + "-" + item.index)] : []),
139
197
  ],
140
198
  }),
141
199
  });
@@ -193,7 +251,16 @@ export default function swarm(pi: ExtensionAPI) {
193
251
  }
194
252
 
195
253
  /** Pool executor: at most DEFAULT_CONCURRENCY items in flight. */
196
- async function executeRun(ctx: UiContext, run: SwarmRun, context: string, fixed?: string, isolate?: boolean): Promise<void> {
254
+ async function executeRun(
255
+ ctx: UiContext,
256
+ run: SwarmRun,
257
+ context: string,
258
+ fixed?: string,
259
+ isolate?: boolean,
260
+ useMailbox?: boolean,
261
+ ): Promise<void> {
262
+ // One shared log per run; only created when the caller asked for it.
263
+ const mailbox = useMailbox ? mailboxDir(getAgentDir(), run.runId) : undefined;
197
264
  const queue = [...run.items];
198
265
  const workers = Array.from({ length: Math.min(DEFAULT_CONCURRENCY, queue.length) }, async () => {
199
266
  for (;;) {
@@ -204,14 +271,14 @@ export default function swarm(pi: ExtensionAPI) {
204
271
  if (isolate) {
205
272
  try {
206
273
  const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
207
- await runItem(ctx, def, item, context, iso.path);
274
+ await runItem(ctx, def, item, context, iso.path, mailbox);
208
275
  if (item.result !== null) item.result = `${item.result}\n\n${isolationNote(iso)}`;
209
276
  } catch (err) {
210
277
  item.status = "error";
211
278
  item.error = err instanceof Error ? err.message : String(err);
212
279
  }
213
280
  } else {
214
- await runItem(ctx, def, item, context);
281
+ await runItem(ctx, def, item, context, undefined, mailbox);
215
282
  }
216
283
  }
217
284
  });
@@ -233,17 +300,29 @@ export default function swarm(pi: ExtensionAPI) {
233
300
  "read-only scout; set agent to force one type for all items. context is prepended to every item. " +
234
301
  "Blocking by default (returns the aggregated report); background=true returns a runId for swarm_status. " +
235
302
  "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.",
303
+ "isolation=worktree: each item gets its own git worktree and branch; reports say how to merge. " +
304
+ "mailbox=true adds swarm_post/swarm_inbox so agents can warn each other about shared files and " +
305
+ "conventions instead of silently conflicting.",
237
306
  parameters: Type.Object({
238
307
  items: Type.Array(Type.String(), { minItems: 1, maxItems: MAX_ITEMS }),
239
308
  context: Type.Optional(Type.String({ description: "Shared preamble for every item" })),
240
309
  agent: Type.Optional(Type.String({ description: "Force one agent type for all items" })),
241
310
  isolation: Type.Optional(Type.String({ description: "Set to worktree to give each item its own git worktree (for mutating items)" })),
311
+ mailbox: Type.Optional(
312
+ Type.Boolean({ description: "Give the agents swarm_post/swarm_inbox to share facts mid-run" }),
313
+ ),
242
314
  background: Type.Optional(Type.Boolean()),
243
315
  }),
244
316
  async execute(
245
317
  _id,
246
- params: { items: string[]; context?: string; agent?: string; background?: boolean; isolation?: string },
318
+ params: {
319
+ items: string[];
320
+ context?: string;
321
+ agent?: string;
322
+ background?: boolean;
323
+ isolation?: string;
324
+ mailbox?: boolean;
325
+ },
247
326
  _signal,
248
327
  _onUpdate,
249
328
  ctx,
@@ -281,7 +360,7 @@ export default function swarm(pi: ExtensionAPI) {
281
360
  renderWidget(uiCtx);
282
361
 
283
362
  if (run.background) {
284
- void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree").then(() => {
363
+ void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true).then(() => {
285
364
  notify(uiCtx, `swarm ${run.runId} finished — collect with swarm_status`, "info");
286
365
  });
287
366
  return {
@@ -292,7 +371,7 @@ export default function swarm(pi: ExtensionAPI) {
292
371
  };
293
372
  }
294
373
 
295
- await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree");
374
+ await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
296
375
  return {
297
376
  content: [{ type: "text", text: buildReport(run) }],
298
377
  details: { runId: run.runId },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.2.1",
3
+ "version": "0.4.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",
@@ -61,8 +61,8 @@
61
61
  }
62
62
  },
63
63
  "devDependencies": {
64
- "@earendil-works/pi-coding-agent": "^0.84.4",
65
- "@earendil-works/pi-tui": "^0.84.4",
64
+ "@earendil-works/pi-coding-agent": "^0.85.1",
65
+ "@earendil-works/pi-tui": "^0.85.1",
66
66
  "@types/node": "^22.10.2",
67
67
  "typebox": "^1.1.38",
68
68
  "typescript": "^5.7.2"
package/src/isolate.ts CHANGED
@@ -79,3 +79,36 @@ export function isolationNote(isolation: Isolation): string {
79
79
  `or inspect: cd "${isolation.path}" && git log --stat`,
80
80
  ].join("\n");
81
81
  }
82
+
83
+ /**
84
+ * Remove a worktree the child left untouched. An isolated run that changed
85
+ * nothing is the common case — a review, a search, a question — and keeping
86
+ * its worktree means a directory and a branch per run accumulate under
87
+ * ~/.worktrees for as long as the machine runs. A worktree with any change,
88
+ * staged or not, committed or not, is kept: that is someone's work.
89
+ *
90
+ * Returns true when it was removed. Never throws: failing to clean up must
91
+ * not fail the run that already succeeded.
92
+ */
93
+ export function removeIfUnchanged(cwd: string, isolation: Isolation): boolean {
94
+ try {
95
+ // Uncommitted work, tracked or not.
96
+ if (git(isolation.path, ["status", "--porcelain"]).trim()) return false;
97
+ // Commits made inside the worktree: the branch moved off the commit it
98
+ // was cut from. (A fresh agent/<slug> branch has no upstream, so asking
99
+ // git for "ahead of upstream" would throw here rather than answer.)
100
+ const head = git(isolation.path, ["rev-parse", "HEAD"]).trim();
101
+ const base = git(cwd, ["rev-parse", "HEAD"]).trim();
102
+ if (!head || head !== base) return false;
103
+ } catch {
104
+ // A worktree we cannot inspect is one we must not delete.
105
+ return false;
106
+ }
107
+ try {
108
+ git(cwd, ["worktree", "remove", "--force", isolation.path]);
109
+ git(cwd, ["branch", "-D", isolation.branch]);
110
+ return true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
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
+ }