agent-horde 0.1.6 → 0.2.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
@@ -50,7 +50,9 @@ found there as its brief. Unrelated files cost members context and invite them t
50
50
  from earlier runs persist in `.horde/` and will steer members; start with a fresh one when the
51
51
  task changes.
52
52
 
53
- A horde is at most twelve members today. Hordes of hordes are on the roadmap.
53
+ A horde is at most twelve members. For more hands, run a swarm: `3/12` launches three hordes of
54
+ twelve on disjoint workstreams of one task, each with its own ledgers, and merges their
55
+ deliverables at the end.
54
56
 
55
57
  **Tips.**
56
58
 
@@ -88,7 +90,7 @@ pi install npm:agent-horde
88
90
  Or from the repository, pinned to a tag:
89
91
 
90
92
  ```
91
- pi install git:github.com/eliyastein/agent-horde@v0.1.6
93
+ pi install git:github.com/eliyastein/agent-horde@v0.2.0
92
94
  ```
93
95
 
94
96
  To hack on it, clone and install in place. pi loads the files from the clone, so edits take effect
@@ -135,14 +137,31 @@ extend, stress-test, or deepen the work.
135
137
  /agent-horde --dashboard --until-stop 8 find every way an unauthenticated request reaches the database
136
138
  ```
137
139
 
140
+ A count of `H/M` runs a swarm: H hordes of M members on one task, at most four hordes. A planner
141
+ helper first splits the task into H disjoint workstreams with acceptance criteria and each horde
142
+ works only its slice, with its own ledgers under `.horde/swarms/<id>/hN/`; if the planner cannot
143
+ produce H distinct workstreams the launch stops and says so. `plan 3/12 a; b; c; d; e; f` skips
144
+ the planner and deals the layers round-robin to the hordes. Members read the other hordes' facts,
145
+ rule-outs and resources through the `horde` tool but write only to their own horde. The hordes
146
+ start a few seconds apart, run the normal lifecycle independently, and when the last one has
147
+ 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.
149
+
150
+ ```
151
+ /agent-horde --dashboard=1234 3/12 audit the service for authorization bugs
152
+ ```
153
+
138
154
  | command | effect |
139
155
  |---|---|
140
- | `/agent-horde status` | the active run and each member |
156
+ | `/agent-horde status` | the active run and each member; one block per horde in a swarm |
141
157
  | `/agent-horde stop-clean [postmortem]` | members wrap up, then the run finalizes; this is how an `--until-stop` run ends |
142
158
  | `/agent-horde stop-hard` | terminate members now |
143
159
  | `/agent-horde pause`, `resume` | suspend and resume member processes |
144
160
  | `/agent-horde help` | every flag and target |
145
161
 
162
+ Every control takes a target: `member-3`, `h2` for one horde of a swarm, `h2:member-3` for one
163
+ member of one horde, or `all`; a bare `member-3` in a swarm names that member in every horde.
164
+
146
165
  When the run ends, the report is injected into your conversation. Everything else stays under
147
166
  `.horde/`; add it to your `.gitignore`.
148
167
 
package/docs/REFERENCE.md CHANGED
@@ -16,8 +16,10 @@ at one problem beat a single linear one: research, audits, design exploration, R
16
16
  /agent-horde help
17
17
  ```
18
18
 
19
- `target` is `all` (default), `member-N`, `N`, or a comma or space separated list. Subcommands and
20
- member targets tab-complete.
19
+ `count` is `M` members (1 to 12) or `H/M`: H hordes of M members, H at most 4; a plain `M` is a
20
+ swarm of one. `target` is `all` (default), `member-N`, `N`, `hN` (one horde of a swarm),
21
+ `hN:member-M`, `hN:M`, or a comma or space separated list; a bare member names that member in
22
+ every horde. Subcommands and targets tab-complete.
21
23
 
22
24
  The command returns immediately. A widget above the editor shows each member's status, current
23
25
  tool, elapsed time, turns, and resets. Members inherit the parent session's model and thinking
