@pify/swarm 0.10.0 → 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
@@ -33,7 +33,7 @@ Blocking by default: returns `N succeeded, M failed` plus a per-item report.
33
33
 
34
34
  ### Gates and outcomes
35
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. A failing gate sends the child back once with the command, the verdict and the output, then re-runs; `gateRepairs: 0` turns that off. A read-only agent is never asked to repair.
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
37
 
38
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
39
 
@@ -66,8 +66,9 @@ The whole graph is checked **before anything spawns**: a cycle, a self-edge, a d
66
66
  | Parameter | Type | Notes |
67
67
  |---|---|---|
68
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) |
69
70
 
70
- 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.
71
72
 
72
73
  ### `swarm_post` / `swarm_inbox`
73
74
 
@@ -98,17 +99,19 @@ The catalog is the same `.pi/agents/*.md` one [`@pify/subagent`](https://github.
98
99
  ## Behaviour
99
100
 
100
101
  - **Independence by design.** Items share nothing, children cannot spawn children, and each child is capped at its agent's `max_turns`.
101
- - **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.
102
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.
103
104
 
104
105
  ## A background run comes back to you
105
106
 
106
- `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.
107
108
 
108
109
  ## Command
109
110
 
110
111
  `/swarm` — runs in this session, and the agent types available for routing.
111
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
+
112
115
  ## Where this sits in the suite
113
116
 
114
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.
@@ -52,9 +52,18 @@ import {
52
52
  } from "../src/mailbox.ts";
53
53
  import { parseAgentFile } from "../src/frontmatter.ts";
54
54
  import { buildReport, buildStatusLine, earlyFailureNotice } from "../src/report.ts";
55
- import { normalizeGate, runGate, sharedWith, type GateContract, type GateSibling } from "../src/gate.ts";
55
+ import {
56
+ normalizeGate,
57
+ runGate,
58
+ sharedWith,
59
+ type GateContract,
60
+ type GateSibling,
61
+ type GateVerdict,
62
+ } from "../src/gate.ts";
56
63
  import { runGateCycle } from "../src/repair.ts";
64
+ import { repairAllowed } from "../src/repair-policy.ts";
57
65
  import { deriveOutcome, parseDeclaredOutcome, stripDeclaration } from "../src/outcome.ts";
66
+ import { waitUntil } from "../src/wait.ts";
58
67
  import { routeItem } from "../src/routing.ts";
59
68
  import { normalizeItems } from "../src/graph.ts";
60
69
  import { runGraph } from "../src/schedule.ts";
@@ -72,6 +81,8 @@ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "no
72
81
  import { basename, join } from "node:path";
73
82
 
74
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;
75
86
  const CLEAN_WORKTREE_NOTE =
76
87
  "Ran isolated in a temporary worktree; it changed nothing, so the worktree was removed.";
77
88
 
@@ -196,6 +207,12 @@ export default function swarm(pi: ExtensionAPI) {
196
207
  workDir?: string,
197
208
  mailbox?: string,
198
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;
199
216
  item.status = "running";
200
217
  renderWidget();
201
218
  let session: AgentSession | null = null;
@@ -233,7 +250,17 @@ export default function swarm(pi: ExtensionAPI) {
233
250
  appendSystemPrompt: [
234
251
  ...(promptOptions.appendSystemPrompt ? [promptOptions.appendSystemPrompt] : []),
235
252
  def.systemPrompt,
236
- "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.",
237
264
  ...(mailbox ? [mailboxPrompt(item.agent + "-" + item.index)] : []),
238
265
  ],
239
266
  });
@@ -251,6 +278,9 @@ export default function swarm(pi: ExtensionAPI) {
251
278
  resourceLoader: loader,
252
279
  });
253
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;
254
284
  releaseLive = live.register(runId, session);
255
285
 
256
286
  const guard = new LoopGuard();
@@ -349,15 +379,6 @@ export default function swarm(pi: ExtensionAPI) {
349
379
  }
350
380
  }
351
381
 
352
- /**
353
- * Readiness scheduler: run each item as soon as its `needs` are done, up to
354
- * DEFAULT_CONCURRENCY at once. A flat run (no needs anywhere) makes every
355
- * item ready immediately, so this is identical to the old parallel pool.
356
- */
357
- /** An agent that can write is one that can fix what a gate complained about. */
358
- const canWrite = (def: AgentDef) =>
359
- def.tools.some((t) => t === "edit" || t === "write" || t === "bash" || t === "powershell");
360
-
361
382
  /**
362
383
  * Settle the two facts the status alone cannot give: what the item's task came
363
384
  * to, and how well that is known. Settled once — re-running it after the
@@ -393,29 +414,70 @@ export default function swarm(pi: ExtensionAPI) {
393
414
  def: AgentDef,
394
415
  opts: RunOptions,
395
416
  ): Promise<void> {
396
- if (!opts.gate || item.status !== "done") return;
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;
397
420
  const subject = item.workDir ?? ctx.cwd;
398
- const self: GateSibling = { id: item.index, label: item.id, status: item.status, workDir: item.workDir };
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) };
399
428
  const siblings: GateSibling[] = run.items
400
429
  .filter((i) => i.index !== item.index)
401
- .map((i) => ({ id: i.index, label: i.id, status: i.status, workDir: i.workDir }));
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);
402
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;
403
443
  const { record, verification } = await runGateCycle(item.item, opts.gate, subject, {
404
- runGate,
405
- canRepair: canWrite(def),
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,
406
450
  maxAttempts: opts.gateRepairs ?? 1,
407
451
  sharedWith: sharedWith(self, subject, siblings),
408
452
  repair: async (prompt) => {
409
- // The repair is the same child type over the same tree; its report
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
410
462
  // replaces the stale one, which described a tree that has changed.
411
- const fix: ItemState = { ...item, result: null, error: null, status: "queued", turns: 0 };
412
- await runItem(ctx, run.runId, def, fix, prompt, item.workDir, undefined);
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);
413
469
  item.turns += fix.turns;
414
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";
415
475
  if (fix.status === "done" && fix.result?.trim()) item.result = fix.result;
416
476
  },
417
477
  });
418
- item.gate = record;
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 };
419
481
  item.verification = verification;
420
482
  } catch (err) {
421
483
  // A gate that throws proved nothing; say so rather than losing the
@@ -437,6 +499,8 @@ export default function swarm(pi: ExtensionAPI) {
437
499
  useMailbox?: boolean;
438
500
  gate?: GateContract;
439
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;
440
504
  onUpstreamFailure?: UpstreamFailurePolicy;
441
505
  /** Called once, for the first item that settles badly while others run. */
442
506
  onEarlyFailure?: (item: ItemState) => void;
@@ -450,6 +514,7 @@ export default function swarm(pi: ExtensionAPI) {
450
514
  const mailbox = useMailbox
451
515
  ? mailboxDir(getAgentDir(), mailboxKey(run.runId, run.startedAt))
452
516
  : undefined;
517
+ const gateOpts: RunOptions = { ...opts, mailbox };
453
518
 
454
519
  // A dependent structurally receives each upstream's output — the thing a
455
520
  // hand-sequenced coordinator forgets. Prepended to the shared preamble.
@@ -501,7 +566,7 @@ export default function swarm(pi: ExtensionAPI) {
501
566
  const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
502
567
  item.workDir = iso.path;
503
568
  await runItem(ctx, run.runId, def, item, itemContext, iso.path, mailbox);
504
- await gateItem(ctx, run, item, def, opts);
569
+ await gateItem(ctx, run, item, def, gateOpts);
505
570
  // Remove the worktree when the item changed nothing (the leak
506
571
  // removeIfUnchanged fixes); keep it when there is work to merge.
507
572
  const removed = removeIfUnchanged(ctx.cwd, iso);
@@ -514,7 +579,7 @@ export default function swarm(pi: ExtensionAPI) {
514
579
  }
515
580
  } else {
516
581
  await runItem(ctx, run.runId, def, item, itemContext, undefined, mailbox);
517
- await gateItem(ctx, run, item, def, opts);
582
+ await gateItem(ctx, run, item, def, gateOpts);
518
583
  }
519
584
  settle(item);
520
585
  },
@@ -745,6 +810,11 @@ export default function swarm(pi: ExtensionAPI) {
745
810
  };
746
811
  void executeRun(uiCtx, run, options)
747
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;
748
818
  notify(uiCtx, `swarm ${run.runId} finished`, "info");
749
819
  // The report goes to the agent, not only to the screen — otherwise
750
820
  // asking again was its only way to find out.
@@ -768,7 +838,14 @@ export default function swarm(pi: ExtensionAPI) {
768
838
  });
769
839
  return {
770
840
  content: [
771
- { 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
+ },
772
849
  ],
773
850
  details: { runId: run.runId },
774
851
  };
@@ -790,22 +867,47 @@ export default function swarm(pi: ExtensionAPI) {
790
867
  name: "swarm_status",
791
868
  label: "Swarm status",
792
869
  promptSnippet: "Progress of a running swarm",
793
- 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.",
794
874
  parameters: Type.Object({
795
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
+ ),
796
883
  }),
797
- async execute(_id, params: { runId?: string }) {
884
+ async execute(_id, params: { runId?: string; wait?: number }, signal, _onUpdate, ctx) {
798
885
  const run = params.runId ? runs.get(params.runId.trim()) : activeRun ?? [...runs.values()].pop();
799
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
+ }
800
893
  if (run.status === "running") {
894
+ const interactive = (ctx as { hasUI?: boolean }).hasUI !== false;
801
895
  const pending = pendingResult({
802
896
  id: run.runId,
803
897
  kind: "running",
804
898
  startedAt: run.startedAt,
805
899
  now: Date.now(),
806
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,
807
904
  });
808
- 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 };
809
911
  }
810
912
  const text =
811
913
  run.status === "cancelled"
@@ -890,9 +992,33 @@ export default function swarm(pi: ExtensionAPI) {
890
992
  });
891
993
 
892
994
  pi.registerCommand("swarm", {
893
- description: "Show swarm runs and routing-capable agent types",
894
- handler: async (_args, ctx) => {
995
+ description: "Show swarm runs and agent types; /swarm stop [runId] cancels a live run",
996
+ handler: async (args, ctx) => {
895
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
+ }
896
1022
  const routed = [...defs.values()]
897
1023
  .map((d) => {
898
1024
  const rules = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.10.0",
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.
package/src/builtin.ts CHANGED
@@ -16,8 +16,18 @@ You are a disciplined review subagent. Inspect, evaluate, and report findings
16
16
  with evidence — never guess; verify from the code itself. You cannot modify
17
17
  anything: your deliverable is the report.
18
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
19
+ Flag only defects you can prove: a concrete, introduced problem with a real
20
+ impact you can name — a bug, a broken contract, data loss, a security hole. Do
21
+ not report style, taste, or hypotheticals; "could theoretically" is not a
22
+ finding. Weight the review on:
23
+ - correctness: logic errors, wrong edge cases, broken or unhandled contracts;
24
+ - untrusted input reaching a dangerous sink: SQL/command injection, path
25
+ traversal, SSRF, open redirect, unsafe deserialization, missing authz;
26
+ - clean code, but only where it bites: needless duplication, dead code, an
27
+ abstraction that hides a real bug — never mere preference.
28
+
29
+ For each finding give: file:line, what is wrong, why it matters (the concrete
30
+ impact), and a concrete fix. Rank by severity. If the code is sound, say so
21
31
  plainly — do not invent issues. End with a one-paragraph verdict.`,
22
32
 
23
33
  scout: `---
@@ -33,11 +33,13 @@ export function parseAgentFile(
33
33
 
34
34
  const thinkingRaw = fields.get("thinking")?.toLowerCase();
35
35
  const maxTurnsRaw = Number.parseInt(fields.get("max_turns") ?? "", 10);
36
+ const tools = parseTools(fields.get("tools"));
37
+ if (tools === null) return null;
36
38
 
37
39
  return {
38
40
  name: name.toLowerCase(),
39
41
  description,
40
- tools: parseTools(fields.get("tools")),
42
+ tools,
41
43
  model: fields.get("model") || null,
42
44
  thinking: (THINKING_LEVELS as readonly string[]).includes(thinkingRaw ?? "")
43
45
  ? (thinkingRaw as ThinkingLevelName)
@@ -64,11 +66,20 @@ function parseList(raw: string | undefined): string[] {
64
66
  .filter(Boolean);
65
67
  }
66
68
 
67
- function parseTools(raw: string | undefined): ValidTool[] {
69
+ /**
70
+ * Read-only default keeps a def missing `tools:` from mutating anything.
71
+ * A `tools:` line where NOTHING resolves is different: the author asked for
72
+ * a specific tool set and got the read-only default instead, so the agent
73
+ * runs with a contract nobody wrote. That is rejected — the file is dropped,
74
+ * rather than quietly running as something else. (Same rule as @pify/subagent.)
75
+ */
76
+ function parseTools(raw: string | undefined): ValidTool[] | null {
68
77
  if (!raw) return ["read", "grep", "find", "ls"];
69
- const valid = raw
78
+ const requested = raw
70
79
  .split(",")
71
80
  .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"];
81
+ .filter(Boolean);
82
+ if (requested.length === 0) return ["read", "grep", "find", "ls"];
83
+ const valid = requested.filter((t): t is ValidTool => (VALID_TOOLS as readonly string[]).includes(t));
84
+ return valid.length > 0 ? valid : null;
74
85
  }
package/src/gate.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  * about having proved nothing either way.
22
22
  */
23
23
 
24
- import { spawnSync } from "node:child_process";
24
+ import { spawn, type ChildProcess } from "node:child_process";
25
25
 
26
26
  export type GateOutcome =
27
27
  | "success"
@@ -202,61 +202,150 @@ export function contractProblems(contract: GateContract): string[] {
202
202
  /** How long a gate may run before the deadline is its verdict. */
203
203
  export const GATE_TIMEOUT_MS = 120_000;
204
204
 
205
+ /**
206
+ * Output kept from a gate. A real suite prints books; the verdict is at the
207
+ * end, so it is the tail that is kept when a gate prints past this.
208
+ */
209
+ const GATE_MAX_OUTPUT = 16 * 1024 * 1024;
210
+
205
211
  /**
206
212
  * Run a gate and judge it against its contract, with the shell in the subject
207
213
  * working directory. A bare string keeps the exit-code meaning; a contract can
208
214
  * also say what success has to look like, which is what stops a command that
209
215
  * never ran the check from passing.
210
216
  *
217
+ * Asynchronous, and that is load-bearing. A gate is a test suite or a build,
218
+ * and the first version ran it with spawnSync — which held pi's whole event
219
+ * loop for the duration: nothing rendered, Esc could not be delivered, and
220
+ * every other child's provider stream sat unread until the gate returned,
221
+ * up to the full deadline. The deadline is enforced here (the shell is
222
+ * killed on timeout) and output is capped rather than erroring, so a
223
+ * chatty-but-passing gate still passes.
224
+ *
211
225
  * The command comes from the caller — the same trust level as the bash tool in
212
226
  * this session — so this adds no capability the caller did not already have.
213
227
  */
214
- export function runGate(gate: string | GateContract, cwd: string): GateVerdict & { output: string } {
228
+ export function runGate(gate: string | GateContract, cwd: string): Promise<GateVerdict & { output: string }> {
215
229
  const contract = normalizeGate(gate);
216
230
  const problems = contractProblems(contract);
217
231
  if (problems.length > 0) {
218
232
  // A gate that cannot be run is not a verdict on the work. Calling this a
219
233
  // failure would report a typo in the gate as a defect in the code.
220
- return { outcome: "no_attestation", ok: false, reason: problems.join("; "), output: "" };
234
+ return Promise.resolve({ outcome: "no_attestation", ok: false, reason: problems.join("; "), output: "" });
221
235
  }
222
236
  const timeoutMs = contract.timeoutMs ?? GATE_TIMEOUT_MS;
223
- try {
224
- const result = spawnSync(contract.command, {
225
- shell: true,
226
- cwd,
227
- encoding: "utf8",
228
- timeout: timeoutMs,
229
- windowsHide: true,
230
- // A real test suite or build easily prints past spawnSync's 1 MiB
231
- // default; overflow surfaces as an ENOBUFS `error`, which this code
232
- // would otherwise read as spawnError → no_attestation, rejecting work
233
- // that in fact passed. Give the gate room to actually report.
234
- maxBuffer: 16 * 1024 * 1024,
235
- });
236
- const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
237
- const verdict = evaluateGate(
238
- { ...contract, timeoutMs },
239
- {
240
- status: result.status,
241
- signal: result.signal,
237
+
238
+ return new Promise((resolve) => {
239
+ let child: ChildProcess;
240
+ try {
241
+ child = spawn(contract.command, {
242
+ shell: true,
243
+ cwd,
244
+ windowsHide: true,
245
+ stdio: ["ignore", "pipe", "pipe"],
246
+ // POSIX: lead a process group, so a timeout can kill the whole tree
247
+ // and not just the shell that started it.
248
+ detached: process.platform !== "win32",
249
+ });
250
+ } catch (err) {
251
+ resolve({
252
+ outcome: "no_attestation",
253
+ ok: false,
254
+ reason: `gate could not be started: ${err instanceof Error ? err.message : String(err)}`,
255
+ output: "",
256
+ });
257
+ return;
258
+ }
259
+
260
+ // stdout and stderr in arrival order — how a person would have read the
261
+ // terminal — trimmed from the front once past the cap.
262
+ const chunks: Buffer[] = [];
263
+ let size = 0;
264
+ const keep = (chunk: Buffer): void => {
265
+ chunks.push(chunk);
266
+ size += chunk.length;
267
+ while (size > GATE_MAX_OUTPUT && chunks.length > 1) size -= chunks.shift()!.length;
268
+ };
269
+ child.stdout?.on("data", keep);
270
+ child.stderr?.on("data", keep);
271
+
272
+ let timedOut = false;
273
+ const timer = setTimeout(() => {
274
+ timedOut = true;
275
+ killTree(child);
276
+ }, timeoutMs);
277
+
278
+ let settled = false;
279
+ let exited: { status: number | null; signal: string | null } | null = null;
280
+ const finish = (spawnError?: string): void => {
281
+ if (settled) return;
282
+ settled = true;
283
+ clearTimeout(timer);
284
+ const output = Buffer.concat(chunks).toString("utf8").trim();
285
+ resolve({
286
+ ...evaluateGate(
287
+ { ...contract, timeoutMs },
288
+ { status: exited?.status ?? null, signal: exited?.signal ?? null, output, timedOut, spawnError },
289
+ ),
242
290
  output,
243
- timedOut: result.error?.message?.includes("ETIMEDOUT") || result.signal === "SIGTERM",
244
- // spawnSync reports a failure to start in `error` rather than by
245
- // throwing, and a timeout arrives the same way — so the timeout has to
246
- // be ruled out first or every deadline would read as "never ran".
247
- spawnError:
248
- result.error && !result.error.message?.includes("ETIMEDOUT")
249
- ? result.error.message
250
- : undefined,
251
- },
252
- );
253
- return { ...verdict, output };
254
- } catch (err) {
255
- return {
256
- outcome: "no_attestation",
257
- ok: false,
258
- reason: `gate could not be started: ${err instanceof Error ? err.message : String(err)}`,
259
- output: "",
291
+ });
260
292
  };
293
+ // A failure to start arrives as an event, not a throw. After a timeout
294
+ // kill, an error is the kill's doing, not a spawn failure.
295
+ child.on("error", (err) => {
296
+ exited ??= { status: null, signal: null };
297
+ finish(timedOut ? undefined : err.message);
298
+ });
299
+ // `close` waits for the pipes, which a grandchild that outlived the shell
300
+ // can hold open indefinitely; `exit` is the shell's own verdict. Wait for
301
+ // the pipes briefly so a normal exit keeps all of its output, then finish
302
+ // regardless — a gate must never hang a run past its deadline.
303
+ child.on("close", (status, signal) => {
304
+ exited ??= { status, signal };
305
+ finish();
306
+ });
307
+ child.on("exit", (status, signal) => {
308
+ exited = { status, signal };
309
+ const grace = setTimeout(() => finish(), EXIT_DRAIN_MS);
310
+ grace.unref?.();
311
+ });
312
+ });
313
+ }
314
+
315
+ /** How long to wait for a gate's pipes after its shell has exited. */
316
+ const EXIT_DRAIN_MS = 500;
317
+
318
+ /**
319
+ * Kill a gate's whole process tree. The shell alone dying leaves the test
320
+ * suite it started running — on Windows `child.kill()` reaches only cmd.exe,
321
+ * and on POSIX only the shell — so a deadline that merely killed the shell
322
+ * would report a timeout while the suite ran on, holding the pipes open.
323
+ */
324
+ function killTree(child: ChildProcess): void {
325
+ const pid = child.pid;
326
+ if (pid === undefined) return;
327
+ if (process.platform === "win32") {
328
+ // By absolute path: PATH is the user's, and a gate has run under odd ones.
329
+ const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
330
+ try {
331
+ spawn(taskkill, ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }).on("error", () => {});
332
+ } catch {
333
+ // taskkill unavailable: the plain kill below is all that is left
334
+ }
335
+ try {
336
+ child.kill();
337
+ } catch {
338
+ // already gone
339
+ }
340
+ return;
341
+ }
342
+ try {
343
+ process.kill(-pid, "SIGKILL");
344
+ } catch {
345
+ try {
346
+ child.kill("SIGKILL");
347
+ } catch {
348
+ // already gone
349
+ }
261
350
  }
262
351
  }
package/src/isolate.ts CHANGED
@@ -33,7 +33,12 @@ export function sanitizeSlug(raw: string): string {
33
33
  return slug || "run";
34
34
  }
35
35
 
36
- export function createIsolationWorktree(cwd: string, rawSlug: string): Isolation {
36
+ /** Where isolation worktrees live by default: ~/.worktrees. */
37
+ export function defaultWorktreeRoot(): string {
38
+ return join(homedir(), ".worktrees");
39
+ }
40
+
41
+ export function createIsolationWorktree(cwd: string, rawSlug: string, root: string = defaultWorktreeRoot()): Isolation {
37
42
  let toplevel: string;
38
43
  try {
39
44
  toplevel = git(cwd, ["rev-parse", "--show-toplevel"]);
@@ -44,11 +49,11 @@ export function createIsolationWorktree(cwd: string, rawSlug: string): Isolation
44
49
  const slug = sanitizeSlug(rawSlug);
45
50
 
46
51
  let branch = `agent/${slug}`;
47
- let path = join(homedir(), ".worktrees", repo, slug);
52
+ let path = join(root, repo, slug);
48
53
  let counter = 2;
49
54
  while (existsSync(path) || branchExists(cwd, branch)) {
50
55
  branch = `agent/${slug}-${counter}`;
51
- path = join(homedir(), ".worktrees", repo, `${slug}-${counter}`);
56
+ path = join(root, repo, `${slug}-${counter}`);
52
57
  counter++;
53
58
  if (counter > 50) throw new Error("Could not find a free worktree slot.");
54
59
  }
@@ -71,15 +76,70 @@ function branchExists(cwd: string, branch: string): boolean {
71
76
  }
72
77
  }
73
78
 
74
- /** Note appended to a child's report when it ran isolated. */
79
+ /**
80
+ * Note appended to a child's report when it ran isolated and left work
81
+ * behind. That work is UNCOMMITTED unless the child chose to commit — the
82
+ * builtin worker never does — and @pify/worktree's worktree_merge refuses a
83
+ * dirty tree, so the old note ("merge with worktree_merge") sent the model to
84
+ * a tool that would turn it away. Say what state the tree is in and what to
85
+ * do about it.
86
+ */
75
87
  export function isolationNote(isolation: Isolation): string {
88
+ const at = `git -C "${isolation.path}"`;
76
89
  return [
77
- `Ran isolated in worktree ${isolation.path} (branch ${isolation.branch}).`,
78
- `The main checkout is untouched. Merge with @pify/worktree's worktree_merge branch="${isolation.branch}",`,
79
- `or inspect: cd "${isolation.path}" && git log --stat`,
90
+ `Ran isolated in worktree ${isolation.path} (branch ${isolation.branch}); the main checkout is untouched.`,
91
+ `Its changes are in that worktree, uncommitted unless the child committed them. To bring them back:`,
92
+ ` review: ${at} status && ${at} diff`,
93
+ ` commit: ${at} add -A && ${at} commit -m "<what changed>"`,
94
+ ` merge: @pify/worktree's worktree_merge branch="${isolation.branch}" (refuses an uncommitted tree)`,
95
+ `or discard it: git worktree remove --force "${isolation.path}".`,
80
96
  ].join("\n");
