@pify/swarm 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,16 +1,50 @@
1
1
  # @pify/swarm
2
2
 
3
- Coordinate multiple [pi](https://github.com/earendil-works/pi) agents working in parallel. One tool call fans a list of task items out to child agents — with auto-routing, a concurrency queue, a live widget, and one aggregated report.
3
+ Run many [pi](https://github.com/earendil-works/pi) agents in parallel. One tool call fans a list of independent items out to child agents — with per-item routing, a concurrency queue, a live widget, and one aggregated report.
4
4
 
5
5
  Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install swarm`](https://github.com/pifydev/cli) or `pi install npm:@pify/swarm`.
6
6
 
7
- ## What it does
7
+ ## Why
8
8
 
9
- - **`swarm_run`** — fan out 1–12 independent items to parallel children (4 at a time, rest queued). Blocking by default: returns `N done, M error` plus a per-item report. `background: true` returns a `runId` immediately.
10
- - **`swarm_status`** — live per-item progress (`1:scout=running(3t) · 2:reviewer=queued`), full report once finished; completed runs survive `/reload`.
11
- - **Per-item auto-routing**: agent definitions can declare `match_patterns` (globs matched against path-like tokens in the item — longest wins) and `match_keywords`. `review src/auth.rs` routes to your Rust auditor; `test the login flow` to your tester; everything else falls back to the read-only `scout` — the fallback never mutates. Force one type for all items with `agent`.
12
- - **Shared context**: the `context` param is prepended to every item, so common constraints are written once.
13
- - **One agent catalog**: reads the same `.pi/agents/*.md` definitions as [`@pify/subagent`](https://github.com/pifydev/subagent) (description/tools/model/thinking/max_turns), plus the two routing keys:
9
+ Some work is a list of things that do not depend on each other: audit twelve modules, summarise nine files, check every package for the same problem. Doing that in one conversation is slow and fills the context with material the main thread does not need. Doing it with twelve separate delegation calls is the same work typed twelve times.
10
+
11
+ The catch is that "independent" is usually a small lie — the items do not depend on each other's *results*, but they may touch the same files. That is what the mailbox and worktree isolation below are for.
12
+
13
+ ## Tools
14
+
15
+ ### `swarm_run`
16
+
17
+ | Parameter | Type | Notes |
18
+ |---|---|---|
19
+ | `items` | string[], 1–12 | One task per item; four run at a time, the rest queue |
20
+ | `context` | string, optional | Prepended to every item, so shared constraints are written once |
21
+ | `agent` | string, optional | Force one agent type for all items instead of routing |
22
+ | `isolation` | `"worktree"`, optional | Give each item its own git worktree — use it when items write |
23
+ | `mailbox` | boolean, optional | Give the children `swarm_post` / `swarm_inbox` |
24
+ | `background` | boolean, optional | Return a `runId` immediately instead of blocking |
25
+
26
+ Blocking by default: returns `N done, M error` plus a per-item report.
27
+
28
+ ### `swarm_status`
29
+
30
+ | Parameter | Type | Notes |
31
+ |---|---|---|
32
+ | `runId` | string, optional | Defaults to the most recent run |
33
+
34
+ Live per-item progress (`1:scout=running(3t) · 2:reviewer=queued`), and the full report once the run finishes. Completed runs survive `/reload`.
35
+
36
+ ### `swarm_post` / `swarm_inbox`
37
+
38
+ Registered for the children only, and only when `mailbox: true`.
39
+
40
+ - `swarm_post(message)` — tell the siblings something that changes their work: a shared file you modified, a convention you had to pick, a blocker they will hit too.
41
+ - `swarm_inbox()` — read what the others have posted since your last check.
42
+
43
+ Without it, parallel agents cannot see each other, so two of them cheerfully fix the same shared helper in two different ways. It is deliberately not a chat: no addressing, no waiting, no replies. An append-only log per run, and an agent never sees its own posts echoed back. A torn line from two simultaneous appends is skipped rather than failing the read.
44
+
45
+ ## Per-item routing
46
+
47
+ Agent definitions declare what they are for, and each item picks its own:
14
48
 
15
49
  ```markdown
16
50
  ---
@@ -21,23 +55,28 @@ match_keywords: rust, memory safety
21
55
  ---
22
56
  ```
23
57
 
24
- - **Independence by design**: items share nothing, children cannot spawn children, and each child is capped at its agent's `max_turns`.
58
+ `match_patterns` are globs matched against path-like tokens in the item — the longest match wins, so a specific rule beats a general one. `match_keywords` match the item's words. `review src/auth.rs` routes to the Rust auditor; `test the login flow` to a tester; anything matching nothing falls back to the read-only `scout`, so **the fallback can never mutate**.
25
59
 
26
- ## Where this sits in the suite
60
+ The catalog is the same `.pi/agents/*.md` one [`@pify/subagent`](https://github.com/pifydev/subagent) reads — `description`, `tools`, `model`, `thinking`, `max_turns` — plus the two routing keys. Project-local definitions load only once pi's project trust has been granted.
27
61
 
28
- `@pify/subagent` = one child, one task. `@pify/swarm` = many independent items at once. `@pify/workflow` = deterministic scripted orchestration. Pick the smallest one that fits.
62
+ ## Behaviour
29
63
 
30
- ## Mailbox (v0.3)
64
+ - **Independence by design.** Items share nothing, children cannot spawn children, and each child is capped at its agent's `max_turns`.
65
+ - **Stopping stops the children.** Pressing Esc, or switching away from the session, aborts every live child rather than leaving them talking to the provider on your money. A cancelled run keeps that verdict — it is never reported as done — and `swarm_status` shows what the items that did finish produced.
66
+ - **Isolated runs clean up after themselves.** With `isolation: "worktree"`, a worktree whose child changed nothing is removed along with its branch; otherwise a read-only step left one of each behind on every run. Anything uncommitted, and any commit the child made, is kept and reported.
31
67
 
32
- `swarm_run(items, { mailbox: true })` gives every agent two extra tools:
68
+ ## A background run comes back to you
33
69
 
34
- - `swarm_post(message)` — tell the siblings something that changes their work: a shared file you modified, a convention you had to pick, a blocker they will hit too.
35
- - `swarm_inbox()` — read what the others posted since your last check.
70
+ `swarm_status` on a run still in flight used to say "still running", which left the model one option: ask again. Now the aggregated report is **delivered** into the conversation when the run finishes, and asking early returns a structured result carrying `retryable`, the elapsed time and `pollRequired: false` — a normal answer rather than an error, because a tool error over a condition only time resolves invites the model's retry machinery into a loop.
36
71
 
37
- Without it, parallel agents cannot see each other, so two of them cheerfully fix the same shared helper in two different ways. It is deliberately not a chat: no addressing, no waiting, no replies — an append-only log per run, and an agent never sees its own posts echoed back. A torn line from two simultaneous appends is skipped rather than failing the read.
72
+ ## Command
73
+
74
+ `/swarm` — runs in this session, and the agent types available for routing.
75
+
76
+ ## Where this sits in the suite
77
+
78
+ [`@pify/subagent`](https://github.com/pifydev/subagent) is one child and one task. `@pify/swarm` is many independent items at once. [`@pify/workflow`](https://github.com/pifydev/workflow) is deterministic scripted orchestration for when the steps genuinely depend on each other. Pick the smallest one that fits.
38
79
 
39
80
  ## License
40
81
 
41
82
  MIT © [Pify maintainers](https://github.com/pifydev)
42
-
43
- **Isolated runs clean up after themselves** (v0.4): a worktree whose child changed nothing is removed along with its branch — otherwise a read-only step left one of each behind, per run. Anything uncommitted, or any commit the child made, is kept and reported.
@@ -25,6 +25,8 @@ 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";
29
+ import { DELIVERY_TYPE, deliveryMessage, pendingResult } from "../src/pending.ts";
28
30
  import { createIsolationWorktree, isolationNote, removeIfUnchanged } from "../src/isolate.ts";
29
31
  import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "../src/mailbox.ts";
30
32
  import { parseAgentFile } from "../src/frontmatter.ts";
@@ -77,6 +79,8 @@ function loadDefs(cwd: string, agentDir: string): Map<string, AgentDef> {
77
79
  export default function swarm(pi: ExtensionAPI) {
78
80
  let defs = new Map<string, AgentDef>();
79
81
  const runs = new Map<string, SwarmRun>();
82
+ /** Live child sessions per run, so a stop actually reaches the children. */
83
+ const live = new LiveChildren();
80
84
  let activeRun: SwarmRun | null = null;
81
85
  let runCounter = 0;
82
86
  let lastUiCtx: UiContext | null = null;
@@ -86,7 +90,7 @@ export default function swarm(pi: ExtensionAPI) {
86
90
  lastUiCtx = ctx;
87
91
  const run = activeRun;
88
92
  const now = Date.now();
89
- if (!run || (run.status === "done" && (run.finishedAt ?? 0) < now - 15_000)) {
93
+ if (!run || (run.status !== "running" && (run.finishedAt ?? 0) < now - 15_000)) {
90
94
  ctx.ui.setWidget("swarm", undefined);
91
95
  return;
92
96
  }
@@ -151,6 +155,7 @@ export default function swarm(pi: ExtensionAPI) {
151
155
 
152
156
  async function runItem(
153
157
  ctx: UiContext,
158
+ runId: string,
154
159
  def: AgentDef,
155
160
  item: ItemState,
156
161
  context: string,
@@ -161,6 +166,7 @@ export default function swarm(pi: ExtensionAPI) {
161
166
  renderWidget();
162
167
  let session: AgentSession | null = null;
163
168
  let unsubscribe: (() => void) | null = null;
169
+ let releaseLive: (() => void) | null = null;
164
170
  try {
165
171
  let model = ctx.model ?? null;
166
172
  if (def.model) {
@@ -198,6 +204,7 @@ export default function swarm(pi: ExtensionAPI) {
198
204
  }),
199
205
  });
200
206
  session = created.session;
207
+ releaseLive = live.register(runId, session);
201
208
 
202
209
  unsubscribe = session.subscribe((event) => {
203
210
  if (event.type === "message_end" && (event as { message?: { role?: string } }).message?.role === "assistant") {
@@ -232,6 +239,7 @@ export default function swarm(pi: ExtensionAPI) {
232
239
  item.status = "error";
233
240
  item.error = err instanceof Error ? err.message : String(err);
234
241
  } finally {
242
+ if (releaseLive) releaseLive();
235
243
  if (unsubscribe) {
236
244
  try {
237
245
  unsubscribe();
@@ -264,6 +272,9 @@ export default function swarm(pi: ExtensionAPI) {
264
272
  const queue = [...run.items];
265
273
  const workers = Array.from({ length: Math.min(DEFAULT_CONCURRENCY, queue.length) }, async () => {
266
274
  for (;;) {
275
+ // A cancelled run stops taking new items; the ones already in flight
276
+ // were aborted by cancelRun.
277
+ if (run.status === "cancelled") return;
267
278
  const item = queue.shift();
268
279
  if (!item) return;
269
280
  const def = routeItem(item.item, defs, fixed);
@@ -271,24 +282,43 @@ export default function swarm(pi: ExtensionAPI) {
271
282
  if (isolate) {
272
283
  try {
273
284
  const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
274
- await runItem(ctx, def, item, context, iso.path, mailbox);
285
+ await runItem(ctx, run.runId, def, item, context, iso.path, mailbox);
275
286
  if (item.result !== null) item.result = `${item.result}\n\n${isolationNote(iso)}`;
276
287
  } catch (err) {
277
288
  item.status = "error";
278
289
  item.error = err instanceof Error ? err.message : String(err);
279
290
  }
280
291
  } else {
281
- await runItem(ctx, def, item, context, undefined, mailbox);
292
+ await runItem(ctx, run.runId, def, item, context, undefined, mailbox);
282
293
  }
283
294
  }
284
295
  });
285
296
  await Promise.all(workers);
286
- run.status = "done";
297
+ if (run.status !== "cancelled") run.status = "done";
287
298
  run.finishedAt = Date.now();
288
299
  pi.appendEntry(RUN_ENTRY, run);
289
300
  renderWidget();
290
301
  }
291
302
 
303
+ /**
304
+ * Stop a run and every child it started. Both meanings of "stop" — the
305
+ * user's abort and session teardown — come through here.
306
+ */
307
+ function cancelRun(run: SwarmRun, reason: CancelReason): void {
308
+ const stopped = live.abortRun(run.runId);
309
+ if (run.status === "running") {
310
+ run.status = "cancelled";
311
+ run.finishedAt = Date.now();
312
+ }
313
+ for (const item of run.items) {
314
+ if (item.status === "running" || item.status === "queued") {
315
+ item.status = "aborted";
316
+ item.error = cancelNote(reason, stopped);
317
+ }
318
+ }
319
+ renderWidget();
320
+ }
321
+
292
322
  // ── Tools ────────────────────────────────────────────────────────────
293
323
 
294
324
  pi.registerTool({
@@ -323,7 +353,7 @@ export default function swarm(pi: ExtensionAPI) {
323
353
  isolation?: string;
324
354
  mailbox?: boolean;
325
355
  },
326
- _signal,
356
+ signal,
327
357
  _onUpdate,
328
358
  ctx,
329
359
  ) {
@@ -359,9 +389,36 @@ export default function swarm(pi: ExtensionAPI) {
359
389
  activeRun = run;
360
390
  renderWidget(uiCtx);
361
391
 
392
+ // Esc must reach the children. A background run outlives this tool call
393
+ // by design, so its signal is not its cancel button.
394
+ let stopListening: (() => void) | null = null;
395
+ if (signal && !run.background) {
396
+ const onAbort = () => cancelRun(run, "user-abort");
397
+ if (signal.aborted) onAbort();
398
+ else {
399
+ signal.addEventListener("abort", onAbort, { once: true });
400
+ stopListening = () => signal.removeEventListener("abort", onAbort);
401
+ }
402
+ }
403
+
362
404
  if (run.background) {
363
405
  void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true).then(() => {
364
- notify(uiCtx, `swarm ${run.runId} finished — collect with swarm_status`, "info");
406
+ notify(uiCtx, `swarm ${run.runId} finished`, "info");
407
+ // The report goes to the agent, not only to the screen — otherwise
408
+ // asking again was its only way to find out.
409
+ try {
410
+ pi.sendMessage(
411
+ {
412
+ customType: DELIVERY_TYPE,
413
+ content: deliveryMessage(run.runId, "swarm", buildReport(run)),
414
+ display: true,
415
+ details: { runId: run.runId, status: run.status, items: run.items.length },
416
+ },
417
+ { deliverAs: "followUp", triggerTurn: true },
418
+ );
419
+ } catch {
420
+ // Delivery is a convenience; swarm_status still works.
421
+ }
365
422
  });
366
423
  return {
367
424
  content: [
@@ -371,7 +428,11 @@ export default function swarm(pi: ExtensionAPI) {
371
428
  };
372
429
  }
373
430
 
374
- await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
431
+ try {
432
+ await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
433
+ } finally {
434
+ if (stopListening) stopListening();
435
+ }
375
436
  return {
376
437
  content: [{ type: "text", text: buildReport(run) }],
377
438
  details: { runId: run.runId },
@@ -389,7 +450,23 @@ export default function swarm(pi: ExtensionAPI) {
389
450
  async execute(_id, params: { runId?: string }) {
390
451
  const run = params.runId ? runs.get(params.runId.trim()) : activeRun ?? [...runs.values()].pop();
391
452
  if (!run) throw new Error("No swarm runs this session.");
392
- const text = run.status === "done" ? buildReport(run) : buildStatusLine(run);
453
+ if (run.status === "running") {
454
+ const pending = pendingResult({
455
+ id: run.runId,
456
+ kind: "running",
457
+ startedAt: run.startedAt,
458
+ now: Date.now(),
459
+ collectWith: "swarm_status",
460
+ });
461
+ return { content: [{ type: "text", text: pending.text }], details: pending.details as never };
462
+ }
463
+ const text =
464
+ run.status === "cancelled"
465
+ ? "This run was cancelled before it finished. Below is what the items that did complete produced.\n" +
466
+ buildReport(run)
467
+ : run.status === "done"
468
+ ? buildReport(run)
469
+ : buildStatusLine(run);
393
470
  return { content: [{ type: "text", text }], details: { runId: run.runId, status: run.status } };
394
471
  },
395
472
  });
@@ -404,7 +481,7 @@ export default function swarm(pi: ExtensionAPI) {
404
481
  const e = entry as { type?: string; customType?: string; data?: unknown };
405
482
  if (e.type !== "custom" || e.customType !== RUN_ENTRY || !isRecord(e.data)) continue;
406
483
  const run = e.data as unknown as SwarmRun;
407
- if (typeof run.runId === "string" && run.status === "done") {
484
+ if (typeof run.runId === "string" && run.status !== "running") {
408
485
  runs.set(run.runId, run);
409
486
  const n = Number.parseInt(run.runId.slice(1), 10);
410
487
  if (Number.isFinite(n) && n > runCounter) runCounter = n;
@@ -414,6 +491,10 @@ export default function swarm(pi: ExtensionAPI) {
414
491
  });
415
492
 
416
493
  pi.on("session_shutdown", async (_event, ctx) => {
494
+ // A run cannot outlive the session that owns it.
495
+ for (const run of runs.values()) {
496
+ if (run.status === "running") cancelRun(run, "session-switch");
497
+ }
417
498
  if (ctx.hasUI) ctx.ui.setWidget("swarm", undefined);
418
499
  });
419
500
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Coordinate multiple pi agents in parallel: swarm_run fan-out with per-item auto-routing, concurrency queue, aggregated reports",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/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/pending.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Telling an agent to wait, without teaching it to poll.
3
+ *
4
+ * A background run gave the model exactly one way to find out it had
5
+ * finished: call the status tool again. "Still running — call agent_result
6
+ * later" is an instruction to spin, and models follow it, burning a turn and a
7
+ * request per check while the thing they are waiting for has not moved.
8
+ *
9
+ * Two halves fix that, and only together:
10
+ *
11
+ * - a not-ready answer that is a normal structured result rather than an
12
+ * error, carrying `retryable` and saying what to do *instead* of waiting.
13
+ * Throwing would be worse than useless — a tool error invites the model's
14
+ * own retry machinery into a loop over a condition that time, not
15
+ * retrying, resolves;
16
+ * - a push when the run actually finishes, so waiting is never the only
17
+ * option on the table.
18
+ *
19
+ * Pure: the shapes and the words. The extension owns the clock and the host.
20
+ */
21
+
22
+ export type PendingKind = "queued" | "running";
23
+
24
+ export interface PendingInput {
25
+ /** The id the caller would poll with. */
26
+ id: string;
27
+ kind: PendingKind;
28
+ startedAt: number;
29
+ now: number;
30
+ /** What the caller asks for to collect it, e.g. `agent_result`. */
31
+ collectWith: string;
32
+ }
33
+
34
+ export interface PendingResult {
35
+ text: string;
36
+ details: {
37
+ id: string;
38
+ status: PendingKind;
39
+ /** True: this will resolve on its own. It is a wait, not a failure. */
40
+ retryable: boolean;
41
+ elapsedMs: number;
42
+ /** False, and load-bearing: there is nothing to poll for. */
43
+ pollRequired: false;
44
+ };
45
+ }
46
+
47
+ function elapsed(ms: number): string {
48
+ if (ms < 1000) return "just started";
49
+ const seconds = Math.round(ms / 1000);
50
+ if (seconds < 60) return `${seconds}s so far`;
51
+ return `${Math.floor(seconds / 60)}m ${seconds % 60}s so far`;
52
+ }
53
+
54
+ /**
55
+ * The answer to "is it done yet". It says no, says why that is fine, and
56
+ * closes the loop the question came from.
57
+ */
58
+ export function pendingResult(input: PendingInput): PendingResult {
59
+ const ms = Math.max(0, input.now - input.startedAt);
60
+ const state = input.kind === "queued" ? "queued behind the concurrency cap" : "still running";
61
+ return {
62
+ text: [
63
+ `${input.id} is ${state} (${elapsed(ms)}).`,
64
+ "",
65
+ "Do not poll for it. The result is delivered to you automatically the moment it lands,",
66
+ `so there is nothing to wait for here — carry on with other work, or finish your turn and`,
67
+ `you will be picked back up. ${input.collectWith} is only needed if you want it early.`,
68
+ ].join("\n"),
69
+ details: { id: input.id, status: input.kind, retryable: true, elapsedMs: ms, pollRequired: false },
70
+ };
71
+ }
72
+
73
+ /** How a finished run introduces itself when it arrives unasked. */
74
+ export function deliveryMessage(id: string, label: string, body: string): string {
75
+ return [
76
+ `<${label}_result id="${id}">`,
77
+ body.trim(),
78
+ `</${label}_result>`,
79
+ "",
80
+ `This is ${id}, which you started in the background; it has just finished and this is its report.`,
81
+ "Fold it into what you are doing. If you had already moved on, say what it changes — or that it changes nothing.",
82
+ ].join("\n");
83
+ }
84
+
85
+ /** The custom-message type a delivered result travels under. */
86
+ export const DELIVERY_TYPE = "pify-background-result";
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;