agent-horde 0.2.0 → 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.0
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)
@@ -42,7 +42,14 @@ synthesize it.
42
42
  The default totals follow the queues' demand (one floor per queue plus one per further review
43
43
  request or blocking challenge), each attempt's share is cut in proportion to its demand, and
44
44
  every attempt gets at least a floor; the phase start records the budget, `repair-start` the
45
- attempt's demand, and `repair-end` its turns and first-turn cost. A user stop skips this phase.
45
+ attempt's demand, and `repair-end` its turns, first-turn cost and the threads it left
46
+ `unchanged`. A thread that two consecutive completed attempts did not move (same state and
47
+ response revision) is held back from that member until another member changes it; a member
48
+ whose whole queue is held is recorded as `repair-skipped` with reason `no-progress` and costs
49
+ later rounds nothing. Each round serves a member at most once, but a round other than the last
50
+ ends early when the only blocking threads left belong to members it already served, so a
51
+ requester rechecks a correction before unserved members' non-blocking threads.
52
+ A user stop skips this phase.
46
53
  4. **Finalize.** Acceptance is frozen (`ACCEPTANCE.*`, `snapshot/`), resource cleanup commands
47
54
  run, `SUMMARY.md` and `REPORT.md` are written, then a synthesis member writes `DELIVERABLE.md`
48
55
  and a read-only reviewer writes `POSTMORTEM.md`. The widget shows `synthesizing` until both
@@ -61,7 +68,14 @@ unfinished ideas return to the open pool.
61
68
  A clean stop still produces the deliverable; add `postmortem` to also run the reviewer. Runs
62
69
  that end on their own (all members finished, solved, canary or provider failure) get both.
63
70
  `PI_HORDE_MAX_MINUTES` forces a clean stop at a wall-clock deadline, reported as
64
- `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.
65
79
 
66
80
  Members and helper agents are children of the pi session. Quitting or reloading pi sends every
67
81
  live child SIGTERM, and each child polls `PI_HORDE_PARENT_PID` every two seconds and exits on its
@@ -225,9 +239,12 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
225
239
  other member running, or no validation within `PI_HORDE_SOLVED_CONFIRM_MS`, an uncontested
226
240
  proposal commits as `solved-unvalidated`. Findings no other member reproduced hold wind-down
227
241
  while other members run, unless `PI_HORDE_ALLOW_UNVERIFIED=1`. A proposal pins the revision of
228
- every finding it cites. An answer that cites a superseded F# or quotes a provisional T# is
229
- refused at `solved` and at `confirm-solved`; a pending proposal whose cited finding is
230
- superseded or amended later is withdrawn (`solved-withdrawn`) for re-proposal. A contest that names idea ids reopens the proposal under a new revision
242
+ every finding and artifact it cites. An answer that cites a superseded F# or quotes a provisional
243
+ T# is refused at `solved` and at `confirm-solved`; a pending proposal whose cited finding is
244
+ superseded or amended later, or whose cited A# is republished, is withdrawn (`solved-withdrawn`)
245
+ for re-proposal, and ANSWER.md's status says so instead of keeping an earlier validation. At
246
+ finalize, ANSWER.md's status names every citation that changed after the proposal pinned it. A
247
+ contest that names idea ids reopens the proposal under a new revision
231
248
  (`solved-reopened`) once every named idea is done or ruled out, with the citations it pinned;
232
249
  if a cited finding changed or a quoted fact is still provisional by then, it is withdrawn instead. Repair serves a request about
233
250
  the pending answer (`target=answer`) with the blockers.
@@ -248,7 +265,10 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
248
265
  still `respond` to a deferred request, and the requester then resolves it. A challenger can
249
266
  withdraw its own blocking challenge on an F# or A# before any response by recording a
250
267
  supporting review of the current revision and resolving with evidence on it; limits that
251
- review discloses remain acceptance debt. Acceptance names an open blocker as `blocked by Q#`.
268
+ review discloses remain acceptance debt. A blocking request about the sender's own work (a
269
+ re-review after a correction) resolves on the recipient's supporting review of the current
270
+ revision, full-scope for an A#, since the owner may not review it. Acceptance names an open
271
+ blocker as `blocked by Q#`.
252
272
  - **Verifier.** The verifier's idea claims are refused while other members' findings await its
253
273
  review at their current revision; the pending F# list follows every review, reattest and board
254
274
  post. A verifier whose session closes with critical or high-importance findings unreviewed is
