@lawaty/lacode 0.10.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.
@@ -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.
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.
@@ -151,6 +150,23 @@ does not.
151
150
  - [ ] Given OmniRoute is disabled, enabled with an invalid block, unhealthy, or returns no usable models, When the config hook runs, Then no provider is registered, no pool slot changes, and a failure is warned about once.
152
151
  - [ ] Given the gateway is unreachable, When pooled work runs, Then the only thing affected is a slot that names `omniroute/<model>`; every other slot keeps working, because the plugin never put an `omniroute` slot in the pool.
153
152
 
153
+ ### US-31: A limit that hangs is killed, and the retry lands on a slot that answers
154
+ **As a** user whose provider has run out of allowance, **I want** the stalled task killed and the retry sent to a slot that is not on cooldown, **so that** one exhausted provider does not strand the session.
155
+
156
+ A limit does not always arrive as an error. Sometimes the provider accepts the request and then says nothing, and opencode keeps retrying the refused model — so the task is neither done nor failed, and the session waits on a provider that has already said no. The plugin has to end that wait itself: abort the stalled session, cool the model that was refused, and let the retry pick a slot that answers. When the provider names itself, the whole provider is benched; when it does not, only the model the session was running on is cooled — that is the one the refusal is evidence about.
157
+
158
+ **Story ID(s) for tests:** US-31
159
+ **Acceptance criteria:**
160
+ - [ ] Given a pooled session's provider reports a limit in a retry status that names no provider, When the status is handled, Then the model that session is running on is cooled and a cooldown file is written for it.
161
+ - [ ] Given a pooled session reports a limit and then goes silent, When the limit is handled, Then that session is aborted and its claim is released, so the slot is not held by a task that will never answer.
162
+ - [ ] Given the refused model is cooling, When the caller retries the spawn, Then the retry is routed to a different slot whose model is not on cooldown.
163
+ - [ ] Given a limit names its provider, When the limit is handled, Then every slot on that provider is cooled and a slot on another provider is not.
164
+ - [ ] Given a limit-shaped error whose text is exactly `limit reached` and which carries no status code, When the error is matched, Then the model is cooled and the in-flight session is aborted.
165
+ - [ ] Given an error whose text is `context limit reached`, When the error is matched, Then nothing is cooled and nothing is aborted, because a prompt that is too long is not a provider quota.
166
+ - [ ] Given a session stalls with no limit signal at all, When the reaper sweeps, Then the session is aborted, its model cooled, and the next spawn routes to a slot that is not on cooldown.
167
+ - [ ] Given a provider reports a transient upstream outage rather than a limit, When the status is handled, Then the model is cooled for one short flat window instead of the quota window, and the session is still aborted, because an outage lifts on its own and must not bench a healthy model.
168
+ - [ ] Given a transient outage was cooled, When the same model later fails with a real limit, Then the cooldown starts at the first strike rather than an escalated one, because an outage lifts on its own and must not make the next quota window longer.
169
+
154
170
  ---
155
171
 
156
172
  ## Epic C — Hang detection and auto-recovery
@@ -240,6 +256,22 @@ does not.
240
256
  - [ ] Given the plugin option `sound` is `false`, or `LACODE_SOUND` is `0`, `false`, `off` or `no`, When a notification is delivered, Then its sound is `none`; when both are set the environment wins over the option, and any other value — a typo included — leaves sound on, because a misspelt variable must not be how you get silence.
241
257
  - [ ] Given a sound name that is not one of `none`, `done`, `question`, `permission` or `error`, When it arrives, Then it is treated as `none` and never becomes a path or a command, because the hop carries data only (see US-18).
242
258
 
259
+ ### US-30: A notification fades once you are working in that chat
260
+ **As a** user with several sessions open, **I want** the popup of the session I am working in to close itself, **so that** a notice I have already dealt with stops sitting on my screen waiting for an X I was never going to press.
261
+
262
+ A popup the user is already looking past has said everything it is going to say: once they answer the permission, answer the question, or type into that session's chat, the answer is on their screen already. So the popup fades on its own — but never sooner than five seconds after it was raised, because a notice nobody could read is no notice, and a popup nobody touched still waits for them, exactly as before.
263
+
264
+ The popup belongs to its session: working in one chat closes that chat's popup and no other. The close is the **helper's** job, never the plugin's, because the popup lives on the desktop and only the desktop knows the notification id the daemon gave it. One code path, two topologies: the helper closes locally, or over the hop where the popup was actually raised.
265
+
266
+ **Story ID(s) for tests:** US-30
267
+ **Acceptance criteria:**
268
+ - [ ] Given a notification has been delivered, When the user answers a permission, answers a question, or types into that session's chat, Then that session's notification closes by itself, without the user clicking the popup's close button.
269
+ - [ ] Given a notification was delivered less than five seconds ago, When the user works in that chat, Then the notification stays until five seconds have passed, because a notice nobody could read is no notice.
270
+ - [ ] Given the user works in one session's chat, When another session still has a popup up, Then that popup stays: a notification belongs to its session, and only that session's user is looking at it.
271
+ - [ ] Given the user never works in that chat, When the notification was delivered, Then it stays until the user closes it, exactly as before.
272
+ - [ ] Given a permission popup is closed this way, When the permission was already answered in the terminal, Then nothing is answered twice: the close takes the popup down and answers nothing.
273
+ - [ ] Given the notification was delivered by a session on a remote host and reached the desktop over the tunnel, When the user works in that chat, Then the popup on the desktop closes too, because the popup lives on the desktop and only the desktop knows its notification id.
274
+
243
275
  ---