81
97
  }
82
98
 
99
+ /** Note when an isolated run changed nothing and its worktree was removed. */
100
+ export const CLEAN_WORKTREE_NOTE =
101
+ "Ran isolated in a temporary worktree; it changed nothing, so the worktree was removed.";
102
+
103
+ /**
104
+ * The fields the isolation epilogue writes onto a call record. Kept structural
105
+ * so isolate.ts stays free of any dependency on the extension's types — the
106
+ * run's AgentCallState satisfies it by shape.
107
+ */
108
+ export interface IsolationSink {
109
+ worktree?: string;
110
+ branch?: string;
111
+ }
112
+
113
+ /**
114
+ * Close out an isolated child's worktree exactly once, whatever its outcome.
115
+ *
116
+ * This is the epilogue EVERY terminal path of a child call must reach —
117
+ * success, schema mismatch, gate failure, abort, or a thrown error. Before,
118
+ * only the prose success path ran it, so a `schema:` step (or any failure or
119
+ * abort) left its worktree and branch behind forever. It runs
120
+ * removeIfUnchanged — a read-only step's worktree is deleted, a step that did
121
+ * work is kept — and when the worktree is kept it records where the edits live
122
+ * on the call so a non-prose result (a schema object, a null from an error)
123
+ * can still name the location instead of orphaning it.
124
+ *
125
+ * Returns the human note for callers that render prose; callers on non-prose
126
+ * paths rely on the pointer written to `sink`. Never throws (removeIfUnchanged
127
+ * already swallows its own failures): the cleanup must not sink a run.
128
+ */
129
+ export function settleWorktree(
130
+ cwd: string,
131
+ isolation: Isolation,
132
+ sink: IsolationSink,
133
+ ): { removed: boolean; note: string } {
134
+ const removed = removeIfUnchanged(cwd, isolation);
135
+ if (removed) return { removed: true, note: CLEAN_WORKTREE_NOTE };
136
+ // Kept: there is work to merge. Record the pointer so the location survives
137
+ // on the call record even when the returned value is data or null.
138
+ sink.worktree = isolation.path;
139
+ sink.branch = isolation.branch;
140
+ return { removed: false, note: isolationNote(isolation) };
141
+ }
142
+
83
143
  /**
84
144
  * Remove a worktree the child left untouched. An isolated run that changed
85
145
  * nothing is the common case — a review, a search, a question — and keeping
package/src/pending.ts CHANGED
@@ -29,6 +29,13 @@ export interface PendingInput {
29
29
  now: number;
30
30
  /** What the caller asks for to collect it, e.g. `agent_result`. */
