@pify/swarm 0.5.0 → 0.6.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
@@ -1,16 +1,50 @@
1
1
  # @pify/swarm
2
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.
3
+ Run many [pi](https://github.com/earendil-works/pi) agents in parallel. One tool call fans a list of independent items out to child agents — with per-item routing, a concurrency queue, a live widget, and one aggregated report.
4
4
 
5
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
6
 
7
- ## What it does
7
+ ## Why
8
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:
9
+ Some work is a list of things that do not depend on each other: audit twelve modules, summarise nine files, check every package for the same problem. Doing that in one conversation is slow and fills the context with material the main thread does not need. Doing it with twelve separate delegation calls is the same work typed twelve times.
10
+
11
+ The catch is that "independent" is usually a small lie — the items do not depend on each other's *results*, but they may touch the same files. That is what the mailbox and worktree isolation below are for.
12
+
13
+ ## Tools
14
+
15
+ ### `swarm_run`
16
+
17
+ | Parameter | Type | Notes |
18
+ |---|---|---|
19
+ | `items` | string[], 1–12 | One task per item; four run at a time, the rest queue |
20
+ | `context` | string, optional | Prepended to every item, so shared constraints are written once |
21
+ | `agent` | string, optional | Force one agent type for all items instead of routing |
22
+ | `isolation` | `"worktree"`, optional | Give each item its own git worktree — use it when items write |
23
+ | `mailbox` | boolean, optional | Give the children `swarm_post` / `swarm_inbox` |
24
+ | `background` | boolean, optional | Return a `runId` immediately instead of blocking |
25
+
26
+ Blocking by default: returns `N done, M error` plus a per-item report.
27
+
28
+ ### `swarm_status`
29
+
30
+ | Parameter | Type | Notes |
31
+ |---|---|---|
32
+ | `runId` | string, optional | Defaults to the most recent run |
33
+
34
+ Live per-item progress (`1:scout=running(3t) · 2:reviewer=queued`), and the full report once the run finishes. Completed runs survive `/reload`.
35
+
36
+ ### `swarm_post` / `swarm_inbox`
37
+
38
+ Registered for the children only, and only when `mailbox: true`.
39
+
40
+ - `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.
41
+ - `swarm_inbox()` — read what the others have posted since your last check.
42
+
43
+ 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.
44
+
45
+ ## Per-item routing
46
+
47
+ Agent definitions declare what they are for, and each item picks its own:
14
48
 
15
49
  ```markdown
16
50
  ---
@@ -21,23 +55,28 @@ match_keywords: rust, memory safety
21
55
  ---
22
56
  ```
23
57
 
24
- - **Independence by design**: items share nothing, children cannot spawn children, and each child is capped at its agent's `max_turns`.
58
+ `match_patterns` are globs matched against path-like tokens in the item — the longest match wins, so a specific rule beats a general one. `match_keywords` match the item's words. `review src/auth.rs` routes to the Rust auditor; `test the login flow` to a tester; anything matching nothing falls back to the read-only `scout`, so **the fallback can never mutate**.
25
59
 
26
- ## Where this sits in the suite
60
+ The catalog is the same `.pi/agents/*.md` one [`@pify/subagent`](https://github.com/pifydev/subagent) reads — `description`, `tools`, `model`, `thinking`, `max_turns` — plus the two routing keys. Project-local definitions load only once pi's project trust has been granted.
27
61
 
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.
62
+ ## Behaviour
29
63
 
30
- ## Mailbox (v0.3)
64
+ - **Independence by design.** Items share nothing, children cannot spawn children, and each child is capped at its agent's `max_turns`.
65
+ - **Stopping stops the children.** Pressing Esc, or switching away from the session, aborts every live child rather than leaving them talking to the provider on your money. A cancelled run keeps that verdict — it is never reported as done — and `swarm_status` shows what the items that did finish produced.
66
+ - **Isolated runs clean up after themselves.** With `isolation: "worktree"`, a worktree whose child changed nothing is removed along with its branch; otherwise a read-only step left one of each behind on every run. Anything uncommitted, and any commit the child made, is kept and reported.
31
67
 
32
- `swarm_run(items, { mailbox: true })` gives every agent two extra tools:
68
+ ## A background run comes back to you
33
69
 
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.
70
+ `swarm_status` on a run still in flight used to say "still running", which left the model one option: ask again. Now the aggregated report is **delivered** into the conversation when the run finishes, and asking early returns a structured result carrying `retryable`, the elapsed time and `pollRequired: false` — a normal answer rather than an error, because a tool error over a condition only time resolves invites the model's retry machinery into a loop.
36
71
 
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.
72
+ ## Command
73
+
74
+ `/swarm` — runs in this session, and the agent types available for routing.
75
+
76
+ ## Where this sits in the suite
77
+
78
+ [`@pify/subagent`](https://github.com/pifydev/subagent) is one child and one task. `@pify/swarm` is many independent items at once. [`@pify/workflow`](https://github.com/pifydev/workflow) is deterministic scripted orchestration for when the steps genuinely depend on each other. Pick the smallest one that fits.
38
79
 
39
80
  ## License
40
81
 
41
82
  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.
@@ -26,6 +26,7 @@ import { Type } from "typebox";
26
26
 
27
27
  import { BUILTIN_AGENTS } from "../src/builtin.ts";
28
28
  import { LiveChildren, cancelNote, type CancelReason } from "../src/cancel.ts";
29
+ import { DELIVERY_TYPE, deliveryMessage, pendingResult } from "../src/pending.ts";
29
30
  import { createIsolationWorktree, isolationNote, removeIfUnchanged } from "../src/isolate.ts";
30
31
  import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "../src/mailbox.ts";
31
32
  import { parseAgentFile } from "../src/frontmatter.ts";
@@ -402,7 +403,22 @@ export default function swarm(pi: ExtensionAPI) {
402
403
 
403
404
  if (run.background) {
404
405
  void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true).then(() => {
405
- notify(uiCtx, `swarm ${run.runId} finished — collect with swarm_status`, "info");
406
+ notify(uiCtx, `swarm ${run.runId} finished`, "info");
407
+ // The report goes to the agent, not only to the screen — otherwise
408
+ // asking again was its only way to find out.
409
+ try {
410
+ pi.sendMessage(
411
+ {
412
+ customType: DELIVERY_TYPE,
413
+ content: deliveryMessage(run.runId, "swarm", buildReport(run)),
414
+ display: true,
415
+ details: { runId: run.runId, status: run.status, items: run.items.length },
416
+ },
417
+ { deliverAs: "followUp", triggerTurn: true },
418
+ );
419
+ } catch {
420
+ // Delivery is a convenience; swarm_status still works.
421
+ }
406
422
  });
407
423
  return {
408
424
  content: [
@@ -434,6 +450,16 @@ export default function swarm(pi: ExtensionAPI) {
434
450
  async execute(_id, params: { runId?: string }) {
435
451
  const run = params.runId ? runs.get(params.runId.trim()) : activeRun ?? [...runs.values()].pop();
436
452
  if (!run) throw new Error("No swarm runs this session.");
453
+ if (run.status === "running") {
454
+ const pending = pendingResult({
455
+ id: run.runId,
456
+ kind: "running",
457
+ startedAt: run.startedAt,
458
+ now: Date.now(),
459
+ collectWith: "swarm_status",
460
+ });
461
+ return { content: [{ type: "text", text: pending.text }], details: pending.details as never };
462
+ }
437
463
  const text =
438
464
  run.status === "cancelled"
439
465
  ? "This run was cancelled before it finished. Below is what the items that did complete produced.\n" +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.5.0",
3
+ "version": "0.6.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/pending.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Telling an agent to wait, without teaching it to poll.
3
+ *
4
+ * A background run gave the model exactly one way to find out it had
5
+ * finished: call the status tool again. "Still running — call agent_result
6
+ * later" is an instruction to spin, and models follow it, burning a turn and a
7
+ * request per check while the thing they are waiting for has not moved.
8
+ *
9
+ * Two halves fix that, and only together:
10
+ *
11
+ * - a not-ready answer that is a normal structured result rather than an
12
+ * error, carrying `retryable` and saying what to do *instead* of waiting.
13
+ * Throwing would be worse than useless — a tool error invites the model's
14
+ * own retry machinery into a loop over a condition that time, not
15
+ * retrying, resolves;
16
+ * - a push when the run actually finishes, so waiting is never the only
17
+ * option on the table.
18
+ *
19
+ * Pure: the shapes and the words. The extension owns the clock and the host.
20
+ */
21
+
22
+ export type PendingKind = "queued" | "running";
23
+
24
+ export interface PendingInput {
25
+ /** The id the caller would poll with. */
26
+ id: string;
27
+ kind: PendingKind;
28
+ startedAt: number;
29
+ now: number;
30
+ /** What the caller asks for to collect it, e.g. `agent_result`. */
31
+ collectWith: string;
32
+ }
33
+
34
+ export interface PendingResult {
35
+ text: string;
36
+ details: {
37
+ id: string;
38
+ status: PendingKind;
39
+ /** True: this will resolve on its own. It is a wait, not a failure. */
40
+ retryable: boolean;
41
+ elapsedMs: number;
42
+ /** False, and load-bearing: there is nothing to poll for. */
43
+ pollRequired: false;
44
+ };
45
+ }
46
+
47
+ function elapsed(ms: number): string {
48
+ if (ms < 1000) return "just started";
49
+ const seconds = Math.round(ms / 1000);
50
+ if (seconds < 60) return `${seconds}s so far`;
51
+ return `${Math.floor(seconds / 60)}m ${seconds % 60}s so far`;
52
+ }
53
+
54
+ /**
55
+ * The answer to "is it done yet". It says no, says why that is fine, and
56
+ * closes the loop the question came from.
57
+ */
58
+ export function pendingResult(input: PendingInput): PendingResult {
59
+ const ms = Math.max(0, input.now - input.startedAt);
60
+ const state = input.kind === "queued" ? "queued behind the concurrency cap" : "still running";
61
+ return {
62
+ text: [
63
+ `${input.id} is ${state} (${elapsed(ms)}).`,
64
+ "",
65
+ "Do not poll for it. The result is delivered to you automatically the moment it lands,",
66
+ `so there is nothing to wait for here — carry on with other work, or finish your turn and`,
67
+ `you will be picked back up. ${input.collectWith} is only needed if you want it early.`,
68
+ ].join("\n"),
69
+ details: { id: input.id, status: input.kind, retryable: true, elapsedMs: ms, pollRequired: false },
70
+ };
71
+ }
72
+
73
+ /** How a finished run introduces itself when it arrives unasked. */
74
+ export function deliveryMessage(id: string, label: string, body: string): string {
75
+ return [
76
+ `<${label}_result id="${id}">`,
77
+ body.trim(),
78
+ `</${label}_result>`,
79
+ "",
80
+ `This is ${id}, which you started in the background; it has just finished and this is its report.`,
81
+ "Fold it into what you are doing. If you had already moved on, say what it changes — or that it changes nothing.",
82
+ ].join("\n");
83
+ }
84
+
85
+ /** The custom-message type a delivered result travels under. */
86
+ export const DELIVERY_TYPE = "pify-background-result";