@@ -290,7 +310,11 @@ resets in the report. A session that finished within `PI_HORDE_CONTINUE_COOLDOWN
290
310
  60000) of starting waits out the remainder first, so a member that finds nothing to do cannot spin.
291
311
  Crash and stall resets apply as usual. `stop-clean` and `stop-hard` end the run as usual and
292
312
  `stop-clean` produces the report, deliverable, and postmortem; the last banked answer stands unless
293
- 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.
294
318
 
295
319
  ## Horde of hordes
296
320
 
@@ -350,7 +374,9 @@ for an abnormal end after measurable work; distinct finding clusters with review
350
374
  superseded findings in their own section, and any resets; claim states; this run's facts split
351
375
  from carried context; resources and cleanup results; coverage (released and still-open ideas,
352
376
  the solved answer); late board posts; and consistency warnings for missing metadata,
353
- mixed-concern clusters, unknown R# citations, and cited paths that do not exist. A killed
377
+ mixed-concern clusters, unknown R# citations, and cited paths that do not exist (rooted URL paths
378
+ and templates with a date or placeholder slot, such as `logs/gate-YYYY-MM-DD.jsonl` or
379
+ `out/<id>.json`, are not checked). A killed
354
380
  member's summary is reconstructed from its last board post. The message injected into the parent
355
381
  carries a one-line diagnosis and, when generated, the post-mortem text.
356
382
 
@@ -441,6 +467,7 @@ run/ the parent-side run lifecycle, sharing one HordeState
441
467
  swarm-report.ts per-horde totals: the swarm REPORT.md and the injected message
442
468
  launch.ts dashboard, deadline, partition, staggered canary preflights, fan-out
443
469
  supervise.ts member spawn, reset on exit, stall detection
470
+ budget.ts `--budget`: swarm-wide spend check, clean stop when it is reached
444
471
  schedule.ts artifact review assignment and superseded-dependency handoffs
445
472
  repair.ts bounded post-discovery reviews, owner repair, and recheck attempts
446
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.0",
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
  }
package/src/citations.ts CHANGED
@@ -32,6 +32,12 @@ const DATA_EXT =
32
32
  /** Path roots that are worth checking even without a file extension. */
33
33
  const RUN_ROOT = /^(?:\.horde|\.tmp|runs|artifacts)(?:[/-]|$)/;
34
34
 
35
+ /**
36
+ * A path with a date or placeholder slot (`logs/gate-YYYY-MM-DD.jsonl`, `out/<id>.json`,
37
+ * `%Y%m%d.log`) names a pattern the code writes, not one file that must exist.
38
+ */
39
+ const PATH_TEMPLATE = /(?:^|[^A-Za-z])(?:YYYY|YYYYMMDD|yyyy|yyyymmdd)(?:[^A-Za-z]|$)|<[^<>]+>|%[YmdHMS]/;
40
+
35
41
  /** Host-anchored references such as docs.example.com/page are URLs, not local paths. */
36
42
  const HOST_PATH = /^(?:[a-z0-9-]+\.)+[a-z]{2,}\//i;
37
43
 
@@ -57,6 +63,7 @@ export function citedDataFiles(text: string): string[] {
57
63
  if (ref === "") return;
58
64
  if (ref.startsWith("/") || /^[a-z][a-z0-9+.-]*:\/\//i.test(ref)) return;
59
65
  if (/[{}*$]/.test(ref)) return;
66
+ if (PATH_TEMPLATE.test(ref)) return;
60
67
  if (HOST_PATH.test(ref)) return;
61
68
  const isRooted = RUN_ROOT.test(ref);
62
69
  // Under a run root, any extension is a member artifact worth checking. Off a
@@ -66,7 +73,9 @@ export function citedDataFiles(text: string): string[] {
66
73
  out.add(ref);
67
74
  };
68
75
  for (const match of text.matchAll(/`([^`\s]+)`/g)) consider(match[1] ?? "");
