agent-horde 0.2.1 → 0.3.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
@@ -90,7 +90,7 @@ pi install npm:agent-horde
90
90
  Or from the repository, pinned to a tag:
91
91
 
92
92
  ```
93
- pi install git:github.com/eliyastein/agent-horde@v0.2.1
93
+ pi install git:github.com/eliyastein/agent-horde@v0.3.0
94
94
  ```
95
95
 
96
96
  To hack on it, clone and install in place. pi loads the files from the clone, so edits take effect
@@ -137,6 +137,16 @@ extend, stress-test, or deepen the work.
137
137
  /agent-horde --dashboard --until-stop 8 find every way an unauthenticated request reaches the database
138
138
  ```
139
139
 
140
+ `--budget=<usd>` puts a dollar ceiling on a run: once the members' combined spend, counted across
141
+ every session they have had, reaches it, the parent performs a clean stop on its own and the
142
+ report says `stopped at budget`. It pairs with `--until-stop` to bound an open-ended run. The
143
+ spend counted is what the members' model calls report; the planner, deliverable and post-mortem
144
+ helpers are not included, and members still get the clean-stop grace period to wrap up.
145
+
146
+ ```
147
+ /agent-horde --until-stop --budget=20 8 find every way an unauthenticated request reaches the database
148
+ ```
149
+
140
150
  A count of `H/M` runs a swarm: H hordes of M members on one task, at most four hordes. A planner
141
151
  helper first splits the task into H disjoint workstreams with acceptance criteria and each horde
142
152
  works only its slice, with its own ledgers under `.horde/swarms/<id>/hN/`; if the planner cannot
@@ -145,7 +155,8 @@ the planner and deals the layers round-robin to the hordes. Members read the oth
145
155
  rule-outs and resources through the `horde` tool but write only to their own horde. The hordes
146
156
  start a few seconds apart, run the normal lifecycle independently, and when the last one has
147
157
  finished a synthesis helper merges their deliverables into the swarm's `DELIVERABLE.md`, each
148
- claim attributed to its horde. `--until-stop` applies to every horde.
158
+ claim attributed to its horde. `--until-stop` applies to every horde, and `--budget` is one ceiling
159
+ for all hordes combined.
149
160
 
150
161
  ```
151
162
  /agent-horde --dashboard=1234 3/12 audit the service for authorization bugs
package/docs/REFERENCE.md CHANGED
@@ -7,7 +7,7 @@ at one problem beat a single linear one: research, audits, design exploration, R
7
7
  ## Usage
8
8
 
9
9
  ```
10
- /agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] <count> <prompt>
10
+ /agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] [--budget=<usd>] <count> <prompt>
11
11
  /agent-horde plan [--dashboard] <count> <layer; layer; ...>
12
12
  /agent-horde status [target]
13
13
  /agent-horde stop-hard [target] SIGTERM, SIGKILL after 5s (alias: stop)
@@ -68,7 +68,14 @@ unfinished ideas return to the open pool.
68
68
  A clean stop still produces the deliverable; add `postmortem` to also run the reviewer. Runs
69
69
  that end on their own (all members finished, solved, canary or provider failure) get both.
70
70
  `PI_HORDE_MAX_MINUTES` forces a clean stop at a wall-clock deadline, reported as
71
- `stopped at deadline`.
71
+ `stopped at deadline`. `--budget=<usd>` forces one when the members' combined spend reaches that
72
+ many dollars, reported as `stopped at budget`; in a swarm the ceiling covers every horde and every
73
+ horde still working is stopped. The check runs on every usage update a member reports, so it fires
74
+ on the model call that crosses the line, during discovery or repair alike. The spend counted is the
75
+ members' own, summed over every session a member slot has had (resets, continuations and repair
76
+ attempts included); the planner, deliverable, post-mortem and merge helpers are not counted, and
77
+ the clean-stop grace period lets members finish, so the final total overshoots the ceiling by what
78
+ the wind-down costs.
72
79
 
73
80
  Members and helper agents are children of the pi session. Quitting or reloading pi sends every
74
81
  live child SIGTERM, and each child polls `PI_HORDE_PARENT_PID` every two seconds and exits on its
@@ -303,7 +310,11 @@ resets in the report. A session that finished within `PI_HORDE_CONTINUE_COOLDOWN
303
310
  60000) of starting waits out the remainder first, so a member that finds nothing to do cannot spin.
304
311
  Crash and stall resets apply as usual. `stop-clean` and `stop-hard` end the run as usual and
305
312
  `stop-clean` produces the report, deliverable, and postmortem; the last banked answer stands unless
306
- a later proposal was validated too.
313
+ a later proposal was validated too. `--budget=<usd>` bounds such a run by spend (see Stopping).
314
+
315
+ Usage totals in the widget, status, dashboard and reports are summed over every session each
316
+ member slot has had, so a reset or continuation never hides what the replaced session spent. The
317
+ per-session figures stay in the `member-exit` events and the per-member report sections.
307
318
 
308
319
  ## Horde of hordes
309
320
 
@@ -456,6 +467,7 @@ run/ the parent-side run lifecycle, sharing one HordeState
456
467
  swarm-report.ts per-horde totals: the swarm REPORT.md and the injected message
457
468
  launch.ts dashboard, deadline, partition, staggered canary preflights, fan-out
458
469
  supervise.ts member spawn, reset on exit, stall detection
470
+ budget.ts `--budget`: swarm-wide spend check, clean stop when it is reached
459
471
  schedule.ts artifact review assignment and superseded-dependency handoffs
460
472
  repair.ts bounded post-discovery reviews, owner repair, and recheck attempts
461
473
  solved.ts two-phase SOLVED poll
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-horde",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Run a horde of pi agents on one task: shared ledgers, peer review, an auditable event log, and a live dashboard",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,7 +50,7 @@
50
50
  "typebox": "*"
51
51
  },
