@lawaty/lacode 0.11.0 → 1.0.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/dist/lacode.js CHANGED
@@ -12,7 +12,7 @@ import { homedir as homedir3 } from "node:os";
12
12
  import { join as join4 } from "node:path";
13
13
 
14
14
  // src/lib/notify.ts
15
- import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync3, statSync as statSync2, unlinkSync as unlinkSync2, writeSync } from "node:fs";
15
+ import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync3, renameSync as renameSync2, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2, writeSync } from "node:fs";
16
16
  import { homedir as homedir2 } from "node:os";
17
17
  import { join as join3 } from "node:path";
18
18
 
@@ -466,7 +466,7 @@ function describe(env = process.env, platform = process.platform) {
466
466
 
467
467
  // src/lib/selfupdate.ts
468
468
  import { spawn as spawn2 } from "node:child_process";
469
- import { closeSync as closeSync2, mkdirSync as mkdirSync4, openSync as openSync2, readFileSync as readFileSync5, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync4, writeFileSync as writeFileSync2, writeSync as writeSync2 } from "node:fs";
469
+ import { closeSync as closeSync2, mkdirSync as mkdirSync4, openSync as openSync2, readFileSync as readFileSync5, renameSync as renameSync3, rmSync, statSync as statSync3, unlinkSync as unlinkSync4, writeFileSync as writeFileSync3, writeSync as writeSync2 } from "node:fs";
470
470
  import { homedir as homedir4 } from "node:os";
471
471
  import { dirname as dirname2, join as join5, resolve } from "node:path";
472
472
  var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
@@ -568,7 +568,7 @@ async function refreshCache(opts) {
568
568
  const aside = `${cacheRoot}.stale-${Date.now()}`;
569
569
  try {
570
570
  rmSync(aside, { recursive: true, force: true });
571
- renameSync2(cacheRoot, aside);
571
+ renameSync3(cacheRoot, aside);
572
572
  } catch (error) {
573
573
  return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
574
574
  }
@@ -603,7 +603,7 @@ async function refreshCache(opts) {
603
603
  }
604
604
  } else {
605
605
  try {
606
- renameSync2(aside, cacheRoot);
606
+ renameSync3(aside, cacheRoot);
607
607
  } catch {
608
608
  }
609
609
  }
package/dist/pool.js CHANGED
@@ -626,48 +626,24 @@ function snapshot(s) {
626
626
  }
627
627
  var loadOf = (counts, model) => counts.get(model) ?? 0;
628
628
  var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
629
- function roundRobinSequence(slots) {
630
- const seq = [];
631
- for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
632
- return seq;
633
- }
634
- var CURSOR_FILE = "cursor.json";
635
- function readCursor(s) {
636
- try {
637
- const parsed = JSON.parse(readFileSync2(join2(s.dir, CURSOR_FILE), "utf8"));
638
- if (typeof parsed.seq === "number" && Number.isInteger(parsed.seq) && parsed.seq >= 0) return parsed.seq;
639
- } catch {
629
+ function pickSlot(s, snap) {
630
+ const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
631
+ const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
632
+ const pool = usable.length > 0 ? usable : slots;
633
+ for (const slot of usable) {
634
+ if (loadOf(snap.counts, slot.model) < slot.weight) return slot;
640
635
  }
641
- return 0;
642
- }
643
- function writeCursor(s, seq) {
644
- if (!s.dirReady) return;
645
- const path = join2(s.dir, CURSOR_FILE);
646
- try {
647
- writeFileSync(`${path}.tmp`, JSON.stringify({ seq }));
648
- renameSync(`${path}.tmp`, path);
649
- } catch {
636
+ for (const slot of usable) {
637
+ if (loadOf(snap.counts, slot.model) < slotCeiling(slot)) return slot;
650
638
  }
639
+ return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
651
640
  }
652
- function pickSlot(s, snap, options) {
653
- const advance = options?.advance !== false;
641
+ function poolPass(s, snap) {
654
642
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
655
- const seq = roundRobinSequence(slots);
656
- if (seq.length > 0) {
657
- const byIndex = new Map(slots.map((slot) => [slot.index, slot]));
658
- const start = readCursor(s) % seq.length;
659
- for (let step = 0; step < seq.length; step++) {
660
- const at = (start + step) % seq.length;
661
- const slot = byIndex.get(seq[at]);
662
- if (slot && isAvailable(s, slot.model, snap.cooling) && underCeiling(slot, snap.counts)) {
663
- if (advance) writeCursor(s, at + 1);
664
- return slot;
665
- }
666
- }
667
- }
668
643
  const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
669
- const pool = usable.length > 0 ? usable : slots;
670
- return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
644
+ if (usable.some((slot) => loadOf(snap.counts, slot.model) < slot.weight)) return 1;
645
+ if (usable.some((slot) => loadOf(snap.counts, slot.model) < slotCeiling(slot))) return 2;
646
+ return "overflow";
671
647
  }
672
648
  function agentFor(base, slot) {
673
649
  return `${base}-${slot}`;
@@ -829,6 +805,7 @@ export {
829
805
  parseSlots,
830
806
  parseTextSlots,
831
807
  pickSlot,
808
+ poolPass,
832
809
  providerOf,
833
810
  pruneOwn,
834
811
  pruneSessions,
@@ -843,7 +820,6 @@ export {
843
820
  reloadSlots,
844
821
  resolveModels,
845
822
  retryAfterMs,
846
- roundRobinSequence,
847
823
  selfFile,
848
824
  slotCeiling,
849
825
  slotFor,
package/dist/tunnel.js CHANGED
@@ -5,7 +5,7 @@ import { homedir as homedir3 } from "node:os";
5
5
  import { join as join4 } from "node:path";
6
6
 
7
7
  // src/lib/notify.ts
8
- import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync3, statSync as statSync2, unlinkSync as unlinkSync2, writeSync } from "node:fs";
8
+ import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync3, renameSync as renameSync2, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2, writeSync } from "node:fs";
9
9
  import { homedir as homedir2 } from "node:os";
10
10
  import { join as join3 } from "node:path";
11
11
 
@@ -68,8 +68,8 @@ fields are not affected; `permission`, `prompt`, `model`, `description`,
68
68
  The hidden pool variants (`explore-1`, `implement-2`, …) inherit
69
69
  nothing here — add a line per variant, or drop the global
70
70
  deny and scope it to the agents that should not have browser access instead.
71
- Note that you cannot *spawn* a variant by name: the pool refuses
72
- `explore-1` and friends and points you at the bare base, so per-variant
71
+ Note that you cannot *spawn* a variant by name: the pool rewrites
72
+ `explore-1` and friends to their base and routes them, so per-variant
73
73
  permissions only ever apply to a spawn the plugin routed itself.
74
74
 
75
75
  The pool files are the exception to the restart rule: editing `pool.txt`,
@@ -15,11 +15,7 @@ line. Criteria that cannot be observed without a running server are not in this
15
15
  document; everything here is testable offline with a stub client and a
16
16
  `mkdtempSync` state directory.
17
17
 
18
- **Terminology.** A *slot* is one entry in `presets/free-tier.json`, carrying a
19
- model and a `weight`. A *base* is a pooled agent type (`explore`), and its slot
20
- holders are `<base>-<slot>`, hidden in `opencode.jsonc`. A *claim* is a running
21
- spawn recorded in `claims.<pid>.json`. A *context map* is the five-file
22
- `.opencode/context/` codemap described in Epic G.
18
+ **Terminology.** A *slot* is one entry in `presets/free-tier.json`, carrying a model and a `weight`. A slot's `weight` is its active-session budget for one pass: the number of concurrent claims it may hold before the next slot is preferred. A *pass* is one declaration-order sweep of the slot list; the pool runs at most two passes, and a slot's absolute cap is twice its weight. Preference is by declaration order, never by weight. A *base* is a pooled agent type (`explore`), and its slot holders are `<base>-<slot>`, hidden in `opencode.jsonc`. A *claim* is a running spawn recorded in `claims.<pid>.json`. A *context map* is the five-file `.opencode/context/` codemap described in Epic G.
23
19
 
24
20
  ---
25
21
 
@@ -96,16 +92,18 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
96
92
  - [ ] Given every model is cooling, When a spawn is routed, Then the least-loaded slot is chosen anyway (degrade, never block).
97
93
  - [ ] Given a limit error names a reset time, When the cooldown is recorded, Then the stated reset is used instead of a guessed backoff.
98
94
 
99
- ### US-5: Weighted round-robin gives every slot a turn per cycle
100
- **As a** user, **I want** weights to say how many turns each slot takes per rotation cycle, **so that** no provider is drained to its limit while the others sit idle.
95
+ ### US-5: Slots fill in declaration order, in up to two passes
96
+ **As a** user, **I want** work to prefer the first slot and spill to later slots only when the earlier ones are full, **so that** a healthy preferred model is used before fallbacks and no slot is used while an earlier usable slot has room.
101
97
 
102
98
  **Story ID(s) for tests:** US-5
103
99
  **Acceptance criteria:**
104
- - [ ] Given a heavy slot and a light slot, When spawns are routed below both ceilings, Then the rotation walks the list in order with each slot taking as many consecutive turns as its weight says, rather than the heavy slot being filled and refilled first.
105
- - [ ] Given a slot is at its ceiling (twice its weight) or cooling, When its turn comes up in the rotation, Then the turn is skipped and the rotation resumes at the following turn.
106
- - [ ] Given every slot is at its ceiling, When another spawn is routed, Then it goes to the least-loaded slot as overflow.
107
- - [ ] Given all slots carry equal weight, When spawns are routed, Then the picks round-robin in declaration order.
108
- - [ ] Given per-slot load is read, When it is asserted, Then a slot's load never exceeds its ceiling (twice its weight) except by overflow.
100
+ - [ ] Given live claims, When the pool has room in the first pass, Then the lowest-index usable slot whose load is below its weight is chosen, and a slot declared later is not used until every earlier usable slot has reached its weight.
101
+ - [ ] Given every usable slot has reached its weight, When another spawn is routed, Then the second pass begins at the lowest-index slot and fills each usable slot up to twice its weight.
102
+ - [ ] Given a slot is cooling or unavailable, When its position in the pass comes up, Then it is skipped and the pass continues to the next slot.
103
+ - [ ] Given a claim is released, When the next spawn is routed, Then the slot that freed capacity is preferred again in declaration order; no stored position keeps it waiting.
104
+ - [ ] Given every usable slot is at its absolute cap, When another spawn is routed, Then it goes to the least-loaded usable slot as overflow, and the pool never refuses work.
105
+ - [ ] Given all slots carry equal weight, When spawns are routed, Then the same ordered two-pass fill applies.
106
+ - [ ] Given per-slot load is read, When it is asserted, Then a slot's load never exceeds twice its weight except through overflow.
109
107
 
110
108
  ### US-6: Live pool visibility
111
109
  **As a** user, **I want** to see per-slot load and which model is cooling and why, **so that** I understand where my subagents are going.
@@ -115,7 +113,8 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
115
113
  - [ ] Given claims are held by several processes, When the status view is requested, Then it shows every process's claims and the aggregate load per slot.
116
114
  - [ ] Given a model is cooling with a stated reason, When the status view is requested, Then the model is listed as cooling together with that reason.
117
115
  - [ ] Given a claim file is older than the dead-file threshold, When the status view is computed, Then the stale claim is pruned rather than counted.
118
- - [ ] Given a slot is at its ceiling, When the status view is requested, Then it is not marked as the slot the next spawn would take, because the rotation's turns are skipped for a full slot until it frees a claim — unless no other slot is available and under its ceiling, in which case that full slot is the next one.
116
+ - [ ] Given a slot is at its absolute cap, When the status view is requested, Then it is not marked as the slot the next spawn would take while any usable slot is below its current pass target; when every usable slot is at its absolute cap, the least-loaded slot is marked next.
117
+ - [ ] Given the pool is filling, When the status view is requested, Then it shows whether the next spawn belongs to the first pass, the second pass, or overflow.
119
118
 
120
119
  ### US-7: Non-free models still work
121
120
  **As a** user who wants a paid model in the pool, **I want** to supply any model list, **so that** the pool is not restricted to free tiers.
@@ -320,7 +319,7 @@ The popup belongs to its session: working in one chat closes that chat's popup a
320
319
  - [ ] Given a body containing `"`, `'`, `;`, `$(…)` or backticks, When the macOS send runs, Then it reaches `display notification` byte-for-byte as data and nothing is executed.
321
320
  - [ ] Given a machine has no notifier for its platform, When a notification is sent, Then the result is a failed delivery with a reason and no exception, and `lacode doctor` names the channel that would run — or reports that there is none — instead of reporting a binary that is not used there.
322
321
  - [ ] Given the CLI runs on macOS, When the send does not complete, Then it is still bounded by a timeout, exactly as on Linux.
323
- - [ ] Given the notification hop, When it is documented, Then the doc leads with the shipped path — the npm-installed plugin, a direct ssh hop (`ControlMaster`) set up by `lacode tunnel setup`, and the portable `lacode receive` forced command that delivers through the platform channel (`notify-send` or `osascript`) — and marks the reverse-forward scripts (`bin/oc`, `bin/oc-notify`, `bin/oc-notify-receiver`, `bin/oc-sync`) as the author's legacy tooling, so a Mac user is not sent to debug a script that was never meant to run on their machine.
322
+ - [ ] Given the notification hop, When it is documented, Then the doc leads with the shipped path — the npm-installed plugin, a direct ssh hop (`ControlMaster`) set up by `lacode tunnel setup`, and the portable `lacode receive` forced command that delivers through the platform channel (`notify-send` or `osascript`) — and marks the reverse-forward scripts (`bin/oc`, `bin/oc-notify`, `bin/oc-notify-receiver`, `bin/oc-sync`) as the author's own scripts, so a Mac user is not sent to debug a script that was never meant to run on their machine.
324
323
  - [ ] Given a machine without GNU `timeout` or with bash older than 4.0, When `bin/oc-sync` runs, Then it refuses **before** touching anything, names each missing prerequisite, and names the `lacode` subcommand to use instead. It must never report a misleading cause: the host list is read with `mapfile`, so without it the run dies at "no hosts configured" — a confident wrong answer that sends the reader to edit `.env`.
325
324
  - [ ] Given `bin/oc-notify-receiver` runs where `notify-send` does not exist, When it is invoked, Then it exits non-zero and names both the missing channel and the portable path, because its stderr reaches whoever opened the ssh connection and nobody else, and a bare "command not found" reads as a desktop that quietly stopped notifying.
326
325
  - [ ] Given the payload is parsed, When the receiver splits `$SSH_ORIGINAL_COMMAND`, Then it splits on newlines only using constructs that exist in bash 3.2 as well as bash 5, and the suite **proves the split by running the receiver** rather than by finding the word `mapfile` in the file. Under bash 3.2 — the version macOS ships — a bash-4-only builtin leaves every field at its default, so the popup says "LaCode" with an empty body and no error anywhere: the same silent blank notification the argv bug causes, reached through a different door.
@@ -511,8 +510,8 @@ table was filled in.
511
510
  | US-3 | A | `tests/stories/a-cost-control.test.mjs` (harness: `tests/benchmark/run.mjs`) |
512
511
  | US-23 | A | `tests/stories/a-cost-control.test.mjs` |
513
512
  | US-4 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-limits-test.mjs` |
514
- | US-5 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-test.mjs`, `tests/pool-models-test.mjs` |
515
- | US-6 | B | `tests/stories/b-provider-resilience.test.mjs` |
513
+ | US-5 | B | `tests/stories/b-slot-fill.test.mjs`, `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-test.mjs`, `tests/pool-models-test.mjs` |
514
+ | US-6 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/stories/b-slot-fill.test.mjs` |
516
515
  | US-7 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-models-test.mjs` |
517
516
  | US-28 | B | `tests/stories/i-omniroute.test.mjs` |
518
517
  | US-31 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/stories/c-hang-recovery.test.mjs` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "0.11.0",
3
+ "version": "1.0.0",
4
4
  "description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  "./package.json": "./package.json"
39
39
  },
40
40
  "bin": {
41
- "lacode": "./dist/lacode.js"
41
+ "lacode": "dist/lacode.js"
42
42
  },
43
43
  "files": [
44
44
  "dist",
@@ -62,6 +62,6 @@
62
62
  "@opencode-ai/plugin": "1.18.34"
63
63
  },
64
64
  "publishConfig": {
65
- "tag": "beta"
65
+ "tag": "latest"
66
66
  }
67
67
  }
package/presets/README.md CHANGED
@@ -63,7 +63,7 @@ else means the flat form.
63
63
  | Field | Required | Meaning |
64
64
  |-------|----------|---------|
65
65
  | `model` | yes | `provider/model-id`, split on the **first** slash only |
66
- | `weight` | no | turns taken per rotation (the slot's share of the rotation); its concurrency ceiling is twice that. Absent = 1 |
66
+ | `weight` | no | active-session budget for one pass; its absolute cap is twice that. Absent = 1 |
67
67
 
68
68
  `$comment` is a JSON-legal array of strings and is ignored by the loader; the
69
69
  bundled preset uses it to explain itself in place.
@@ -81,7 +81,7 @@ The loader checks shape and nothing else:
81
81
  - at most `MAX_SLOTS` (4) slots; extra entries are **dropped with a warning**,
82
82
  because one hidden variant agent per base per slot exists and a fifth slot
83
83
  would route at an agent that does not exist
84
- - `tier` is accepted as a deprecated alias for `weight` (`primary` → 2,
84
+ - `tier` is accepted as an alias for `weight` (`primary` → 2,
85
85
  `overflow` → 1) and warns
86
86
 
87
87
  **Cost is never validated.** Any provider/model-id string is accepted, including
@@ -92,28 +92,26 @@ because the pool requires it.
92
92
 
93
93
  ## Weight, precisely
94
94
 
95
- A weight is the number of turns a slot takes per rotation — its share of the
96
- work — and its concurrency ceiling is **twice that weight**: a weight-3 slot may
97
- hold 6 concurrent sessions. Routing is **order-first weighted round-robin**:
98
-
99
- 1. the slot list is expanded into a run in which each slot appears once per
100
- unit of its weight — `[3,2,1,1]` becomes `1,1,1,2,2,3,4` — and the next
101
- spawn takes the turn a persistent cursor (a file in the pool directory,
102
- shared by every opencode process) points at
103
- 2. a turn whose slot is cooling or already at its ceiling is **skipped**, and
104
- the rotation resumes at the following turn
105
- 3. once **every** slot is at its ceiling, overflow goes to the least loaded slot —
106
- a burst past the summed ceilings degrades to even spreading and never blocks
107
-
108
- Weight is no longer a priority: a heavy slot is not drained to its ceiling
109
- before the others see any work. Every slot gets a turn each cycle, so a fast
110
- provider with a large usage window is used early in the cycle while it is
111
- still fast — instead of one provider being refilled until it hits its limit
112
- and its 429s displace every agent type at once.
113
-
114
- Equal weights give a plain round robin in list order. List order is the
115
- rotation itself, so put the models you want taking the earlier turns of a
116
- cycle at the top and the ones you want taking the later turns at the bottom.
95
+ A weight is a slot's active-session budget for one pass: the number of
96
+ concurrent claims it may hold before the next slot is preferred. Its absolute
97
+ cap is **twice that weight** — a weight-3 slot may hold 6 concurrent sessions.
98
+ Slots are filled in declaration order; preference is order, never weight.
99
+
100
+ Routing runs in two passes:
101
+
102
+ 1. pass 1 fills each usable slot up to its weight, in declaration order
103
+ 2. once every usable slot has reached its weight, pass 2 repeats the order,
104
+ filling each usable slot up to twice its weight
105
+
106
+ A slot is skipped when it is cooling, unavailable, or at its absolute cap.
107
+ When every usable slot is at its absolute cap, overflow goes to the least
108
+ loaded usable slot; the pool never refuses work.
109
+
110
+ Live claims decide the next slot: a released claim immediately reopens its
111
+ slot, and there is no stored position.
112
+
113
+ Equal weights give a plain pass in list order. List order is preference, so
114
+ put the slots you want preferred first at the top of the list.
117
115
 
118
116
  Spreading at least two providers across the slots is worth doing deliberately: a
119
117
  provider-wide rate limit takes out every slot on that provider at once.
@@ -13,52 +13,36 @@
13
13
  "AT MOST 4 SLOTS: one hidden variant per base per slot is declared, so a fifth entry would",
14
14
  "route at an agent that does not exist. Extra entries are dropped with a warning.",
15
15
  "",
16
- "WEIGHT IS THE ONLY KNOWN KNOB: one position per unit of weight in the rotation run,",
17
- "and a concurrency ceiling of TWICE that weight -- a share of the work, not a priority.",
18
- "Routing is ORDER-FIRST",
19
- "WEIGHTED ROUND-ROBIN: the slot list is walked IN ORDER and each slot takes as many",
20
- "consecutive turns as its weight says, so weights 3,2,1,1 produce the run 1,1,1,2,2,3,4,",
21
- "which then repeats. The rule is:",
22
- " 1. a persistent cursor in the pool directory marks where in that run the next spawn",
23
- " lands, so every opencode process on the machine shares one rotation",
24
- " 2. a turn is SKIPPED only when its slot is cooling or already at its ceiling, and the",
25
- " rotation resumes at the following turn",
26
- " 3. once EVERY slot is at its ceiling, the overflow goes to the least loaded slot,",
27
- " so a burst past the summed ceilings degrades to even spreading and never blocks",
16
+ "WEIGHT IS THE ACTIVE-SESSION BUDGET FOR ONE PASS: a slot may hold up to its weight in",
17
+ "concurrent claims before the next slot is preferred. Slots are filled in declaration",
18
+ "order; preference is order, never weight.",
19
+ "Routing runs in two passes:",
20
+ " 1. pass 1 fills each usable slot up to its weight, in declaration order",
21
+ " 2. once every usable slot has reached its weight, pass 2 repeats the order, filling",
22
+ " each usable slot up to twice its weight",
23
+ "A slot is skipped when it is cooling, unavailable, or at its absolute cap (twice its",
24
+ "weight). When every usable slot is at its absolute cap, overflow goes to the least",
25
+ "loaded usable slot; the pool never refuses work.",
26
+ "Live claims decide the next slot: a released claim immediately reopens its slot, and",
27
+ "there is no stored position.",
28
28
  "",
29
- "With this file: big-pickle takes 3 consecutive turns, then beam-free",
30
- "(opencode-go) takes 2, then deepseek-v4.1-flash-free and glm-5.3-flash-free take 1",
31
- "each -- sequence 1,1,1,2,2,3,4,...",
32
- "Equal weights give a plain round robin in list order. 2,2,1,1 reproduces the old",
33
- "primary/overflow cycle exactly, which is why the old tier values still work.",
29
+ "With this file: weights 3,2,1,1 give 7 active sessions in pass 1 and 14 total across",
30
+ "both passes.",
31
+ "Equal weights give a plain pass in list order.",
34
32
  "",
35
- "WEIGHTS DESCEND, AND THAT IS NOT COSMETIC. The rotation is order-first: the run is",
36
- "expanded in declaration order, so the heaviest slot must come first for a skip to",
37
- "land on the heaviest slot that is LEFT. A heavier slot declared after a lighter one",
38
- "is unreachable in the way the pool means.",
33
+ "DECLARATION ORDER IS PREFERENCE. Slots are filled in declaration order, so the slot",
34
+ "you want preferred comes first; weight sets its per-pass budget, not its priority.",
39
35
  "",
40
36
  "EVERY SLOT IS A MODEL THAT IS PROBABLY STILL THERE NEXT WEEK, AND NO TWO SLOTS",
41
- "SHARE A FAMILY. A free model can be withdrawn by its provider without warning --",
42
- "space-bunny-free was pulled from the opencode provider while it was still this",
43
- "file's first slot, and every new session then died on 'Model not found'. So the",
44
- "slots here are the long-lived free ones (big-pickle has been on the opencode",
45
- "provider for years), spread over two providers and four model families, because a",
46
- "pool where every slot is one family is one withdrawal away from being empty. If a",
47
- "slot does go away, the plugin warns at startup and the rest keep working; the fix",
48
- "is a one-line edit here, not a redesign.",
49
- "",
50
- "Round-robin rather than heaviest-first because re-prioritising the heaviest slot on",
51
- "EVERY spawn drained the fastest provider's free-tier window early: one provider",
52
- "absorbed all work up to its ceiling and kept being refilled the moment a claim",
53
- "completed, while the other slots sat idle -- until the fast provider hit its limit and",
54
- "its 429s displaced every agent type at once. Every slot a turn per cycle puts a fast",
55
- "slot with a large usage window to work early in the cycle while it is still fast, and",
56
- "no provider's quota sits unused while another's is drained.",
37
+ "SHARE A FAMILY. A free model can be withdrawn by its provider without warning, and",
38
+ "a pool where every slot is one family is one withdrawal away from being empty. The",
39
+ "slots here are long-lived free ones spread over two providers and four model",
40
+ "families. If a slot does go away, the plugin warns at startup and the rest keep",
41
+ "working; the fix is a one-line edit here, not a redesign.",
57
42
  "",
58
43
  "Absent means weight 1. Must be a number in (0, 100]; anything else warns and uses 1.",
59
- "List order sets the rotation for every slot, not just a tie-break: earlier positions",
60
- "take their turns earlier in each cycle, so put the slots you want worked first at the",
61
- "top of the list.",
44
+ "List order sets preference: earlier slots are preferred first, so put the slots you",
45
+ "want worked first at the top of the list.",
62
46
  "",
63
47
  "COST IS THE DEFAULT, NOT A CONSTRAINT. These four slots are free because that is what",
64
48
  "the pool is FOR: mechanical work -- reading files, grepping, mechanical edits, cartography.",