@lawaty/lacode 0.7.1 → 0.9.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/pool.js CHANGED
@@ -34,12 +34,15 @@ var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
34
34
  var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
35
35
  var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
36
36
  var MAX_WEIGHT = 100;
37
+ var SLOT_CEILING_FACTOR = 2;
38
+ var slotCeiling = (slot) => slot.weight * SLOT_CEILING_FACTOR;
37
39
  var MAX_SLOTS = 4;
38
40
  var COOLDOWN_BASE_MS = 60 * 1e3;
39
41
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
40
42
  var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
41
43
  var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
42
44
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
45
+ var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
43
46
  var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
44
47
  var BASES = ["explore", "implement", "context"];
45
48
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
@@ -48,10 +51,10 @@ var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
48
51
  var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
49
52
  var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
50
53
  var FALLBACK_SLOTS = [
51
- { index: 1, model: "opencode/space-bunny-free", weight: 3 },
52
- { index: 2, model: "opencode-go/longcat-2.5-preview-free", weight: 2 },
53
- { index: 3, model: "opencode/big-pickle", weight: 1 },
54
- { index: 4, model: "opencode/longcat-2.5-preview-free", weight: 1 }
54
+ { index: 1, model: "opencode/big-pickle", weight: 3 },
55
+ { index: 2, model: "opencode-go/minimax-m3.1-flash-free", weight: 2 },
56
+ { index: 3, model: "opencode/deepseek-v4.1-flash-free", weight: 1 },
57
+ { index: 4, model: "opencode/glm-5.3-flash-free", weight: 1 }
55
58
  ];