52
52
  "devDependencies": {
53
- "@earendil-works/pi-coding-agent": "1.2.0",
53
+ "@earendil-works/pi-coding-agent": "1.5.2",
54
54
  "@types/node": "26.6.5",
55
55
  "oxlint": "1.88.0",
56
56
  "typescript": "7.0.2"
package/src/args.ts CHANGED
@@ -4,7 +4,7 @@ export const MAX_MEMBERS = 12;
4
4
  export const MAX_HORDES = 4;
5
5
 
6
6
  export const USAGE = [
7
- "usage: /agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] <count> <prompt>",
7
+ "usage: /agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] [--budget=<usd>] <count> <prompt>",
8
8
  " spawn <count> members with the same prompt;",
9
9
  " <count> may be H/M: H hordes of M members, the",
10
10
  " task partitioned between the hordes by a planner",
@@ -15,7 +15,7 @@ export const USAGE = [
15
15
  " additionally run a post-mortem on the run",
16
16
  " /agent-horde pause [target] suspend members (SIGSTOP)",
17
17
  " /agent-horde resume [target] resume suspended members (SIGCONT)",
18
- ` /agent-horde plan [--dashboard] <count> <a; b; ...>`,
18
+ " /agent-horde plan [--dashboard[=<port>]] [--no-verifier] [--until-stop] [--budget=<usd>] <count> <a; b; ...>",
19
19
  " partition members across workstreams; with H/M",
20
20
  " the layers go round-robin to the hordes",
21
21
  " /agent-horde help show this text",
@@ -30,6 +30,8 @@ export const USAGE = [
30
30
  "--until-stop keeps the whole horde working until you run stop-clean or stop-hard: a",
31
31
  "validated solve is banked as the current answer and a member that finishes gets a fresh",
32
32
  "session told to add new ideas.",
33
+ "--budget=<usd> clean-stops the run (every horde of a swarm) once the members' combined",
34
+ "spend reaches that many dollars.",
33
35
  ].join("\n");
34
36
 
35
37
  /** One token of a control target: a horde, a member in every horde, or a member of one horde. */
@@ -72,6 +74,8 @@ type HordeArgs =
72
74
  noVerifier?: boolean | undefined;
73
75
  /** Present (true) only when `--until-stop` was given. */
74
76
  untilStop?: boolean | undefined;
77
+ /** Dollar spend at which the run clean-stops itself; present only when `--budget` was given. */
78
+ budget?: number | undefined;
75
79
  };
76
80
 
77
81
  export type HordeRunArgs = Extract<HordeArgs, { kind: "run" }>;
@@ -155,7 +159,9 @@ function parseStopRest(
155
159
  const DASHBOARD_FLAG = /^--dashboard(?:=(\S*))?(?=\s|$)/;
156
160
  const NO_VERIFIER_FLAG = /^--no-verifier(?=\s|$)/;
157
161
  const UNTIL_STOP_FLAG = /^--until-stop(?=\s|$)/;
162
+ const BUDGET_FLAG = /^--budget(?:=(\S*)|\s+(\S+))?(?=\s|$)/;
158
163
  const DASHBOARD_ERROR = "invalid --dashboard; use --dashboard or --dashboard=<port 1-65535>";
164
+ const BUDGET_ERROR = "invalid --budget; use --budget=<usd>, a positive dollar amount";
159
165
 
160
166
  /** Launch flags found at one position of the command; `rest` is the remaining text. */
161
167
  interface LaunchFlags {
@@ -163,6 +169,7 @@ interface LaunchFlags {
163
169
  dashboard?: DashboardOption | undefined;
164
170
  noVerifier?: boolean | undefined;
165
171
  untilStop?: boolean | undefined;
172
+ budget?: number | undefined;
166
173
  error?: string | undefined;
167
174
  }
168
175
 
@@ -174,7 +181,14 @@ function parseDashboardFlag(port: string | undefined): DashboardOption | { error
174
181
  return { port: value };
175
182
  }
176
183
 
177
- /** Strip leading `--dashboard[=<port>]`, `--no-verifier`, and `--until-stop` tokens, in any order. */
184
+ function parseBudgetFlag(amount: string | undefined): number | { error: string } {
185
+ if (amount === undefined || !/^\d+(?:\.\d+)?$/.test(amount)) return { error: BUDGET_ERROR };
186
+ const value = Number(amount);
187
+ if (value <= 0) return { error: BUDGET_ERROR };
188
+ return value;
189
+ }
190
+
191
+ /** Strip leading `--dashboard[=<port>]`, `--no-verifier`, `--until-stop` and `--budget` tokens, in any order. */
178
192
  function takeLaunchFlags(text: string): LaunchFlags {
179
193
  const flags: LaunchFlags = { rest: text.trim() };
180
194
  for (;;) {
@@ -186,6 +200,14 @@ function takeLaunchFlags(text: string): LaunchFlags {
186
200
  flags.rest = flags.rest.slice(dashboard[0].length).trim();
187
201
  continue;
188
202
  }
203
+ const budget = BUDGET_FLAG.exec(flags.rest);
204
+ if (budget) {
205
+ const value = parseBudgetFlag(budget[1] ?? budget[2]);
206
+ if (typeof value !== "number") return { ...flags, error: value.error };
207
+ flags.budget = value;
208
+ flags.rest = flags.rest.slice(budget[0].length).trim();
209
+ continue;
210
+ }
189
211
  const noVerifier = NO_VERIFIER_FLAG.exec(flags.rest);
190
212
  if (noVerifier) {
191
213
  flags.noVerifier = true;
@@ -209,6 +231,7 @@ function mergeLaunchFlags(leading: LaunchFlags, trailing: LaunchFlags): LaunchFl
209
231
  dashboard: leading.dashboard ?? trailing.dashboard,
210
232
  noVerifier: leading.noVerifier === true || trailing.noVerifier === true ? true : undefined,
211
233
  untilStop: leading.untilStop === true || trailing.untilStop === true ? true : undefined,
234
+ budget: leading.budget ?? trailing.budget,
212
235
  error: leading.error ?? trailing.error,
213
236
  };
214
237
  }
@@ -242,6 +265,7 @@ function runArgs(parsed: CountedRun, prompt: string, plan?: string[]): HordeRunA
242
265
  if (flags.dashboard) args.dashboard = flags.dashboard;
243
266
  if (flags.noVerifier) args.noVerifier = true;
244
267
  if (flags.untilStop) args.untilStop = true;
268
+ if (flags.budget !== undefined) args.budget = flags.budget;
245
269
  return args;
246
270
  }
247
271
 
@@ -269,7 +293,9 @@ export function parseHordeArgs(raw: string): HordeArgs {
269
293
  const leading = takeLaunchFlags(raw);
270
294
  if (leading.error) return { kind: "usage", error: leading.error };
271
295
  const trimmed = leading.rest;
272
- const hasFlags = leading.dashboard !== undefined || leading.noVerifier === true || leading.untilStop === true;
296
+ const hasFlags =
297
+ leading.dashboard !== undefined || leading.noVerifier === true || leading.untilStop === true ||
298
+ leading.budget !== undefined;
273
299
  if (trimmed === "") {
274
300
  return hasFlags ? { kind: "usage", error: "count is required" } : { kind: "usage" };
275
301
  }
@@ -26,7 +26,7 @@ import { buildRunGraph } from "./run-graph.js";
26
26
  import { buildSwarmGraph } from "./swarm-graph.js";
27
27
  import type { RunGraph } from "./run-graph.js";
28
28
  import { readCleanStop } from "./ledger/run-signals.js";
29
- import { memberSessionDir, parseMemberLog } from "./member.js";
29
+ import { emptyUsage, memberSessionDir, parseMemberLog, USAGE_KEYS } from "./member.js";
30
30
  import type { MemberEndCause, MemberState, MemberStatus, MemberUsage } from "./member.js";
31
31
  import { memberActivity, parseReportOutcome, parseTaskMarkdown, statusCounts, totalUsage } from "./prompt.js";
32
32
  import type { RunSummaryInput, UsageTotals } from "./prompt.js";
@@ -72,6 +72,7 @@ export interface LiveSwarm {
72
72
  runs: LiveRun[];
73
73
  partition?: string[] | undefined;
74
74
  untilStop?: boolean | undefined;
75
+ budget?: number | undefined;
75
76
  /** The swarm step still running after every horde finalized. */
76
77
  finalizing?: string[] | undefined;
77
78
  deliverable?: string | undefined;
@@ -383,6 +384,7 @@ interface SwarmRecordOnDisk {
383
384
  count?: number;
384
385
  startedAt?: number;
385
386
  untilStop?: boolean;
387
+ budget?: number;
386
388
  partition?: string[];
387
389
  runs?: Array<{ label?: string; runId?: string; startedAt?: number; endedAt?: number; outcome?: string }>;
388
390
  merge?: { startedAt?: number; endedAt?: number; written?: boolean };
@@ -500,7 +502,7 @@ function liveMemberRow(m: MemberState, now: number, runDir: string): MemberRowVm
500
502
  paused: m.paused === true,
501
503
  turns: m.turns,
502
504
  toolCalls: m.toolCalls,
503
- usage: { ...m.usage },
505
+ usage: { ...m.lifetime },
504
506
  elapsedMs: (m.endedAt ?? now) - m.startedAt,
505
507
  endCause: m.endCause,
506
508
  partial: m.partial === true,
@@ -521,10 +523,28 @@ function safeSessionStats(filePath: string): SessionStats | undefined {
521
523
  }
522
524
  }
523
525
 
526
+ /** Stats over every session file of a member (resets and continuations leave one each); oversized files are skipped. */
527
+ function memberSessionStats(runDir: string, name: string): SessionStats | undefined {
528
+ const dir = memberSessionDir(runDir, name);
529
+ const all = listSessionFiles(dir).map((file) => safeSessionStats(path.join(dir, file.name)));
530
+ const parts = all.filter((stats): stats is SessionStats => stats !== undefined);
531
+ if (parts.length === 0) return undefined;
532
+ const total: SessionStats = { entries: 0, turns: 0, toolCalls: 0, usage: emptyUsage() };
533
+ for (const stats of parts) {
534
+ total.entries += stats.entries;
535
+ total.turns += stats.turns;
536
+ total.toolCalls += stats.toolCalls;
537
+ for (const key of USAGE_KEYS) total.usage[key] += stats.usage[key];
538
+ if (stats.first && (!total.first || stats.first < total.first)) total.first = stats.first;
539
+ if (stats.last && (!total.last || stats.last > total.last)) total.last = stats.last;
540
+ }
541
+ return total;
542
+ }
543
+
524
544
  function diskMemberRow(runDir: string, name: string): MemberRowVm {
525
545
  const log = parseMemberLog(readOptional(path.join(runDir, `${name}.log`)) ?? "");
526
546
  const sessionFile = memberSessionFile(runDir, name);
527
- const stats = sessionFile ? safeSessionStats(sessionFile) : undefined;
547
+ const stats = memberSessionStats(runDir, name);
528
548
  const elapsedMs =
529
549
  stats?.first && stats.last ? Date.parse(stats.last) - Date.parse(stats.first) : undefined;
530
550
  return {
@@ -535,7 +555,7 @@ function diskMemberRow(runDir: string, name: string): MemberRowVm {
535
555
  paused: false,
536
556
  turns: log.turns ?? stats?.turns ?? 0,
537
557
  toolCalls: log.toolCalls ?? stats?.toolCalls ?? 0,
538
- usage: stats?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
558
+ usage: stats?.usage ?? emptyUsage(),
539
559
  elapsedMs,
540
560
  endCause: log.endCause,
541
561
  partial: log.partial,
@@ -572,6 +592,8 @@ export interface RunOverviewVm {
572
592
  outcome?: string | undefined;
573
593
  stopLabel?: string | undefined;
574
594
  cleanStop?: { secondsLeft?: number | undefined; members: string[] } | undefined;
595
+ /** `--budget` in dollars; the header shows `totals.cost` against it. */
596
+ budget?: number | undefined;
575
597
  plan?: string[] | undefined;
576
598
  canary?: string | undefined;
577
599
  canaryError?: string | undefined;
@@ -617,6 +639,7 @@ function liveOverview(src: DashboardSource, live: LiveRun): RunOverviewVm {
617
639
  outcome: liveOutcome(live),
618
640
  stopLabel: live.stopLabel,
619
641
  cleanStop: liveCleanStop(live, now),
642
+ budget: live.budget,
620
643
  plan: live.plan,
621
644
  canary: live.canary,
622
645
  canaryError: live.canaryError,
@@ -658,6 +681,7 @@ function diskOverview(runId: string, runDir: string): RunOverviewVm {
658
681
  elapsedMs: elapsed.length > 0 ? Math.max(...elapsed) : undefined,
659
682
  outcome: runStateOnDisk(runDir) === "pending" ? "pending" : diskOutcome(runDir),
660
683
  cleanStop: cleanStop ? { members: cleanStop.members } : undefined,
684
+ budget: task.budget,
661
685
  plan: readOptional(path.join(runDir, "PLAN.md"))
662
686
  ?.split("\n")
663
687
  .filter((line) => line.startsWith("- layer ")),
@@ -1095,6 +1119,8 @@ export interface SwarmVm {
1095
1119
  started?: string | undefined;
1096
1120
  elapsedMs?: number | undefined;
1097
1121
  untilStop: boolean;
1122
+ /** `--budget` in dollars across every horde; the header shows the hordes' summed cost against it. */
1123
+ budget?: number | undefined;
1098
1124
  partition: string[];
1099
1125
  /** What the merge step is doing or did: "pending", "merging", "merged", "no deliverable". */
1100
1126
  merge: string;
@@ -1175,6 +1201,7 @@ export function loadSwarm(src: DashboardSource, swarmId: string): SwarmVm {
1175
1201
  started: startedAt !== undefined ? formatDateTime(new Date(startedAt)) : undefined,
1176
1202
  elapsedMs: startedAt !== undefined ? (endedAt ?? nowOf(src)) - startedAt : undefined,
1177
1203
  untilStop: live?.untilStop === true || record?.untilStop === true,
1204
+ budget: live?.budget ?? record?.budget,
1178
1205
  partition,
1179
1206
  merge: mergeLabel(live, record, rows),
1180
1207
  rows,
@@ -456,6 +456,7 @@ function overviewHeader(vm: RunOverviewVm): string {
456
456
  const left = vm.cleanStop.secondsLeft === undefined ? "" : `hard stop in ${vm.cleanStop.secondsLeft}s: `;
457
457
  rows.push(["clean stop", e(`${left}${who}`)]);
458
458
  }
459
+ if (vm.budget !== undefined) rows.push(["budget", `${cost(vm.totals.cost)} of ${cost(vm.budget)} spent`]);
459
460
  if (vm.canary) rows.push(["canary", e(vm.canary + (vm.canaryError ? ` (${vm.canaryError})` : ""))]);
460
461
  if (vm.diagnosis) rows.push(["diagnosis", e(vm.diagnosis)]);
461
462
  if (vm.plan && vm.plan.length > 0) rows.push(["plan", pre(vm.plan.join("\n"))]);
@@ -981,6 +982,9 @@ function swarmHeader(vm: SwarmVm): string {
981
982
  ["until stop", vm.untilStop ? "yes" : "no"],
982
983
  ["merge", e(vm.merge)],
983
984
  ];
985
+ if (vm.budget !== undefined) {
986
+ rows.push(["budget", `${cost(vm.rows.reduce((sum, row) => sum + row.totals.cost, 0))} of ${cost(vm.budget)} spent`]);
987
+ }
984
988
  if (vm.notStarted) rows.push(["not started", e(vm.notStarted)]);
985
989
  if (vm.partition.length > 0) {
986
990
  rows.push(["partition", pre(vm.partition.map((slice, index) => `h${index + 1}: ${slice}`).join("\n"))]);
package/src/format.ts CHANGED
@@ -43,6 +43,11 @@ export function formatTokens(n: number): string {
43
43
  return `${(n / 1_000_000).toFixed(2)}M`;
44
44
  }
45
45
 
46
+ /** A dollar amount for the widget, status and reports, e.g. `$1.20`. */
47
+ export function formatUsd(amount: number): string {
48
+ return `$${amount.toFixed(2)}`;
49
+ }
50
+
46
51
  export function oneLine(text: string, max: number): string {
47
52
  const collapsed = text.replace(/\s+/g, " ").trim();
48
53
  if (collapsed.length <= max) return collapsed;
package/src/member.ts CHANGED
@@ -35,7 +35,10 @@ export interface MemberState {
35
35
  streaming?: "text" | "thinking" | undefined;
36
36
  turns: number;
37
37
  toolCalls: number;
38
+ /** Usage of the current session; zeroed by every reset, continuation and repair attempt. */
38
39
  usage: MemberUsage;
40
+ /** Usage across every session of this member slot; never reset. Totals and the budget read it. */
41
+ lifetime: MemberUsage;
39
42
  lastText: string;
40
43
  finalText: string;
41
44
  /** In-progress assistant text retained if termination interrupts the final message. */
@@ -120,6 +123,7 @@ interface SpawnMemberInput {
120
123
  onFirstOutput?: () => void;
121
124
  }
122
125
 
126
+ export const USAGE_KEYS = ["input", "output", "cacheRead", "cacheWrite", "cost"] as const;
123
127
  const LAST_TEXT_CHARS = 80;
124
128
  const STDERR_TAIL_LINES = 20;
125
129
  const KILL_GRACE_MS = 5_000;
@@ -254,13 +258,18 @@ export function newMemberState(index: number): MemberState {
254
258
  status: "starting",
255
259
  turns: 0,
256
260
  toolCalls: 0,
257
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
261
+ usage: emptyUsage(),
262
+ lifetime: emptyUsage(),
258
263
  lastText: "",
259
264
  finalText: "",
260
265
  startedAt: Date.now(),
261
266
  };
262
267
  }
263
268
 
269
+ export function emptyUsage(): MemberUsage {
270
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
271
+ }
272
+
264
273
  /** Reset a member to its initial state so it can be respawned (canary retry, stall reset). */
265
274
  export function resetMemberState(member: MemberState): void {
266
275
  member.status = "starting";
@@ -268,7 +277,7 @@ export function resetMemberState(member: MemberState): void {
268
277
  member.attempt = randomUUID();
269
278
  member.turns = 0;
270
279
  member.toolCalls = 0;
271
- member.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
280
+ member.usage = emptyUsage();
272
281
  member.lastText = "";
273
282
  member.finalText = "";
274
283
  member.draftText = undefined;
@@ -419,11 +428,17 @@ function applyAssistantMessageEnd(member: MemberState, message: Record<string, u
419
428
  member.streaming = undefined;
420
429
  const usage = (message.usage ?? {}) as Record<string, unknown>;
421
430
  const num = (v: unknown): number => (typeof v === "number" ? v : 0);
422
- member.usage.input += num(usage.input);
423
- member.usage.output += num(usage.output);
424
- member.usage.cacheRead += num(usage.cacheRead);
425
- member.usage.cacheWrite += num(usage.cacheWrite);
426
- member.usage.cost += num((usage.cost as Record<string, unknown> | undefined)?.total);
431
+ const delta: MemberUsage = {
432
+ input: num(usage.input),
433
+ output: num(usage.output),
434
+ cacheRead: num(usage.cacheRead),
435
+ cacheWrite: num(usage.cacheWrite),
436
+ cost: num((usage.cost as Record<string, unknown> | undefined)?.total),
437
+ };
438
+ for (const key of USAGE_KEYS) {
439
+ member.usage[key] += delta[key];
440
+ member.lifetime[key] += delta[key];
441
+ }
427
442
  const text = messageText(message);
428
443
  if (text.trim()) {
429
444
  member.finalText = text;
package/src/prompt.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { formatClock, formatDateTime, formatElapsed, formatTokens, oneLine, taskHash } from "./format.js";
1
+ import { formatClock, formatDateTime, formatElapsed, formatTokens, formatUsd, oneLine, taskHash } from "./format.js";
2
2
  import type { BoardEntry } from "./ledger/board.js";
3
3
  import type { ClaimEntry } from "./ledger/claims.js";
4
4
  import type { FindingCluster } from "./ledger/clusters.js";
@@ -26,6 +26,8 @@ export interface RunSummaryInput {
26
26
  userStop?: boolean | undefined;
27
27
  /** Human-readable stop reason, e.g. "stopped by user" or "stopped at deadline". */
28
28
  stopLabel?: string | undefined;
29
+ /** The `--budget` in dollars the run (or its swarm) clean-stops at. */
30
+ budget?: number | undefined;
29
31
  /** Set when a member reported the goal solved; the end sentence states whether the run wound down on it. */
30
32
  solved?: SolvedSignal | undefined;
31
33
  /** The other member that validated the proposal, if any. */
@@ -299,10 +301,16 @@ export function taskMarkdown(input: {
299
301
  startedAt: number;
300
302
  verifier?: string | undefined;
301
303
  untilStop?: boolean | undefined;
304
+ /** `--budget` in dollars; a swarm's budget covers every horde. */
305
+ budget?: number | undefined;
302
306
  /** Set for a horde of a larger swarm: which horde this is and its workstream. */
303
307
  swarm?: { id: string; horde: number; hordes: number; slice?: string | undefined } | undefined;
304
308
  }): string {
305
309
  const swarm = input.swarm;
310
+ const budget =
311
+ input.budget === undefined
312
+ ? "none"
313
+ : `${formatUsd(input.budget)} (the run clean-stops when member spend${swarm ? " across the swarm" : ""} reaches it)`;
306
314
  return [
307
315
  `# Horde run ${input.id}`,
308
316
  "",
@@ -312,6 +320,7 @@ export function taskMarkdown(input: {
312
320
  `Thinking: ${input.thinkingLevel ?? "parent default"}`,
313
321
  `Verifier: ${input.verifier ?? "none"}`,
314
322
  `Until stop: ${input.untilStop ? "yes (the run continues until stop-clean or stop-hard)" : "no"}`,
323
+ `Budget: ${budget}`,
315
324
  ...(swarm ? [`Swarm: ${swarm.id} (horde h${swarm.horde} of ${swarm.hordes})`] : []),
316
325
  ...(swarm?.slice ? [`Workstream: ${swarm.slice}`] : []),
317
326
  `Task-SHA256: ${taskHash(input.prompt)}`,
@@ -331,6 +340,8 @@ interface TaskInfo {
331
340
  model?: string | undefined;
332
341
  thinking?: string | undefined;
333
342
  hash?: string | undefined;
343
+ /** The `--budget` in dollars, when the run was started with one. */
344
+ budget?: number | undefined;
334
345
  prompt: string;
335
346
  }
336
347
 
@@ -342,13 +353,17 @@ export function parseTaskMarkdown(content: string): TaskInfo {
342
353
  for (const line of lines.slice(0, promptAt === -1 ? lines.length : promptAt)) {
343
354
  const title = /^# Horde run (\S+)$/.exec(line);
344
355
  if (title) info.id = title[1];
345
- const field = /^(Started|Members|Model|Thinking|Task-SHA256): (.*)$/.exec(line);
356
+ const field = /^(Started|Members|Model|Thinking|Budget|Task-SHA256): (.*)$/.exec(line);
346
357
  if (!field) continue;
347
358
  const value = field[2] ?? "";
348
359
  if (field[1] === "Started") info.started = value;
349
360
  else if (field[1] === "Members") info.count = Number(/^\d+/.exec(value)?.[0] ?? NaN) || undefined;
350
361
  else if (field[1] === "Model") info.model = value;
351
362
  else if (field[1] === "Thinking") info.thinking = value;
363
+ else if (field[1] === "Budget") {
364
+ const amount = Number(/^\$(\d+(?:\.\d+)?)/.exec(value)?.[1] ?? NaN);
365
+ if (amount > 0) info.budget = amount;
366
+ }
352
367
  else if (field[1] === "Task-SHA256") info.hash = value;
353
368
  }
354
369
  return info;
@@ -374,13 +389,14 @@ export interface UsageTotals {
374
389
  cost: number;
375
390
  }
376
391
 
392
+ /** Run totals over every session each member has had; turns count the current sessions only. */
377
393
  export function totalUsage(members: MemberState[]): UsageTotals {
378
394
  return members.reduce(
379
395
  (acc, m) => ({
380
396
  turns: acc.turns + m.turns,
381
- input: acc.input + m.usage.input,
382
- output: acc.output + m.usage.output,
383
- cost: acc.cost + m.usage.cost,
397
+ input: acc.input + m.lifetime.input,
398
+ output: acc.output + m.lifetime.output,
399
+ cost: acc.cost + m.lifetime.cost,
384
400
  }),
385
401
  { turns: 0, input: 0, output: 0, cost: 0 },
386
402
  );
@@ -586,6 +602,9 @@ export function formatStatusReport(run: RunSummaryInput, now: number = Date.now(
586
602
  const left = Math.max(0, Math.ceil((run.cleanStopUntil - now) / 1000));
587
603
  lines.push(`clean stop requested: members wrap up now, hard stop in ${left}s`);
588
604
  }
605
+ if (run.budget !== undefined) {
606
+ lines.push(`budget: ${formatUsd(totalUsage(run.members).cost)} of ${formatUsd(run.budget)} spent`);
607
+ }
589
608
  for (const m of run.members) {
590
609
  lines.push(
591
610
  `${m.name.padEnd(10)} ${m.status.padEnd(8)} ${memberActivity(m)} ` +
@@ -653,7 +672,8 @@ function reportHeader(input: RunReportInput, now: number): string[] {
653
672
  if (input.model) lines.push(`Model: ${input.model}`);
654
673
  lines.push(
655
674
  `Usage: ${usage.turns} turns, ${formatTokens(usage.input)} in / ` +
656
- `${formatTokens(usage.output)} out, $${usage.cost.toFixed(4)} cost`,
675
+ `${formatTokens(usage.output)} out, $${usage.cost.toFixed(4)} cost` +
676
+ (input.budget === undefined ? "" : ` (budget ${formatUsd(input.budget)})`),
657
677
  );
658
678
  return lines;
659
679
  }
@@ -0,0 +1,30 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { formatUsd } from "../format.js";
3
+ import { totalUsage } from "../prompt.js";
4
+ import { requestCleanStop } from "./controls.js";
5
+ import type { HordeState, Swarm } from "./types.js";
6
+
7
+ /** Dollars the swarm's members have spent over every session, replaced ones included. */
8
+ export function swarmSpend(swarm: Pick<Swarm, "runs">): number {
9
+ return swarm.runs.reduce((sum, run) => sum + totalUsage(run.members).cost, 0);
10
+ }
11
+
12
+ /**
13
+ * `--budget`: once the members' combined spend reaches the threshold, clean-stop every horde
14
+ * still working. Called on every child event, so it fires on the usage update that crosses the
15
+ * line. The flag is set before the first await so a burst of events requests the stop once.
16
+ * Hordes that already reported are skipped: a clean stop on one would re-arm a timer that
17
+ * `stopRunTimers` cleared and record a stop-hard on a finished run.
18
+ */
19
+ export function checkBudget(launch: { state: HordeState; swarm: Swarm; ctx: ExtensionCommandContext }): void {
20
+ const { state, swarm, ctx } = launch;
21
+ if (swarm.budget === undefined || swarm.budgetHit || state.disposed) return;
22
+ const spent = swarmSpend(swarm);
23
+ if (spent < swarm.budget) return;
24
+ swarm.budgetHit = true;
25
+ ctx.ui.notify(`horde ${swarm.id}: budget reached (${formatUsd(spent)} of ${formatUsd(swarm.budget)}); stopping`, "warning");
26
+ for (const run of swarm.runs) {
27
+ if (run.stopped || run.reported || run.finalized) continue;
28
+ void requestCleanStop(run, { kind: "all" }, ctx, { postmortem: false, label: "budget" });
29
+ }
30
+ }
@@ -31,7 +31,12 @@ const UNTIL_STOP_COMPLETION = {
31
31
  description: "keep the whole horde working until stop-clean or stop-hard; validated solves are banked",
32
32
  };
33
33
 
34
- const LAUNCH_FLAG_COMPLETIONS = [DASHBOARD_COMPLETION, NO_VERIFIER_COMPLETION, UNTIL_STOP_COMPLETION];
34
+ const BUDGET_COMPLETION = {
35
+ value: "--budget=5 ",
36
+ description: "clean-stop the run once the members' combined spend reaches this many dollars; edit the number",
37
+ };
38
+
39
+ const LAUNCH_FLAG_COMPLETIONS = [DASHBOARD_COMPLETION, NO_VERIFIER_COMPLETION, UNTIL_STOP_COMPLETION, BUDGET_COMPLETION];
35
40
 
36
41
  /** Argument completions that are not member targets. */
37
42
  const EXTRA_COMPLETIONS: Record<string, Array<{ value: string; description: string }>> = {
@@ -19,7 +19,7 @@ const CLEAN_STOP_ACTIVE_GRACE_MS = Number(process.env.PI_HORDE_ACTIVE_GRACE_MS)
19
19
  const SOLVED_STOP_MS = Number(process.env.PI_HORDE_SOLVED_STOP_MS) || 30_000;
20
20
 
21
21
  /** Why a clean stop was requested; selects the report's stop label. */
22
- type CleanStopLabel = "stop-clean" | "deadline" | "solved";
22
+ type CleanStopLabel = "stop-clean" | "deadline" | "solved" | "budget";
23
23
 
24
24
  interface CleanStopOptions {
25
25
  graceMs?: number | undefined;
@@ -168,6 +168,7 @@ export function setPaused(
168
168
 
169
169
  function stopLabelFor(label: CleanStopLabel): string {
170
170
  if (label === "deadline") return "stopped at deadline";
171
+ if (label === "budget") return "stopped at budget";
171
172
  if (label === "solved") return "stopped during solved wind-down";
172
173
  return "stopped by user";
173
174
  }
@@ -29,7 +29,7 @@ import type { PendingCleanup } from "../ledger/resources.js";
29
29
  import { archiveSolved, clearCleanStop, clearSolved, readSolvedState, resolveSolvedState, writeAnswer } from "../ledger/run-signals.js";
30
30
  import type { SolvedSignal } from "../ledger/run-signals.js";
31
31
  import { childEnv, execPi } from "../member.js";
32
- import { reportText, runReportMarkdown, summaryMarkdown } from "../prompt.js";
32
+ import { reportText, runReportMarkdown, summaryMarkdown, totalUsage } from "../prompt.js";
33
33
  import { buildRunGraph } from "../run-graph.js";
34
34
  import { runGraphMarkdown } from "../run-graph-mermaid.js";
35
35
  import type { CleanupStatus, ReportResource, RunReportInput } from "../prompt.js";
@@ -569,7 +569,10 @@ export async function finalizeRun(state: HordeState, swarm: Swarm, run: HordeRun
569
569
  await synthesize(state, run, ledgers, reportInput, now);
570
570
  if (annotate && run.deliverable) annotateAnswer(run, answer, acceptance, stale);
571
571
  sendReport(state, swarm, run, ledgers, now);
572
- await recordLifecycle(run, "run-end", { outcome: runOutcome(run), elapsedMs: Date.now() - run.startedAt, stopLabel: run.stopLabel });
572
+ await recordLifecycle(run, "run-end", {
573
+ outcome: runOutcome(run), elapsedMs: Date.now() - run.startedAt, stopLabel: run.stopLabel,
574
+ spent: totalUsage(run.members).cost, budget: run.budget,
575
+ });
573
576
  writeRunGraph(run);
574
577
  } catch (error) {
575
578
  safeNotify(run, `horde ${run.id} failed to finalize: ${errorMessage(error)}`, "error");
package/src/run/launch.ts CHANGED
@@ -277,6 +277,7 @@ function buildSwarm(
277
277
  plan: parsed.plan,
278
278
  partition: partition.slices,
279
279
  untilStop: parsed.untilStop === true ? true : undefined,
280
+ budget: parsed.budget,
280
281
  dashboardUrl,
281
282
  };
282
283
  return { swarm, launches };
@@ -287,7 +288,7 @@ async function seedHorde(swarm: Swarm, run: HordeRun, roles: string[]): Promise<
287
288
  await seedRun(run);
288
289
  await recordLifecycle(run, "run-start", {
289
290
  count: run.count, model: run.model, verifier: run.verifier, plan: run.plan, untilStop: run.untilStop,
290
- roles: roles.map((role) => oneLine(role, 120)), dashboardUrl: run.dashboardUrl,
291
+ budget: run.budget, roles: roles.map((role) => oneLine(role, 120)), dashboardUrl: run.dashboardUrl,
291
292
  ...(swarm.hordes > 1 ? { swarm: swarm.id, horde: run.horde, hordes: swarm.hordes, slice: run.slice } : {}),
292
293
  });
293
294
  await recordPhase(run, "discovery", "start");
package/src/run/repair.ts CHANGED
@@ -7,7 +7,7 @@ import { recordEvent } from "../ledger/events.js";
7
7
  import type { EventContext } from "../ledger/events.js";
8
8
  import { recordPhase } from "./lifecycle.js";
9
9
  import { envNumber } from "../format.js";
10
- import { resetMemberState } from "../member.js";
10
+ import { resetMemberState, USAGE_KEYS } from "../member.js";
11
11
  import type { MemberState } from "../member.js";
12
12
  import { hardStopMembers } from "./controls.js";
13
13
  import { scheduleCollaboration, stopReason } from "./schedule.js";
@@ -289,7 +289,7 @@ async function repairMember(phase: RepairPhase, member: MemberState, ms: number,
289
289
  run.repairTokens = (run.repairTokens ?? 0) + spent();
290
290
  member.turns += prior.turns;
291
291
  member.toolCalls += prior.toolCalls;
292
- for (const key of ["input", "output", "cacheRead", "cacheWrite", "cost"] as const) member.usage[key] += prior.usage[key];
292
+ for (const key of USAGE_KEYS) member.usage[key] += prior.usage[key];
293
293
  member.startedAt = prior.startedAt;
294
294
  }
295
295
  }
package/src/run/setup.ts CHANGED
@@ -93,7 +93,8 @@ export function createRun(
93
93
  fs.writeFileSync(
94
94
  path.join(runDir, "TASK.md"),
95
95
  taskMarkdown({
96
- id, prompt, count, model, thinkingLevel: ctx.thinkingLevel, startedAt, verifier, untilStop: parsed.untilStop, swarm,
96
+ id, prompt, count, model, thinkingLevel: ctx.thinkingLevel, startedAt, verifier, untilStop: parsed.untilStop,
97
+ budget: parsed.budget, swarm,
97
98
  }),
98
99
  "utf-8",
99
100
  );
@@ -123,6 +124,7 @@ export function createRun(
123
124
  plan,
124
125
  verifier,
125
126
  untilStop: parsed.untilStop === true ? true : undefined,
127
+ budget: parsed.budget,
126
128
  resets: [],
127
129
  dashboardUrl,
128
130
  };
@@ -9,6 +9,7 @@ import { reconcileOwnedIdeas } from "../ledger/ideas.js";
9
9
  import { cleanStopRequestedFor, clearHeartbeat, readHeartbeat } from "../ledger/run-signals.js";
10
10
  import { resetMemberState, spawnMember } from "../member.js";
11
11
  import type { MemberState } from "../member.js";
12
+ import { checkBudget } from "./budget.js";
12
13
  import { hardStopMembers } from "./controls.js";
13
14
  import { recordLifecycle } from "./lifecycle.js";
14
15
  import type { HordeRun, HordeState, Swarm } from "./types.js";
@@ -72,7 +73,7 @@ export async function spawnOne(
72
73
  approve: ctx.isProjectTrusted(),
73
74
  signal: controller.signal,
74
75
  role: launch.roles[member.index - 1],
75
- onChange: () => { onProgress?.(); refreshRun(run); },
76
+ onChange: () => { onProgress?.(); checkBudget(launch); refreshRun(run); },
76
77
  onSpawn: (proc) => run.procs.set(member.name, proc),
77
78
  onExit: () => {
78
79
  run.controller.signal.removeEventListener("abort", onMasterAbort);
@@ -84,7 +85,7 @@ export async function spawnOne(
84
85
  await recordLifecycle(run, "member-exit", {
85
86
  member: member.name, attempt: member.attempt, status: member.status, endCause: member.endCause,
86
87
  exitCode: member.exitCode, turns: member.turns, toolCalls: member.toolCalls,
87
- tokens: member.usage.input + member.usage.output, partial: member.partial === true,
88
+ tokens: member.usage.input + member.usage.output, cost: member.usage.cost, partial: member.partial === true,
88
89
  });
89
90
  }
90
91
 
@@ -15,6 +15,8 @@ export interface SwarmRecord {
15
15
  count: number;
16
16
  startedAt: number;
17
17
  untilStop: boolean;
18
+ /** The swarm's `--budget` in dollars, when one was given. */
19
+ budget?: number | undefined;
18
20
  partition: string[];
19
21
  runs: Array<{ label: string; runId: string; runDir: string; hordeDir: string; startedAt: number; endedAt?: number | undefined; outcome?: string | undefined }>;
20
22
  /** The merge step: when the swarm deliverable helper ran and whether it wrote anything. */
@@ -30,6 +32,7 @@ export function swarmRecord(swarm: Swarm): SwarmRecord {
30
32
  count: swarm.count,
31
33
  startedAt: swarm.startedAt,
32
34
  untilStop: swarm.untilStop === true,
35
+ budget: swarm.budget,
33
36
  partition: swarm.partition ?? [],
34
37
  runs: swarm.runs.map((run) => ({
35
38
  label: `h${run.horde}`,
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import { formatDateTime, formatElapsed, formatTokens, oneLine } from "../format.js";
2
+ import { formatDateTime, formatElapsed, formatTokens, formatUsd, oneLine } from "../format.js";
3
3
  import { listFindingsForRun } from "../ledger/findings.js";
4
4
  import { statusCounts, totalUsage } from "../prompt.js";
5
5
  import { hordeLabel } from "./types.js";
@@ -96,6 +96,7 @@ export function swarmReportMarkdown(swarm: Swarm, now: number = Date.now()): str
96
96
  `- elapsed: ${formatElapsed(now - swarm.startedAt)}`,
97
97
  `- hordes: ${swarm.hordes} x ${swarm.count} members`,
98
98
  `- until stop: ${swarm.untilStop ? "yes" : "no"}`,
99
+ ...(swarm.budget === undefined ? [] : [`- budget: ${formatUsd(swarm.budget)} across every horde`]),
99
100
  `- outcomes: ${rows.map((row) => `${row.label} ${row.outcome}`).join(", ")}`,
100
101
  `- merged deliverable: ${swarm.deliverable ? "DELIVERABLE.md" : "not written"}`,
101
102
  "",
package/src/run/swarm.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { formatDateTime, formatRunId, taskHash } from "../format.js";
3
+ import { formatDateTime, formatRunId, formatUsd, taskHash } from "../format.js";
4
4
  import { hordePaths, initHordeDirAt } from "../ledger/files.js";
5
5
  import type { SiblingHorde } from "../member.js";
6
6
  import { hordeLabel } from "./types.js";
@@ -99,6 +99,7 @@ export function swarmMarkdown(swarm: Swarm): string {
99
99
  `Hordes: ${swarm.hordes} x ${swarm.count} members (${hordeLabel(1)} .. ${hordeLabel(swarm.hordes)})`,
100
100
  `Model: ${swarm.runs[0]?.model ?? "parent default"}`,
101
101
  `Until stop: ${swarm.untilStop ? "yes (every horde continues until stop-clean or stop-hard)" : "no"}`,
102
+ `Budget: ${swarm.budget === undefined ? "none" : `${formatUsd(swarm.budget)} across every horde`}`,
102
103
  `Partition: ${swarm.plan ? "plan layers dealt round-robin" : "planner helper"}`,
103
104
  `Task-SHA256: ${taskHash(swarm.prompt)}`,
104
105
  "",
package/src/run/types.ts CHANGED
@@ -83,6 +83,8 @@ export interface HordeRun extends MemberRunInfo {
83
83
  solvedContest?: { proposal: SolvedSignal; blockers: number[] } | undefined;
84
84
  /** True once the parent has warned that a validated solve still has unverified findings. */
85
85
  solvedVerificationNotice?: boolean | undefined;
86
+ /** The swarm's `--budget` in dollars, recorded per run for TASK.md and the events. */
87
+ budget?: number | undefined;
86
88
  /** Solves banked so far in an `--until-stop` run. */
87
89
  solveRounds?: number | undefined;
88
90
  /** The latest banked solve of an `--until-stop` run: the answer unless a later proposal is validated. */
@@ -116,6 +118,10 @@ export interface Swarm {
116
118
  /** Per-horde workstream, indexed by horde - 1; absent for a swarm of one. */
117
119
  partition?: string[] | undefined;
118
120
  untilStop?: boolean | undefined;
121
+ /** `--budget`: combined member spend in dollars at which every horde is clean-stopped. */
122
+ budget?: number | undefined;
123
+ /** True once the budget tripped, so the stop is requested exactly once. */
124
+ budgetHit?: boolean | undefined;
119
125
  dashboardUrl?: string | undefined;
120
126
  /** Generated merged deliverable across the hordes' deliverables. */
121
127
  deliverable?: string | undefined;
@@ -23,7 +23,7 @@ import { readEvents, recordEvent } from "../ledger/events.js";
23
23
  import type { EventContext } from "../ledger/events.js";
24
24
 
25
25
  const CLEAN_STOP_NOTICE =
26
- "A clean stop was requested by the user. Stop starting new work. Mark your claimed ideas done " +
26
+ "A clean stop is in effect for this run. Stop starting new work. Mark your claimed ideas done " +
27
27
  "or drop them, post a one-line status to BOARD.md, then write your final summary and make no " +
28
28
  "further tool calls.";
29
29
 
@@ -1,7 +1,19 @@
1
- import { StringEnum } from "@earendil-works/pi-ai";
2
- import { Type } from "typebox";
1
+ import { Type, type TUnsafe } from "typebox";
3
2
  import { FINDING_CONFIDENCES, FINDING_IMPORTANCE, FINDING_SUPPORT } from "../ledger/findings.js";
4
3
 
4
+ /**
5
+ * A string enum schema whose static type is the union of its values.
6
+ *
7
+ * pi-ai's StringEnum returns a pi-schema type since pi 1.5, which TypeBox's `Type.Object`
8
+ * cannot infer a static type from, so the schema is built here with TypeBox directly.
9
+ */
10
+ function StringEnum<T extends readonly string[]>(
11
+ values: T,
12
+ options: { description?: string } = {},
13
+ ): TUnsafe<T[number]> {
14
+ return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
15
+ }
16
+
5
17
  export const HordeParams = Type.Object({
6
18
  action: StringEnum(
7
19
  [
package/src/widget.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
- import { formatElapsed, formatTokens } from "./format.js";
3
+ import { formatElapsed, formatTokens, formatUsd } from "./format.js";
4
4
  import type { MemberState } from "./member.js";
5
5
  import { memberActivity, statusCounts, totalUsage } from "./prompt.js";
6
6
 
@@ -16,12 +16,15 @@ interface WidgetRun {
16
16
  reported?: boolean | undefined;
17
17
  finalized?: boolean | undefined;
18
18
  outcome?: string | undefined;
19
+ /** `--budget` in dollars; the header shows spend against it. */
20
+ budget?: number | undefined;
19
21
  }
20
22
 
21
23
  interface WidgetSwarm {
22
24
  id: string;
23
25
  runs: WidgetRun[];
24
26
  startedAt: number;
27
+ budget?: number | undefined;
25
28
  dashboardUrl?: string | undefined;
26
29
  /** The swarm step still running after every horde finalized, e.g. ["merge"]. */
27
30
  finalizing?: string[] | undefined;
@@ -56,6 +59,7 @@ export function headerLine(run: WidgetRun, now: number, theme: Theme): string {
56
59
  if (c.failed) parts.push(theme.fg("error", `${c.failed} failed`));
57
60
  if (c.stopped) parts.push(theme.fg("warning", `${c.stopped} stopped`));
58
61
  parts.push(formatElapsed(now - run.startedAt));
62
+ if (run.budget !== undefined) parts.push(budgetPart(run.members, run.budget, theme));
59
63
  if (run.cleanStopUntil !== undefined) {
60
64
  const left = Math.max(0, Math.ceil((run.cleanStopUntil - now) / 1000));
61
65
  parts.push(theme.fg("warning", `clean stop in ${left}s`));
@@ -67,6 +71,11 @@ export function headerLine(run: WidgetRun, now: number, theme: Theme): string {
67
71
  return `${parts.join(" ")} ${theme.fg("dim", "/agent-horde status | stop-hard | stop-clean")}`;
68
72
  }
69
73
 
74
+ /** `$1.20 of $5.00`: spend over every session so far against the `--budget`. */
75
+ function budgetPart(members: MemberState[], budget: number, theme: Theme): string {
76
+ return theme.fg("dim", `${formatUsd(totalUsage(members).cost)} of ${formatUsd(budget)}`);
77
+ }
78
+
70
79
  function memberLine(m: MemberState, now: number, frame: number, theme: Theme): string {
71
80
  const elapsed = formatElapsed((m.endedAt ?? now) - m.startedAt);
72
81
  const activity = memberActivity(m);
@@ -118,7 +127,7 @@ export function hordeLine(run: WidgetRun, now: number, frame: number, theme: The
118
127
  ];
119
128
  if (c.failed) parts.push(theme.fg("error", `${c.failed} failed`));
120
129
  if (c.stopped) parts.push(theme.fg("warning", `${c.stopped} stopped`));
121
- parts.push(theme.fg("dim", `t${usage.turns} ${formatTokens(usage.input + usage.output)} tok $${usage.cost.toFixed(2)}`));
130
+ parts.push(theme.fg("dim", `t${usage.turns} ${formatTokens(usage.input + usage.output)} tok ${formatUsd(usage.cost)}`));
122
131
  parts.push(theme.fg("muted", hordeActivity(run, c)));
123
132
  return ` ${glyph} ${`h${run.horde}`.padEnd(3)} ${parts.join(" ")}`;
124
133
  }
@@ -135,6 +144,7 @@ function swarmHeader(swarm: WidgetSwarm, now: number, theme: Theme): string {
135
144
  if (c.failed) parts.push(theme.fg("error", `${c.failed} failed`));
136
145
  if (c.stopped) parts.push(theme.fg("warning", `${c.stopped} stopped`));
137
146
  parts.push(formatElapsed(now - swarm.startedAt));
147
+ if (swarm.budget !== undefined) parts.push(budgetPart(members, swarm.budget, theme));
138
148
  if (swarm.finalizing && swarm.finalizing.length > 0) {
139
149
  parts.push(theme.fg("warning", `synthesizing ${swarm.finalizing.join(", ")}`));
140
150
  }