@pify/swarm 0.9.2 → 0.11.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
@@ -23,9 +23,25 @@ The catch is that "independent" is usually a small lie — the items do not depe
23
23
  | `agent` | string, optional | Force one agent type for all items instead of routing |
24
24
  | `isolation` | `"worktree"`, optional | Give each item its own git worktree — use it when items write |
25
25
  | `mailbox` | boolean, optional | Give the children `swarm_post` / `swarm_inbox` |
26
+ | `gate` | string, optional | A command every item must pass — `bun test`, `tsc --noEmit` — run in that item's own working directory |
27
+ | `gateExpect` | string, optional | Regex the gate output must match, for checks that exit 0 without proving anything |
28
+ | `gateRepairs` | number, optional | Repair passes per item after a failed gate, 0–5 (default 1) |
29
+ | `on_upstream_failure` | `"continue"` / `"skip"`, optional | What a dependent does when something it needs did not succeed (default `continue`) |
26
30
  | `background` | boolean, optional | Return a `runId` immediately instead of blocking |
27
31
 
28
- Blocking by default: returns `N done, M error` plus a per-item report.
32
+ Blocking by default: returns `N succeeded, M failed` plus a per-item report.
33
+
34
+ ### Gates and outcomes
35
+
36
+ A gate asks the shell, not a model. Each item's gate runs in the tree that item worked in — its own worktree under `isolation: "worktree"` — after the child finishes, so it judges what you would merge. It runs asynchronously: pi keeps rendering, Esc still lands, and the other children's streams are still read while a two-minute suite runs; at the deadline the whole process tree is killed, not just the shell that started it, so a timed-out suite does not run on holding the pipes open. A failing gate sends the child back once with the command, the verdict and the output, then re-runs; `gateRepairs: 0` turns that off. That repair brief is the whole prompt the child gets — it quotes the original task under `== Original task ==` and says to fix the cause and stop, not to do the task again — and the child rejoins the run's mailbox. A read-only agent is never asked to repair, and neither is a child that ended its report with `OUTCOME: blocked`: the wall it named is outside its reach, and a failing gate does not move it. The gate still runs once so the report says what it proved. While a repair is in flight the item shows as running, not finished.
37
+
38
+ The verdict can say more than pass/fail: `success`, `failure`, `result_missing` (exited 0 but never showed the evidence `gateExpect` asked for — a runner that matched no tests), `timeout`, or `no_attestation` (never ran at all — a typo, a missing runner; not a verdict on the work, and never repaired). If other items were changing the same directory while a gate ran, the report says the verdict is true of the tree, not of that item alone — which is what `isolation` is for.
39
+
40
+ Every item then reports two facts. Its **status** says whether the child finished; its **outcome** says whether the task did. A failed gate outranks a child that claims success; without a gate the outcome is the child's own account, and a child that could not finish can end its report with `OUTCOME: blocked` or `OUTCOME: failed` to say so in one parseable place. The header counts outcomes, so an item that ran to the end and failed its check is filed under `failed`, not `done` — and the widget shows it as ✗, not ✓.
41
+
42
+ **Failures interrupt.** A background run wakes you once, as soon as the first item fails hard, while the rest are still running — the same rule `@pify/subagent` uses for a failed background child. The message says it is a warning and that the full report still follows; it does not ask you to poll. Subsequent failures wait for the aggregate report, since N interrupts for N failures would be worse than none.
43
+
44
+ **`on_upstream_failure`.** By default a dependent still runs when something it needed failed, with a `(failed: …)` notice in place of that item's output — visible, but it spends a child on a step that is usually doomed. `skip` settles the dependent instead, marks it `skipped` (its own state, not a second failure), and the skip cascades down the branch. Independent items are unaffected either way.
29
45
 
30
46
  ### Dependencies: `needs`
31
47
 
@@ -50,8 +66,9 @@ The whole graph is checked **before anything spawns**: a cycle, a self-edge, a d
50
66
  | Parameter | Type | Notes |
51
67
  |---|---|---|
52
68
  | `runId` | string, optional | Defaults to the most recent run |
69
+ | `wait` | number, optional | Seconds to hold the call for the run to finish, 0–120 (default 0) |
53
70
 
54
- Live per-item progress (`1:scout=running(3t) · 2:reviewer=queued`), and the full report once the run finishes. Completed runs survive `/reload`.
71
+ A "not ready" answer while the run is in flight (per-item progress is on the widget and in `/swarm`), and the full report once the run finishes. Completed runs survive `/reload`. `wait` is for the headless case (`pi -p`), where nothing is delivered after the turn ends: one call that waits returns the report in one turn instead of several; the wait ends early on Esc.
55
72
 