56
59
  function parseSlots(parsed, origin) {
57
60
  const problems = [];
@@ -207,12 +210,13 @@ var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
207
210
  var TRANSIENT_GATEWAY_PATTERNS = [/queue budget/i, /exceeded queue/i, /aborted due to timeout/i, /timeout/i];
208
211
  var isTransientGatewayText = (text) => TRANSIENT_GATEWAY_PATTERNS.some((re) => re.test(text));
209
212
  var statedCooldown = (text) => {
210
- const hm = /resets?\s+in\s+(\d+)\s*(?:h|hr|hour)s?\s*(\d+)?\s*(?:m|min|minute)?s?/i.exec(text);
211
- if (hm) return (Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3;
212
- const m = /resets?\s+in\s+(\d+)\s*(?:m|min|minute)s?/i.exec(text);
213
- if (m) return Number(m[1]) * 60 * 1e3;
214
- const s = /retry[\s-]?in\s+(\d+)\s*(?:s|sec|second)s?/i.exec(text);
215
- if (s) return Number(s[1]) * 1e3;
213
+ const bounded = (ms) => Number.isFinite(ms) && ms > 0 && ms <= STATED_COOLDOWN_MAX_MS ? ms : void 0;
214
+ const hm = /resets?\s+in\s+(\d+)\s*(?:hour|hr|h)s?\s*(\d+)?\s*(?:minute|min|m)?s?/i.exec(text);
215
+ if (hm) return bounded((Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3);
216
+ const m = /resets?\s+in\s+(\d+)\s*(?:minute|min|m)s?/i.exec(text);
217
+ if (m) return bounded(Number(m[1]) * 60 * 1e3);
218
+ const s = /retry[\s-]?in\s+(\d+)\s*(?:second|sec|s)s?/i.exec(text);
219
+ if (s) return bounded(Number(s[1]) * 1e3);
216
220
  return void 0;
217
221
  };
218
222
  var retryAfterMs = (headers) => {
@@ -246,10 +250,14 @@ function state(opts = {}) {
246
250
  cooling: /* @__PURE__ */ new Map(),
247
251
  strikes: /* @__PURE__ */ new Map(),
248
252
  activity: /* @__PURE__ */ new Map(),
253
+ probes: /* @__PURE__ */ new Set(),
254
+ probing: false,
255
+ lastProbe: 0,
249
256
  children: /* @__PURE__ */ new Map(),
250
257
  reaped: 0,
251
258
  reaping: false,
252
259
  lastSweep: 0,
260
+ lastDirSweep: 0,
253
261
  id,
254
262
  dir,
255
263
  modelsFile,
@@ -286,10 +294,88 @@ function init(s, onWarn) {
286
294
  onWarn(`claim dir ${s.dir} unusable; pool load tracked in memory only`);
287
295
  }
288
296
  }
297
+ var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
298
+ function sweepStateDir(s) {
299
+ const removed = [];
300
+ if (!s.dirReady) return removed;
301
+ let entries;
302
+ try {
303
+ entries = readdirSync(s.dir);
304
+ } catch {
305
+ return removed;
306
+ }
307
+ const now = Date.now();
308
+ const drop = (name) => {
309
+ try {
310
+ unlinkSync(join2(s.dir, name));
311
+ removed.push(name);
312
+ } catch {
313
+ }
314
+ };
315
+ for (const entry of entries) {
316
+ let age;
317
+ try {
318
+ age = now - statSync(join2(s.dir, entry)).mtimeMs;
319
+ } catch {
320
+ continue;
321
+ }
322
+ if (entry.endsWith(".tmp")) {
323
+ if (age > ORPHAN_TMP_MS) drop(entry);
324
+ continue;
325
+ }
326
+ const claim = /^claims\.(.+)\.json$/.exec(entry);
327
+ if (claim) {
328
+ if (claim[1] === s.id) continue;
329
+ if (/^\d+$/.test(claim[1]) && !isPidAlive(Number(claim[1]))) {
330
+ drop(entry);
331
+ continue;
332
+ }
333
+ if (age > DEAD_FILE_MS) drop(entry);
334
+ continue;
335
+ }
336
+ if (entry.startsWith("limit.") && entry.endsWith(".json")) {
337
+ try {
338
+ const strike = JSON.parse(readFileSync2(join2(s.dir, entry), "utf8"));
339
+ if (typeof strike.until !== "number") {
340
+ drop(entry);
341
+ continue;
342
+ }
343
+ if (strike.until > now) continue;
344
+ if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) continue;
345
+ drop(entry);
346
+ } catch {
347
+ drop(entry);
348
+ }
349
+ }
350
+ }
351
+ return removed;
352
+ }
289
353
  function pruneOwn(s) {
290
354
  const now = Date.now();
291
355
  for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) > CLAIM_TTL_MS) s.own.delete(key);
292
356
  }
357
+ function pruneActivity(s, now) {
358
+ if (s.activity.size === 0) return;
359
+ const live = /* @__PURE__ */ new Set();
360
+ for (const claim of s.own.values()) if (claim.g) live.add(claim.g);
361
+ for (const [id, seen] of s.activity) {
362
+ if (live.has(id)) continue;
363
+ if (now - seen > CLAIM_TTL_MS) s.activity.delete(id);
364
+ }
365
+ }
366
+ function pruneChildren(s, now) {
367
+ if (s.children.size === 0) return;
368
+ for (const [parent, ring] of s.children) {
369
+ const fresh = ring.filter((child) => now - child.t <= CHILD_MEMORY_MS);
370
+ if (fresh.length === 0) s.children.delete(parent);
371
+ else if (fresh.length !== ring.length) s.children.set(parent, fresh);
372
+ }
373
+ }
374
+ function pruneSessions(s) {
375
+ const now = Date.now();
376
+ pruneActivity(s, now);
377
+ pruneChildren(s, now);
378
+ }
293
379
  function publish(s) {
294
380
  if (!s.dirReady) return;
295
381
  const now = Date.now();
@@ -435,6 +521,50 @@ function coolUnavailable(s, model) {
435
521
  }
436
522
  return strike;
437
523
  }
524
+ function clearCooldowns(s, models) {
525
+ const cleared = [];
526
+ const now = Date.now();
527
+ const wanted = models && models.length > 0 ? new Set(models) : void 0;
528
+ let entries;
529
+ try {
530
+ entries = readdirSync(s.dir);
531
+ } catch {
532
+ return cleared;
533
+ }
534
+ for (const entry of entries) {
535
+ if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
536
+ const path = join2(s.dir, entry);
537
+ let strike;
538
+ try {
539
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
540
+ if (parsed && typeof parsed.model === "string") strike = parsed;
541
+ } catch {
542
+ strike = void 0;
543
+ }
544
+ if (wanted && (strike === void 0 || !wanted.has(strike.model))) continue;
545
+ try {
546
+ unlinkSync(path);
547
+ } catch {
548
+ continue;
549
+ }
550
+ if (strike === void 0) {
551
+ cleared.push({ model: entry, reason: "unreadable cooldown file", strikes: 0, secondsLeft: 0 });
552
+ continue;
553
+ }
554
+ s.cooling.delete(strike.model);
555
+ s.strikes.delete(strike.model);
556
+ cleared.push({
557
+ model: strike.model,
558
+ reason: typeof strike.reason === "string" ? strike.reason : "unknown",
559
+ strikes: typeof strike.strikes === "number" ? strike.strikes : 0,
560
+ secondsLeft: typeof strike.until === "number" ? Math.max(0, Math.ceil((strike.until - now) / 1e3)) : 0
561
+ });
562
+ }
563
+ return cleared;
564
+ }
565
+ var markProbe = (s, sessionID) => s.probes.add(sessionID);
566
+ var unmarkProbe = (s, sessionID) => s.probes.delete(sessionID);
567
+ var isProbe = (s, sessionID) => s.probes.has(sessionID);
438
568
  function refreshCatalog(s, serverUrl) {
439
569
  if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
440
570
  s.catalogAt = Date.now();
@@ -457,6 +587,7 @@ function isAvailable(s, model, cooling) {
457
587
  }
458
588
  function snapshot(s) {
459
589
  pruneOwn(s);
590
+ pruneSessions(s);
460
591
  const siblings = readSiblings(s);
461
592
  const cooling = readLimits(s);
462
593
  const counts = /* @__PURE__ */ new Map();
@@ -466,7 +597,7 @@ function snapshot(s) {
466
597
  return { counts, cooling, siblings, own: s.own, dirReady: s.dirReady };
467
598
  }
468
599
  var loadOf = (counts, model) => counts.get(model) ?? 0;
469
- var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slot.weight;
600
+ var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
470
601
  function roundRobinSequence(slots) {
471
602
  const seq = [];
472
603
  for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
@@ -629,6 +760,9 @@ export {
629
760
  PRESETS_DIR,
630
761
  QUOTA_COOLDOWN_BASE_MS,
631
762
  QUOTA_COOLDOWN_MAX_MS,
763
+ SLOT_CEILING_FACTOR,
764
+ STATED_COOLDOWN_MAX_MS,
765
+ STATE_SWEEP_INTERVAL_MS,
632
766
  STUCK_IDLE_MS,
633
767
  STUCK_MIN_AGE_MS,
634
768
  UNAVAILABLE_COOLDOWN_MS,
@@ -640,6 +774,7 @@ export {
640
774
  backfillChild,
641
775
  bindChild,
642
776
  claimKey,
777
+ clearCooldowns,
643
778
  condemnReason,
644
779
  coolProvider,
645
780
  coolUnavailable,
@@ -650,10 +785,12 @@ export {
650
785
  init,
651
786
  isAvailable,
652
787
  isLimitText,
788
+ isProbe,
653
789
  isQuotaReason,
654
790
  isTransientGatewayText,
655
791
  isUnavailableText,
656
792
  manualVariant,
793
+ markProbe,
657
794
  noteActivity,
658
795
  parseSlotText,
659
796
  parseSlots,
@@ -661,6 +798,7 @@ export {
661
798
  pickSlot,
662
799
  providerOf,
663
800
  pruneOwn,
801
+ pruneSessions,
664
802
  publish,
665
803
  readLimits,
666
804
  readSiblings,
@@ -673,10 +811,13 @@ export {
673
811
  retryAfterMs,
674
812
  roundRobinSequence,
675
813
  selfFile,
814
+ slotCeiling,
676
815
  slotFor,
677
816
  snapshot,
678
817
  splitModel,
679
818
  state,
680
819
  statedCooldown,
681
- underCeiling
820
+ sweepStateDir,
821
+ underCeiling,
822
+ unmarkProbe
682
823
  };
package/docs/CONTEXT.md CHANGED
@@ -29,10 +29,11 @@ the exploration it replaces is worse than no map:
29
29
  for order mutations", not "there is a file called OrderService in
30
30
  `src/orders/`". The cartographer is forbidden by name from writing directory
31
31
  listings, copying source, or restating what the README already says.
32
- - **One file per question.** Five files, each with a defined scope, so an agent
33
- reads one of them rather than all of them.
32
+ - **One file per question.** Five navigation files, each with a defined scope, so
33
+ an agent reads one of them rather than all of them. A sixth file,
34
+ `active.md`, holds the work in flight rather than a map of the repository.
34
35
  - **Canonical entry points.** The highest-value thing in the map is a short
35
- "Start here" list per subsystem: the five files an agent should open, each
36
+ "Start here" list per subsystem: the canonical files an agent should open, each
36
37
  with a one-phrase reason.
37
38
  - **It links out.** Your project's own README, ADRs and docs are authoritative
38
39
  and are not duplicated here.
@@ -47,7 +48,8 @@ harness that produces a number for your own workload.
47
48
 
48
49
  ## The files in the map
49
50
 
50
- `.opencode/context/` holds the five generated files below:
51
+ `.opencode/context/` holds the five navigation files below, plus one file that is
52
+ a work list rather than a map:
51
53
 
52
54
  | File | What belongs in it |
53
55
  | --- | --- |
@@ -56,6 +58,7 @@ harness that produces a number for your own workload.
56
58
  | `conventions.md` | Repository-specific rules: naming, module layout, error handling, testing, persistence. Read this before writing code, not after. |
57
59
  | `workflows.md` | The few runtime flows worth tracing end to end — how a request becomes a mutation, how a plugin hook reaches the thing it hooks. Read this when you need to know what actually happens at runtime. |
58
60
  | `decisions.md` | Architectural decisions that the repository's own decision log does not already record. Concise, variable length. |
61
+ | `active.md` | The active backlog: work in flight, grouped by the ISO week each task was opened in. A task that is not marked `[x]` is open and is never removed; a task that is finished and dated is kept for a week after it was finished, then dropped. The plugin enforces that window — see [How it stays current](#how-it-stays-current). Not a navigation file: there is no question it is the answer to. |
59
62
 
60
63
  The project's root `AGENTS.md` is the standing-instructions home: the client's
61
64
  own statement of intent, what they want worked on and how they want it
@@ -135,6 +138,18 @@ your source.
135
138
  If you have defined a command of the same name, yours wins and is left exactly
136
139
  as you wrote it — the same merge-without-clobber rule the agents follow.
137
140
 
141
+ `active.md` is the one file in the directory the plugin maintains rather than
142
+ the cartographer. The cartographer records what happened — work in flight, and a
143
+ task marked finished with the date it finished — and the plugin decides what is
144
+ still worth keeping: the file is regrouped by week and a task that was finished
145
+ more than a week ago is dropped. The arithmetic lives in
146
+ `src/lib/active-backlog.ts`, not in the cartographer's prompt, because a rule a
147
+ model applies is a rule the model applies when it feels like it, and the failure
148
+ is silent — the backlog just grows. The plugin normalizes the file on every
149
+ edit of it and once when the session starts, so a hand edit is grouped and
150
+ pruned whether the cartographer ran or not, and the file is rewritten only when
151
+ its canonical form differs from what is on disk, so the write cannot loop.
152
+
138
153
  ## One writer
139
154
 
140
155
  Only `context` writes the map. Every other agent is told never to edit
@@ -152,7 +167,7 @@ naming the one writer, and the permission rule — a permission denial tells an
152
167
  agent nothing about what it should have done instead.
153
168
 
154
169
  This exists because two writers produce a map that is neither current nor
155
- trustworthy. Concurrent sessions rewriting the same five files produce
170
+ trustworthy. Concurrent sessions rewriting the same files produce
156
171
  contradictions that no later reader can resolve, and the reader has no way to
157
172
  tell which version is newer.
158
173
 
@@ -193,7 +208,7 @@ startup check has no way to see a worktree it was not told about.
193
208
 
194
209
  How to detect it: open opencode from the repository root, not from a
195
210
  subdirectory of it. If `ls .opencode/context` from your shell's current
196
- directory does not list the five files, your shell is not at the root and
211
+ directory does not list the map files, your shell is not at the root and
197
212
  neither is opencode's worktree. The cost of getting it wrong is not a crash —
198
213
  it is a map maintained only by prompt, with no mechanism enforcing who writes it.
199
214
 
package/docs/OMNIROUTE.md CHANGED
@@ -25,8 +25,10 @@ as an opencode provider for you. It is **off by default**: if you do not set
25
25
  `auto` is the zero-config default, but on a free tier it often resolves to a
26
26
  slow chain. [A tested fast combo](#a-tested-fast-combo-free-mix) below is
27
27
  ordered for latency and is worth applying on a new client.
28
- 3. Optionally, to route pooled work through it, make `omniroute/auto 3` the
29
- **first** line of `~/.config/lacode/pool.txt`. `pool.txt` is optional user
28
+ 3. Optionally, to route pooled work through it, add an `omniroute/<id>` slot to
29
+ `~/.config/lacode/pool.txt` exactly as you would any other provider. The
30
+ integration gives it no special precedence, priority or weight — its place and
31
+ weight in the pool are whatever you configure. `pool.txt` is optional user
30
32
  config; the bundled default preset does **not** include OmniRoute.
31
33
  4. **Restart opencode.** Nothing here hot-reloads.
32
34
 
@@ -64,7 +66,7 @@ The full option set, for reference:
64
66
  | `enabled` | `false` | Turns the integration on. |
65
67
  | `baseURL` | `http://localhost:20128/v1` | Gateway base URL, normalised to exactly one `/v1`. |
66
68
  | `apiKey` | — | Bearer key. **`OMNIROUTE_API_KEY` overrides this value.** The key is never logged. |
67
- | `combo` | — | An OmniRoute combo id (or `auto`) to expose as a selectable model even though the gateway does not list it. Exposed first. |
69
+ | `combo` | — | An OmniRoute combo id (or `auto`) to expose as a selectable model even though the gateway does not list it. It leads the provider's own model list — an OmniRoute-side detail, unrelated to pool order. |
68
70
  | `models` | — | A **hard allowlist**: only these ids are ever exposed, even if the gateway lists more. |
69
71
  | `tiers` | — | `{ "primary": [...], "backup": [...] }` preference order over models the allowlist already permits. |
70
72
  | `stickyPerSession` | `true` | Keep a session on one model while that model is healthy. |
@@ -95,23 +97,23 @@ failed.
95
97
 
96
98
  **Using OmniRoute in the pool.** The integration only *registers* the provider —
97
99
  it never touches your pool, and it does not place a slot for you. To route pooled
98
- work (exploration, implementation, cartography) through the gateway, list it in
99
- your pool like any other model:
100
+ work (exploration, implementation, cartography) through the gateway, add an
101
+ `omniroute/<id>` slot to your pool like any other model:
100
102
 
101
103
  ```
102
104
  # ~/.config/lacode/pool.txt
103
- omniroute/auto 3
104
- opencode/space-bunny-free 2
105
- opencode-go/longcat-2.5-preview-free 1
106
- opencode/big-pickle 1
105
+ omniroute/free-mix
106
+ opencode/big-pickle
107
+ opencode-go/minimax-m3.1-flash-free
107
108
  ```
108
109
 
109
- `omniroute/auto` then takes slot 1 (weight 3) and drains pooled mechanical work
110
- before it reaches the bundled opencode/opencode-go ("zen") models. Any
111
- `omniroute/<id>` works, provided the id is exposed — by `combo`/`models`, or the
112
- whole catalog when neither is set. Because the plugin writes the pool nothing,
113
- the only thing that changes when the gateway is down is that `omniroute/<id>`
114
- fails; every other slot keeps working. See
110
+ OmniRoute is a provider like any other: the pool decides its place and weight,
111
+ and neither the pool nor this integration gives it any built-in precedence or
112
+ priority — where it sits and how many turns it takes are whatever you configure.
113
+ Any `omniroute/<id>` works, provided the id is exposed — by `combo`/`models`, or
114
+ the whole catalog when neither is set. Because the plugin writes the pool
115
+ nothing, the only thing that changes when the gateway is down is that
116
+ `omniroute/<id>` fails; every other slot keeps working. See
115
117
  [docs/USER-STORIES.md](USER-STORIES.md) US-28.
116
118
 
117
119
 
@@ -102,10 +102,10 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
102
102
  **Story ID(s) for tests:** US-5
103
103
  **Acceptance criteria:**
104
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 weight ceiling or cooling, When its turn comes up in the rotation, Then the turn is skipped and the rotation resumes at the following turn.
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
106
  - [ ] Given every slot is at its ceiling, When another spawn is routed, Then it goes to the least-loaded slot as overflow.
107
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 weight ceiling except by overflow.
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.
109
109
 
110
110
  ### US-6: Live pool visibility
111
111
  **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "0.7.1",
3
+ "version": "0.9.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",
package/presets/README.md CHANGED
@@ -36,10 +36,10 @@ in place, no JSON and no objects:
36
36
 
37
37
  ```text
38
38
  # provider/model-id weight
39
- opencode-go/longcat-2.5-preview-free 3
40
- opencode/space-bunny-free 2
41
- opencode/big-pickle 1
42
- opencode/longcat-2.5-preview-free 1
39
+ opencode/big-pickle 3
40
+ opencode-go/minimax-m3.1-flash-free 2
41
+ opencode/deepseek-v4.1-flash-free 1
42
+ opencode/glm-5.3-flash-free 1
43
43
  ```
44
44
 
45
45
  `#` starts a comment (whole-line or trailing), blank lines are ignored, and the
@@ -62,7 +62,7 @@ else means the flat form.
62
62
  | Field | Required | Meaning |
63
63
  |-------|----------|---------|
64
64
  | `model` | yes | `provider/model-id`, split on the **first** slash only |
65
- | `weight` | no | soft ceiling on concurrent claims, and turns taken per rotation. Absent = 1 |
65
+ | `weight` | no | turns taken per rotation (the slot's share of the rotation); its concurrency ceiling is twice that. Absent = 1 |
66
66
 
67
67
  `$comment` is a JSON-legal array of strings and is ignored by the loader; the
68
68
  bundled preset uses it to explain itself in place.
@@ -91,9 +91,9 @@ because the pool requires it.
91
91
 
92
92
  ## Weight, precisely
93
93
 
94
- A weight is a count of concurrent sessions — a soft ceiling — and the number
95
- of turns a slot takes per rotation. Routing is **order-first weighted
96
- round-robin**:
94
+ A weight is the number of turns a slot takes per rotation — its share of the
95
+ work — and its concurrency ceiling is **twice that weight**: a weight-3 slot may
96
+ hold 6 concurrent sessions. Routing is **order-first weighted round-robin**:
97
97
 
98
98
  1. the slot list is expanded into a run in which each slot appears once per
99
99
  unit of its weight — `[3,2,1,1]` becomes `1,1,1,2,2,3,4` — and the next
@@ -13,8 +13,9 @@
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, and it is a SOFT CEILING on how many concurrent claims a",
17
- "slot holds -- not a share of the work and not a priority. Routing is ORDER-FIRST",
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",
18
19
  "WEIGHTED ROUND-ROBIN: the slot list is walked IN ORDER and each slot takes as many",
19
20
  "consecutive turns as its weight says, so weights 3,2,1,1 produce the run 1,1,1,2,2,3,4,",
20
21
  "which then repeats. The rule is:",
@@ -25,11 +26,27 @@
25
26
  " 3. once EVERY slot is at its ceiling, the overflow goes to the least loaded slot,",
26
27
  " so a burst past the summed ceilings degrades to even spreading and never blocks",
27
28
  "",
28
- "With this file: space-bunny takes 3 consecutive turns, then longcat-2.5-preview",
29
- "(opencode-go) takes 2, then big-pickle and longcat-2.5-preview (Zen) take 1 each -- sequence 1,1,1,2,2,3,4,...",
29
+ "With this file: big-pickle takes 3 consecutive turns, then minimax-m3.1-flash-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,...",
30
32
  "Equal weights give a plain round robin in list order. 2,2,1,1 reproduces the old",
31
33
  "primary/overflow cycle exactly, which is why the old tier values still work.",
32
34
  "",
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.",
39
+ "",
40
+ "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
+ "",
33
50
  "Round-robin rather than heaviest-first because re-prioritising the heaviest slot on",
34
51
  "EVERY spawn drained the fastest provider's free-tier window early: one provider",
35
52
  "absorbed all work up to its ceiling and kept being refilled the moment a claim",
@@ -66,9 +83,9 @@
66
83
  "Changing this file requires restarting every opencode process: plugins are not hot-reloaded."
67
84
  ],
68
85
  "slots": [
69
- { "model": "opencode/space-bunny-free", "weight": 3 },
70
- { "model": "opencode-go/longcat-2.5-preview-free", "weight": 2 },
71
- { "model": "opencode/big-pickle", "weight": 1 },
72
- { "model": "opencode/longcat-2.5-preview-free", "weight": 1 }
86
+ { "model": "opencode/big-pickle", "weight": 3 },
87
+ { "model": "opencode-go/minimax-m3.1-flash-free", "weight": 2 },
88
+ { "model": "opencode/deepseek-v4.1-flash-free", "weight": 1 },
89
+ { "model": "opencode/glm-5.3-flash-free", "weight": 1 }
73
90
  ]
74
- }
91
+ }