@pify/swarm 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pifydev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @pify/swarm
2
+
3
+ Coordinate multiple [pi](https://github.com/earendil-works/pi) agents working in parallel. One tool call fans a list of task items out to child agents — with auto-routing, a concurrency queue, a live widget, and one aggregated report.
4
+
5
+ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install swarm`](https://github.com/pifydev/cli) or `pi install npm:@pify/swarm`.
6
+
7
+ ## What it does
8
+
9
+ - **`swarm_run`** — fan out 1–12 independent items to parallel children (4 at a time, rest queued). Blocking by default: returns `N done, M error` plus a per-item report. `background: true` returns a `runId` immediately.
10
+ - **`swarm_status`** — live per-item progress (`1:scout=running(3t) · 2:reviewer=queued`), full report once finished; completed runs survive `/reload`.
11
+ - **Per-item auto-routing**: agent definitions can declare `match_patterns` (globs matched against path-like tokens in the item — longest wins) and `match_keywords`. `review src/auth.rs` routes to your Rust auditor; `test the login flow` to your tester; everything else falls back to the read-only `scout` — the fallback never mutates. Force one type for all items with `agent`.
12
+ - **Shared context**: the `context` param is prepended to every item, so common constraints are written once.
13
+ - **One agent catalog**: reads the same `.pi/agents/*.md` definitions as [`@pify/subagent`](https://github.com/pifydev/subagent) (description/tools/model/thinking/max_turns), plus the two routing keys:
14
+
15
+ ```markdown
16
+ ---
17
+ description: Rust audit specialist
18
+ tools: read, grep, find, ls
19
+ match_patterns: *.rs, src/**
20
+ match_keywords: rust, memory safety
21
+ ---
22
+ ```
23
+
24
+ - **Independence by design**: items share nothing, children cannot spawn children, and each child is capped at its agent's `max_turns`.
25
+
26
+ ## Where this sits in the suite
27
+
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
+
30
+ ## License
31
+
32
+ MIT © [Pify maintainers](https://github.com/pifydev)
@@ -0,0 +1,345 @@
1
+ /**
2
+ * @pify/swarm — coordinate multiple pi agents working in parallel.
3
+ *
4
+ * swarm_run fans a list of task items out to child agents (the same
5
+ * in-process createAgentSession runner proven in @pify/subagent), with a
6
+ * concurrency queue, per-item auto-routing via agent-def match_patterns /
7
+ * match_keywords (gjczone's model), a live widget, and an aggregated
8
+ * report. Blocking by default; background: true returns a runId polled
9
+ * with swarm_status. Items are independent — no shared state, no nesting.
10
+ *
11
+ * Reads the SAME .pi/agents/*.md definitions as @pify/subagent (plus the
12
+ * two routing keys), so one agent catalog serves both packages.
13
+ */
14
+ import {
15
+ createAgentSession,
16
+ DefaultResourceLoader,
17
+ getAgentDir,
18
+ SessionManager,
19
+ type AgentSession,
20
+ type ExtensionAPI,
21
+ type ExtensionContext,
22
+ } from "@earendil-works/pi-coding-agent";
23
+ import { Text } from "@earendil-works/pi-tui";
24
+ import { Type } from "typebox";
25
+
26
+ import { BUILTIN_AGENTS } from "../src/builtin.ts";
27
+ import { parseAgentFile } from "../src/frontmatter.ts";
28
+ import { buildReport, buildStatusLine } from "../src/report.ts";
29
+ import { routeItem } from "../src/routing.ts";
30
+ import { buildWidgetLines } from "../src/widget.ts";
31
+ import {
32
+ DEFAULT_CONCURRENCY,
33
+ MAX_ITEMS,
34
+ isRecord,
35
+ type AgentDef,
36
+ type ItemState,
37
+ type SwarmRun,
38
+ } from "../src/types.ts";
39
+ import { readFileSync, readdirSync } from "node:fs";
40
+ import { basename, join } from "node:path";
41
+
42
+ const RUN_ENTRY = "swarm-run";
43
+
44
+ type UiContext = ExtensionContext;
45
+
46
+ function loadDefs(cwd: string, agentDir: string): Map<string, AgentDef> {
47
+ const defs = new Map<string, AgentDef>();
48
+ for (const [name, content] of Object.entries(BUILTIN_AGENTS)) {
49
+ const def = parseAgentFile(name, content, "builtin");
50
+ if (def) defs.set(def.name, def);
51
+ }
52
+ for (const [dir, source] of [
53
+ [join(agentDir, "agents"), "global"],
54
+ [join(cwd, ".pi", "agents"), "project"],
55
+ ] as const) {
56
+ try {
57
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
58
+ try {
59
+ const def = parseAgentFile(basename(file, ".md"), readFileSync(join(dir, file), "utf8"), source);
60
+ if (def) defs.set(def.name, def);
61
+ } catch {
62
+ // skip unreadable
63
+ }
64
+ }
65
+ } catch {
66
+ // dir missing
67
+ }
68
+ }
69
+ return defs;
70
+ }
71
+
72
+ export default function swarm(pi: ExtensionAPI) {
73
+ let defs = new Map<string, AgentDef>();
74
+ const runs = new Map<string, SwarmRun>();
75
+ let activeRun: SwarmRun | null = null;
76
+ let runCounter = 0;
77
+ let lastUiCtx: UiContext | null = null;
78
+
79
+ function renderWidget(ctx: UiContext | null = lastUiCtx): void {
80
+ if (!ctx || !ctx.hasUI) return;
81
+ lastUiCtx = ctx;
82
+ const run = activeRun;
83
+ const now = Date.now();
84
+ if (!run || (run.status === "done" && (run.finishedAt ?? 0) < now - 15_000)) {
85
+ ctx.ui.setWidget("swarm", undefined);
86
+ return;
87
+ }
88
+ ctx.ui.setWidget(
89
+ "swarm",
90
+ (_tui: unknown, theme: { fg(c: string, s: string): string; bold(s: string): string }) =>
91
+ new Text(buildWidgetLines(run, theme, Date.now()).join("\n"), 0, 0),
92
+ { placement: "aboveEditor" },
93
+ );
94
+ }
95
+
96
+ function notify(ctx: UiContext, message: string, level: "info" | "warning" | "error"): void {
97
+ if (ctx.hasUI) ctx.ui.notify(message, level);
98
+ }
99
+
100
+ // ── Child runner (subagent-proven pattern, one per item) ─────────────
101
+
102
+ async function runItem(ctx: UiContext, def: AgentDef, item: ItemState, context: string): Promise<void> {
103
+ item.status = "running";
104
+ renderWidget();
105
+ let session: AgentSession | null = null;
106
+ let unsubscribe: (() => void) | null = null;
107
+ try {
108
+ let model = ctx.model ?? null;
109
+ if (def.model) {
110
+ const [provider, ...rest] = def.model.split("/");
111
+ const found =
112
+ provider && rest.length > 0 ? ctx.modelRegistry.find(provider, rest.join("/")) : undefined;
113
+ if (found) model = found;
114
+ }
115
+ if (!model) throw new Error("No model available");
116
+
117
+ const promptHost = ctx as unknown as {
118
+ getSystemPromptOptions?: () => { customPrompt?: string; appendSystemPrompt?: string };
119
+ };
120
+ const promptOptions = promptHost.getSystemPromptOptions?.() ?? {};
121
+
122
+ const created = await createAgentSession({
123
+ sessionManager: SessionManager.inMemory(ctx.cwd),
124
+ model,
125
+ thinkingLevel: (def.thinking ?? pi.getThinkingLevel()) as never,
126
+ tools: def.tools,
127
+ resourceLoader: new DefaultResourceLoader({
128
+ cwd: ctx.cwd,
129
+ agentDir: getAgentDir(),
130
+ noExtensions: true,
131
+ noPromptTemplates: true,
132
+ noThemes: true,
133
+ systemPrompt: promptOptions.customPrompt,
134
+ appendSystemPrompt: [
135
+ ...(promptOptions.appendSystemPrompt ? [promptOptions.appendSystemPrompt] : []),
136
+ def.systemPrompt,
137
+ "You are one agent in a swarm, handling exactly one item. Your final assistant message is the deliverable — make it complete and self-contained.",
138
+ ],
139
+ }),
140
+ });
141
+ session = created.session;
142
+
143
+ unsubscribe = session.subscribe((event) => {
144
+ if (event.type === "message_end" && (event as { message?: { role?: string } }).message?.role === "assistant") {
145
+ item.turns++;
146
+ const usage = (event as { message?: { usage?: { totalTokens?: number } } }).message?.usage;
147
+ if (usage && typeof usage.totalTokens === "number") item.tokens += usage.totalTokens;
148
+ renderWidget();
149
+ if (item.turns >= def.maxTurns) void session?.abort().catch(() => {});
150
+ }
151
+ });
152
+
153
+ const prompt = context ? `${context.trim()}\n\nYour item: ${item.item}` : item.item;
154
+ await session.prompt(prompt, { source: "extension" } as never);
155
+
156
+ const messages = session.messages as Array<{
157
+ role?: string;
158
+ stopReason?: unknown;
159
+ content?: Array<{ type?: string; text?: string }>;
160
+ }>;
161
+ const last = [...messages].reverse().find((m) => m.role === "assistant");
162
+ const text = (last?.content ?? [])
163
+ .filter((c) => c.type === "text" && typeof c.text === "string")
164
+ .map((c) => c.text)
165
+ .join("\n")
166
+ .trim();
167
+
168
+ item.result = text || null;
169
+ item.status =
170
+ last?.stopReason === "aborted" ? "aborted" : last?.stopReason === "error" ? "error" : "done";
171
+ if (item.status === "error") item.error = text || "child session error";
172
+ } catch (err) {
173
+ item.status = "error";
174
+ item.error = err instanceof Error ? err.message : String(err);
175
+ } finally {
176
+ if (unsubscribe) {
177
+ try {
178
+ unsubscribe();
179
+ } catch {
180
+ // gone
181
+ }
182
+ }
183
+ if (session) {
184
+ try {
185
+ session.dispose();
186
+ } catch {
187
+ // fine
188
+ }
189
+ }
190
+ renderWidget();
191
+ }
192
+ }
193
+
194
+ /** Pool executor: at most DEFAULT_CONCURRENCY items in flight. */
195
+ async function executeRun(ctx: UiContext, run: SwarmRun, context: string, fixed?: string): Promise<void> {
196
+ const queue = [...run.items];
197
+ const workers = Array.from({ length: Math.min(DEFAULT_CONCURRENCY, queue.length) }, async () => {
198
+ for (;;) {
199
+ const item = queue.shift();
200
+ if (!item) return;
201
+ const def = routeItem(item.item, defs, fixed);
202
+ item.agent = def.name;
203
+ await runItem(ctx, def, item, context);
204
+ }
205
+ });
206
+ await Promise.all(workers);
207
+ run.status = "done";
208
+ run.finishedAt = Date.now();
209
+ pi.appendEntry(RUN_ENTRY, run);
210
+ renderWidget();
211
+ }
212
+
213
+ // ── Tools ────────────────────────────────────────────────────────────
214
+
215
+ pi.registerTool({
216
+ name: "swarm_run",
217
+ label: "Run swarm",
218
+ description:
219
+ `Fan out 1-${MAX_ITEMS} independent task items to parallel child agents (concurrency ${DEFAULT_CONCURRENCY}). ` +
220
+ "Each item auto-routes to an agent type via its match_patterns/match_keywords, falling back to the " +
221
+ "read-only scout; set agent to force one type for all items. context is prepended to every item. " +
222
+ "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.",
224
+ parameters: Type.Object({
225
+ items: Type.Array(Type.String(), { minItems: 1, maxItems: MAX_ITEMS }),
226
+ context: Type.Optional(Type.String({ description: "Shared preamble for every item" })),
227
+ agent: Type.Optional(Type.String({ description: "Force one agent type for all items" })),
228
+ background: Type.Optional(Type.Boolean()),
229
+ }),
230
+ async execute(
231
+ _id,
232
+ params: { items: string[]; context?: string; agent?: string; background?: boolean },
233
+ _signal,
234
+ _onUpdate,
235
+ ctx,
236
+ ) {
237
+ const uiCtx = ctx as UiContext;
238
+ const items = params.items.map((s) => s.trim()).filter(Boolean);
239
+ if (items.length === 0) throw new Error("swarm_run requires at least one non-empty item.");
240
+ if (params.agent && !defs.has(params.agent.toLowerCase())) {
241
+ throw new Error(`Unknown agent type "${params.agent}". Available: ${[...defs.keys()].sort().join(", ")}`);
242
+ }
243
+ if (activeRun?.status === "running") {
244
+ throw new Error(`Swarm ${activeRun.runId} is still running — wait or check swarm_status.`);
245
+ }
246
+
247
+ runCounter++;
248
+ const run: SwarmRun = {
249
+ runId: `s${runCounter}`,
250
+ background: params.background === true,
251
+ status: "running",
252
+ startedAt: Date.now(),
253
+ finishedAt: null,
254
+ items: items.map((item, index) => ({
255
+ index,
256
+ item,
257
+ agent: params.agent?.toLowerCase() ?? "?",
258
+ status: "queued",
259
+ turns: 0,
260
+ tokens: 0,
261
+ result: null,
262
+ error: null,
263
+ })),
264
+ };
265
+ runs.set(run.runId, run);
266
+ activeRun = run;
267
+ renderWidget(uiCtx);
268
+
269
+ if (run.background) {
270
+ void executeRun(uiCtx, run, params.context ?? "", params.agent).then(() => {
271
+ notify(uiCtx, `swarm ${run.runId} finished — collect with swarm_status`, "info");
272
+ });
273
+ return {
274
+ content: [
275
+ { type: "text", text: `Swarm ${run.runId} started (${items.length} items). Poll swarm_status runId="${run.runId}".` },
276
+ ],
277
+ details: { runId: run.runId },
278
+ };
279
+ }
280
+
281
+ await executeRun(uiCtx, run, params.context ?? "", params.agent);
282
+ return {
283
+ content: [{ type: "text", text: buildReport(run) }],
284
+ details: { runId: run.runId },
285
+ };
286
+ },
287
+ });
288
+
289
+ pi.registerTool({
290
+ name: "swarm_status",
291
+ label: "Swarm status",
292
+ description: "Progress of a swarm run (default: the latest). Returns the full report when finished.",
293
+ parameters: Type.Object({
294
+ runId: Type.Optional(Type.String()),
295
+ }),
296
+ async execute(_id, params: { runId?: string }) {
297
+ const run = params.runId ? runs.get(params.runId.trim()) : activeRun ?? [...runs.values()].pop();
298
+ if (!run) throw new Error("No swarm runs this session.");
299
+ const text = run.status === "done" ? buildReport(run) : buildStatusLine(run);
300
+ return { content: [{ type: "text", text }], details: { runId: run.runId, status: run.status } };
301
+ },
302
+ });
303
+
304
+ // ── Lifecycle & command ──────────────────────────────────────────────
305
+
306
+ pi.on("session_start", async (_event, ctx) => {
307
+ defs = loadDefs(ctx.cwd, getAgentDir());
308
+ runs.clear();
309
+ activeRun = null;
310
+ for (const entry of ctx.sessionManager.getBranch()) {
311
+ const e = entry as { type?: string; customType?: string; data?: unknown };
312
+ if (e.type !== "custom" || e.customType !== RUN_ENTRY || !isRecord(e.data)) continue;
313
+ const run = e.data as unknown as SwarmRun;
314
+ if (typeof run.runId === "string" && run.status === "done") {
315
+ runs.set(run.runId, run);
316
+ const n = Number.parseInt(run.runId.slice(1), 10);
317
+ if (Number.isFinite(n) && n > runCounter) runCounter = n;
318
+ }
319
+ }
320
+ renderWidget(ctx);
321
+ });
322
+
323
+ pi.on("session_shutdown", async (_event, ctx) => {
324
+ if (ctx.hasUI) ctx.ui.setWidget("swarm", undefined);
325
+ });
326
+
327
+ pi.registerCommand("swarm", {
328
+ description: "Show swarm runs and routing-capable agent types",
329
+ handler: async (_args, ctx) => {
330
+ if (!ctx.hasUI) return;
331
+ const routed = [...defs.values()]
332
+ .map((d) => {
333
+ const rules = [
334
+ ...d.matchPatterns.map((p) => `glob:${p}`),
335
+ ...d.matchKeywords.map((k) => `kw:${k}`),
336
+ ].join(", ");
337
+ return `${d.name} (${d.source})${rules ? ` [${rules}]` : ""}`;
338
+ })
339
+ .join("\n");
340
+ const runLines =
341
+ [...runs.values()].map((r) => buildStatusLine(r)).join("\n") || "(no runs yet)";
342
+ ctx.ui.notify(`Agent types\n${routed}\n\nRuns\n${runLines}`, "info");
343
+ },
344
+ });
345
+ }
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@pify/swarm",
3
+ "version": "0.1.0",
4
+ "description": "Coordinate multiple pi agents in parallel: swarm_run fan-out with per-item auto-routing, concurrency queue, aggregated reports",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi",
9
+ "pify",
10
+ "swarm",
11
+ "agents"
12
+ ],
13
+ "homepage": "https://github.com/pifydev/swarm#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/pifydev/swarm/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/pifydev/swarm.git"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Pify maintainers",
23
+ "type": "module",
24
+ "engines": {
25
+ "node": ">=22.19.0"
26
+ },
27
+ "files": [
28
+ "extensions",
29
+ "src",
30
+ "skills",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "pi": {
35
+ "extensions": [
36
+ "./extensions/swarm.ts"
37
+ ],
38
+ "skills": [
39
+ "./skills"
40
+ ]
41
+ },
42
+ "scripts": {
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "bun test",
45
+ "prepublishOnly": "npm run typecheck && npm test"
46
+ },
47
+ "peerDependencies": {
48
+ "@earendil-works/pi-coding-agent": "*",
49
+ "@earendil-works/pi-tui": "*",
50
+ "typebox": "*"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@earendil-works/pi-coding-agent": {
54
+ "optional": true
55
+ },
56
+ "@earendil-works/pi-tui": {
57
+ "optional": true
58
+ },
59
+ "typebox": {
60
+ "optional": true
61
+ }
62
+ },
63
+ "devDependencies": {
64
+ "@earendil-works/pi-coding-agent": "^0.84.4",
65
+ "@earendil-works/pi-tui": "^0.84.4",
66
+ "@types/node": "^22.10.2",
67
+ "typebox": "^1.1.38",
68
+ "typescript": "^5.7.2"
69
+ },
70
+ "publishConfig": {
71
+ "access": "public"
72
+ }
73
+ }
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: swarm
3
+ description: Use when work splits into several independent items that can run in parallel - multi-file reviews, sweeps, parallel research - explains swarm_run fan-out, auto-routing, and how to slice items well
4
+ ---
5
+
6
+ # Swarm
7
+
8
+ This project has the `@pify/swarm` extension installed: `swarm_run` fans a
9
+ list of independent items out to parallel child agents (concurrency 4) and
10
+ returns one aggregated report; `swarm_status` polls background runs.
11
+
12
+ ## When to fan out
13
+
14
+ - Reviewing or auditing several files/modules independently.
15
+ - The same question asked across many places ("check each package for X").
16
+ - Parallel research where items do not depend on each other.
17
+
18
+ Do NOT use a swarm when items depend on each other's results (do them
19
+ sequentially yourself) or for a single task (use agent_run from
20
+ @pify/subagent instead).
21
+
22
+ ## Slicing items
23
+
24
+ - Each item must be a self-contained brief: the child sees only its item
25
+ plus the shared `context` preamble — never this conversation.
26
+ - Prefer one file/module per item; 3-8 items is the sweet spot (max 12).
27
+ - Put everything common (goal, output format, constraints) in `context`
28
+ once instead of repeating it per item.
29
+
30
+ ## Routing
31
+
32
+ Items auto-route by agent-def `match_patterns` (globs against paths in the
33
+ item) then `match_keywords`, falling back to the read-only scout. Force one
34
+ type with `agent` when the routing does not fit. Mutating items must
35
+ explicitly target `worker` — the fallback never mutates.
36
+
37
+ ## Collecting
38
+
39
+ Blocking runs return the report directly. For `background: true`, ALWAYS
40
+ collect with `swarm_status` before relying on any item's outcome.
package/src/builtin.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Built-in agent types (adapted from nicobailon/pi-subagents' agent set,
3
+ * trimmed to three archetypes). Overridable: a project or global .md file
4
+ * with the same name wins.
5
+ */
6
+
7
+ export const BUILTIN_AGENTS: Record<string, string> = {
8
+ reviewer: `---
9
+ description: Read-only review specialist for diffs, plans, and code health
10
+ tools: read, grep, find, ls
11
+ thinking: high
12
+ max_turns: 25
13
+ ---
14
+
15
+ You are a disciplined review subagent. Inspect, evaluate, and report findings
16
+ with evidence — never guess; verify from the code itself. You cannot modify
17
+ anything: your deliverable is the report.
18
+
19
+ For each finding give: file:line, what is wrong, why it matters, and a
20
+ concrete suggestion. Rank findings by severity. If the code is fine, say so
21
+ plainly — do not invent issues. End with a one-paragraph verdict.`,
22
+
23
+ scout: `---
24
+ description: Fast read-only exploration and research across the codebase
25
+ tools: read, grep, find, ls
26
+ thinking: low
27
+ max_turns: 25
28
+ ---
29
+
30
+ You are a scout subagent: locate, map, and summarize — quickly. Answer the
31
+ question with file paths and line references, quoting only the smallest
32
+ relevant excerpts. Prefer breadth over depth unless asked otherwise. If
33
+ something cannot be found, report exactly what you searched so the caller
34
+ can redirect you. Your final message is the entire deliverable.`,
35
+
36
+ worker: `---
37
+ description: Implementation agent with full tool access for a scoped task
38
+ tools: read, bash, edit, write, grep, find, ls
39
+ thinking: medium
40
+ max_turns: 60
41
+ ---
42
+
43
+ You are a worker subagent implementing one scoped task. Stay strictly within
44
+ the task's boundaries: no drive-by refactors, no scope creep. Follow the
45
+ project's existing conventions. Verify your work (build, tests, or a smoke
46
+ check) before finishing. Your final message must state exactly what changed,
47
+ what you verified, and anything you deliberately left undone.`,
48
+ };
@@ -0,0 +1,74 @@
1
+ import {
2
+ DEFAULT_MAX_TURNS,
3
+ THINKING_LEVELS,
4
+ VALID_TOOLS,
5
+ type AgentDef,
6
+ type ThinkingLevelName,
7
+ type ValidTool,
8
+ } from "./types.ts";
9
+
10
+ /**
11
+ * Parse an agent definition file: same schema as @pify/subagent plus the
12
+ * routing keys `match_patterns` / `match_keywords` (comma-separated, also
13
+ * accepts the camelCase spellings gjczone used). One definition file serves
14
+ * both packages.
15
+ */
16
+ export function parseAgentFile(
17
+ name: string,
18
+ content: string,
19
+ source: AgentDef["source"],
20
+ ): AgentDef | null {
21
+ const normalized = content.replace(/\r\n/g, "\n");
22
+ const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(normalized);
23
+ if (!match) return null;
24
+
25
+ const fields = new Map<string, string>();
26
+ for (const line of match[1]!.split("\n")) {
27
+ const kv = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line.trim());
28
+ if (kv) fields.set(kv[1]!.toLowerCase(), kv[2]!.trim());
29
+ }
30
+
31
+ const description = fields.get("description") ?? "";
32
+ if (!description) return null;
33
+
34
+ const thinkingRaw = fields.get("thinking")?.toLowerCase();
35
+ const maxTurnsRaw = Number.parseInt(fields.get("max_turns") ?? "", 10);
36
+
37
+ return {
38
+ name: name.toLowerCase(),
39
+ description,
40
+ tools: parseTools(fields.get("tools")),
41
+ model: fields.get("model") || null,
42
+ thinking: (THINKING_LEVELS as readonly string[]).includes(thinkingRaw ?? "")
43
+ ? (thinkingRaw as ThinkingLevelName)
44
+ : null,
45
+ maxTurns:
46
+ Number.isFinite(maxTurnsRaw) && maxTurnsRaw > 0 && maxTurnsRaw <= 200
47
+ ? maxTurnsRaw
48
+ : DEFAULT_MAX_TURNS,
49
+ systemPrompt: match[2]!.trim(),
50
+ source,
51
+ matchPatterns: parseList(fields.get("match_patterns") ?? fields.get("matchpatterns")),
52
+ matchKeywords: parseList(fields.get("match_keywords") ?? fields.get("matchkeywords")).map((k) =>
53
+ k.toLowerCase(),
54
+ ),
55
+ };
56
+ }
57
+
58
+ function parseList(raw: string | undefined): string[] {
59
+ if (!raw) return [];
60
+ return raw
61
+ .replace(/^\[|\]$/g, "")
62
+ .split(",")
63
+ .map((t) => t.trim().replace(/^["']|["']$/g, ""))
64
+ .filter(Boolean);
65
+ }
66
+
67
+ function parseTools(raw: string | undefined): ValidTool[] {
68
+ if (!raw) return ["read", "grep", "find", "ls"];
69
+ const valid = raw
70
+ .split(",")
71
+ .map((t) => t.trim().toLowerCase())
72
+ .filter((t): t is ValidTool => (VALID_TOOLS as readonly string[]).includes(t));
73
+ return valid.length > 0 ? valid : ["read", "grep", "find", "ls"];
74
+ }
package/src/report.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { SwarmRun } from "./types.ts";
2
+
3
+ /** Aggregated report returned to the parent model when a run finishes. */
4
+ export function buildReport(run: SwarmRun): string {
5
+ const counts = { done: 0, error: 0, aborted: 0 };
6
+ for (const item of run.items) {
7
+ if (item.status === "done") counts.done++;
8
+ else if (item.status === "error") counts.error++;
9
+ else if (item.status === "aborted") counts.aborted++;
10
+ }
11
+
12
+ const header = `[swarm ${run.runId}] ${run.items.length} items — ${counts.done} done, ${counts.error} error, ${counts.aborted} aborted`;
13
+
14
+ const sections = run.items.map((item) => {
15
+ const label = `### ${item.index + 1}. [${item.agent}] ${item.item}`;
16
+ if (item.status === "done") return `${label}\n${item.result ?? "(empty report)"}`;
17
+ if (item.status === "error") return `${label}\nError: ${item.error ?? "unknown"}`;
18
+ if (item.status === "aborted") {
19
+ return `${label}\nAborted (turn cap or stop). Partial:\n${item.result ?? "(none)"}`;
20
+ }
21
+ return `${label}\n(${item.status})`;
22
+ });
23
+
24
+ return [header, ...sections].join("\n\n");
25
+ }
26
+
27
+ /** One-line progress for swarm_status while a run is live. */
28
+ export function buildStatusLine(run: SwarmRun): string {
29
+ const parts = run.items.map(
30
+ (i) => `${i.index + 1}:${i.agent}=${i.status}${i.turns ? `(${i.turns}t)` : ""}`,
31
+ );
32
+ return `[swarm ${run.runId}] ${run.status} — ${parts.join(" · ")}`;
33
+ }
package/src/routing.ts ADDED
Binary file
package/src/types.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Local structural types for @pify/swarm.
3
+ * No imports from pi packages: src/ typechecks and runs standalone.
4
+ */
5
+
6
+ export const VALID_TOOLS = [
7
+ "read",
8
+ "bash",
9
+ "powershell",
10
+ "edit",
11
+ "write",
12
+ "grep",
13
+ "find",
14
+ "ls",
15
+ ] as const;
16
+ export type ValidTool = (typeof VALID_TOOLS)[number];
17
+
18
+ export type ThinkingLevelName = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
19
+ export const THINKING_LEVELS: readonly ThinkingLevelName[] = [
20
+ "off",
21
+ "minimal",
22
+ "low",
23
+ "medium",
24
+ "high",
25
+ "xhigh",
26
+ "max",
27
+ ];
28
+
29
+ /** Agent definition; superset of @pify/subagent's with routing keys. */
30
+ export interface AgentDef {
31
+ name: string;
32
+ description: string;
33
+ tools: ValidTool[];
34
+ model: string | null;
35
+ thinking: ThinkingLevelName | null;
36
+ maxTurns: number;
37
+ systemPrompt: string;
38
+ source: "builtin" | "global" | "project";
39
+ /** Glob patterns matched against path-like tokens in an item (routing). */
40
+ matchPatterns: string[];
41
+ /** Case-insensitive keywords matched against the item text (routing). */
42
+ matchKeywords: string[];
43
+ }
44
+
45
+ export const DEFAULT_MAX_TURNS = 30;
46
+ export const MAX_ITEMS = 12;
47
+ export const DEFAULT_CONCURRENCY = 4;
48
+ /** Safe default when no routing rule matches: read-only exploration. */
49
+ export const FALLBACK_AGENT = "scout";
50
+
51
+ export type ItemStatus = "queued" | "running" | "done" | "error" | "aborted";
52
+
53
+ export interface ItemState {
54
+ index: number;
55
+ item: string;
56
+ agent: string;
57
+ status: ItemStatus;
58
+ turns: number;
59
+ tokens: number;
60
+ result: string | null;
61
+ error: string | null;
62
+ }
63
+
64
+ export type RunStatus = "running" | "done";
65
+
66
+ export interface SwarmRun {
67
+ runId: string;
68
+ background: boolean;
69
+ status: RunStatus;
70
+ startedAt: number;
71
+ finishedAt: number | null;
72
+ items: ItemState[];
73
+ }
74
+
75
+ export interface ThemeLike {
76
+ fg(color: string, text: string): string;
77
+ bold(text: string): string;
78
+ }
79
+
80
+ export interface BranchEntryLike {
81
+ type?: string;
82
+ customType?: string;
83
+ data?: unknown;
84
+ [key: string]: unknown;
85
+ }
86
+
87
+ export function isRecord(value: unknown): value is Record<string, unknown> {
88
+ return typeof value === "object" && value !== null && !Array.isArray(value);
89
+ }
package/src/widget.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { SwarmRun, ThemeLike } from "./types.ts";
2
+
3
+ const WIDTH = 54;
4
+
5
+ function icon(status: string): string {
6
+ switch (status) {
7
+ case "queued":
8
+ return "·";
9
+ case "running":
10
+ return "⟳";
11
+ case "done":
12
+ return "✓";
13
+ case "error":
14
+ return "✗";
15
+ default:
16
+ return "◼";
17
+ }
18
+ }
19
+
20
+ /** Widget above the editor for the active (or just-finished) run. */
21
+ export function buildWidgetLines(run: SwarmRun | null, theme: ThemeLike, now: number): string[] {
22
+ if (!run) return [];
23
+ if (run.status === "done" && (run.finishedAt ?? 0) < now - 15_000) return [];
24
+
25
+ const dim = (s: string) => theme.fg("dim", s);
26
+ const lines: string[] = [];
27
+ const title = ` 🐝 swarm ${run.runId} `;
28
+ const hint = " /swarm ";
29
+ const pad = Math.max(1, WIDTH - title.length - hint.length);
30
+ lines.push(dim(`╭${title}${"─".repeat(pad)}${hint}╮`));
31
+
32
+ for (const item of run.items) {
33
+ const paint =
34
+ item.status === "running"
35
+ ? (s: string) => theme.fg("warning", s)
36
+ : item.status === "done"
37
+ ? (s: string) => theme.fg("success", s)
38
+ : item.status === "queued"
39
+ ? dim
40
+ : (s: string) => theme.fg("error", s);
41
+ const text = item.item.length > 32 ? `${item.item.slice(0, 32)}…` : item.item;
42
+ lines.push(`${dim("│ ")}${paint(`${icon(item.status)} ${item.agent}`)}${dim(` ${text}`)}`);
43
+ }
44
+
45
+ lines.push(dim(`╰${"─".repeat(WIDTH)}╯`));
46
+ return lines;
47
+ }