@@ -76,9 +78,19 @@ Every page has a JSON twin under `/api/<same path>`. The footer of every page na
76
78
  agent-horde version from its package.json and, when it runs from a git checkout, the commit it is
77
79
  at (`-dirty` when the tree has uncommitted changes), linked to the commit on GitHub.
78
80
 
81
+ Every page belongs to one scope, fixed by its URL: the **project** (`/` and the bare ledger paths,
82
+ the ledgers single runs share), one **run** (`/runs/<id>/...`), or one **swarm**
83
+ (`/swarms/<id>/...`). Under the `runs` link a scope line names the scope, and a page row offers that
84
+ scope's pages (overview, the six ledgers, collaboration, graph; the project has the ledgers only),
85
+ with the current page marked. Inside a swarm the scope line adds horde tabs, `all | h1 | h2 ...`,
86
+ that keep the page type: from h2's findings, h3 opens h3's findings and `all` the swarm's. A horde's
87
+ pages are its run's pages, `/runs/<swarm-id>-h2/...`. No link falls back to another run. While a swarm
88
+ is running or finishing, project pages show a pointer to the same page in the swarm, since its
89
+ hordes write their own ledgers and leave the project ones untouched.
90
+
79
91
  | page | shows |
80
92
  |------|-------|
81
- | `/` | every run: active marker, start, count, model, outcome, prompt, files present |
93
+ | `/` | every swarm and run: state (active, finalizing, pending, finished, not started), start, count, model, outcome, prompt, files present |
82
94
  | `/runs/<id>` | prompt, outcome, canary, clean-stop countdown, plan layers, members table, run files |
83
95
  | `/runs/<id>/members/<m>` | live activity, last and final text, log tail (`?tail=<n>`), transcript link |
84
96
  | `/runs/<id>/members/<m>/log` | the member log; `tail=0` is the whole file (1 MiB cap) |
@@ -88,16 +100,27 @@ at (`-dirty` when the tree has uncommitted changes), linked to the commit on Git
88
100
  | `/runs/<id>/collaboration` | messages, review scope, artifact acceptance, resources, event audit |
89
101
  | `/runs/<id>/graph` | the run as a swimlane: one lane per member plus the parent, phase bands, every event a link to its ledger entry (`?w=<px>`, `?from=<s>&to=<s>`); the axis is real time until the last member exits, then the synthesis phases share a compressed tail |
90
102
  | `/runs/<id>/graph.mmd` | the same run as a Mermaid flowchart with absolute dashboard links (text/plain) |
91
- | `/ideas`, `/board`, `/findings`, `/claims`, `/facts`, `/resources` | the shared ledgers; `?run=<id>\|all`, `?view=clusters`, `?owner=<m>` filter them; `/resources` also lists who cited each resource in the active run, as REPORT.md counts reuse |
103
+ | `/runs/<id>/ideas`, `board`, `findings`, `claims`, `facts`, `resources` | the run's ledgers: a horde's own, or for a single run the project ledgers with board and findings narrowed to the run and facts split into this run and carried; `?view=clusters`, `?owner=<m>`, `?order=oldest` |
104
+ | `/ideas`, `/board`, `/findings`, `/claims`, `/facts`, `/resources` | the project ledgers across every single run; `/resources` also lists who cited each resource in a live single run, as REPORT.md counts reuse |
105
+ | `/swarms/<id>` | one row per horde (state, outcome, members, findings, accepted artifacts, cost, workstream), the partition, the merge state, swarm files |
106
+ | `/swarms/<id>/ideas`, `board`, `findings`, `claims`, `facts`, `resources`, `collaboration` | every horde's ledger, or its acceptance, requests and reviews, on one page under a per-horde heading; ids repeat per horde, and each horde's event audit stays on its run page |
107
+ | `/swarms/<id>/graph` | one lane per horde with its phase marks, and the merge step |
108
+ | `/swarms/<id>/files/<NAME>` | `SWARM`, `REPORT`, `DELIVERABLE`; 404 until written |
92
109
  | `/export` | confirms the export and starts the download of `/export.zip` (`?back=<path>` names the page to return to); no JSON twin |