69
- for (const match of text.matchAll(/(?<![\w:/.])(?:\.{1,2}\/)?[\w.-]+(?:\/[\w.-]+)+(?:\.[A-Za-z0-9]{1,6})?/g)) {
76
+ // A match never starts inside a hyphenated word: `/standalone-client-gate/logs` is one URL path,
77
+ // not a relative `client-gate/logs` with its leading slash cut off.
78
+ for (const match of text.matchAll(/(?<![\w:/.-])(?:\.{1,2}\/)?[\w.-]+(?:\/[\w.-]+)+(?:\.[A-Za-z0-9]{1,6})?/g)) {
70
79
  consider(match[0] ?? "", text[(match.index ?? 0) + match[0].length] ?? "");
71
80
  }
72
81
  for (const match of text.matchAll(/(?<![\w/.-])([\w-]+\.[A-Za-z0-9]{1,6})\b/g)) consider(match[1] ?? "");
@@ -95,14 +104,16 @@ function fileNamedUnder(dir: string, name: string, depth = 6): boolean {
95
104
 
96
105
  /**
97
106
  * True when a cited file cannot be resolved to an existing file on disk. Paths
98
- * are tried against the repo root, the run directory, its artifacts directory,
99
- * and the directory of the citing file. Because generated artifacts usually cite
100
- * retained files by a relative name, and a deliverable cites the reviewed project's
107
+ * are tried against the repo root, the horde directory (where `runs/<id>/...`
108
+ * references resolve, in a single run and in a swarm horde alike), the run
109
+ * directory, its artifacts directory, and the directory of the citing file; a
110
+ * path without a file extension may name a directory. Because generated artifacts
111
+ * usually cite retained files by a relative name, and a deliverable cites the reviewed project's
101
112
  * own files by bare name, a basename match in this run's tree or in the project tree
102
113
  * also counts, which keeps the warning precise instead of noisy.
103
114
  */
104
115
  function citedFileMissing(run: HordeRun, ref: string, baseDir?: string): boolean {
105
- const roots = [run.cwd, run.runDir, path.join(run.runDir, "artifacts")];
116
+ const roots = [run.cwd, run.hordeDir, run.runDir, path.join(run.runDir, "artifacts")];
106
117
  if (baseDir) roots.push(baseDir);
107
118
  const mustBeFile = FILE_EXT.test(ref);
108
119
  for (const root of roots) {
@@ -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;
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { collaborationState } from "./collaboration.js";
4
- import type { PeerMessage, ReviewRecord } from "./collaboration.js";
4
+ import type { ArtifactRecord, PeerMessage, ReviewRecord } from "./collaboration.js";
5
5
  import { formatClock } from "../format.js";
6
6
  import { RUN_HEADING } from "./board.js";
7
7
  import { registerConcern } from "./concerns.js";
@@ -558,28 +558,47 @@ export function supersededCitations(text: string, findings: FindingEntry[]): str
558
558
  .map((finding) => `F${finding.id} (now F${finding.supersededBy})`);
559
559
  }
560
560
 
561
- /** The current revision of every live finding a text cites, pinned so a later amend is visible. */
562
- export function answerCitations(findings: FindingEntry[], text: string): Record<string, string> {
561
+ /**
562
+ * The current revision of every live finding and published artifact a text cites, pinned so a
563
+ * later amend or republish is visible.
564
+ */
565
+ export function answerCitations(findings: FindingEntry[], text: string, artifacts: ArtifactRecord[] = []): Record<string, string> {
563
566
  const cites: Record<string, string> = {};
564
567
  for (const finding of findings) {
565
568
  if (finding.supersededBy === undefined && new RegExp(`\\bF${finding.id}\\b`).test(text)) cites[`F${finding.id}`] = findingRevision(finding);
566
569
  }
570
+ for (const artifact of artifacts) {
571
+ if (new RegExp(`\\b${artifact.id}\\b`).test(text)) cites[artifact.id] = artifact.hash;
572
+ }
567
573
  return cites;
568
574
  }
569
575
 
570
576
  /**
571
- * Citations an answer can no longer stand on: findings superseded since it was written, and
572
- * findings amended since the revisions it pinned. Both ship a stale reading as the result.
577
+ * Citations an answer can no longer stand on: findings superseded since it was written, findings
578
+ * amended and artifacts republished since the revisions it pinned. Each ships a stale reading as
579
+ * the result.
573
580
  */
574
- export function staleAnswerCitations(findings: FindingEntry[], text: string, cites: Record<string, string> = {}): string[] {
581
+ export function staleAnswerCitations(
582
+ findings: FindingEntry[],
583
+ text: string,
584
+ cites: Record<string, string> = {},
585
+ artifacts: ArtifactRecord[] = [],
586
+ ): string[] {
575
587
  const stale = supersededCitations(text, findings).map((citation) => `superseded ${citation}`);
576
588
  for (const [target, revision] of Object.entries(cites)) {
577
589
  const finding = findings.find((entry) => `F${entry.id}` === target);
578
590
  if (finding && finding.supersededBy === undefined && findingRevision(finding) !== revision) stale.push(`amended ${target}`);
591
+ const artifact = artifacts.find((entry) => entry.id === target);
592
+ if (artifact && artifact.hash !== revision) stale.push(`republished ${target}`);
579
593
  }
580
594
  return stale;
581
595
  }
582
596
 
597
+ /** The answer's stale citations against this run's current findings and artifacts. */
598
+ export function staleAnswerFor(hordeDir: string, runId: string, text: string, cites?: Record<string, string>): string[] {
599
+ return staleAnswerCitations(listFindingsForRun(hordeDir, runId), text, cites, collaborationState(hordeDir, runId).artifacts);
600
+ }
601
+
583
602
  /** A body line that reads as a verification note would move the subject boundary the revision hashes. */
584
603
  function neutralizeNotes(text: string): string {
585
604
  return text.replace(/^((?:Verified|Reviewed) by )/gm, "\\$1");
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;