56
73
  ### `swarm_post` / `swarm_inbox`
57
74
 
@@ -82,17 +99,19 @@ The catalog is the same `.pi/agents/*.md` one [`@pify/subagent`](https://github.
82
99
  ## Behaviour
83
100
 
84
101
  - **Independence by design.** Items share nothing, children cannot spawn children, and each child is capped at its agent's `max_turns`.
85
- - **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.
102
+ - **Stopping stops the children.** Pressing Esc stops a foreground run, `/swarm stop [runId]` stops a background one (its tool call returned long ago, so Esc has nothing to reach), and switching away from the session stops both — in every case every live child is aborted rather than left 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, with each stopped item saying who stopped it.
86
103
  - **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.
87
104
 
88
105
  ## A background run comes back to you
89
106
 
90
- `swarm_status` on a run still in flight used to say "still running", which left the model one option: ask again. The aggregated report is **delivered** into the conversation when the run finishes — measured, not assumed: `test/live/delivery-wire.mjs` drives a real background swarm through pi, holds the session open the way an interactive one naturally stays open, and reads the report out of pi's own provider payload (3/3; the run finished and the report arrived unasked). One caveat the measurement taught: delivery is a property of sessions that outlive their runs — interactive sessions do, `pi -p` does not. 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.
107
+ `swarm_status` on a run still in flight used to say "still running", which left the model one option: ask again. The aggregated report is **delivered** into the conversation when the run finishes — measured, not assumed: `test/live/delivery-wire.mjs` drives a real background swarm through pi, holds the session open the way an interactive one naturally stays open, and reads the report out of pi's own provider payload (3/3; the run finished and the report arrived unasked). One caveat the measurement taught: delivery is a property of sessions that outlive their runs — interactive sessions do, `pi -p` does not. Asking early returns a structured result carrying `retryable`, the elapsed time and `pollRequired` (`false` interactively; `true` under `pi -p`, where the text tells the model to collect within the turn or use `wait`) — 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.
91
108
 
92
109
  ## Command
93
110
 
94
111
  `/swarm` — runs in this session, and the agent types available for routing.
95
112
 
113
+ `/swarm stop [runId]` — cancel a live run (default: the active one) and abort its children. The way to stop a background run; a foreground run stops on Esc.
114
+
96
115
  ## Where this sits in the suite
97
116
 
98
117
  [`@pify/subagent`](https://github.com/pifydev/subagent) is one child and one task. `@pify/swarm` is many items at once — independent, or wired together with a declarative `needs` graph (fan-out, chains, joins). [`@pify/workflow`](https://github.com/pifydev/workflow) is for when orchestration needs real control flow — loops, conditionals, retries, fan-out computed at run time — that a static graph can't express. Pick the smallest one that fits.
@@ -22,6 +22,7 @@ import {
22
22
  type ExtensionContext,
23
23
  } from "@earendil-works/pi-coding-agent";
24
24
  import { Text } from "@earendil-works/pi-tui";
25
+ import { StringEnum } from "@earendil-works/pi-ai";
25
26
  import { Type } from "typebox";
26
27
 
27
28
  import { BUILTIN_AGENTS } from "../src/builtin.ts";
@@ -50,7 +51,19 @@ import {
50
51
  readInbox,
51
52
  } from "../src/mailbox.ts";
52
53
  import { parseAgentFile } from "../src/frontmatter.ts";
53
- import { buildReport, buildStatusLine } from "../src/report.ts";
54
+ import { buildReport, buildStatusLine, earlyFailureNotice } from "../src/report.ts";
55
+ import {
56
+ normalizeGate,
57
+ runGate,
58
+ sharedWith,
59
+ type GateContract,
60
+ type GateSibling,
61
+ type GateVerdict,
62
+ } from "../src/gate.ts";
63
+ import { runGateCycle } from "../src/repair.ts";
64
+ import { repairAllowed } from "../src/repair-policy.ts";
65
+ import { deriveOutcome, parseDeclaredOutcome, stripDeclaration } from "../src/outcome.ts";
66
+ import { waitUntil } from "../src/wait.ts";
54
67
  import { routeItem } from "../src/routing.ts";
55
68
  import { normalizeItems } from "../src/graph.ts";
56
69
  import { runGraph } from "../src/schedule.ts";
@@ -62,11 +75,14 @@ import {
62
75
  type AgentDef,
63
76
  type ItemState,
64
77
  type SwarmRun,
78
+ type UpstreamFailurePolicy,
65
79
  } from "../src/types.ts";
66
80
  import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
67
81
  import { basename, join } from "node:path";
68
82
 
69
83
  const RUN_ENTRY = "swarm-run";
84
+ /** Longest a swarm_status call may hold on to a running run, in seconds. */
85
+ const MAX_STATUS_WAIT_S = 120;
70
86
  const CLEAN_WORKTREE_NOTE =
71
87
  "Ran isolated in a temporary worktree; it changed nothing, so the worktree was removed.";
72
88
 
@@ -191,6 +207,12 @@ export default function swarm(pi: ExtensionAPI) {
191
207
  workDir?: string,
192
208
  mailbox?: string,
193
209
  ): Promise<void> {
210
+ // A stop can land before this item has a session to abort — the scheduler
211
+ // launched it, the loader is still reloading — and cancelRun has already
212
+ // written its record. Starting anyway would overwrite that with "running"
213
+ // and leave a child no stop can reach.
214
+ const cancelled = (): boolean => runs.get(runId)?.status === "cancelled";
215
+ if (cancelled()) return;
194
216
  item.status = "running";
195
217
  renderWidget();
196
218
  let session: AgentSession | null = null;
@@ -228,7 +250,17 @@ export default function swarm(pi: ExtensionAPI) {
228
250
  appendSystemPrompt: [
229
251
  ...(promptOptions.appendSystemPrompt ? [promptOptions.appendSystemPrompt] : []),
230
252
  def.systemPrompt,
231
- "You are one agent in a swarm, handling exactly one item. Your final assistant message is the deliverable — make it complete and self-contained.",
253
+ // The same contract subagent's children get, including the OUTCOME
254
+ // line: the report tallies `blocked`, and skip-on-upstream-failure
255
+ // keys off the outcome, so a child that is never told the protocol
256
+ // can never be anything but succeeded or failed.
257
+ "You are one agent in a swarm, handling exactly one item. Your final assistant message is the deliverable — " +
258
+ "make it complete and self-contained; the swarm cannot reply to it. Close by stating each requirement of your " +
259
+ "item and the concrete evidence it is met (the command you ran and what it showed); mark anything you could " +
260
+ "not verify as unverified rather than done. Finishing your turn is not the same as finishing the item: if you " +
261
+ "could not do it, end the report with a line reading exactly `OUTCOME: blocked` (a decision, access or " +
262
+ "information you do not have) or `OUTCOME: failed` (you tried and it does not work), so the swarm does not " +
263
+ "have to infer it from your prose. Say nothing if it went fine.",
232
264
  ...(mailbox ? [mailboxPrompt(item.agent + "-" + item.index)] : []),
233
265
  ],
234
266
  });
@@ -246,6 +278,9 @@ export default function swarm(pi: ExtensionAPI) {
246
278
  resourceLoader: loader,
247
279
  });
248
280
  session = created.session;
281
+ // Same window, other side: a stop during session creation found nothing
282
+ // registered. Do not prompt a child of a run that is already over.
283
+ if (cancelled()) return;
249
284
  releaseLive = live.register(runId, session);
250
285
 
251
286
  const guard = new LoopGuard();
@@ -345,24 +380,141 @@ export default function swarm(pi: ExtensionAPI) {
345
380
  }
346
381
 
347
382
  /**
348
- * Readiness scheduler: run each item as soon as its `needs` are done, up to
349
- * DEFAULT_CONCURRENCY at once. A flat run (no needs anywhere) makes every
350
- * item ready immediately, so this is identical to the old parallel pool.
383
+ * Settle the two facts the status alone cannot give: what the item's task came
384
+ * to, and how well that is known. Settled once re-running it after the
385
+ * isolation note is appended would find the declaration already stripped and
386
+ * quietly promote a blocked item to a successful one.
351
387
  */
352
- async function executeRun(
388
+ function settleItem(item: ItemState): void {
389
+ if (item.status === "queued" || item.status === "running" || item.outcome) return;
390
+ // A skipped item was never attempted, so it has no outcome to report — not
391
+ // a failure of its own, and calling it one would double-count the upstream
392
+ // failure that caused it.
393
+ if (item.status === "skipped") return;
394
+ const declared = parseDeclaredOutcome(item.result);
395
+ if (declared && item.result) item.result = stripDeclaration(item.result);
396
+ item.verification ??= "not-requested";
397
+ item.outcome = deriveOutcome({
398
+ status: item.status === "done" ? "done" : item.status === "aborted" ? "aborted" : "error",
399
+ declared,
400
+ verification: item.verification,
401
+ });
402
+ }
403
+
404
+ /**
405
+ * Run the caller's gate in the tree this item worked in and, if it failed,
406
+ * send the child back to fix it. Recorded either way — a gate that passed is
407
+ * a fact worth saying, and a gate that could not run says so rather than
408
+ * blaming the work.
409
+ */
410
+ async function gateItem(
353
411
  ctx: UiContext,
354
412
  run: SwarmRun,
355
- context: string,
356
- fixed?: string,
357
- isolate?: boolean,
358
- useMailbox?: boolean,
413
+ item: ItemState,
414
+ def: AgentDef,
415
+ opts: RunOptions,
359
416
  ): Promise<void> {
417
+ // A cancelled run has nothing left to prove; a gate is a test suite, and
418
+ // spending one on work nobody is waiting for is the stop not stopping.
419
+ if (!opts.gate || item.status !== "done" || run.status === "cancelled") return;
420
+ const subject = item.workDir ?? ctx.cwd;
421
+ // sharedWith() reads an undefined workDir as "the subject's directory", so
422
+ // an item that ran in place — no worktree of its own — would count as
423
+ // sharing every isolated sibling's worktree, and every isolated item's
424
+ // pass came out attributed to the tree. Name the directory each item was
425
+ // actually in.
426
+ const dirOf = (i: ItemState): string => i.workDir ?? ctx.cwd;
427
+ const self: GateSibling = { id: item.index, label: item.id, status: item.status, workDir: dirOf(item) };
428
+ const siblings: GateSibling[] = run.items
429
+ .filter((i) => i.index !== item.index)
430
+ .map((i) => ({ id: i.index, label: i.id, status: i.status, workDir: dirOf(i) }));
431
+ // Read the child's declaration now, while it is still in the result:
432
+ // settleItem strips it, and a blocked child is not sent to fix a gate.
433
+ const canRepair = repairAllowed(def, item.result);
434
+ try {
435
+ // The gate that actually ran, whatever the cycle reports. After a repair
436
+ // the cycle re-runs the gate; on a cancelled run that is up to the whole
437
+ // deadline spent proving nothing anyone will read — but a check that
438
+ // DID run and failed before the stop landed is a real verdict, and
439
+ // answering no_attestation for it would file a failed gate as
440
+ // "proved nothing", which is the case the outcome model exists to stop.
441
+ let last: (GateVerdict & { output: string }) | null = null;
442
+ let repaired = false;
443
+ const { record, verification } = await runGateCycle(item.item, opts.gate, subject, {
444
+ runGate: async (contract, cwd) => {
445
+ if (run.status === "cancelled" && last) return last;
446
+ last = await runGate(contract, cwd);
447
+ return last;
448
+ },
449
+ canRepair,
450
+ maxAttempts: opts.gateRepairs ?? 1,
451
+ sharedWith: sharedWith(self, subject, siblings),
452
+ repair: async (prompt) => {
453
+ if (run.status === "cancelled") return;
454
+ repaired = true;
455
+ // The brief IS the item. Handed over as `context` it arrived as "fix
456
+ // exactly this" followed by the whole original task under `Your
457
+ // item:`, which reads as an invitation to do the task again. The
458
+ // brief already quotes the task; nothing else is sent.
459
+ //
460
+ // The repair is the same child type over the same tree, in the same
461
+ // mailbox — the siblings' facts still apply — and its report
462
+ // replaces the stale one, which described a tree that has changed.
463
+ const fix: ItemState = { ...item, item: prompt, result: null, error: null, status: "queued", turns: 0, tokens: 0 };
464
+ // The widget draws `item`, not `fix`; without this the row sat as a
465
+ // finished ✓ for the whole repair.
466
+ item.status = "running";
467
+ renderWidget();
468
+ await runItem(ctx, run.runId, def, fix, "", item.workDir, opts.mailbox);
469
+ item.turns += fix.turns;
470
+ item.tokens += fix.tokens;
471
+ // cancelRun may have marked the item aborted meanwhile; that verdict
472
+ // and its note stand, and the stale result is not swapped under it.
473
+ if (item.status !== "running") return;
474
+ item.status = "done";
475
+ if (fix.status === "done" && fix.result?.trim()) item.result = fix.result;
476
+ },
477
+ });
478
+ // The cycle counts a repair pass it asked for; one the stop refused is
479
+ // not a repair, and the record must not say the tree was fixed.
480
+ item.gate = repaired ? record : { ...record, repairs: undefined };
481
+ item.verification = verification;
482
+ } catch (err) {
483
+ // A gate that throws proved nothing; say so rather than losing the
484
+ // child's work to an error in the checking machinery.
485
+ item.gate = {
486
+ command: opts.gate.command,
487
+ outcome: "no_attestation",
488
+ ok: false,
489
+ reason: `gate could not be run: ${err instanceof Error ? err.message : String(err)}`,
490
+ };
491
+ item.verification = "inconclusive";
492
+ }
493
+ }
494
+
495
+ interface RunOptions {
496
+ context: string;
497
+ fixed?: string;
498
+ isolate?: boolean;
499
+ useMailbox?: boolean;
500
+ gate?: GateContract;
501
+ gateRepairs?: number;
502
+ /** The run's mailbox dir once executeRun has made one, so a repair child joins the same log. */
503
+ mailbox?: string;
504
+ onUpstreamFailure?: UpstreamFailurePolicy;
505
+ /** Called once, for the first item that settles badly while others run. */
506
+ onEarlyFailure?: (item: ItemState) => void;
507
+ }
508
+
509
+ async function executeRun(ctx: UiContext, run: SwarmRun, opts: RunOptions): Promise<void> {
510
+ const { context, fixed, isolate, useMailbox } = opts;
360
511
  // One shared log per run; only created when the caller asked for it. Keyed
361
512
  // on a per-run token (not the reused "s1" run id), so one run never reads a
362
513
  // previous run's stale messages, and removed at the end so it never leaks.
363
514
  const mailbox = useMailbox
364
515
  ? mailboxDir(getAgentDir(), mailboxKey(run.runId, run.startedAt))
365
516
  : undefined;
517
+ const gateOpts: RunOptions = { ...opts, mailbox };
366
518
 
367
519
  // A dependent structurally receives each upstream's output — the thing a
368
520
  // hand-sequenced coordinator forgets. Prepended to the shared preamble.
@@ -375,6 +527,18 @@ export default function swarm(pi: ExtensionAPI) {
375
527
  return [context, ...blocks].filter((s) => s && s.trim()).join("\n\n");
376
528
  };
377
529
 
530
+ // The first hard failure wakes the caller once, and only while there is
531
+ // still a run to warn it about; after that the aggregate report is the
532
+ // report. N interrupts for N failures would be worse than none.
533
+ let warned = false;
534
+ const settle = (item: ItemState): void => {
535
+ settleItem(item);
536
+ if (warned || !opts.onEarlyFailure) return;
537
+ if (item.outcome !== "failed" || item.status === "aborted") return;
538
+ warned = true;
539
+ opts.onEarlyFailure(item);
540
+ };
541
+
378
542
  // Run each item once its needs finish, up to the concurrency cap; a flat
379
543
  // run (no needs) has everything ready at once, exactly like the old pool.
380
544
  try {
@@ -382,13 +546,27 @@ export default function swarm(pi: ExtensionAPI) {
382
546
  run.items,
383
547
  DEFAULT_CONCURRENCY,
384
548
  async (item, upstream) => {
549
+ // A failed node still counts as done so the graph drains rather than
550
+ // wedging — but "unblocked" and "worth running" are different
551
+ // questions. With skip, a dependent of work that did not succeed is
552
+ // settled without spending a child on input that is a failure notice.
553
+ const broken = upstream.filter((up) => up.status !== "done" || up.outcome !== "succeeded");
554
+ if (opts.onUpstreamFailure === "skip" && broken.length > 0) {
555
+ item.status = "skipped";
556
+ item.error = `${broken.map((u) => u.id).join(", ")} did not succeed`;
557
+ settleItem(item);
558
+ renderWidget();
559
+ return;
560
+ }
385
561
  const def = routeItem(item.item, defs, fixed);
386
562
  item.agent = def.name;
387
563
  const itemContext = contextFor(upstream);
388
564
  if (isolate) {
389
565
  try {
390
566
  const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
567
+ item.workDir = iso.path;
391
568
  await runItem(ctx, run.runId, def, item, itemContext, iso.path, mailbox);
569
+ await gateItem(ctx, run, item, def, gateOpts);
392
570
  // Remove the worktree when the item changed nothing (the leak
393
571
  // removeIfUnchanged fixes); keep it when there is work to merge.
394
572
  const removed = removeIfUnchanged(ctx.cwd, iso);
@@ -401,7 +579,9 @@ export default function swarm(pi: ExtensionAPI) {
401
579
  }
402
580
  } else {
403
581
  await runItem(ctx, run.runId, def, item, itemContext, undefined, mailbox);
582
+ await gateItem(ctx, run, item, def, gateOpts);
404
583
  }
584
+ settle(item);
405
585
  },
406
586
  () => run.status === "cancelled",
407
587
  );
@@ -418,6 +598,10 @@ export default function swarm(pi: ExtensionAPI) {
418
598
  }
419
599
 
420
600
  if (run.status !== "cancelled") run.status = "done";
601
+ // A cancelled run's items were marked aborted without going through the
602
+ // scheduler's settle path; the report still has to be able to name what
603
+ // each one came to.
604
+ for (const item of run.items) settleItem(item);
421
605
  run.finishedAt = Date.now();
422
606
  pi.appendEntry(RUN_ENTRY, run);
423
607
  renderWidget();
@@ -459,7 +643,11 @@ export default function swarm(pi: ExtensionAPI) {
459
643
  "conventions instead of silently conflicting. " +
460
644
  "An item can be a plain string (independent) OR an object {task, id, needs:[ids]} to declare a " +
461
645
  "dependency: a needed item's output is prepended to the dependent automatically, and the dependent " +
462
- "starts only once its needs finish. A cycle, a self-edge, or an unknown id is rejected before anything runs.",
646
+ "starts only once its needs finish. A cycle, a self-edge, or an unknown id is rejected before anything runs. " +
647
+ "gate is a command every item must pass — it runs in that item's own working directory when it finishes, " +
648
+ "a failure sends the child back to fix it once, and the report says what the check proved rather than " +
649
+ "only what the child claims. on_upstream_failure=skip settles a dependent without spending a child when " +
650
+ "something it needed did not succeed.",
463
651
  parameters: Type.Object({
464
652
  items: Type.Array(
465
653
  Type.Union([
@@ -480,6 +668,27 @@ export default function swarm(pi: ExtensionAPI) {
480
668
  mailbox: Type.Optional(
481
669
  Type.Boolean({ description: "Give the agents swarm_post/swarm_inbox to share facts mid-run" }),
482
670
  ),
671
+ gate: Type.Optional(
672
+ Type.String({
673
+ description:
674
+ "Shell command every item must pass, e.g. \"bun test\". Run in that item's working directory once it finishes.",
675
+ }),
676
+ ),
677
+ gateExpect: Type.Optional(
678
+ Type.String({
679
+ description:
680
+ "Regex the gate output must match. Use it when exit 0 does not prove the check ran; exiting 0 without a match is reported as verifying nothing.",
681
+ }),
682
+ ),
683
+ gateRepairs: Type.Optional(
684
+ Type.Number({ description: "Repair passes per item after a failed gate, 0-5 (default 1)" }),
685
+ ),
686
+ on_upstream_failure: Type.Optional(
687
+ StringEnum(["continue", "skip"], {
688
+ description:
689
+ "What a dependent does when something it needs did not succeed: continue (default, it runs and is told) or skip (it is settled without spending a child)",
690
+ }),
691
+ ),
483
692
  background: Type.Optional(Type.Boolean()),
484
693
  }),
485
694
  async execute(
@@ -491,6 +700,10 @@ export default function swarm(pi: ExtensionAPI) {
491
700
  background?: boolean;
492
701
  isolation?: string;
493
702
  mailbox?: boolean;
703
+ gate?: string;
704
+ gateExpect?: string;
705
+ gateRepairs?: number;
706
+ on_upstream_failure?: UpstreamFailurePolicy;
494
707
  },
495
708
  signal,
496
709
  _onUpdate,
@@ -509,6 +722,25 @@ export default function swarm(pi: ExtensionAPI) {
509
722
  throw new Error(`Swarm ${activeRun.runId} is still running — wait or check swarm_status.`);
510
723
  }
511
724
 
725
+ // A gate is validated up front: a broken contract should be a tool error
726
+ // the caller can fix now, not a "verified nothing" verdict on every item
727
+ // after a whole fan-out has already been spent.
728
+ let gate: GateContract | undefined;
729
+ if (params.gate?.trim()) {
730
+ gate = normalizeGate(params.gate.trim());
731
+ const expect = params.gateExpect?.trim();
732
+ if (expect) {
733
+ try {
734
+ new RegExp(expect, "m");
735
+ } catch {
736
+ throw new Error(`gateExpect is not a valid regular expression: ${expect}`);
737
+ }
738
+ gate.expect = expect;
739
+ }
740
+ } else if (params.gateExpect?.trim()) {
741
+ throw new Error("gateExpect needs a gate command to judge.");
742
+ }
743
+
512
744
  runCounter++;
513
745
  const run: SwarmRun = {
514
746
  runId: `s${runCounter}`,
@@ -545,9 +777,44 @@ export default function swarm(pi: ExtensionAPI) {
545
777
  }
546
778
  }
547
779
 
780
+ const options: RunOptions = {
781
+ context: params.context ?? "",
782
+ fixed: params.agent,
783
+ isolate: params.isolation === "worktree",
784
+ useMailbox: params.mailbox === true,
785
+ gate,
786
+ gateRepairs: params.gateRepairs,
787
+ onUpstreamFailure: params.on_upstream_failure,
788
+ };
789
+
548
790
  if (run.background) {
549
- void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true)
791
+ // A background run's caller is off doing something else, so the first
792
+ // hard failure interrupts it the way @pify/subagent already interrupts
793
+ // for a failed background child. A foreground run is already blocking
794
+ // that turn, so there is nothing to interrupt.
795
+ options.onEarlyFailure = (item) => {
796
+ try {
797
+ pi.sendMessage(
798
+ {
799
+ customType: DELIVERY_TYPE,
800
+ content: earlyFailureNotice(run, item),
801
+ display: true,
802
+ details: { runId: run.runId, item: item.id, status: item.status },
803
+ },
804
+ { deliverAs: "steer", triggerTurn: true },
805
+ );
806
+ } catch {
807
+ // A warning that cannot be delivered must not take the run with it;
808
+ // the aggregate report is still coming.
809
+ }
810
+ };
811
+ void executeRun(uiCtx, run, options)
550
812
  .then(() => {
813
+ // A run the user just stopped is not one that "finished", and the
814
+ // model is not woken to fold in a report of work that was
815
+ // cancelled out from under it. The stop already said what it
816
+ // stopped; what the items produced is in swarm_status.
817
+ if (run.status === "cancelled") return;
551
818
  notify(uiCtx, `swarm ${run.runId} finished`, "info");
552
819
  // The report goes to the agent, not only to the screen — otherwise
553
820
  // asking again was its only way to find out.
@@ -571,14 +838,21 @@ export default function swarm(pi: ExtensionAPI) {
571
838
  });
572
839
  return {
573
840
  content: [
574
- { type: "text", text: `Swarm ${run.runId} started (${run.items.length} items). Poll swarm_status runId="${run.runId}".` },
841
+ {
842
+ type: "text",
843
+ text:
844
+ `Swarm ${run.runId} started (${run.items.length} items) in the background. Its report is delivered to you ` +
845
+ `when it finishes, and the first hard failure interrupts you early — do not poll. ` +
846
+ `swarm_status runId="${run.runId}" shows progress if you need it early. ` +
847
+ `The user can stop it with /swarm stop ${run.runId} (Esc does not reach a background run).`,
848
+ },
575
849
  ],
576
850
  details: { runId: run.runId },
577
851
  };
578
852
  }
579
853
 
580
854
  try {
581
- await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
855
+ await executeRun(uiCtx, run, options);
582
856
  } finally {
583
857
  if (stopListening) stopListening();
584
858
  }
@@ -593,22 +867,47 @@ export default function swarm(pi: ExtensionAPI) {
593
867
  name: "swarm_status",
594
868
  label: "Swarm status",
595
869
  promptSnippet: "Progress of a running swarm",
596
- description: "Progress of a swarm run (default: the latest). Returns the full report when finished.",
870
+ description:
871
+ "Progress of a swarm run (default: the latest). Returns the full report when finished. " +
872
+ "wait=N (seconds, up to 120) holds this call until the run finishes or N seconds pass, so a headless " +
873
+ "session can collect the report in one call instead of asking repeatedly.",
597
874
  parameters: Type.Object({
598
875
  runId: Type.Optional(Type.String()),
876
+ wait: Type.Optional(
877
+ Type.Number({
878
+ description: "Seconds to wait for the run to finish before answering, 0-120 (default 0)",
879
+ minimum: 0,
880
+ maximum: MAX_STATUS_WAIT_S,
881
+ }),
882
+ ),
599
883
  }),
600
- async execute(_id, params: { runId?: string }) {
884
+ async execute(_id, params: { runId?: string; wait?: number }, signal, _onUpdate, ctx) {
601
885
  const run = params.runId ? runs.get(params.runId.trim()) : activeRun ?? [...runs.values()].pop();
602
886
  if (!run) throw new Error("No swarm runs this session.");
887
+ // Bounded and abortable: the wait is the caller's turn, and Esc must end
888
+ // it the way it ends anything else the tool call is doing.
889
+ const waitMs = Math.max(0, Math.min(MAX_STATUS_WAIT_S, params.wait ?? 0)) * 1000;
890
+ if (run.status === "running" && waitMs > 0) {
891
+ await waitUntil(() => run.status !== "running", waitMs, 250, signal);
892
+ }
603
893
  if (run.status === "running") {
894
+ const interactive = (ctx as { hasUI?: boolean }).hasUI !== false;
604
895
  const pending = pendingResult({
605
896
  id: run.runId,
606
897
  kind: "running",
607
898
  startedAt: run.startedAt,
608
899
  now: Date.now(),
609
900
  collectWith: "swarm_status",
901
+ // A headless `pi -p` run ends with this turn: "it will be delivered"
902
+ // is a promise nothing can keep there, so the text says to collect.
903
+ interactive,
610
904
  });
611
- return { content: [{ type: "text", text: pending.text }], details: pending.details as never };
905
+ // Headless is where repeated calls actually happen; one call that
906
+ // waits is the same answer for one turn instead of several.
907
+ const text = interactive
908
+ ? pending.text
909
+ : `${pending.text}\nPass wait=${MAX_STATUS_WAIT_S} (seconds) to swarm_status to hold one call until it finishes instead of asking again.`;
910
+ return { content: [{ type: "text", text }], details: pending.details as never };
612
911
  }
613
912
  const text =
614
913
  run.status === "cancelled"
@@ -693,9 +992,33 @@ export default function swarm(pi: ExtensionAPI) {
693
992
  });
694
993
 
695
994
  pi.registerCommand("swarm", {
696
- description: "Show swarm runs and routing-capable agent types",
697
- handler: async (_args, ctx) => {
995
+ description: "Show swarm runs and agent types; /swarm stop [runId] cancels a live run",
996
+ handler: async (args, ctx) => {
698
997
  if (!ctx.hasUI) return;
998
+ const words = (args ?? "").trim().split(/\s+/).filter(Boolean);
999
+ if (words[0] === "stop") {
1000
+ // Esc reaches a foreground run through its tool call's signal; a
1001
+ // background run's tool call returned long ago, so until this command
1002
+ // nothing the user could do reached its children.
1003
+ const wanted = words[1];
1004
+ const run = wanted ? runs.get(wanted) : activeRun;
1005
+ if (!run) {
1006
+ ctx.ui.notify(wanted ? `No swarm run ${wanted}.` : "No swarm run to stop.", "warning");
1007
+ return;
1008
+ }
1009
+ if (run.status !== "running") {
1010
+ ctx.ui.notify(`swarm ${run.runId} is not running (${run.status}).`, "warning");
1011
+ return;
1012
+ }
1013
+ const before = live.count(run.runId);
1014
+ cancelRun(run, "user-abort");
1015
+ ctx.ui.notify(
1016
+ `swarm ${run.runId} stopped — ${before === 0 ? "no child agents were running" : `${before} child agent${before === 1 ? "" : "s"} stopped`}.`,
1017
+ "info",
1018
+ );
1019
+ renderWidget(ctx);
1020
+ return;
1021
+ }
699
1022
  const routed = [...defs.values()]
700
1023
  .map((d) => {
701
1024
  const rules = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.9.2",
3
+ "version": "0.11.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",
@@ -6,8 +6,10 @@ description: Use when work splits into several independent items that can run in
6
6
  # Swarm
7
7
 
8
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.
9
+ list of items out to parallel child agents (concurrency 4) and returns one
10
+ aggregated report. A background run's report is delivered to you when it
11
+ finishes, and the first hard failure interrupts you early — do not poll;
12
+ `swarm_status` shows progress if you need it before then.
11
13
 
12
14
  ## When to fan out
13
15
 
@@ -15,9 +17,11 @@ returns one aggregated report; `swarm_status` polls background runs.
15
17
  - The same question asked across many places ("check each package for X").
16
18
  - Parallel research where items do not depend on each other.
17
19
 
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).
20
+ Items that depend on each other's results are fine: write them as
21
+ `{task, id, needs: [ids]}` and each dependent receives its needs' output
22
+ automatically, starting only once they finish. Set
23
+ `on_upstream_failure: "skip"` when a dependent is pointless without its
24
+ input. For a single task use agent_run from @pify/subagent instead.
21
25
 
22
26
  ## Slicing items
23
27
 
@@ -34,7 +38,17 @@ item) then `match_keywords`, falling back to the read-only scout. Force one
34
38
  type with `agent` when the routing does not fit. Mutating items must
35
39
  explicitly target `worker` — the fallback never mutates.
36
40
 
41
+ ## Verifying
42
+
43
+ Give `gate` a command every item must pass (`bun test`, `tsc --noEmit`); it
44
+ runs in each item's own working directory after the child finishes, a
45
+ failure sends the child back once to fix it, and the report says what the
46
+ check proved. Each item reports an outcome (succeeded / blocked / failed)
47
+ separately from whether its child finished; the header counts outcomes.
48
+
37
49
  ## Collecting
38
50
 
39
- Blocking runs return the report directly. For `background: true`, ALWAYS
40
- collect with `swarm_status` before relying on any item's outcome.
51
+ Blocking runs return the report directly. A `background: true` run delivers
52
+ its report when it finishes — carry on with other work or end your turn; do
53
+ not call `swarm_status` in a loop. In a headless run (no UI) nothing can be
54
+ delivered after your turn ends, so collect with `swarm_status` within it.