93
110
  | `/export.zip` | the whole dashboard as static HTML (`application/zip`, streamed, no JSON twin); see below |
94
111
 
112
+ A page auto-refreshes while what it shows can change: a run's pages while the run is live or
113
+ finalizing, a swarm's while the swarm is in memory, the project ledgers while a single run is
114
+ live, and the index always. A swarm and its hordes show as `pending` from the moment their directories
115
+ are allocated until the hordes have a TASK.md, which covers the minutes the planner takes, and as
116
+ `not started` when the planner could not partition the task.
117
+
95
118
  The download icon in the top-right corner of every page opens a warning that the export may
96
119
  leak sensitive data from this machine. Its download link goes to `/export`, which closes the
97
120
  warning, explains the wait, and starts the download of `/export.zip`. The zip holds one
98
121
  folder, `horde-dashboard-<timestamp>/`, whose `index.html` opens the dashboard from disk: every
99
122
  run, member, transcript page and entry, log, run document, collaboration page, graph (each width,
100
- plus the Mermaid file), and the ledgers under each run filter, with links rewritten relative to
123
+ plus the Mermaid file), and the ledgers of every scope, with links rewritten relative to
101
124
  each other. Exported pages do not refresh, expandable entries use the browser's own disclosure,
102
125
  finding bodies are shown inline, transcripts are paged fifty entries at a time, and the export
103
126
  control itself is absent. Pages that fail to load are exported as their error page, so no link
@@ -134,11 +157,17 @@ the session files, so they show end cause, turns, and usage but no activity.
134
157
  SUMMARY.md REPORT.md per-member status and usage; the reconciled run report
135
158
  ANSWER.md DELIVERABLE.md POSTMORTEM.md solved answer; synthesis output; reviewer critique
136
159
  GRAPH.md Mermaid graph of the run, written last at finalize
160
+ swarms/<swarm-id>/
161
+ SWARM.md SWARM.json prompt, partition, horde list; the swarm's timeline for the dashboard
162
+ planner-sessions/ deliverable-sessions/ planner and merge helper sessions
163
+ REPORT.md DELIVERABLE.md per-horde totals and file list; the merged deliverable
164
+ h1/ h2/ ... one complete horde directory per horde: its own ledgers,
165
+ .lock and runs/<swarm-id>-hN/ with everything a run has
137
166
  ```
138
167
 
139
168
  Each run appends a `## Run <id>` heading to BOARD.md and FINDINGS.md that cites `TASK.md` by
140
- sha256. `runs/` is gitignored inside `.horde/`; add `.horde/` to the project `.gitignore` to keep
141
- the ledgers out of git too.
169
+ sha256. `runs/` and `swarms/` are gitignored inside `.horde/`; add `.horde/` to the project
170
+ `.gitignore` to keep the ledgers out of git too.
142
171
 
143
172
  ## The `horde` tool
144
173
 
@@ -263,6 +292,46 @@ Crash and stall resets apply as usual. `stop-clean` and `stop-hard` end the run
263
292
  `stop-clean` produces the report, deliverable, and postmortem; the last banked answer stands unless
264
293
  a later proposal was validated too.
265
294
 