31
31
  collectWith: string;
32
+ /**
33
+ * Whether a UI/interactive session is present. Delivery needs a session
34
+ * that outlives the run; a headless `pi -p` run tears down when the prompt
35
+ * resolves, so "it will be delivered, do not poll" is a promise that cannot
36
+ * be kept there. Default true so existing callers keep the interactive text.
37
+ */
38
+ interactive?: boolean;
32
39
  }
33
40
 
34
41
  export interface PendingResult {
@@ -39,8 +46,12 @@ export interface PendingResult {
39
46
  /** True: this will resolve on its own. It is a wait, not a failure. */
40
47
  retryable: boolean;
41
48
  elapsedMs: number;
42
- /** False, and load-bearing: there is nothing to poll for. */
43
- pollRequired: false;
49
+ /**
50
+ * Interactive: false, and load-bearing — there is nothing to poll for.
51
+ * Headless: true — the session ends with this turn, so the model MUST
52
+ * collect within it or the result is lost.
53
+ */
54
+ pollRequired: boolean;
44
55
  };
45
56
  }
46
57
 
@@ -58,15 +69,28 @@ function elapsed(ms: number): string {
58
69
  export function pendingResult(input: PendingInput): PendingResult {
59
70
  const ms = Math.max(0, input.now - input.startedAt);
60
71
  const state = input.kind === "queued" ? "queued behind the concurrency cap" : "still running";
72
+ // A headless run has no session to deliver into — it ends when this turn
73
+ // does. Telling the model "do not poll, it will be delivered" there strands
74
+ // it awaiting a message that never comes; it must collect within the turn.
75
+ const headless = input.interactive === false;
76
+ const text = headless
77
+ ? [
78
+ `${input.id} is ${state} (${elapsed(ms)}).`,
79
+ "",
80
+ "This is a headless run: nothing is delivered after your turn ends. Call",
81
+ `${input.collectWith} again in this same turn until it returns the result — do not end`,
82
+ "your turn expecting to be picked back up.",
83
+ ].join("\n")
84
+ : [
85
+ `${input.id} is ${state} (${elapsed(ms)}).`,
86
+ "",
87
+ "Do not poll for it. The result is delivered to you automatically the moment it lands,",
88
+ `so there is nothing to wait for here — carry on with other work, or finish your turn and`,
89
+ `you will be picked back up. ${input.collectWith} is only needed if you want it early.`,
90
+ ].join("\n");
61
91
  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 },
92
+ text,
93
+ details: { id: input.id, status: input.kind, retryable: true, elapsedMs: ms, pollRequired: headless },
70
94
  };