244
276
 
245
277
  ## Epic E — Remote hosts over SSH
@@ -287,7 +319,7 @@ does not.
287
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.
288
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.
289
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.
290
- - [ ] 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.
291
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`.
292
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.
293
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.
@@ -380,7 +412,7 @@ agents read it before they explore, then keeps it current on its own.
380
412
  **Acceptance criteria:**
381
413
  - [ ] Given a config that defines none of LaCode's agents, When the config hook runs, Then a `context` subagent is injected, and it carries no model of its own, because the pool is the only thing that decides which model runs it.
382
414
  - [ ] Given the injected agent set, When the slot variants are built, Then a hidden `context-<slot>` variant exists for every slot the pool can hand out, so a cartography run borrows shared capacity instead of a dedicated model.
383
- - [ ] Given a user config with no `instructions` array, When the config hook runs, Then the standing context-map rule is injected, naming all five map files, telling the agent to check with `ls` and never the `glob` tool, to read only the file relevant to the task, and to start from the canonical entry points the map lists.
415
+ - [ ] Given the shipped agent prompts and the context agent, When they are inspected, Then they carry the standing context-map rule — naming all five map files, checking with `ls` and never the `glob` tool, reading only the file relevant to the task, and starting from the canonical entry points the map lists — because that rule now lives in the repo-level `AGENTS.md` rather than being injected into the user's `instructions` array.
384
416
  - [ ] Given the injected `context`, When its prompt is inspected, Then it names the five files it owns, requires a short "Start here" list of canonical files per significant subsystem, and forbids file inventories — the map answers "what should I look at first?", never "tell me everything".
385
417
 
386
418
  ### US-20: The map stays current without being asked
@@ -424,10 +456,49 @@ Enforcement is split deliberately, and the split matters: **what the permission
424
456
 
425
457
  ---
426
458
 
459
+ ## Epic H — Retention (sweeps)
460
+
461
+ ### US-32: Retire old sessions
462
+ **As an** opencode user whose session list only ever grows, **I want** a sweep that retires sessions past a retention window together with everything that belongs to them, **so that** the database stays usable and I keep a compact record of what went.
463
+
464
+ **Story ID(s) for tests:** US-32
465
+ **Acceptance criteria:**
466
+ - [ ] Given sessions whose `time_created` is older than `--days` (default 30) and whose `time_updated` is older than `--grace-hours` (default 24), When the sweep runs, Then those sessions are retired together with their messages, parts, todos, shares, inputs, context epochs and event rows.
467
+ - [ ] Given a session that is old but was touched inside the grace window, When the sweep runs, Then it is left alone, because the grace window is a stop, not a speed.
468
+ - [ ] Given an old parent session with a child, When the sweep runs, Then the child is retired together with the parent, so no orphan remains.
469
+ - [ ] Given `--directory D`, When the sweep runs, Then only sessions rooted in that directory are selected, and a child in another directory goes with its parent.
470
+ - [ ] Given `--limit N`, When the sweep runs, Then at most N top-level sessions (with their descendants) are retired.
471
+ - [ ] Given `--dry-run`, When the sweep runs, Then it reports what would go and changes nothing on disk.
472
+ - [ ] Given `--no-events`, When the sweep runs, Then the event rows of the retired sessions are kept.
473
+ - [ ] Given `--vacuum`, When the sweep runs, Then the database is rewritten afterwards so the freed pages go back to the filesystem.
474
+ - [ ] Given sessions are retired, When the sweep completes, Then a compact record (id, title, directory, project, created, updated) is written under the session archive, which is the recovery path.
475
+
476
+ ### US-33: Prune the event log
477
+ **As an** opencode user whose `event` table grows without bound, **I want** a sweep that deletes event rows older than a window except those belonging to protected sessions, **so that** the database stops growing while the transcript and sync stay intact.
478
+
479
+ **Story ID(s) for tests:** US-33
480
+ **Acceptance criteria:**
481
+ - [ ] Given event rows whose id is older than `--days` (default 7), When the sweep runs, Then those rows are candidates and newer rows are not, because the id carries the row's creation time.
482
+ - [ ] Given a session that takes part in session sync (`event_sequence.owner_id` set), When the sweep runs, Then none of its event rows are pruned.
483
+ - [ ] Given a session that belongs to a workspace, When the sweep runs, Then none of its event rows are pruned.
484
+ - [ ] Given a session touched inside the grace window (`--grace-hours`, default 1), When the sweep runs, Then all of its event rows are kept.
485
+ - [ ] Given an event whose session row is gone, When the sweep runs, Then that event is pruned, because it is garbage.
486
+ - [ ] Given `--dry-run`, When the sweep runs, Then it reports the counts and deletes nothing, and still writes one JSON line to the run log.
487
+ - [ ] Given `--limit N`, When the sweep runs, Then at most N rows are deleted, oldest first, and the remainder is reported for a later run.
488
+ - [ ] Given a window longer than the id era, When the sweep runs, Then the window is clamped to the era and the table is not wiped, and the operator is warned.
489
+ - [ ] Given candidate rows, When they are deleted, Then deletion happens in chunks and a failure mid-run leaves the rest for a later run.
490
+ - [ ] Given `--vacuum` (and not `--dry-run`), When the sweep runs, Then the database is rewritten afterwards.
491
+ - [ ] Given the sweep runs, When it completes, Then the `event_sequence` and `session` tables are never changed.
492
+ - [ ] Given a run, When it completes, Then one JSON line is appended to `~/.local/share/opencode/lacode/event-prune.jsonl` carrying ts, days, graceHours, cutoff, rows, bytes, aggregates, dryRun and elapsedMs.
493
+
494
+ ---
495
+
427
496
  ## Coverage
428
497
 
429
- Every story below has at least one behavioural test in `tests/stories/`, and
430
- `tests/stories/coverage.test.mjs` enforces that mechanically in both directions:
498
+ Every story below has at least one behavioural test. The story suites live in
499
+ `tests/stories/`, and the root-level suites `tests/*-test.mjs` are scanned too
500
+ (US-32, US-33), so `tests/stories/coverage.test.mjs` enforces coverage in both
501
+ directions:
431
502
  it fails if a story here has no test, and it fails if a test names a story this
432
503
  document does not define. The acceptance criteria above are unchanged; only this
433
504
  table was filled in.
@@ -439,10 +510,11 @@ table was filled in.
439
510
  | US-3 | A | `tests/stories/a-cost-control.test.mjs` (harness: `tests/benchmark/run.mjs`) |
440
511
  | US-23 | A | `tests/stories/a-cost-control.test.mjs` |
441
512
  | US-4 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-limits-test.mjs` |
442
- | US-5 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-test.mjs`, `tests/pool-models-test.mjs` |
443
- | 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` |
444
515
  | US-7 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-models-test.mjs` |
445
516
  | US-28 | B | `tests/stories/i-omniroute.test.mjs` |
517
+ | US-31 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/stories/c-hang-recovery.test.mjs` |
446
518
  | US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
447
519
  | US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
448
520
  | US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
@@ -450,6 +522,7 @@ table was filled in.
450
522
  | US-12 | D | `tests/stories/d-notifications.test.mjs`, `tests/notify-test.mjs` |
451
523
  | US-25 | D | `tests/stories/d-notifications.test.mjs` |
452
524
  | US-29 | D | `tests/stories/d-notifications.test.mjs` |
525
+ | US-30 | D | `tests/stories/d-notifications.test.mjs`, `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
453
526
  | US-13 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/notify-test.mjs`, `tests/oc-test.mjs` |
454
527
  | US-14 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
455
528
  | US-18 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
@@ -463,10 +536,15 @@ table was filled in.
463
536
  | US-20 | G | `tests/stories/g-context-management.test.mjs` |
464
537
  | US-21 | G | `tests/stories/g-context-management.test.mjs`, `tests/permission-test.mjs` |
465
538
  | US-22 | G | `tests/stories/g-context-management.test.mjs` |
539
+ | US-32 | H | `tests/retire-old-sessions-test.mjs` |
540
+ | US-33 | H | `tests/prune-old-events-test.mjs` |
466
541
 
467
542
  The nine suites under `tests/` are unit-style; the `tests/stories/` suites are
468
543
  the behavioural layer, written one epic per file with every test name carrying
469
544
  its story id. Run everything with `npm test`.
470
545
 
471
- **No story is uncovered.** US-1, US-2, US-3, US-6, and all of epic F have tests,
472
- and the coverage guard fails the build if any of them loses one.
546
+ **No story is uncovered.** The guard reports 33 stories, 325 test cases, and
547
+ 33/33 stories with at least one behavioural test; US-1, US-2, US-3, US-6, and
548
+ all of epic F have tests, and the coverage guard fails the build if any of them
549
+ loses one. The scan covers the `tests/stories/` suites and the root-level
550
+ `tests/*-test.mjs` suites.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "0.10.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
@@ -26,8 +26,9 @@ file in without `mkdir` first.
26
26
  Sources 1 and 2 win outright: if they parse, the bundled preset is never read.
27
27
  Source 3 is a default, not a floor.
28
28
 
29
- Nothing is hot-reloaded. Change a preset, then restart every running opencode
30
- process.
29
+ The pool files are not frozen at startup: the running process watches them,
30
+ reloads the slot table, re-binds the variant agents and validates every slot, so a
31
+ change here takes effect without a restart. Plugin CODE still needs one.
31
32
 
32
33
  ## The flat pool file (recommended)
33
34
 
@@ -37,7 +38,7 @@ in place, no JSON and no objects:
37
38
  ```text
38
39
  # provider/model-id weight
39
40
  opencode/big-pickle 3
40
- opencode-go/minimax-m3.1-flash-free 2
41
+ opencode-go/beam-free 2
41
42
  opencode/deepseek-v4.1-flash-free 1
42
43
  opencode/glm-5.3-flash-free 1
43
44
  ```
@@ -62,7 +63,7 @@ else means the flat form.
62
63
  | Field | Required | Meaning |
63
64
  |-------|----------|---------|
64
65
  | `model` | yes | `provider/model-id`, split on the **first** slash only |
65
- | `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 |
66
67
 
67
68
  `$comment` is a JSON-legal array of strings and is ignored by the loader; the
68
69
  bundled preset uses it to explain itself in place.
@@ -80,7 +81,7 @@ The loader checks shape and nothing else:
80
81
  - at most `MAX_SLOTS` (4) slots; extra entries are **dropped with a warning**,
81
82
  because one hidden variant agent per base per slot exists and a fifth slot
82
83
  would route at an agent that does not exist
83
- - `tier` is accepted as a deprecated alias for `weight` (`primary` → 2,
84
+ - `tier` is accepted as an alias for `weight` (`primary` → 2,
84
85
  `overflow` → 1) and warns
85
86
 
86
87
  **Cost is never validated.** Any provider/model-id string is accepted, including
@@ -91,28 +92,26 @@ because the pool requires it.
91
92
 
92
93
  ## Weight, precisely
93
94
 
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
-
98
- 1. the slot list is expanded into a run in which each slot appears once per
99
- unit of its weight — `[3,2,1,1]` becomes `1,1,1,2,2,3,4` — and the next
100
- spawn takes the turn a persistent cursor (a file in the pool directory,
101
- shared by every opencode process) points at
102
- 2. a turn whose slot is cooling or already at its ceiling is **skipped**, and
103
- the rotation resumes at the following turn
104
- 3. once **every** slot is at its ceiling, overflow goes to the least loaded slot —
105
- a burst past the summed ceilings degrades to even spreading and never blocks
106
-
107
- Weight is no longer a priority: a heavy slot is not drained to its ceiling
108
- before the others see any work. Every slot gets a turn each cycle, so a fast
109
- provider with a large usage window is used early in the cycle while it is
110
- still fast — instead of one provider being refilled until it hits its limit
111
- and its 429s displace every agent type at once.
112
-
113
- Equal weights give a plain round robin in list order. List order is the
114
- rotation itself, so put the models you want taking the earlier turns of a
115
- 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.
116
115
 
117
116
  Spreading at least two providers across the slots is worth doing deliberately: a
118
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 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,...",
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.",
@@ -80,11 +64,11 @@
80
64
  "Re-check any model you substitute against models.dev; this project does not look prices",
81
65
  "up at runtime and will not guess at them for you.",
82
66
  "",
83
- "Changing this file requires restarting every opencode process: plugins are not hot-reloaded."
67
+ "Changing this file is picked up live: the pool watches its sources, reloads the slot table and validates every slot. A restart is only needed for plugin CODE, not for a pool edit."
84
68
  ],
85
69
  "slots": [
86
70
  { "model": "opencode/big-pickle", "weight": 3 },
87
- { "model": "opencode-go/minimax-m3.1-flash-free", "weight": 2 },
71
+ { "model": "opencode-go/beam-free", "weight": 2 },
88
72
  { "model": "opencode/deepseek-v4.1-flash-free", "weight": 1 },
89
73
  { "model": "opencode/glm-5.3-flash-free", "weight": 1 }
90
74
  ]