bullswarm 0.32.0 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/GOAL.md +1 -1
  3. package/docs/guide/routing.md +81 -10
  4. package/docs/reference/cli.md +5 -2
  5. package/docs/reference/configuration.md +1 -1
  6. package/docs/reference/providers.md +5 -5
  7. package/docs/workflow-design.md +1 -1
  8. package/mods/bullswarm/hooks/pane.tsx +20 -2
  9. package/mods/bullswarm/hooks/pools.ts +2 -0
  10. package/mods/bullswarm/types/index.d.ts +4 -0
  11. package/package.json +1 -1
  12. package/providers/contrib/README.md +1 -1
  13. package/providers/contrib/command-code/provider.mjs +13 -5
  14. package/providers/contrib/{opencode2 → opencode}/connector.json +2 -1
  15. package/providers/contrib/{opencode2 → opencode}/provider.mjs +3 -3
  16. package/skill/references/providers.md +1 -1
  17. package/src/cli.js +56 -8
  18. package/src/help.js +7 -4
  19. package/src/lib/cli-flags.js +1 -1
  20. package/src/lib/config.js +73 -2
  21. package/src/lib/forecast.js +4 -2
  22. package/src/lib/route.js +173 -46
  23. package/src/lib/state.js +395 -3
  24. package/src/lib/strategy.js +2 -2
  25. package/src/lib/usage.js +14 -0
  26. package/src/meters/framework.js +55 -1
  27. package/src/meters/registry.js +112 -13
  28. package/src/provider-kit.js +9 -3
  29. package/src/providers/claude-code/provider.mjs +9 -3
  30. package/src/providers/codex/provider.mjs +9 -4
  31. package/src/providers/grok/provider.mjs +9 -4
  32. package/src/workflow/cli.js +51 -1
  33. package/src/workflow/v2-dispatch.js +183 -26
  34. package/src/workflow/v2-outcome.js +4 -1
  35. package/src/workflow/v2-runtime.js +43 -10
  36. package/src/workflow/v2-state.js +7 -1
  37. package/src/workflow/watch-cli.js +43 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.32.1 — free models first, with graceful failover