71
95
  }
72
96
 
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Who gets sent back to fix a failed gate.
3
+ *
4
+ * repair.ts bounds *how many* repair passes a failing gate may spend; this
5
+ * decides whether one is worth spending at all. Two facts rule it out. An
6
+ * agent with no writing tool can only re-read the failure and re-report it,
7
+ * so the pass buys nothing. And a child that ended its report with
8
+ * `OUTCOME: blocked` has said the wall is outside its reach — a decision,
9
+ * access or information it does not have — and a failing gate does not move
10
+ * that wall; a repair pass would just re-discover it at the cost of a full
11
+ * child run.
12
+ *
13
+ * The declaration has to be read *before* the item settles: settleItem
14
+ * strips it from the result so the report does not repeat it.
15
+ */
16
+
17
+ import { parseDeclaredOutcome } from "./outcome.ts";
18
+ import type { AgentDef } from "./types.ts";
19
+
20
+ /** An agent that can write is one that can fix what a gate complained about. */
21
+ export function canWrite(def: AgentDef): boolean {
22
+ return def.tools.some((t) => t === "edit" || t === "write" || t === "bash" || t === "powershell");
23
+ }
24
+
25
+ /** May this item be sent on a repair pass, given the agent and what it said? */
26
+ export function repairAllowed(def: AgentDef, result: string | null | undefined): boolean {
27
+ return canWrite(def) && parseDeclaredOutcome(result) !== "blocked";
28
+ }
package/src/repair.ts CHANGED
@@ -47,8 +47,8 @@ export function repairPrompt(
47
47
  }
