@pify/swarm 0.12.0 → 0.12.1

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
@@ -99,6 +99,7 @@ The catalog is the same `.pi/agents/*.md` one [`@pify/subagent`](https://github.
99
99
  ## Behaviour
100
100
 
101
101
  - **Independence by design.** Items share nothing, children cannot spawn children, and each child is capped at its agent's `max_turns` — and at 60 minutes of wall-clock, so a tool that never returns cannot hold a concurrency slot (or a blocking `swarm_run`) forever; such an item is reported as aborted with the reason.
102
+ - **What the children cost is shown.** Every child message's cost and tokens go to a process-wide tally; with [`@pify/usage`](https://github.com/pifydev/usage) installed the footer shows it as ` · agents $…` beside the session's own cost, which child sessions otherwise never reach.
102
103
  - **A report is the children's words, and is framed as such.** The aggregated report carries a one-line note that it is model output with no user authority, and no item's text can close the `<swarm_result>` wrapper early or contain a literal control tag such as `<system-reminder>` — the same neutralization memory and btw apply to their blocks.
103
104
  - **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.
104
105
  - **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.
@@ -38,6 +38,7 @@ import {
38
38
  } from "../src/consent.ts";
39
39
  import { LiveChildren, cancelNote, type CancelReason } from "../src/cancel.ts";
40
40
  import { outlasts, settleWithin } from "../src/deadline.ts";
41
+ import { addChildSpend } from "../src/child-cost.ts";
41
42
  import { DELIVERY_TYPE, deliveryMessage, pendingResult } from "../src/pending.ts";
42
43
  import { createIsolationWorktree, isolationNote, removeIfUnchanged } from "../src/isolate.ts";
43
44
  import {
@@ -292,7 +293,7 @@ export default function swarm(pi: ExtensionAPI) {
292
293
  event as {
293
294
  message?: {
294
295
  role?: string;
295
- usage?: { totalTokens?: number };
296
+ usage?: { totalTokens?: number; cost?: { total?: number } };
296
297
  content?: Array<{ type?: string; text?: string }>;
297
298
  };
298
299
  }
@@ -301,6 +302,9 @@ export default function swarm(pi: ExtensionAPI) {
301
302
  item.turns++;
302
303
  const usage = message.usage;
303
304
  if (usage && typeof usage.totalTokens === "number") item.tokens += usage.totalTokens;
305
+ // A child's spend never reaches the parent's branch; tell the
306
+ // suite-wide tally so @pify/usage can show it beside the session cost.
307
+ if (usage) addChildSpend("swarm", { cost: usage.cost?.total, tokens: usage.totalTokens });
304
308
 
305
309
  // Stop an item that is spinning — restating itself without acting —
306
310
  // rather than letting it run to the turn cap. See loop-guard.ts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
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",
@@ -0,0 +1,78 @@
1
+ /**
2
+ * What the children cost, across every @pify extension in the process.
3
+ *
4
+ * subagent, swarm and workflow run their children as separate in-memory pi
5
+ * sessions, so a child's spend never reaches the parent session's branch —
6
+ * and the usage footer, which folds the branch, under-reports every session
7
+ * that delegates. This is the one place they all add to and the footer reads.
8
+ *
9
+ * The state lives on `globalThis` under a cross-realm `Symbol.for` key, not
10
+ * in a module variable: pi's loader gives each extension its own module realm
11
+ * (jiti, `moduleCache: false`), so a module singleton would be a different
12
+ * object in every package that vendors this file. Same reasoning as
13
+ * ui-lock.ts. Vendored per package, byte-identical, zero dependencies; every
14
+ * function is safe to call whether or not any other package is installed.
15
+ */
16
+
17
+ const KEY = Symbol.for("pify.child-cost");
18
+
19
+ export interface ChildSpend {
20
+ /** USD, as pi priced each child message (usage.cost.total). */
21
+ cost: number;
22
+ /** usage.totalTokens summed over child messages. */
23
+ tokens: number;
24
+ }
25
+
26
+ interface Store {
27
+ bySource: Map<string, ChildSpend>;
28
+ listeners: Set<() => void>;
29
+ }
30
+
31
+ function store(): Store {
32
+ const g = globalThis as unknown as { [KEY]?: Store };
33
+ return (g[KEY] ??= { bySource: new Map(), listeners: new Set() });
34
+ }
35
+
36
+ /** Record one child message's spend under its package ("subagent", "swarm", "workflow"). Non-finite or negative parts are ignored. */
37
+ export function addChildSpend(source: string, spend: { cost?: number; tokens?: number }): void {
38
+ const cost = typeof spend.cost === "number" && Number.isFinite(spend.cost) && spend.cost > 0 ? spend.cost : 0;
39
+ const tokens =
40
+ typeof spend.tokens === "number" && Number.isFinite(spend.tokens) && spend.tokens > 0 ? Math.round(spend.tokens) : 0;
41
+ if (cost === 0 && tokens === 0) return;
42
+ const s = store();
43
+ const prev = s.bySource.get(source) ?? { cost: 0, tokens: 0 };
44
+ s.bySource.set(source, { cost: prev.cost + cost, tokens: prev.tokens + tokens });
45
+ for (const listener of s.listeners) {
46
+ try {
47
+ listener();
48
+ } catch {
49
+ // A footer that cannot redraw is not the child's problem.
50
+ }
51
+ }
52
+ }
53
+
54
+ /** Everything the children have spent this session, and which packages spent it. */
55
+ export function childSpendTotal(): ChildSpend & { sources: string[] } {
56
+ const s = store();
57
+ let cost = 0;
58
+ let tokens = 0;
59
+ for (const spend of s.bySource.values()) {
60
+ cost += spend.cost;
61
+ tokens += spend.tokens;
62
+ }
63
+ return { cost, tokens, sources: [...s.bySource.keys()].sort() };
64
+ }
65
+
66
+ /** Start a new tally — a new session's children, not the last one's. */
67
+ export function resetChildSpend(): void {
68
+ store().bySource.clear();
69
+ }
70
+
71
+ /** Be told after every addition; returns the unsubscribe. */
72
+ export function onChildSpend(listener: () => void): () => void {
73
+ const s = store();
74
+ s.listeners.add(listener);
75
+ return () => {
76
+ s.listeners.delete(listener);
77
+ };
78
+ }