6
+
7
+ - meters: rate-limited or failed live reads now persist a per-pool negative-cache hold (honouring `Retry-After` or the five-minute freshness window), and `pools`/the Claude Mod identify the stale snapshot's error and retry time.
8
+ - rename: the OpenCode pool is now `opencode`; the first run migrates saved
9
+ state, routing, provider, and meter names automatically, while historical
10
+ workflow run records keep their recorded `opencode2` name.
11
+ - routing: a pool whose model for the effort tier costs nothing is ranked ahead
12
+ of every metered pool while it is healthy. Free-ness is per (pool, effort
13
+ tier) and comes from the connector — `modelProfiles[].free`, or a model name
14
+ carrying a standalone `free` segment. The free tier sits below the forecast
15
+ gate and the 5-hour headroom tier and above expiring-soon urgency, so an
16
+ expiring metered window can now go unspent while free work runs; the ranking
17
+ among metered pools is unchanged. The reason reads `free pool first: <pool>
18
+ (free model <model>, …) · metered pools ranked below free: <pool> <surplus>`.
19
+ - routing: a pool that stalls, returns a server error, or (on a free pool) hands
20
+ back literally empty output takes a strike. The first is recorded; the second
21
+ consecutive one soft-benches the pool for a 10-minute cooldown, after which it
22
+ returns automatically. The strike count survives the cooldown and is cleared
23
+ only by a success. Auth is untouched: an upstream auth failure still
24
+ quarantines and still spreads across a credential group. An answer the
25
+ verifier judged thin but not empty stays semantic and is not retried
26
+ elsewhere.
27
+ - workflow: a free pool's silence clock is the median wall time of its own
28
+ recorded runs at that (pool, effort) rung — the `p50`, once 3 runs exist there
29
+ — with a 5-minute floor, instead of the one-hour default a metered pool keeps.
30
+ The multiplier on that median is 1 (`FREE_STALL_P50_FACTOR`): a worker silent
31
+ for as long as the whole rung usually takes has stopped working. Stalled
32
+ attempts are excluded from the median so a pool cannot tighten its own
33
+ threshold by stalling.
34
+ - workflow: a stalled attempt ends, keeps its partial output on disk (the retry
35
+ writes a new `-attempt-N` file beside it, never over it), releases its
36
+ in-flight ledger entry, and re-dispatches the same action on the next eligible
37
+ pool in the same run. The attempt record and `attempt.finished` carry
38
+ `stalled`, `partialOutput`, `silentSec` and `willRetry`; a new `pool.benched` event names
39
+ the pool, reason, strike count and deadline; the retry's reason is prefixed
40
+ `fallback from <pool> after stall <n>s`. A free-pool stall (and the
41
+ free-only literally-empty-output provider reclassification) does not spend
42
+ `maxMechanicalRetries`; the tried-set is the bound and each pool is tried at
43
+ most once for the action. A metered-pool stall keeps the mechanical retry
44
+ accounting.
45
+ - workflow: evidence and acceptance steps are exempt from the free tier and
46
+ route on pace as before, with one preference — a pool that wrote the work
47
+ being judged is chosen last, and only when no other pool is eligible, which
48
+ the reason then says (`evidence step: only the writer pool <pool> is
49
+ eligible`). This restores, in prefer-not rather than forbid form, the steering
50
+ removed in `eb83b79`; the reason line now names the exception, which its
51
+ absence was half the reason for that removal.
52
+ - pools: `bullswarm pools` prints `free=<model>` for a pool whose model costs
53
+ nothing (`free=<tier>:<model>` when it differs per effort tier, since `pools`
54
+ names no lane), `BENCHED until <time> (<reason>, <n> strikes)` for a benched
55
+ pool, and `strikes=<n>(<reason>)` for one carrying an uncounted-out strike. It
56
+ sweeps expired benches the same way it sweeps expired quarantines.
57
+ - routing: measured pacing rates now charge timed in-flight work at the actual
58
+ `rate × remaining minutes` with no 3-point floor, so an expiring-soon pool
59
+ with a known burn rate is not demoted by a tie-breaker meant for unmeasured
60
+ pools. `config.inflightPenaltyPct` remains the per-agent fallback for pools
61
+ without a measured rate (and for in-flight records with unknown duration).
62
+ - workflow: every attempt record now carries `routeWhy` (the router's reason)
63
+ and `routeCandidates` (each pool's effective surplus, urgency state and
64
+ pacing forecast at pick time); `workflow action show --json` and
65
+ `runs result --json` print them, and older state files still load.
66
+
67
+ - routing: a pool whose pacing window is about to reset is `draining` only
68
+ when its forecast is at or above 95% *and* ahead of the window's own clock,
69
+ the same clock-relative shape the 5h near-limit line already has. A pool on
70
+ or behind pace keeps spending right up to its reset instead of being ranked
71
+ last by a fixed line (observed 2026-09-16: command-code at 94.9% used with
72
+ 98% of the month gone was passed over while +3 points of quota expired).
73
+ The skip reason now reads `expiring but draining (forecast >= 95% and past
74
+ its clock): <pool> <forecast>% (<elapsed>% elapsed)`.
75
+ - workflow goal: refuses to launch a duplicate of a run that is already going.
76
+ Before anything is validated or launched, an ongoing run with the same goal
77
+ text and cwd exits 2, naming that run's shortId, age, and watch command
78
+ (JSON: `{"error":"duplicate-goal",...}`); `--again` starts the copy anyway
79
+ (observed 2026-09-16: a caller whose JSON parser failed on the first
80
+ launch's output retried five seconds later and two identical workflows ran
81
+ side by side in the same directory).
82
+
3
83
  ## 0.32.0 — the dashboard is the main screen
4
84
 
5
85
  - wording: a program run's dependency levels are called phases everywhere the
package/GOAL.md CHANGED
@@ -35,7 +35,7 @@ A working prototype installed at `~/.bullswarm/` with:
35
35
  2. **Connector registry** (`~/.bullswarm/connectors/*.json`): declarative per-CLI
36
36
  spawn command, auth-failure signatures, output extraction, verify contract,
37
37
  quirk fields (e.g. PWD resolution). Seeded with codex, grok,
38
- command-code, opencode2 — extracted from the proven `/offload` skill.
38
+ command-code, opencode — extracted from the proven `/offload` skill.
39
39
  3. **Meter layer**: per-pool window definition (5h / weekly / none) with
40
40
  programmatic readers where providers expose usage, manual declaration
41
41
  (`--meter pool=window,pct`) where they don't. Unmetered pools pace as
@@ -9,19 +9,20 @@ After this page you can predict which pool a task will go to, read the reason li
9
9
 
10
10
  ## The order decisions are made
11
11
 
12
- Routing runs the same five checks every time, in this order. Each one is a filter or a preference within the set that survived the one before it.
12
+ Routing runs the same six checks every time, in this order. Each one is a filter or a preference within the set that survived the one before it.
13
13
 
14
- 1. **Eligibility** — enabled, capable of the lane, not quarantined, not exhausted, and allowed a model for the effort tier.
14
+ 1. **Eligibility** — enabled, capable of the lane, not quarantined, not benched, not exhausted, and allowed a model for the effort tier.
15
15
  2. **Forecast gate** — a pool projected at or above 90% of its 5-hour window is pushed to the back.
16
16
  3. **5-hour headroom** — while any pool is below the near-limit line, only those are selectable.
17
- 4. **Expiring-soon urgency** — while any pool's week or month is about to reset with quota left, only those are selectable.
18
- 5. **Preference inside the survivors** — an explicit tier assignment, then incumbency, then the highest effective surplus.
17
+ 4. **Free models** — while any surviving pool's model for this effort tier costs nothing, only those are selectable.
18
+ 5. **Expiring-soon urgency** — while any pool's week or month is about to reset with quota left, only those are selectable.
19
+ 6. **Preference inside the survivors** — an explicit tier assignment, then incumbency, then the highest effective surplus.
19
20
 
20
- The rest of this page is those five steps in detail.
21
+ The rest of this page is those six steps in detail.
21
22
 
22
23
  ## Eligibility
23
24
 
24
- A pool has to be enabled, declare the lane, hold any capabilities the work requires, not be quarantined, not be exhausted (100% of its window used), and have an allowed model for the effort tier. A disabled pool simply is not a candidate. Nothing in the later steps can rescue a pool that fails here.
25
+ A pool has to be enabled, declare the lane, hold any capabilities the work requires, not be quarantined, not be soft-benched, not be exhausted (100% of its window used), and have an allowed model for the effort tier. A disabled pool simply is not a candidate. Nothing in the later steps can rescue a pool that fails here.
25
26
 
26
27
  ## Pace and surplus
27
28
 
@@ -31,13 +32,78 @@ Pace compares a pool with itself: **surplus = elapsed% − used%** of its own su
31
32
 
32
33
  The rolling 5-hour window never paces — it only gates. A pool whose forecast is at or above 75% (`FIVE_HOUR_NEAR_LIMIT_PCT`) **and** above the share of that window already elapsed is tiered down: it is picked only when no eligible pool below the line exists for the lane. A pool at or above 90% (`BURST_BLOCK_PCT`) is left out of selection entirely, and that gate ignores the clock.
33
34
 
35
+ ## Free models first
36
+
37
+ A model that costs nothing does not spend anyone's quota, so a pool holding one is ranked ahead of every metered pool. While any eligible pool's model for this effort tier is free, the metered pools are not selectable at all — they are not merely outscored, so the pick is not a close call that a surplus swing can flip.
38
+
39
+ Free-ness is a property of **(pool, effort tier)**, never of a pool: the same pool can hold a free model on `medium` and a paid one on `high`. It comes from the connector, which declares `"free": true` on the model profile that matches the selected model (`providers/*/connector.json`, `modelProfiles`); a discovered model whose name carries a standalone `free` segment — `openrouter/qwen:free` — counts as well.
40
+
41
+ The rest of the order is untouched. Free sits *below* the forecast gate and the 5-hour headroom tier, so a free pool at or above its 5-hour line still loses to a pool with headroom. It sits *above* expiring-soon urgency, which is a deliberate trade: while free work is available, a metered pool's expiring quota can expire unspent. And the ranking **among** metered pools is exactly what it was — the free tier is a constant across all of them, so their relative order is still decided by the existing keys.
42
+
43
+ The reason line says which rule applied, and names what it passed over:
44
+
45
+ ```text
46
+ free pool first: opencode (free model opencode/union-alpha, 5h used 12.0%, 1 in flight) · metered pools ranked below free: codex 40.0, grok 12.3
47
+ ```
48
+
49
+ ## Soft bench: alive, but not producing
50
+
51
+ A free endpoint does not fail the way a metered one does. It has no usage meter, so it can never read as exhausted — when its free window ends the API starts erroring or falling silent rather than reporting 100%. The soft bench is the backstop.
52
+
53
+ Three failures count as a **strike** against a pool:
54
+
55
+ - **stall** — the worker wrote no output for longer than its silence threshold and was stopped;
56
+ - **provider** — the provider returned a server error;
57
+ - **empty output** — a free pool returned literally nothing. (An answer that has substance but the verifier judged thin stays a semantic failure and is not retried elsewhere; that is a verdict about the answer, not about the pool.)
58
+
59
+ Strikes are **consecutive**. The first one is recorded without taking the pool out of service. The second benches it for a 10-minute cooldown — the same re-probe window a quarantine uses — after which it returns automatically. The count survives that cooldown and is cleared only by a success, so a pool that stalls again straight after coming back is benched again immediately.
60
+
61
+ A bench is not a quarantine, and it changes nothing about auth. An upstream auth failure still quarantines, still spreads across a credential group, and its deadline is never shortened by a bench. A benched pool also keeps having its meter read, because it is coming back in ten minutes.
62
+
63
+ ## Stall fallback
64
+
65
+ The silence clock bounds silence, not run time: it restarts on every byte a worker writes. A metered pool keeps the one-hour default. A **free** pool is stopped after the median wall time its own recorded runs at that (pool, effort) rung actually took — the `p50` in `bullswarm strategy`, and only once at least 3 runs have been recorded there — with a floor of 5 minutes. Stalled attempts are excluded from that median, so a pool cannot tighten its own threshold by stalling. `BULLSWARM_WORKER_SILENCE_SEC` overrides it for a probe.
66
+
67
+ When an attempt stalls, the run does not stop and the work is not lost:
68
+
69
+ - the worker's process is ended and **whatever it had already written stays on disk** — the retry writes a new `out-<action>-attempt-<n>` file beside it, never over it;
70
+ - the in-flight ledger entry is released, so the next pick sees the pool as idle;
71
+ - the same action is re-dispatched on the next eligible pool **in the same run**, and its reason line is prefixed with where it came from;
72
+ - the second consecutive stall benches the pool, so later actions in that run do not each wait out the threshold.
73
+
74
+ `--retry-attempts` (default 1) caps mechanical retries for metered failures. A
75
+ stall on a free pool (and its literally-empty-output provider reclassification)
76
+ does not spend that allowance: the dispatcher advances through eligible pools,
77
+ trying each pool at most once for the action, so the tried-set remains the
78
+ termination bound. A stall on a metered pool keeps the mechanical accounting.
79
+
80
+ ```text
81
+ fallback from opencode after stall 300s · most-behind capable pool (surplus 40)
82
+ benched (stall, back at 2026-09-17T02:40:36.911Z): opencode
83
+ ```
84
+
85
+ The run's events carry the same facts: `attempt.finished` gains `stalled`,
86
+ `partialOutput`, `silentSec` and `willRetry`, and a `pool.benched` event names
87
+ the pool, the reason, the strike count and the deadline. `bullswarm workflow
88
+ watch` renders `retrying on another pool` only when `willRetry` is true;
89
+ otherwise it says `no retry left`.
90
+
91
+ ## Evidence steps keep normal routing
92
+
93
+ An evidence or acceptance step is never pushed onto the free pool: the free tier is switched off for it, and it routes on pace like any other action. It does get one preference — **a pool that wrote the work being judged is chosen last**. While any other eligible pool exists, the writer is not selected; when it is the only one left, it runs and the reason says so rather than failing the step.
94
+
95
+ ```text
96
+ evidence step: normal routing (free tier not applied)
97
+ evidence step: only the writer pool answerer is eligible
98
+ ```
99
+
34
100
  ## Expiring-soon urgency
35
101
 
36
- A pool whose pacing window resets within 24 hours (weekly) or 3 days (monthly) is ranked on urgency — its surplus divided by the fraction of the window still to run — instead of on the surplus alone. While any urgent pool can still spend its quota, it is the only one selectable, which is how a pool with two hours left beats a pool with three days left. A pool already forecast at or above 95% of its pacing window is `draining` and goes last.
102
+ A pool whose pacing window resets within 24 hours (weekly) or 3 days (monthly) is ranked on urgency — its surplus divided by the fraction of the window still to run — instead of on the surplus alone. While any urgent pool can still spend its quota, it is the only one selectable, which is how a pool with two hours left beats a pool with three days left. A pool forecast at or above 95% of its pacing window *and* ahead of the window's own clock (forecast above the elapsed share) is `draining` and goes last; a pool at 96% with 98% of its month gone is spending at its own pace, not draining, and keeps its quota in play until the reset.
37
103
 
38
104
  ## In-flight load
39
105
 
40
- Work already dispatched is charged against a pool before the next pick: each pool's effective surplus is its pace minus what its in-flight agents are expected to spend, and minus at least 3 surplus points per agent when no measured rate exists. A busier pool therefore yields to a quieter one at similar pace. The floor is `config.inflightPenaltyPct` in `~/.bullswarm/state.json` (default 3; `0` turns it off).
106
+ Work already dispatched is charged against a pool before the next pick: each pool's effective surplus is its pace minus what its in-flight agents and the candidate are expected to spend. When the pacing window has a measured rate, each timed in-flight record is charged at `rate × remaining minutes` with no floor. A pool with no measured rate uses the flat `config.inflightPenaltyPct` tie-breaker in `~/.bullswarm/state.json` (default 3; `0` turns it off); a measured pool uses that value only for an in-flight record whose remaining duration is unknown. A busier pool therefore yields to a quieter one at similar pace.
41
107
 
42
108
  ## Assignments and incumbency
43
109
 
@@ -57,7 +123,12 @@ A relayed credential fails upstream, not in the CLI. When a provider's event str
57
123
 
58
124
  ## How the decision shows its work
59
125
 
60
- `bullswarm pools` prints one line per pool: `cost=`, `lanes=`, the meter it is paced from, `surplus=`, `inflight=`, the 5-hour column as `5h=<reading>%-><projected>%` with `(<n>% elapsed)`, then `ready`, `disabled`, `QUARANTINED until …`, `NEAR-5H-LIMIT`, `BURST-GATED`, or `resets in … EXPIRING-SOON urgency=<n>`.
126
+ `bullswarm pools` prints one line per pool: `cost=`, `lanes=`, the meter it is paced from, `surplus=`, `inflight=`, the 5-hour column as `5h=<reading>%-><projected>%` with `(<n>% elapsed)`, `free=<model>` when its model costs nothing, then `ready`, `disabled`, `QUARANTINED until …`, `BENCHED until … (<reason>, <n> strikes)`, `NEAR-5H-LIMIT`, `BURST-GATED`, or `resets in … EXPIRING-SOON urgency=<n>`. A pool carrying an uncounted-out strike prints `strikes=<n>(<reason>)` beside `ready`. `pools` names no lane and therefore no effort tier, so when free-ness differs per tier the column names each one — `free=medium:opencode/union-alpha`. A stale meter held after a failed poll is marked `[stale · <status-or-kind>, retry in <time>]`; `pools --json` carries the same `meterError` and `meterHoldUntil` fields.
127
+
128
+ ```text
129
+ answerer cost=3 lanes=analyze/build/chore unmetered surplus=0 inflight=0 ready
130
+ opencode cost=1 lanes=analyze/build/chore unmetered surplus=0 inflight=0 free=opencode/union-alpha BENCHED until 10:40:36 AM (stall, 2 strikes)
131
+ ```
61
132
 
62
133
  ```bash
63
134
  # every pool, with its meter, pace, load, and quarantine state
@@ -92,7 +163,7 @@ forecast: inflight=0 5h ?%->?% expected=5.67m rate=unmeasured basis=bootstrap
92
163
 
93
164
  Grok won because it was the only pool left: this `analyze` task resolves to the medium effort tier, whose allow-list names a model only on grok and codex, and codex is disabled — eligibility runs before any pace comparison, so the +35.6 on `claude-code:wati` never entered the race.
94
165
 
95
- When a pool is passed over, the reason says so in the same line — `skipped near 5h limit (projected): claude-code:wati 88.1% (92.3% elapsed)`, `forecast-gated at/above 90%: …`, `expiring but draining (forecast >= 95%): …`, or `preferred over busier: … (2 in flight)`.
166
+ When a pool is passed over, the reason says so in the same line — `skipped near 5h limit (projected): claude-code:wati 88.1% (92.3% elapsed)`, `forecast-gated at/above 90%: …`, `expiring but draining (forecast >= 95% and past its clock): grok 99.5% (98.8% elapsed)`, or `preferred over busier: … (2 in flight)`.
96
167
 
97
168
  ## Next steps
98
169
 
@@ -522,7 +522,7 @@ Record known subscription pricing for a pool so refresh's value-multiple math is
522
522
  ```bash
523
523
  # Record plan economics, then declare a refill date a wallet does not report.
524
524
  bullswarm strategy set-subscription claude --plan max --monthly-usd 200 --included-usd 1000
525
- bullswarm strategy set-subscription opencode2 --resets-at 2026-09-17T01:46:01Z
525
+ bullswarm strategy set-subscription opencode --resets-at 2026-09-17T01:46:01Z
526
526
  ```
527
527
 
528
528
  | Flag | Meaning | Default |
@@ -619,7 +619,7 @@ Write a commented provider directory: a `provider.mjs` skeleton showing every ex
619
619
 
620
620
  ```bash
621
621
  # Scaffold a reseller that runs through OpenCode.
622
- bullswarm provider scaffold relay --from opencode2
622
+ bullswarm provider scaffold relay --from opencode
623
623
  ```
624
624
 
625
625
  | Flag | Meaning | Default |
@@ -679,6 +679,8 @@ Without a program the command refuses (exit 2, nothing launched) unless `--scout
679
679
 
680
680
  Launches independently by default. `--resume <shortId|runId>` resumes a V2 run and is mutually exclusive with new goal text.
681
681
 
682
+ A duplicate launch is refused before anything is validated or started: when an ongoing run in the same `--cwd` already has this goal text, the command exits 2 with that run's `shortId`, its age, and the command to watch it. `--json` prints `{"error":"duplicate-goal","shortId":…,"runId":…,"startedAt":…,"next":{"watch":…,"again":…}}`. Pass `--again` to start the second copy anyway.
683
+
682
684
  ```bash
683
685
  # Caller-planned launch that follows progress until terminal.
684
686
  bullswarm workflow goal "1. Fix src/parser.js. 2. Update docs." --cwd . --program plan.json --watch
@@ -711,6 +713,7 @@ bullswarm workflow goal "1. Fix src/parser.js. 2. Update docs." --cwd . --progra
711
713
  | `--retry-attempts <0..3>` | bounded retries for mechanical failures only; semantic evidence never auto-repairs | `1` |
712
714
  | `--resume <shortId\|runId>` | resume a V2 autonomous run; old autonomous runs fail closed before dispatch | starts a new goal |
713
715
  | `--detach` | rarely needed — explicitly requests the default independent-launch behavior; cannot combine with `--watch` | the default launch already detaches |
716
+ | `--again` | start another copy even when an ongoing run already has the same goal text in the same `--cwd`; only a new launch is checked, never `--resume` or the internal `--request` relaunch | off (a duplicate of an ongoing goal is refused) |
714
717
 
715
718
  Workers keep their edits even when their action fails. Failed dependencies skip downstream actions and independent branches finish. Saved V2 runs keep their original completion and isolation policy when resumed.
716
719
 
@@ -116,7 +116,7 @@ A provider is a directory with `connector.json` and/or `provider.mjs`. Three tie
116
116
 
117
117
  ```bash
118
118
  # Write a local provider directory (default ~/.bullswarm/providers/<name>/).
119
- bullswarm provider scaffold relay --from opencode2
119
+ bullswarm provider scaffold relay --from opencode
120
120
  bullswarm provider validate relay
121
121
  bullswarm provider probe relay
122
122
  ```
@@ -16,7 +16,7 @@ A provider is a directory holding a `connector.json`, a `provider.mjs`, or both.
16
16
  | Tier | Directory | Loaded when | Members |
17
17
  |---|---|---|---|
18
18
  | first-class | `src/providers/<name>/` | always | `claude-code`, `codex`, `grok`, `echo` |
19
- | contrib | `providers/contrib/<name>/` | listed in `~/.bullswarm/providers.json` | `command-code`, `opencode2` |
19
+ | contrib | `providers/contrib/<name>/` | listed in `~/.bullswarm/providers.json` | `command-code`, `opencode` |
20
20
  | local | `~/.bullswarm/providers/<name>/` | always | your own, never in the repository |
21
21
 
22
22
  First-class and contrib providers ship in the package. A contrib provider loads only on a machine that lists it:
@@ -80,7 +80,7 @@ Every method receives the same `ctx`:
80
80
 
81
81
  `readUsage` also receives `subscription`: the pool's entry in `state.strategy.subscriptions`, or `null`. It carries `includedValueUsd`, `quotaWindow`, `resetsAt`, `plan`, and `monthlyPriceUsd`.
82
82
 
83
- `templates` exists so a provider can build on a shipped CLI without copying its template: a reseller of OpenCode access clones `templates.opencode2`.
83
+ `templates` exists so a provider can build on a shipped CLI without copying its template: a reseller of OpenCode access clones `templates.opencode`.
84
84
 
85
85
  ## The kit
86
86
 
@@ -143,7 +143,7 @@ These are all the pool fields a provider may set, and the part of the core that
143
143
 
144
144
  ## Example: a reseller named relay
145
145
 
146
- Relay sells OpenCode access through two accounts. The provider clones the shipped `opencode2` template once per account, pins each account's model and reasoning variants, groups both under one credential so a usage limit on one benches its sibling, and reports spend from a wallet:
146
+ Relay sells OpenCode access through two accounts. The provider clones the shipped `opencode` template once per account, pins each account's model and reasoning variants, groups both under one credential so a usage limit on one benches its sibling, and reports spend from a wallet:
147
147
 
148
148
  ```js
149
149
  // ~/.bullswarm/providers/relay/provider.mjs
@@ -152,7 +152,7 @@ export const displayName = 'Relay';
152
152
 
153
153
  export function connectors({ kit, templates }) {
154
154
  const accounts = [{ id: 'a', models: ['gpt-5.6-sol'] }, { id: 'b', models: ['gpt-5.6-sol'] }];
155
- return accounts.map((acc, i) => kit.clonePool(templates.opencode2, {
155
+ return accounts.map((acc, i) => kit.clonePool(templates.opencode, {
156
156
  name: i === 0 ? 'relay' : `relay:${acc.id}`,
157
157
  model: `${acc.id}/gpt-5.6-sol`,
158
158
  env: { OPENCODE_CONFIG_CONTENT: kit.opencodeVariants(acc.id, acc.models) },
@@ -187,7 +187,7 @@ The `usedUsd = 12.5` line is a placeholder: a real `readUsage` fetches the walle
187
187
 
188
188
  ```bash
189
189
  # Author a local provider from a shipped template, then check shape and spawn.
190
- bullswarm provider scaffold relay --from opencode2
190
+ bullswarm provider scaffold relay --from opencode
191
191
  bullswarm provider validate relay
192
192
  bullswarm provider probe relay --json
193
193
  ```
@@ -175,7 +175,7 @@ bullswarm workflow · route-review · run wf-a1b2c3
175
175
  ▐ phase 2/2 · review ⏳ running
176
176
  ⟡ per-file-review[0/47] grok 31.0s … verifying
177
177
  ✓ per-file-review[1/47] command-code 28.9s ok
178
- ✗ per-file-review[2/47] opencode2 40.1s fail · announcement without substance
178
+ ✗ per-file-review[2/47] opencode 40.1s fail · announcement without substance
179
179
  ↳ escalate → codex
180
180
  ⋈ per-file-review[3/47] — — quarantined pool, waiting
181
181
 
@@ -69,6 +69,25 @@ export type PaneActions = {
69
69
  close: () => void
70
70
  }
71
71
 
72
+ /** The single-row Usage header, including the oldest failed meter reason. */
73
+ export function poolsHeaderText(
74
+ pools: readonly BullswarmPool[],
75
+ nowMs: number,
76
+ nameOf: (pool: string | null) => string = pool => pool ?? '',
77
+ ): string {
78
+ const sampled = pools
79
+ .filter(p => !!p.capturedAt)
80
+ .sort((a, b) => Date.parse(a.capturedAt!) - Date.parse(b.capturedAt!))
81
+ const oldest = sampled[0] ?? null
82
+ const oldestError = sampled.find(p => !!p.meterError) ?? null
83
+ const shown = oldestError ?? oldest
84
+ if (!shown) return 'Pools · no meter snapshot yet'
85
+ const age = ageOf(shown.capturedAt, nowMs) || '0m'
86
+ return oldestError
87
+ ? `Pools · ${nameOf(shown.name)} sampled ${age} ago · ${oldestError.meterError}`
88
+ : `Pools · sampled ${age} ago`
89
+ }
90
+
72
91
  /** Below this many body rows the pane draws its compact layout (a phone terminal, a short inline pane). */
73
92
  export const COMPACT_ROWS = 20
74
93
  /** Blank rows drawn past the footer so the engine reports the pane's true height. */
@@ -253,13 +272,12 @@ export function paneView(
253
272
  const blank = (key: string) => <Text key={key}> </Text>
254
273
 
255
274
  if (model.poolsPage) {
256
- const sampled = model.pools.map(p => p.capturedAt).filter((x): x is string => !!x).sort()[0] ?? null
257
275
  const header: RenderElement[] = [
258
276
  <Text key="h0" wrap="truncate-end">
259
277
  <Text bold color="cyan">
260
278
  Pools
261
279
  </Text>
262
- <Text dimColor>{sampled ? ` · sampled ${ageOf(sampled, model.nowMs) || '0m'} ago` : ' · no meter snapshot yet'}</Text>
280
+ <Text dimColor>{poolsHeaderText(model.pools, model.nowMs, nameOf).slice('Pools'.length)}</Text>
263
281
  </Text>,
264
282
  ]
265
283
  if (!compact) header.push(nav)
@@ -33,6 +33,8 @@ export function parsePools(stdout: string): BullswarmPool[] {
33
33
  fiveHourUsedPct: num(p.fiveHourUsedPct),
34
34
  costRank: num(p.costRank),
35
35
  meterSource: str(p.meterSource),
36
+ meterError: str(p.meterError),
37
+ meterHoldUntil: num(p.meterHoldUntil),
36
38
  incumbentLane: Array.isArray(p.incumbentLane)
37
39
  ? p.incumbentLane.filter((l): l is string => typeof l === 'string')
38
40
  : [],
@@ -166,6 +166,10 @@ export type BullswarmPool = {
166
166
  fiveHourUsedPct: number | null
167
167
  costRank: number | null
168
168
  meterSource: string | null
169
+ /** A short provider error reason while a stale meter is held. */
170
+ meterError: string | null
171
+ /** Epoch milliseconds at which a stale-meter hold may be retried. */
172
+ meterHoldUntil: number | null
169
173
  incumbentLane: readonly string[]
170
174
  quarantine: { until: number; reason: string; kind: string } | null
171
175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
4
4
  "description": "Route work across coding-agent CLI subscriptions — paced by live quota meters, verified by content, never trusting exit codes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,7 +7,7 @@ Bullswarm loads one only when your machine opts in. The providers under
7
7
  | provider | what it runs | meter |
8
8
  |---|---|---|
9
9
  | `command-code` | the Command Code CLI (`command-code -p`) | monthly credits, 5-hour and weekly windows from the Command Code billing API |
10
- | `opencode2` | the OpenCode CLI (`opencode run`) | none; also the template reseller providers clone as `ctx.templates.opencode2` |
10
+ | `opencode` | the OpenCode CLI (`opencode run`) | none; also the template reseller providers clone as `ctx.templates.opencode` |
11
11
 
12
12
  ## Enabling one
13
13
 
@@ -8,6 +8,7 @@ import os from 'node:os';
8
8
  import path from 'node:path';
9
9
 
10
10
  import { MeterError } from '../../../src/provider-kit.js';
11
+ import { retryAfterMsFromHeaders } from '../../../src/meters/framework.js';
11
12
 
12
13
  export const name = 'command-code';
13
14
  export const displayName = 'Command Code';
@@ -39,8 +40,8 @@ const PLAN_CREDITS = {
39
40
  };
40
41
 
41
42
  export class CommandCodeMeterError extends MeterError {
42
- constructor(message, code) {
43
- super(message, code); // no_auth | http | parse | network
43
+ constructor(message, code, options = {}) {
44
+ super(message, code, options); // no_auth | http | parse | network
44
45
  this.name = 'CommandCodeMeterError';
45
46
  }
46
47
  }
@@ -176,13 +177,14 @@ async function fetchJson(url, key) {
176
177
  } catch (err) {
177
178
  throw new CommandCodeMeterError(`Network error reaching Command Code billing: ${err.message}`, 'network');
178
179
  }
180
+ const retryAfterMs = retryAfterMsFromHeaders(res.headers);
179
181
  let body = null;
180
182
  try {
181
183
  body = await res.json();
182
184
  } catch {
183
185
  /* leave null */
184
186
  }
185
- return { status: res.status, body };
187
+ return { status: res.status, body, retryAfterMs };
186
188
  }
187
189
 
188
190
  /**
@@ -197,10 +199,16 @@ export async function readUsage(pool = name, ctx = {}) {
197
199
 
198
200
  const creditsRes = await fetchJson(`${base}${CREDITS_PATH}`, key);
199
201
  if (creditsRes.status === 401 || creditsRes.status === 403) {
200
- throw new CommandCodeMeterError(`Command Code credits HTTP ${creditsRes.status}`, 'no_auth');
202
+ throw new CommandCodeMeterError(`Command Code credits HTTP ${creditsRes.status}`, 'no_auth', {
203
+ status: creditsRes.status,
204
+ retryAfterMs: creditsRes.retryAfterMs,
205
+ });
201
206
  }
202
207
  if (creditsRes.status < 200 || creditsRes.status >= 300) {
203
- throw new CommandCodeMeterError(`Command Code credits HTTP ${creditsRes.status}`, 'http');
208
+ throw new CommandCodeMeterError(`Command Code credits HTTP ${creditsRes.status}`, 'http', {
209
+ status: creditsRes.status,
210
+ retryAfterMs: creditsRes.retryAfterMs,
211
+ });
204
212
  }
205
213
 
206
214
  const credits = parseCommandCodeCredits(creditsRes.body);
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "opencode2",
2
+ "name": "opencode",
3
3
  "bin": "opencode",
4
4
  "configDirs": ["~/.config/opencode"],
5
5
  "spawn": {
@@ -46,6 +46,7 @@
46
46
  { "match": "gpt-5\\.6-sol$", "tier": "high", "qualityRank": 6, "autoRecommend": true },
47
47
  { "match": "(?:opus|gpt-5\\.5$)", "tier": "high", "qualityRank": 5 },
48
48
  { "match": "(?:sonnet|terra|gpt-5\\.4$|deepseek-v4)", "tier": "medium", "qualityRank": 4 },
49
+ { "match": "(?:union-alpha|-free$)", "tier": "medium", "qualityRank": 3, "free": true },
49
50
  { "match": "(?:haiku|luna|(?:^|[/.-])mini(?:$|[/.-])|flash|free)", "tier": "low", "qualityRank": 2 }
50
51
  ],
51
52
  "subscription": { "plan": null, "monthlyPriceUsd": null, "includedValueUsd": null, "quotaWindow": null },
@@ -1,7 +1,7 @@
1
- // bullswarm contrib provider: opencode2 — the generic OpenCode CLI pool.
1
+ // bullswarm contrib provider: opencode — the generic OpenCode CLI pool.
2
2
  // connector.json is the whole pool; there is no meter. It is also the
3
- // template a reseller provider clones through `ctx.templates.opencode2`
3
+ // template a reseller provider clones through `ctx.templates.opencode`
4
4
  // (see docs/reference/providers.md).
5
5
 
6
- export const name = 'opencode2';
6
+ export const name = 'opencode';
7
7
  export const displayName = 'OpenCode';
@@ -40,7 +40,7 @@ second login), skip most of this: clone the shipped template.
40
40
 
41
41
  ```bash
42
42
  bullswarm provider scaffold <name>
43
- bullswarm provider scaffold <name> --from opencode2
43
+ bullswarm provider scaffold <name> --from opencode
44
44
  ```
45
45
 
46
46
  This writes a commented `provider.mjs` with every export. `--from` copies
package/src/cli.js CHANGED
@@ -4,15 +4,15 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from
4
4
  import { join, resolve } from 'node:path';
5
5
  import { homedir, tmpdir } from 'node:os';
6
6
  import {
7
- expiringSoonView, fiveHourElapsedPct, formatResetsIn, pickPool,
7
+ expiringSoonView, fiveHourElapsedPct, formatResetsIn, isBenched, pickPool,
8
8
  } from './lib/route.js';
9
9
  import { argvWithModel, watchOnce } from './lib/watch.js';
10
10
  import {
11
11
  isReasoningLevel, REASONING_DEFAULT, REASONING_LEVELS, resolveReasoningLevel,
12
12
  } from './lib/reasoning.js';
13
13
  import {
14
- loadState, quarantinePool, quarantineUpstreamSiblings, sweepQuarantines, updateState,
15
- assertDepthAllowed, childDepthEnv, upstreamGroupOf,
14
+ loadState, quarantinePool, quarantineUpstreamSiblings, sweepBenches, sweepQuarantines,
15
+ updateState, assertDepthAllowed, childDepthEnv, upstreamGroupOf,
16
16
  } from './lib/state.js';
17
17
  import { buildPools, buildPoolsLive } from './lib/config.js';
18
18
  import { getAllMeterReadings } from './meters/registry.js';
@@ -73,6 +73,23 @@ export function parseArgs(argv) {
73
73
 
74
74
  // --- pools ----------------------------------------------------------------
75
75
 
76
+ /** The bracketed meter source shown by `bullswarm pools`. */
77
+ export function meterSourceLabel(pool, nowMs = Date.now()) {
78
+ const source = pool?.meterSource ?? 'none';
79
+ if (source === 'stale' && pool?.meterError) {
80
+ const holdUntil = Number(pool.meterHoldUntil);
81
+ const remainingMs = holdUntil - nowMs;
82
+ const retry = Number.isFinite(remainingMs) && remainingMs > 0
83
+ ? `, retry in ${remainingMs < 60_000
84
+ ? `${Math.ceil(remainingMs / 1000)}s`
85
+ : `${Math.ceil(remainingMs / 60_000)}m`}`
86
+ : '';
87
+ return `stale · ${pool.meterError}${retry}`;
88
+ }
89
+ const resetTag = pool?.resetSource === 'declared' ? ' declared-reset' : '';
90
+ return `${source}${resetTag}`;
91
+ }
92
+
76
93
  async function cmdPools(opts) {
77
94
  const now = Date.now();
78
95
  const { state, pools } = await buildPoolsLive(getBullswarmDir(), now, {
@@ -84,13 +101,26 @@ async function cmdPools(opts) {
84
101
  // persists when it actually released something: `pools` is an observation
85
102
  // command and must not rewrite state.json just for being run.
86
103
  let released = [];
104
+ let unbenched = [];
87
105
  updateState(getBullswarmDir(), (fresh) => {
88
106
  released = sweepQuarantines(fresh, now);
89
- return released.length > 0;
107
+ // A soft bench expires on the same terms and by the same rule (S6): the
108
+ // cooldown passing returns the pool to service, and the strike count stays
109
+ // so a stall immediately afterwards is still its second in a row.
110
+ unbenched = sweepBenches(fresh, now);
111
+ return released.length > 0 || unbenched.length > 0;
90
112
  });
91
113
  if (released.length && !opts.json) {
92
114
  console.error(`quarantine expired, returned to service: ${released.join(', ')}`);
93
115
  }
116
+ if (unbenched.length && !opts.json) {
117
+ console.error(`bench expired, returned to service: ${unbenched.join(', ')}`);
118
+ }
119
+ // The sweep above wrote to state, not to the pool views built before it, so
120
+ // an expired bench would still print as BENCHED. Re-read what the sweep left.
121
+ for (const p of pools) {
122
+ if (p.bench && !isBenched(p, now)) p.bench = { ...p.bench, until: null };
123
+ }
94
124
  // Current cross-process load, from the shared ledger rather than this
95
125
  // process's own memory: work another Bullswarm started still shows here —
96
126
  // plus the spend rates that turn that load into a projected utilization.
@@ -108,10 +138,9 @@ async function cmdPools(opts) {
108
138
  const window = p.pacingWindow && p.elapsedPct != null ? `${p.pacingWindow} ` : '';
109
139
  // A window whose end the operator declared (the provider reported none)
110
140
  // is paced from that date and says so; the used% is still the provider's.
111
- const resetTag = p.resetSource === 'declared' ? ' declared-reset' : '';
112
141
  const meter = src === 'none'
113
142
  ? 'unmetered'
114
- : `${window}used ${p.usedPct ?? '?'}% elapsed ${p.elapsedPct ?? '?'}% [${src}${resetTag}]`;
143
+ : `${window}used ${p.usedPct ?? '?'}% elapsed ${p.elapsedPct ?? '?'}% [${meterSourceLabel(p, now)}]`;
115
144
  const burst = p.burstGate ? ' BURST-GATED' : '';
116
145
  // 5h is a gate, never a pace (doctrine M3): show the reading and whether
117
146
  // routing now deprioritizes this pool for it. When in-flight work makes
@@ -137,13 +166,32 @@ async function cmdPools(opts) {
137
166
  ? ` resets in ${formatResetsIn(expiring.minutesToReset)} EXPIRING-SOON`
138
167
  + ` urgency=${Math.round(expiring.urgency)}`
139
168
  : '';
169
+ // R12: the model this pool would run costs nothing, so routing puts it
170
+ // ahead of every metered pool while it is healthy. `pools` names no lane
171
+ // and therefore no effort tier, and free-ness is per tier — so the tiers
172
+ // that hold a free model are named one by one rather than collapsed into a
173
+ // single claim that would be untrue on the others.
174
+ const freeTiers = Object.entries(p.freeTiers ?? {});
175
+ const free = p.free === true
176
+ ? ` free=${p.freeModel ?? '?'}`
177
+ : freeTiers.length
178
+ ? ` free=${freeTiers.map(([tier, model]) => `${tier}:${model}`).join(',')}`
179
+ : '';
180
+ // A soft bench (S6) is not a quarantine: the pool is alive but was not
181
+ // producing. A first strike is counted without taking it out, so say that
182
+ // too — otherwise a pool one stall from the bench looks perfectly healthy.
183
+ const strikes = p.bench && !isBenched(p, now) && Number(p.bench.count ?? 0) > 0
184
+ ? ` strikes=${p.bench.count}(${p.bench.reason ?? '?'})`
185
+ : '';
140
186
  const status = !p.enabled
141
187
  ? 'disabled'
142
188
  : p.quarantine
143
189
  ? `QUARANTINED until ${new Date(p.quarantine.until).toLocaleTimeString()} (${p.quarantine.reason})`
144
- : `ready${burst}${nearLimit}`;
190
+ : isBenched(p, now)
191
+ ? `BENCHED until ${new Date(Number(p.bench.until)).toLocaleTimeString()} (${p.bench.reason ?? '?'}, ${p.bench.count ?? '?'} strikes)`
192
+ : `ready${burst}${nearLimit}${strikes}`;
145
193
  console.log(
146
- `${p.name.padEnd(14)} cost=${p.costRank} lanes=${p.lanes.join('/')} ${meter} surplus=${p.pace ?? '-'} inflight=${p.inflight?.count ?? 0}${fiveHour} ${status}${expiringNote}`,
194
+ `${p.name.padEnd(14)} cost=${p.costRank} lanes=${p.lanes.join('/')} ${meter} surplus=${p.pace ?? '-'} inflight=${p.inflight?.count ?? 0}${fiveHour}${free} ${status}${expiringNote}`,
147
195
  );
148
196
  }
149
197
  return 0;