48
48
 
49
49
  export interface GateCycleDeps {
50
- /** Run the gate in `cwd` and judge it (src/gate.ts runGate). */
51
- runGate(contract: GateContract, cwd: string): GateVerdict & { output: string };
50
+ /** Run the gate in `cwd` and judge it (src/gate.ts runGate); a stub may answer synchronously. */
51
+ runGate(contract: GateContract, cwd: string): (GateVerdict & { output: string }) | Promise<GateVerdict & { output: string }>;
52
52
  /** Send the child back with a repair brief; resolves when that pass settles. */
53
53
  repair(prompt: string): Promise<void>;
54
54
  /**
@@ -79,14 +79,14 @@ export async function runGateCycle(
79
79
  cwd: string,
80
80
  deps: GateCycleDeps,
81
81
  ): Promise<{ record: GateRecord; verification: Verification }> {
82
- let verdict = deps.runGate(contract, cwd);
82
+ let verdict = await deps.runGate(contract, cwd);
83
83
  let repairs = 0;
84
84
  const limit = Math.max(0, Math.min(5, deps.maxAttempts));
85
85
 
86
86
  while (!verdict.ok && deps.canRepair && repairs < limit && repairable(verdict.outcome)) {
87
87
  await deps.repair(repairPrompt(task, contract, verdict));
88
88
  repairs++;
89
- verdict = deps.runGate(contract, cwd);
89
+ verdict = await deps.runGate(contract, cwd);
90
90
  }
91
91
 
92
92
  const record: GateRecord = {
package/src/report.ts CHANGED
@@ -66,7 +66,12 @@ export function buildReport(run: SwarmRun): string {
66
66
  return `${label}\nSkipped — ${item.error ?? "something it needed did not succeed"}. Nothing ran, so nothing was spent on it.`;
67
67
  }
68
68
  if (item.status === "aborted") {
69
- return `${label}\nAborted (turn cap or stop). Partial:\n${item.result ?? "(none)"}`;
69
+ // cancelRun writes who stopped the run and what that cost into `error`;
70
+ // a turn-cap stop leaves it empty. Either way the reader should not have
71
+ // to guess which one it was.
72
+ // cancelNote ends its sentence itself; do not add a second period.
73
+ const why = (item.error ?? "turn cap or stop").replace(/\.$/, "");
74
+ return `${label}\nAborted — ${why}. Partial:\n${item.result ?? "(none)"}`;
70
75
  }
71
76
  return `${label}\n(${item.status})`;
72
77
  });
package/src/wait.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Waiting on a run without teaching the model to poll.
3
+ *
4
+ * pending.ts says "do not poll" and means it — in an interactive session the
5
+ * report arrives on its own. A headless `pi -p` run has no such delivery, so
6
+ * there the model's only move was to call swarm_status again, and again, each
7
+ * call a full turn. `wait` lets one call sit on the run for a bounded time
8
+ * instead: the answer is the same either way, it just arrives in one turn.
9
+ *
10
+ * Pure: the condition, the budget and the tool's own AbortSignal. The
11
+ * extension owns what is being waited for.
12
+ */
13
+
14
+ /**
15
+ * Resolve true as soon as `check()` holds, false when `ms` runs out or the
16
+ * signal fires first. Polls rather than subscribes: the run has no event to
17
+ * listen to, and a check every `intervalMs` costs nothing measurable.
18
+ */
19
+ export function waitUntil(
20
+ check: () => boolean,
21
+ ms: number,
22
+ intervalMs = 250,
23
+ signal?: AbortSignal,
24
+ ): Promise<boolean> {
25
+ if (check()) return Promise.resolve(true);
26
+ if (!(ms > 0) || signal?.aborted) return Promise.resolve(false);
27
+ const interval = Math.max(1, intervalMs);
28
+ return new Promise((resolve) => {
29
+ const deadline = Date.now() + ms;
30
+ let timer: ReturnType<typeof setTimeout> | undefined;
31
+ const finish = (value: boolean): void => {
32
+ if (timer !== undefined) clearTimeout(timer);
33
+ signal?.removeEventListener("abort", onAbort);
34
+ resolve(value);
35
+ };
36
+ // An abort is the caller leaving, not a verdict; if the condition happens
37
+ // to hold by then, say so rather than reporting a wait that never happened.
38
+ const onAbort = (): void => finish(check());
39
+ const tick = (): void => {
40
+ if (check()) return finish(true);
41
+ const left = deadline - Date.now();
42
+ if (left <= 0) return finish(false);
43
+ timer = setTimeout(tick, Math.min(interval, left));
44
+ };
45
+ signal?.addEventListener("abort", onAbort, { once: true });
46
+ timer = setTimeout(tick, Math.min(interval, ms));
47
+ });
48
+ }