@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 +11 -0
- package/extensions/swarm.ts +88 -9
- package/package.json +3 -3
- package/src/isolate.ts +33 -0
- package/src/mailbox.ts +112 -0
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.
|
package/extensions/swarm.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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: {
|
|
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.
|
|
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.
|
|
65
|
-
"@earendil-works/pi-tui": "^0.
|
|
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
|
+
}
|