@pify/swarm 0.4.0 → 0.5.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.
@@ -25,6 +25,7 @@ import { Text } from "@earendil-works/pi-tui";
25
25
  import { Type } from "typebox";
26
26
 
27
27
  import { BUILTIN_AGENTS } from "../src/builtin.ts";
28
+ import { LiveChildren, cancelNote, type CancelReason } from "../src/cancel.ts";
28
29
  import { createIsolationWorktree, isolationNote, removeIfUnchanged } from "../src/isolate.ts";
29
30
  import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "../src/mailbox.ts";
30
31
  import { parseAgentFile } from "../src/frontmatter.ts";
@@ -77,6 +78,8 @@ function loadDefs(cwd: string, agentDir: string): Map<string, AgentDef> {
77
78
  export default function swarm(pi: ExtensionAPI) {
78
79
  let defs = new Map<string, AgentDef>();
79
80
  const runs = new Map<string, SwarmRun>();
81
+ /** Live child sessions per run, so a stop actually reaches the children. */
82
+ const live = new LiveChildren();
80
83
  let activeRun: SwarmRun | null = null;
81
84
  let runCounter = 0;
82
85
  let lastUiCtx: UiContext | null = null;
@@ -86,7 +89,7 @@ export default function swarm(pi: ExtensionAPI) {
86
89
  lastUiCtx = ctx;
87
90
  const run = activeRun;
88
91
  const now = Date.now();
89
- if (!run || (run.status === "done" && (run.finishedAt ?? 0) < now - 15_000)) {
92
+ if (!run || (run.status !== "running" && (run.finishedAt ?? 0) < now - 15_000)) {
90
93
  ctx.ui.setWidget("swarm", undefined);
91
94
  return;
92
95
  }
@@ -151,6 +154,7 @@ export default function swarm(pi: ExtensionAPI) {
151
154
 
152
155
  async function runItem(
153
156
  ctx: UiContext,
157
+ runId: string,
154
158
  def: AgentDef,
155
159
  item: ItemState,
156
160
  context: string,
@@ -161,6 +165,7 @@ export default function swarm(pi: ExtensionAPI) {
161
165
  renderWidget();
162
166
  let session: AgentSession | null = null;
163
167
  let unsubscribe: (() => void) | null = null;
168
+ let releaseLive: (() => void) | null = null;
164
169
  try {
165
170
  let model = ctx.model ?? null;
166
171
  if (def.model) {
@@ -198,6 +203,7 @@ export default function swarm(pi: ExtensionAPI) {
198
203
  }),
199
204
  });
200
205
  session = created.session;
206
+ releaseLive = live.register(runId, session);
201
207
 
202
208
  unsubscribe = session.subscribe((event) => {
203
209
  if (event.type === "message_end" && (event as { message?: { role?: string } }).message?.role === "assistant") {
@@ -232,6 +238,7 @@ export default function swarm(pi: ExtensionAPI) {
232
238
  item.status = "error";
233
239
  item.error = err instanceof Error ? err.message : String(err);
234
240
  } finally {
241
+ if (releaseLive) releaseLive();
235
242
  if (unsubscribe) {
236
243
  try {
237
244
  unsubscribe();
@@ -264,6 +271,9 @@ export default function swarm(pi: ExtensionAPI) {
264
271
  const queue = [...run.items];
265
272
  const workers = Array.from({ length: Math.min(DEFAULT_CONCURRENCY, queue.length) }, async () => {
266
273
  for (;;) {
274
+ // A cancelled run stops taking new items; the ones already in flight
275
+ // were aborted by cancelRun.
276
+ if (run.status === "cancelled") return;
267
277
  const item = queue.shift();
268
278
  if (!item) return;
269
279
  const def = routeItem(item.item, defs, fixed);
@@ -271,24 +281,43 @@ export default function swarm(pi: ExtensionAPI) {
271
281
  if (isolate) {
272
282
  try {
273
283
  const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
274
- await runItem(ctx, def, item, context, iso.path, mailbox);
284
+ await runItem(ctx, run.runId, def, item, context, iso.path, mailbox);
275
285
  if (item.result !== null) item.result = `${item.result}\n\n${isolationNote(iso)}`;
276
286
  } catch (err) {
277
287
  item.status = "error";
278
288
  item.error = err instanceof Error ? err.message : String(err);
279
289
  }
280
290
  } else {
281
- await runItem(ctx, def, item, context, undefined, mailbox);
291
+ await runItem(ctx, run.runId, def, item, context, undefined, mailbox);
282
292
  }
283
293
  }
284
294
  });
285
295
  await Promise.all(workers);
286
- run.status = "done";
296
+ if (run.status !== "cancelled") run.status = "done";
287
297
  run.finishedAt = Date.now();
288
298
  pi.appendEntry(RUN_ENTRY, run);
289
299
  renderWidget();
290
300
  }
291
301
 
302
+ /**
303
+ * Stop a run and every child it started. Both meanings of "stop" — the
304
+ * user's abort and session teardown — come through here.
305
+ */
306
+ function cancelRun(run: SwarmRun, reason: CancelReason): void {
307
+ const stopped = live.abortRun(run.runId);
308
+ if (run.status === "running") {
309
+ run.status = "cancelled";
310
+ run.finishedAt = Date.now();
311
+ }
312
+ for (const item of run.items) {
313
+ if (item.status === "running" || item.status === "queued") {
314
+ item.status = "aborted";
315
+ item.error = cancelNote(reason, stopped);
316
+ }
317
+ }
318
+ renderWidget();
319
+ }
320
+
292
321
  // ── Tools ────────────────────────────────────────────────────────────
293
322
 
294
323
  pi.registerTool({
@@ -323,7 +352,7 @@ export default function swarm(pi: ExtensionAPI) {
323
352
  isolation?: string;
324
353
  mailbox?: boolean;
325
354
  },
326
- _signal,
355
+ signal,
327
356
  _onUpdate,
328
357
  ctx,
329
358
  ) {
@@ -359,6 +388,18 @@ export default function swarm(pi: ExtensionAPI) {
359
388
  activeRun = run;
360
389
  renderWidget(uiCtx);
361
390
 
391
+ // Esc must reach the children. A background run outlives this tool call
392
+ // by design, so its signal is not its cancel button.
393
+ let stopListening: (() => void) | null = null;
394
+ if (signal && !run.background) {
395
+ const onAbort = () => cancelRun(run, "user-abort");
396
+ if (signal.aborted) onAbort();
397
+ else {
398
+ signal.addEventListener("abort", onAbort, { once: true });
399
+ stopListening = () => signal.removeEventListener("abort", onAbort);
400
+ }
401
+ }
402
+
362
403
  if (run.background) {
363
404
  void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true).then(() => {
364
405
  notify(uiCtx, `swarm ${run.runId} finished — collect with swarm_status`, "info");
@@ -371,7 +412,11 @@ export default function swarm(pi: ExtensionAPI) {
371
412
  };
372
413
  }
373
414
 
374
- await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
415
+ try {
416
+ await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
417
+ } finally {
418
+ if (stopListening) stopListening();
419
+ }
375
420
  return {
376
421
  content: [{ type: "text", text: buildReport(run) }],
377
422
  details: { runId: run.runId },
@@ -389,7 +434,13 @@ export default function swarm(pi: ExtensionAPI) {
389
434
  async execute(_id, params: { runId?: string }) {
390
435
  const run = params.runId ? runs.get(params.runId.trim()) : activeRun ?? [...runs.values()].pop();
391
436
  if (!run) throw new Error("No swarm runs this session.");
392
- const text = run.status === "done" ? buildReport(run) : buildStatusLine(run);
437
+ const text =
438
+ run.status === "cancelled"
439
+ ? "This run was cancelled before it finished. Below is what the items that did complete produced.\n" +
440
+ buildReport(run)
441
+ : run.status === "done"
442
+ ? buildReport(run)
443
+ : buildStatusLine(run);
393
444
  return { content: [{ type: "text", text }], details: { runId: run.runId, status: run.status } };
394
445
  },
395
446
  });
@@ -404,7 +455,7 @@ export default function swarm(pi: ExtensionAPI) {
404
455
  const e = entry as { type?: string; customType?: string; data?: unknown };
405
456
  if (e.type !== "custom" || e.customType !== RUN_ENTRY || !isRecord(e.data)) continue;
406
457
  const run = e.data as unknown as SwarmRun;
407
- if (typeof run.runId === "string" && run.status === "done") {
458
+ if (typeof run.runId === "string" && run.status !== "running") {
408
459
  runs.set(run.runId, run);
409
460
  const n = Number.parseInt(run.runId.slice(1), 10);
410
461
  if (Number.isFinite(n) && n > runCounter) runCounter = n;
@@ -414,6 +465,10 @@ export default function swarm(pi: ExtensionAPI) {
414
465
  });
415
466
 
416
467
  pi.on("session_shutdown", async (_event, ctx) => {
468
+ // A run cannot outlive the session that owns it.
469
+ for (const run of runs.values()) {
470
+ if (run.status === "running") cancelRun(run, "session-switch");
471
+ }
417
472
  if (ctx.hasUI) ctx.ui.setWidget("swarm", undefined);
418
473
  });
419
474
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.4.0",
3
+ "version": "0.5.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/cancel.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Stopping means stopping the children too.
3
+ *
4
+ * A run in this package is not one process: it is a tree of child agent
5
+ * sessions, each with its own provider connection. The tool that started them
6
+ * is handed an AbortSignal and the extension is told when the session goes
7
+ * away — and until now neither reached the children. Pressing Esc, or
8
+ * switching sessions with a run in flight, marked a record "aborted" while the
9
+ * children kept talking to the provider on the user's money, writing into a
10
+ * conversation nobody was reading.
11
+ *
12
+ * So every live child registers here, and the two places that mean "stop"
13
+ * abort all of them. (The rule is FradSer-adjacent prior art: @zhushanwen's
14
+ * subagent-workflow terminates running runs on session switch or shutdown
15
+ * rather than letting them outlive the session that owns them.)
16
+ */
17
+
18
+ /** The part of a child agent session this module needs. */
19
+ export interface Abortable {
20
+ abort(): unknown;
21
+ }
22
+
23
+ export type CancelReason = "user-abort" | "session-switch" | "timeout";
24
+
25
+ /**
26
+ * Live child sessions, grouped by the run that owns them. Registration
27
+ * returns its own release, so a child that finishes normally leaves no trace
28
+ * and cannot be aborted twice.
29
+ */
30
+ export class LiveChildren {
31
+ private byRun = new Map<string, Set<Abortable>>();
32
+
33
+ register(runId: string, child: Abortable): () => void {
34
+ let set = this.byRun.get(runId);
35
+ if (!set) {
36
+ set = new Set();
37
+ this.byRun.set(runId, set);
38
+ }
39
+ set.add(child);
40
+ return () => {
41
+ const current = this.byRun.get(runId);
42
+ if (!current) return;
43
+ current.delete(child);
44
+ if (current.size === 0) this.byRun.delete(runId);
45
+ };
46
+ }
47
+
48
+ /** How many children of this run are still live. */
49
+ count(runId: string): number {
50
+ return this.byRun.get(runId)?.size ?? 0;
51
+ }
52
+
53
+ /** Total live children across every run. */
54
+ total(): number {
55
+ let sum = 0;
56
+ for (const set of this.byRun.values()) sum += set.size;
57
+ return sum;
58
+ }
59
+
60
+ /**
61
+ * Abort every live child of one run and return how many were stopped. A
62
+ * child that throws from abort() is still counted and still dropped: the
63
+ * point is that nothing is left holding a connection, and one stubborn
64
+ * child must not spare the others.
65
+ */
66
+ abortRun(runId: string): number {
67
+ const set = this.byRun.get(runId);
68
+ if (!set) return 0;
69
+ let stopped = 0;
70
+ for (const child of [...set]) {
71
+ try {
72
+ const result = child.abort();
73
+ // abort() is async in pi; a rejection here is not ours to surface.
74
+ void Promise.resolve(result).catch(() => {});
75
+ } catch {
76
+ // already gone
77
+ }
78
+ stopped++;
79
+ }
80
+ this.byRun.delete(runId);
81
+ return stopped;
82
+ }
83
+
84
+ /** Abort every live child of every run. */
85
+ abortAll(): number {
86
+ let stopped = 0;
87
+ for (const runId of [...this.byRun.keys()]) stopped += this.abortRun(runId);
88
+ return stopped;
89
+ }
90
+ }
91
+
92
+ /** One line for the run log, naming who stopped it and what that cost. */
93
+ export function cancelNote(reason: CancelReason, stopped: number): string {
94
+ const children =
95
+ stopped === 0 ? "no child agents were running" : `${stopped} child agent${stopped === 1 ? "" : "s"} stopped`;
96
+ switch (reason) {
97
+ case "user-abort":
98
+ return `Cancelled by the user — ${children}. Work already finished is kept; the run itself did not complete.`;
99
+ case "session-switch":
100
+ return `The session went away, so the run was terminated — ${children}. Tokens already spent are not recoverable; start a new run for a result.`;
101
+ case "timeout":
102
+ return `The run exceeded its time limit — ${children}.`;
103
+ }
104
+ }
package/src/types.ts CHANGED
@@ -61,7 +61,11 @@ export interface ItemState {
61
61
  error: string | null;
62
62
  }
63
63
 
64
- export type RunStatus = "running" | "done";
64
+ /**
65
+ * "cancelled" is its own outcome, not a completion: someone stopped the run,
66
+ * and calling it done would report results nobody produced.
67
+ */
68
+ export type RunStatus = "running" | "done" | "cancelled";
65
69
 
66
70
  export interface SwarmRun {
67
71
  runId: string;