295
+ ## Horde of hordes
296
+
297
+ `H/M` launches a swarm: H concurrent hordes of M members on one task, H at most 4. A plain `M` is
298
+ a swarm of one and keeps the layout above; a larger swarm gets `.horde/swarms/<swarm-id>/` with one
299
+ complete horde directory per horde (`h1/`, `h2/`, ...), each with its own ledgers, lock and
300
+ `runs/<swarm-id>-hN/`. Hordes therefore never share a lock, a dedup pass or a coverage watermark,
301
+ and run ids stay unique across the swarm.
302
+
303
+ **Partition.** A plain prompt is split before launch by a read-only planner helper (`pi -p`, the
304
+ parent's model, `PI_HORDE_PLANNER_MS` timeout) into exactly H disjoint workstreams with one
305
+ acceptance criterion each, recorded in `SWARM.md` and in each horde's `TASK.md`; every member of a
306
+ horde is told its workstream and to work only on it. If the planner cannot produce H distinct
307
+ workstreams the launch stops with that message, the swarm directory keeps the planner session and
308
+ a `SWARM.md` saying why, and the slot stays free. `plan H/M a; b; c; ...` skips the planner: the
309
+ layers are dealt round-robin to the hordes (layer 1 to h1, layer 2 to h2, ...) and each horde runs
310
+ its share as its own plan, so there must be at least H layers.
311
+
312
+ **Sharing.** Shared read, local write. Each member's system prompt lists the sibling horde
313
+ directories; `horde facts` and `horde resources` append every sibling's entries tagged `[hN]`, and
314
+ `horde list` appends their done and ruled-out ideas, so a fact, a rule-out or a lab another horde
315
+ recorded is not re-derived. Open sibling ideas are not shown and nothing is written to a sibling:
316
+ the lock, dedup and coverage rules keep their per-horde scope. Members reuse a sibling's resource
317
+ by its path, not its R#.
318
+
319
+ **Lifecycle.** The hordes start `PI_HORDE_SWARM_STAGGER_MS` (default 5000) apart so H canaries do
320
+ not hit the provider at once; each then runs the lifecycle above unchanged, with its own canary,
321
+ resets, repair, acceptance, deliverable and post-mortem. `--until-stop` applies to every horde.
322
+ Controls take horde targets: `stop-clean h2` winds one horde down, `pause h2:member-3` one member,
323
+ `stop-hard` every horde, and `status` prints one block per horde. The swarm slot frees once every
324
+ horde has written its REPORT.md; after the last horde's finalize a synthesis helper reads the
325
+ hordes' DELIVERABLE.md, REPORT.md and ACCEPTANCE.md and writes the swarm `DELIVERABLE.md`, keeping
326
+ each claim attributed to its horde and to that horde's acceptance label, and the swarm `REPORT.md`
327
+ with per-horde members, turns, tokens, cost, findings and outcome. One swarm report is injected into
328
+ the parent conversation instead of one per horde. No horde reviews another's work; a verification
329
+ pass over the merged deliverable is not built.
330
+
331
+ **Dashboard and widget.** Each swarm has its own scope in the dashboard, with horde tabs and
332
+ swarm-wide ledger, collaboration and graph pages; see [Dashboard](#dashboard). The widget shows one
333
+ line per horde.
334
+
266
335
  ## Finding metadata
267
336
 
268
337
  `importance` (critical, high, medium, low, info), `confidence` (high, medium, low), and
@@ -295,6 +364,8 @@ carries a one-line diagnosis and, when generated, the post-mortem text.
295
364
  | `PI_HORDE_CANARY_MS` | 60000 | time for member-1 to produce output before a retry |
296
365
  | `PI_HORDE_STALL_MS` | 1800000 | reset a member with no ledger write for this long; `0` disables |
297
366
  | `PI_HORDE_RESET_CAP` | 2 | resets per member |
367
+ | `PI_HORDE_SWARM_STAGGER_MS` | 5000 | pause between one horde's launch and the next in a swarm |
368
+ | `PI_HORDE_PLANNER_MS` | 300000 | planner helper timeout when a swarm partitions a plain prompt |
298
369
  | `PI_HORDE_ACTIVE_GRACE_MS` | 15000 | one clean-stop extension for a member still producing output |
299
370
  | `PI_HORDE_SOLVED_CONFIRM_MS` | 120000 | wait for a second member to validate a solve |
300
371
  | `PI_HORDE_SOLVED_STOP_MS` | 30000 | extra wind-down window for a solve landing at the deadline |
@@ -337,6 +408,7 @@ build-info.ts the running package's version and checkout commit, for the
337
408
  collaboration-report.ts acceptance checks, frozen snapshots, collaboration metrics
338
409
  run-graph.ts events.jsonl projected into lanes, phases, nodes, and edges with dashboard links
339
410
  run-graph-svg.ts the swimlane SVG and its legend; run-graph-mermaid.ts the Mermaid export and GRAPH.md
411
+ swarm-graph.ts a swarm's hordes as lanes of lifecycle marks, plus the merge step
340
412
 
341
413
  ledger/ one module per .horde/ document, all writes under the .horde lock
342
414
  files.ts paths, init, atomic writes, shared entry formatting
@@ -361,10 +433,13 @@ tool/ the member-side `horde` tool
361
433
  resource-contract.ts resource generations, readiness, leases, and port coordination
362
434
 
363
435
  run/ the parent-side run lifecycle, sharing one HordeState
364
- types.ts HordeRun, HordeState, ReportDetails
365
- command.ts /agent-horde dispatch and session shutdown
436
+ types.ts HordeRun, Swarm, HordeState, ReportDetails
437
+ command.ts /agent-horde dispatch, horde-prefixed targets, session shutdown
366
438
  setup.ts run directory, TASK.md, roles, plan seeding
367
- launch.ts dashboard, deadline, canary preflight, fan-out
439
+ swarm.ts swarm directories and ids, dealing plan layers, planner output parsing
440
+ swarm-finalize.ts the swarm slot, the merge step, SWARM.json
441
+ swarm-report.ts per-horde totals: the swarm REPORT.md and the injected message
442
+ launch.ts dashboard, deadline, partition, staggered canary preflights, fan-out
368
443
  supervise.ts member spawn, reset on exit, stall detection
369
444
  schedule.ts artifact review assignment and superseded-dependency handoffs
370
445
  repair.ts bounded post-discovery reviews, owner repair, and recheck attempts
@@ -372,7 +447,7 @@ run/ the parent-side run lifecycle, sharing one HordeState
372
447
  controls.ts stop-hard, pause and resume, clean stop and its deadline
373
448
  finalize.ts reconcile, cleanups, SUMMARY.md and REPORT.md, report message, GRAPH.md
374
449
  lifecycle.ts parent lifecycle events in events.jsonl
375
- synthesis.ts deliverable and post-mortem helper agents
450
+ synthesis.ts deliverable, post-mortem, swarm merge and planner helper agents
376
451
  diagnosis.ts one-line run diagnosis
377
452
  ui.ts widget mount, status line, report renderer, notifications
378
453
  completions.ts command argument completions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-horde",
3
- "version": "0.1.6",
3
+ "version": "0.2.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",
package/src/args.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  export const MAX_MEMBERS = 12;
2
2
 
3
+ /** Hordes per swarm; `H/M` launches H hordes of M members. */
4
+ export const MAX_HORDES = 4;
5
+
3
6
  export const USAGE = [
4
7
  "usage: /agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] <count> <prompt>",
5
- " spawn <count> members with the same prompt",
8
+ " spawn <count> members with the same prompt;",
9
+ " <count> may be H/M: H hordes of M members, the",
10
+ " task partitioned between the hordes by a planner",
6
11
  " /agent-horde status [target] show the active run",
7
12
  " /agent-horde stop-hard [target] terminate members now (SIGTERM, then SIGKILL)",
8
13
  " /agent-horde stop-clean [target] let members wrap up, then reconcile their ideas",
@@ -11,11 +16,13 @@ export const USAGE = [
11
16
  " /agent-horde pause [target] suspend members (SIGSTOP)",
12
17
  " /agent-horde resume [target] resume suspended members (SIGCONT)",
13
18
  ` /agent-horde plan [--dashboard] <count> <a; b; ...>`,
14
- " partition members across workstreams",
19
+ " partition members across workstreams; with H/M",
20
+ " the layers go round-robin to the hordes",
15
21
  " /agent-horde help show this text",
16
22
  "",
17
- "target is 'all' (default), 'member-N', 'N', or a comma/space separated list.",
18
- `count is a whole number from 1 to ${MAX_MEMBERS}`,
23
+ "target is 'all' (default), 'member-N', 'N', 'hN' (one horde), 'hN:member-M', 'hN:M', or a",
24
+ "comma/space separated list; a bare member names that member in every horde.",
25
+ `count is a whole number from 1 to ${MAX_MEMBERS}, or H/M with H from 1 to ${MAX_HORDES}`,
19
26
  "--dashboard serves a read-only localhost dashboard (default port 7433, or",
20
27
  "PI_HORDE_DASHBOARD_PORT); --dashboard=<port> picks the port.",
21
28
  "--no-verifier skips the default verifier role that runs of 3 or more members assign",
@@ -25,10 +32,18 @@ export const USAGE = [
25
32
  "session told to add new ideas.",
26
33
  ].join("\n");
27
34
 
28
- /** Which members a control command applies to. */
35
+ /** One token of a control target: a horde, a member in every horde, or a member of one horde. */
36
+ export interface TargetItem {
37
+ /** 1-based horde index (`h2`); absent for every horde. */
38
+ horde?: number;
39
+ /** `member-N`; absent for every member of the horde. */
40
+ member?: string;
41
+ }
42
+
43
+ /** Which hordes and members a control command applies to. */
29
44
  export type HordeTarget =
30
45
  | { kind: "all" }
31
- | { kind: "members"; names: string[] };
46
+ | { kind: "selection"; items: TargetItem[] };
32
47
 
33
48
  type HordeStopMode = "hard" | "clean";
34
49
 
@@ -46,6 +61,9 @@ type HordeArgs =
46
61
  | { kind: "resume"; target: HordeTarget }
47
62
  | {
48
63
  kind: "run";
64
+ /** Concurrent hordes; `12` is one horde, `3/12` three. */
65
+ hordes: number;
66
+ /** Members per horde. */
49
67
  count: number;
50
68
  prompt: string;
51
69
  plan?: string[] | undefined;
@@ -58,22 +76,51 @@ type HordeArgs =
58
76
 
59
77
  export type HordeRunArgs = Extract<HordeArgs, { kind: "run" }>;
60
78
 
79
+ const HORDE_TOKEN = /^h(\d+)$/i;
80
+ const MEMBER_TOKEN = /^(?:member-)?(\d+)$/i;
81
+
82
+ function memberName(token: string): string | undefined {
83
+ const match = MEMBER_TOKEN.exec(token);
84
+ if (!match) return undefined;
85
+ const index = Number(match[1]);
86
+ return index >= 1 && index <= MAX_MEMBERS ? `member-${index}` : undefined;
87
+ }
88
+
89
+ function hordeIndex(token: string): number | undefined {
90
+ const match = HORDE_TOKEN.exec(token);
91
+ if (!match) return undefined;
92
+ const index = Number(match[1]);
93
+ return index >= 1 && index <= MAX_HORDES ? index : undefined;
94
+ }
95
+
96
+ /** `h2`, `h2:member-3`, `h2:3`, `member-3`, or `3`; undefined for anything else. */
97
+ function parseTargetItem(token: string): TargetItem | undefined {
98
+ const colon = token.indexOf(":");
99
+ if (colon === -1) {
100
+ const horde = hordeIndex(token);
101
+ if (horde !== undefined) return { horde };
102
+ const member = memberName(token);
103
+ return member === undefined ? undefined : { member };
104
+ }
105
+ const horde = hordeIndex(token.slice(0, colon));
106
+ const member = memberName(token.slice(colon + 1));
107
+ return horde === undefined || member === undefined ? undefined : { horde, member };
108
+ }
109
+
61
110
  function parseTargets(raw: string): HordeTarget | undefined {
62
111
  const trimmed = raw.trim();
63
112
  if (trimmed === "") return { kind: "all" };
64
- const names: string[] = [];
113
+ const items: TargetItem[] = [];
65
114
  for (const token of trimmed.split(/[\s,]+/).filter(Boolean)) {
66
115
  if (token.toLowerCase() === "all") return { kind: "all" };
67
- const match = /^(?:member-)?(\d+)$/i.exec(token);
68
- if (!match) return undefined;
69
- const index = Number(match[1]);
70
- if (index < 1 || index > MAX_MEMBERS) return undefined;
71
- names.push(`member-${index}`);
116
+ const item = parseTargetItem(token);
117
+ if (!item) return undefined;
118
+ items.push(item);
72
119
  }
73
- return names.length > 0 ? { kind: "members", names } : { kind: "all" };
120
+ return items.length > 0 ? { kind: "selection", items } : { kind: "all" };
74
121
  }
75
122
 
76
- const TARGET_ERROR = "invalid target; use all, member-N, or N";
123
+ const TARGET_ERROR = "invalid target; use all, member-N, N, hN, or hN:member-M";
77
124
 
78
125
  function parseStopRest(
79
126
  rest: string,
@@ -166,24 +213,31 @@ function mergeLaunchFlags(leading: LaunchFlags, trailing: LaunchFlags): LaunchFl
166
213
  };
167
214
  }
168
215
 
169
- /** Parse `<count> [flags] <tail>`. */
170
- function parseCountedRun(
171
- text: string,
172
- leading: LaunchFlags,
173
- ): { count: number; rest: string; flags: LaunchFlags } | { error: string } {
174
- const match = /^(\d+)(?:\s+([\s\S]*))?$/.exec(text.trim());
175
- if (!match) return { error: `count must be a whole number from 1 to ${MAX_MEMBERS}` };
176
- const count = Number(match[1]);
177
- if (count < 1 || count > MAX_MEMBERS) {
178
- return { error: `count must be a whole number from 1 to ${MAX_MEMBERS}` };
179
- }
180
- const flags = mergeLaunchFlags(leading, takeLaunchFlags(match[2] ?? ""));
216
+ const COUNT_ERROR =
217
+ `count must be a whole number from 1 to ${MAX_MEMBERS}, or H/M with H from 1 to ${MAX_HORDES} hordes`;
218
+
219
+ interface CountedRun {
220
+ hordes: number;
221
+ count: number;
222
+ rest: string;
223
+ flags: LaunchFlags;
224
+ }
225
+
226
+ /** Parse `<count> [flags] <tail>`, where count is `M` or `H/M`. */
227
+ function parseCountedRun(text: string, leading: LaunchFlags): CountedRun | { error: string } {
228
+ const match = /^(?:(\d+)\/)?(\d+)(?:\s+([\s\S]*))?$/.exec(text.trim());
229
+ if (!match) return { error: COUNT_ERROR };
230
+ const hordes = match[1] === undefined ? 1 : Number(match[1]);
231
+ const count = Number(match[2]);
232
+ if (count < 1 || count > MAX_MEMBERS || hordes < 1 || hordes > MAX_HORDES) return { error: COUNT_ERROR };
233
+ const flags = mergeLaunchFlags(leading, takeLaunchFlags(match[3] ?? ""));
181
234
  if (flags.error) return { error: flags.error };
182
- return { count, rest: flags.rest, flags };
235
+ return { hordes, count, rest: flags.rest, flags };
183
236
  }
184
237
 
185
- function runArgs(count: number, prompt: string, flags: LaunchFlags, plan?: string[]): HordeRunArgs {
186
- const args: HordeRunArgs = { kind: "run", count, prompt };
238
+ function runArgs(parsed: CountedRun, prompt: string, plan?: string[]): HordeRunArgs {
239
+ const { hordes, count, flags } = parsed;
240
+ const args: HordeRunArgs = { kind: "run", hordes, count, prompt };
187
241
  if (plan) args.plan = plan;
188
242
  if (flags.dashboard) args.dashboard = flags.dashboard;
189
243
  if (flags.noVerifier) args.noVerifier = true;
@@ -191,6 +245,26 @@ function runArgs(count: number, prompt: string, flags: LaunchFlags, plan?: strin
191
245
  return args;
192
246
  }
193
247
 
248
+ function parsePlan(rest: string, leading: LaunchFlags): HordeArgs {
249
+ const afterPlan = takeLaunchFlags(rest);
250
+ if (afterPlan.error) return { kind: "usage", error: afterPlan.error };
251
+ const parsed = parseCountedRun(afterPlan.rest, mergeLaunchFlags(leading, afterPlan));
252
+ if ("error" in parsed) {
253
+ return { kind: "usage", error: "plan usage: /agent-horde plan <count> <layer1; layer2; ...>" };
254
+ }
255
+ const layers = parsed.rest
256
+ .split(/[;\n]/)
257
+ .map((layer) => layer.trim())
258
+ .filter(Boolean);
259
+ if (layers.length < 2) {
260
+ return { kind: "usage", error: "plan needs at least 2 layers separated by ';'" };
261
+ }
262
+ if (layers.length < parsed.hordes) {
263
+ return { kind: "usage", error: `plan with ${parsed.hordes} hordes needs at least ${parsed.hordes} layers` };
264
+ }
265
+ return runArgs(parsed, layers.join("; "), layers);
266
+ }
267
+
194
268
  export function parseHordeArgs(raw: string): HordeArgs {
195
269
  const leading = takeLaunchFlags(raw);
196
270
  if (leading.error) return { kind: "usage", error: leading.error };
@@ -203,7 +277,7 @@ export function parseHordeArgs(raw: string): HordeArgs {
203
277
  const space = trimmed.search(/\s/);
204
278
  const head = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
205
279
  const rest = space === -1 ? "" : trimmed.slice(space + 1);
206
- if (hasFlags && head !== "plan" && !/^\d+$/.test(head)) {
280
+ if (hasFlags && head !== "plan" && !/^\d+(?:\/\d+)?$/.test(head)) {
207
281
  return { kind: "usage", error: "launch flags only apply when starting a run" };
208
282
  }
209
283
 
@@ -232,22 +306,8 @@ export function parseHordeArgs(raw: string): HordeArgs {
232
306
  const target = parseTargets(rest);
233
307
  return target ? { kind: "pause", target } : { kind: "usage", error: TARGET_ERROR };
234
308
  }
235
- case "plan": {
236
- const afterPlan = takeLaunchFlags(rest);
237
- if (afterPlan.error) return { kind: "usage", error: afterPlan.error };
238
- const parsed = parseCountedRun(afterPlan.rest, mergeLaunchFlags(leading, afterPlan));
239
- if ("error" in parsed) {
240
- return { kind: "usage", error: "plan usage: /agent-horde plan <count> <layer1; layer2; ...>" };
241
- }
242
- const layers = parsed.rest
243
- .split(/[;\n]/)
244
- .map((layer) => layer.trim())
245
- .filter(Boolean);
246
- if (layers.length < 2) {
247
- return { kind: "usage", error: "plan needs at least 2 layers separated by ';'" };
248
- }
249
- return runArgs(parsed.count, layers.join("; "), parsed.flags, layers);
250
- }
309
+ case "plan":
310
+ return parsePlan(rest, leading);
251
311
  case "resume": {
252
312
  const target = parseTargets(rest);
253
313
  return target ? { kind: "resume", target } : { kind: "usage", error: TARGET_ERROR };
@@ -257,5 +317,5 @@ export function parseHordeArgs(raw: string): HordeArgs {
257
317
  const parsed = parseCountedRun(trimmed, leading);
258
318
  if ("error" in parsed) return { kind: "usage", error: parsed.error };
259
319
  if (parsed.rest === "") return { kind: "usage", error: "prompt is required" };
260
- return runArgs(parsed.count, parsed.rest, parsed.flags);
320
+ return runArgs(parsed, parsed.rest);
261
321
  }