@lawaty/lacode 0.0.0-stage → 0.1.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/LICENSE +32 -0
- package/README.md +415 -2
- package/dist/agents.js +819 -0
- package/dist/commands.js +109 -0
- package/dist/index.js +2250 -0
- package/dist/lacode.js +223 -0
- package/dist/pool.js +487 -0
- package/dist/tunnel.js +150 -0
- package/docs/BENCHMARKS.md +127 -0
- package/docs/CONTEXT.md +211 -0
- package/docs/TUNNEL.md +141 -0
- package/docs/USER-STORIES.md +396 -0
- package/package.json +58 -4
- package/presets/README.md +121 -0
- package/presets/free-tier.json +60 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# LaCode — User Stories
|
|
2
|
+
|
|
3
|
+
> Ultimate opencode setup for Go users
|
|
4
|
+
|
|
5
|
+
Every story below is a behavioural contract, not a feature wish. Each one is
|
|
6
|
+
traceable to a pain point the author actually hit, and each carries acceptance
|
|
7
|
+
criteria written so that a test can assert them mechanically. Phase 3 fills in
|
|
8
|
+
the test file for each row of the Coverage table.
|
|
9
|
+
|
|
10
|
+
**Reading the criteria.** Every criterion is written as *Given … When … Then …
|
|
11
|
+
* so that it maps to one test case. "Given" is a state or fixture, "When" is a
|
|
12
|
+
single action (usually a hook invocation or a script run), "Then" is an
|
|
13
|
+
observable result — a routing decision, a file on disk, an exit status, a log
|
|
14
|
+
line. Criteria that cannot be observed without a running server are not in this
|
|
15
|
+
document; everything here is testable offline with a stub client and a
|
|
16
|
+
`mkdtempSync` state directory.
|
|
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-fast`), 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.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Epics and the pains behind them
|
|
27
|
+
|
|
28
|
+
| Epic | Pain point it solves |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| **A — Cost control** | Quota exhaustion. Routine work (grep, reading, cartography) was consuming the same expensive budget as design work, and the main model was being spent on file listing. |
|
|
31
|
+
| **B — Provider resilience** | Provider limits. Free-tier quotas are per-provider and per-minute, so a long session would hard-fail the moment one provider's window closed, even while three other providers sat idle. |
|
|
32
|
+
| **C — Hang recovery** | Hangs. A wedged or silently-stalled subagent held its slot forever; one bad task stalled the session with no error and no timeout. |
|
|
33
|
+
| **D — Notifications** | Parallel sessions. With several sessions open, work finishing — or a question blocking — was invisible unless you happened to be looking at that terminal. |
|
|
34
|
+
| **E — Remote hosts** | Remote SSH notification. Work ran on remote hosts, so notifications raised there died on that host's nonexistent desktop instead of reaching the local one. |
|
|
35
|
+
| **F — Adoption and safety** | Installability. A personal config cannot be adopted safely: it either clobbers what you already have, or needs manual surgery before it runs. |
|
|
36
|
+
| **G — Context and cost** | Cold exploration. Every task started by re-discovering the structure of the codebase from scratch, so the same layout was re-read — and re-paid for — on every single run. |
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Epic A — Cost control (route bulk work off the expensive model)
|
|
41
|
+
|
|
42
|
+
### US-1: Bulk exploration runs on cheap/free subagents
|
|
43
|
+
**As an** opencode user paying per token, **I want** file reading and searching delegated to cheap subagents, **so that** my main model's budget goes to design work instead of grep.
|
|
44
|
+
|
|
45
|
+
**Story ID(s) for tests:** US-1
|
|
46
|
+
**Acceptance criteria:**
|
|
47
|
+
- [ ] Given the root agent `build` with read/glob/grep denied, When a read-family tool is invoked, Then the call is denied and the agent is pointed at an exploration base rather than executing inline.
|
|
48
|
+
- [ ] Given a pooled base such as `explore-fast` is spawned, When the plugin rewrites the spawn, Then the concrete model selected is a slot declared in `presets/free-tier.json`.
|
|
49
|
+
- [ ] Given a pool whose slots are all zero-cost, When N exploration spawns run, Then none of them is dispatched to a paid model.
|
|
50
|
+
|
|
51
|
+
### US-2: One knob selects the exploration/implementation model
|
|
52
|
+
**As a** user, **I want** to change which model handles delegated work without editing prompts, **so that** I can change cost strategy in one place.
|
|
53
|
+
|
|
54
|
+
**Story ID(s) for tests:** US-2
|
|
55
|
+
**Acceptance criteria:**
|
|
56
|
+
- [ ] Given the pool preset is edited to swap a slot's model, When a pooled spawn is routed, Then the new model is used with no change to any agent prompt in `opencode.jsonc`.
|
|
57
|
+
- [ ] Given `opencode.jsonc` hard-codes a model for a pooled agent, When the plugin boots, Then it warns that the hard-coded value is overridden and that `presets/free-tier.json` wins.
|
|
58
|
+
- [ ] Given a slot declares an invalid `weight`, When the pool loads, Then it falls back to weight 1 and warns, rather than dropping the slot.
|
|
59
|
+
|
|
60
|
+
### US-3: Reproducible cost benchmark
|
|
61
|
+
**As a** maintainer, **I want** a script that measures the token/cost of a representative task with and without the pool, **so that** any savings claim is measured rather than asserted.
|
|
62
|
+
|
|
63
|
+
**Story ID(s) for tests:** US-3
|
|
64
|
+
**Acceptance criteria:**
|
|
65
|
+
- [ ] Given the benchmark script is run against a fixed task prompt, When it executes, Then it reports token counts and cost for the pooled run and the unpooled run from the same input.
|
|
66
|
+
- [ ] Given a slot's model is free, When the benchmark prices it, Then its cost is 0 rather than unknown or omitted.
|
|
67
|
+
- [ ] Given the benchmark runs with no network access, When it cannot reach a pricing source, Then it fails loudly instead of reporting 0 cost for a paid model.
|
|
68
|
+
|
|
69
|
+
### US-23: The pool takes any model; free is the shipped default, not an enforced rule
|
|
70
|
+
**As a** user, **I want** the pool to accept whatever models I give it and only default to free ones, **so that** I keep full control of what the pool runs on instead of being second-guessed about it.
|
|
71
|
+
|
|
72
|
+
**Story ID(s) for tests:** US-23
|
|
73
|
+
|
|
74
|
+
This is the policy behind US-1 and it does not contradict US-7. The pool exists to take **mechanical** work — reading, searching, mechanical edits, cartography — off the main model, so the bundled preset ships free models: that is what those tokens cost when you have not said otherwise. It is a **default, not a restriction**. LaCode holds no opinion about which models a user may run: no whitelist, no filter, no per-model rule beyond the structural `provider/model-id` shape a slot must have to be addressable at all. There is no cost advisory either — a plugin that guesses what a model costs from a pinned snapshot it does not ship would be advisory theatre, so the shipped default is expressed once, in `presets/free-tier.json`, where a user reads it and edits it.
|
|
75
|
+
|
|
76
|
+
The reconciliation with US-7 is that **acceptance and endorsement are different claims**: the pool accepts anything, and the preset states what it is for.
|
|
77
|
+
|
|
78
|
+
**Acceptance criteria:**
|
|
79
|
+
- [ ] Given the bundled preset, When its slots are priced against the pinned models.dev snapshot, Then every slot is free or very cheap, and any slot the snapshot cannot price is named as unknown rather than assumed to be free.
|
|
80
|
+
- [ ] Given a pool configured with any arbitrary `provider/model-id` string — a paid one, an exotic one, an invented one — When the plugin boots and a spawn is routed, Then the model binds verbatim and runs, with no warning about it, no rejection of it, and no special-casing of it.
|
|
81
|
+
- [ ] Given the shipped preset, When a user reads it, Then it says the pool is for mechanical work, and says they MAY substitute their own list via `~/.config/lacode/pool.json`, so the free default never reads as a prohibition.
|
|
82
|
+
- [ ] Given no price information exists for a configured model, When the plugin boots, Then it is silent: the pool does not inspect, fetch, or infer prices at all, because a default is expressed in the preset and not enforced at runtime.
|
|
83
|
+
- [ ] Given a paid or exotic model is configured in the pool, When a spawn is routed, Then it is routed to that model exactly as any other slot would be, because rejecting it would break US-7.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Epic B — Provider resilience (weighted model pool)
|
|
88
|
+
|
|
89
|
+
### US-4: Automatic failover when a provider rate-limits
|
|
90
|
+
**As a** user on free tiers that hit per-provider limits, **I want** subagents to move to a different provider automatically, **so that** long sessions do not fail.
|
|
91
|
+
|
|
92
|
+
**Story ID(s) for tests:** US-4
|
|
93
|
+
**Acceptance criteria:**
|
|
94
|
+
- [ ] Given a spawn fails with a rate-limit-shaped error, When the error is serialized and matched, Then the offending model is marked cooling and a cooldown file is written for it.
|
|
95
|
+
- [ ] Given model A is cooling, When the next spawn is routed, Then the pick is a different model and the spawn is never refused outright.
|
|
96
|
+
- [ ] Given every model is cooling, When a spawn is routed, Then the least-loaded slot is chosen anyway (degrade, never block).
|
|
97
|
+
- [ ] 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
|
+
|
|
99
|
+
### US-5: Weighted preference drains preferred providers first
|
|
100
|
+
**As a** user, **I want** to express preference between models via weights, **so that** the pool prefers the cheapest/freest provider up to its ceiling.
|
|
101
|
+
|
|
102
|
+
**Story ID(s) for tests:** US-5
|
|
103
|
+
**Acceptance criteria:**
|
|
104
|
+
- [ ] Given a heavy slot and a light slot, When spawns are routed below both ceilings, Then the heavier slot is filled before the lighter one.
|
|
105
|
+
- [ ] Given a slot is at its weight ceiling, When another spawn is routed, Then the pick moves to the next slot under its ceiling.
|
|
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 weight ceiling except by overflow.
|
|
109
|
+
|
|
110
|
+
### US-6: Live pool visibility
|
|
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.
|
|
112
|
+
|
|
113
|
+
**Story ID(s) for tests:** US-6
|
|
114
|
+
**Acceptance criteria:**
|
|
115
|
+
- [ ] 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
|
+
- [ ] 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
|
+
- [ ] 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 marked as the slot the next spawn would take.
|
|
119
|
+
|
|
120
|
+
### US-7: Non-free models still work
|
|
121
|
+
**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.
|
|
122
|
+
|
|
123
|
+
**Story ID(s) for tests:** US-7
|
|
124
|
+
**Acceptance criteria:**
|
|
125
|
+
- [ ] Given a slot names a paid model, When the pool loads, Then the slot is accepted and routed to without a free-tier filter rejecting it.
|
|
126
|
+
- [ ] Given a model id contains a vendor prefix with slashes (e.g. `openrouter/nvidia/model:free`), When it is split into provider and model id, Then the split is on the first slash only and the id tail is preserved.
|
|
127
|
+
- [ ] Given more slots are declared than the pool allows, When the pool loads, Then the surplus entries are dropped with a warning naming the cap.
|
|
128
|
+
- [ ] Given a provider configured with **no** `whitelist` key, When the config hook binds a slot or a `deepModel` on that provider, Then no `whitelist` is written for it, because an absent whitelist already allows every model the provider offers.
|
|
129
|
+
- [ ] Given a provider the pool never names a slot or a `deepModel` on, When the config hook runs, Then that provider entry is left exactly as it was, with no `whitelist` key added.
|
|
130
|
+
- [ ] Given a provider that already carries a `whitelist`, When the config hook binds a slot model or a `deepModel` on it, Then the model id is appended and every entry the user wrote is preserved in order.
|
|
131
|
+
- [ ] Given any provider after the config hook has run, When its whitelist is inspected, Then it is either absent or non-empty, because an empty allowlist names no model at all and opencode deletes the provider outright.
|
|
132
|
+
|
|
133
|
+
`provider.<name>.whitelist` is an **allowlist**, not a to-do list: opencode
|
|
134
|
+
deletes every model a provider offers that the whitelist does not name, and
|
|
135
|
+
deletes the provider itself if nothing survives. An absent whitelist is the open
|
|
136
|
+
form — everything is allowed. That makes writing one where the user had none the
|
|
137
|
+
single most destructive thing the plugin could do, since a list holding only the
|
|
138
|
+
pool's models silently deletes everything else they run there. So appending is
|
|
139
|
+
correct exactly when a whitelist exists (it is a real restriction, and the pool
|
|
140
|
+
models would otherwise resolve to nothing) and is not merely unnecessary when one
|
|
141
|
+
does not.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Epic C — Hang detection and auto-recovery
|
|
146
|
+
|
|
147
|
+
### US-8: Wedged subagent is detected and aborted
|
|
148
|
+
**As a** user with many parallel sessions, **I want** a silent or stuck subagent killed automatically, **so that** one bad task cannot stall my session forever.
|
|
149
|
+
|
|
150
|
+
**Story ID(s) for tests:** US-8
|
|
151
|
+
**Acceptance criteria:**
|
|
152
|
+
- [ ] Given a claim has exceeded its TTL and produced no recent activity, When the reaper sweeps, Then that claim's process is aborted.
|
|
153
|
+
- [ ] Given the reaper is idle, When its timer interval elapses, Then a sweep runs on its own, and a sweep also runs on the next pooled spawn without waiting for the timer.
|
|
154
|
+
- [ ] Given a claim is young and active, When the reaper sweeps, Then it is left alone.
|
|
155
|
+
- [ ] Given a claim is swept, When the sweep completes, Then the total reaped count increases and a greppable log line names the reaped claim.
|
|
156
|
+
|
|
157
|
+
### US-9: Capacity recovers without restart
|
|
158
|
+
**As a** user, **I want** the aborted slot returned to the pool, **so that** throughput returns without restarting opencode.
|
|
159
|
+
|
|
160
|
+
**Story ID(s) for tests:** US-9
|
|
161
|
+
**Acceptance criteria:**
|
|
162
|
+
- [ ] Given a claim is reaped, When the next routing decision is made, Then the freed capacity is available and is not still counted as in use.
|
|
163
|
+
- [ ] Given a claim file's owning process is gone, When claims are read, Then the file is pruned as dead and its capacity returned.
|
|
164
|
+
- [ ] Given an orphaned temporary claim file exists, When claims are read, Then the orphan is cleaned up rather than counted.
|
|
165
|
+
- [ ] Given a reaper sweep empties the backlog, When routing resumes, Then no manual intervention or restart is required.
|
|
166
|
+
|
|
167
|
+
### US-10: Hangs are surfaced, not silent
|
|
168
|
+
**As a** user, **I want** to know a hang happened and was reaped, **so that** I can investigate rather than wonder why an agent vanished.
|
|
169
|
+
|
|
170
|
+
**Story ID(s) for tests:** US-10
|
|
171
|
+
**Acceptance criteria:**
|
|
172
|
+
- [ ] Given a claim is reaped, When the reaper reports, Then the log names the reaped session and the reason (hung past TTL with no activity).
|
|
173
|
+
- [ ] Given the reaper is armed, When it starts, Then a single greppable line records that it is armed and on what interval.
|
|
174
|
+
- [ ] Given a reaper sweep fails internally, When the sweep is reported, Then the failure is reported through the app log and never thrown out of the hook.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Epic D — Notifications for parallel sessions
|
|
179
|
+
|
|
180
|
+
### US-11: Notification when a session finishes
|
|
181
|
+
**As a** user running many parallel sessions, **I want** a desktop notification when a session goes idle or errors, **so that** I can work elsewhere and know when to return.
|
|
182
|
+
|
|
183
|
+
**Story ID(s) for tests:** US-11
|
|
184
|
+
**Acceptance criteria:**
|
|
185
|
+
- [ ] Given a session went busy and is now idle, When the idle event fires, Then a `done` notification is delivered.
|
|
186
|
+
- [ ] Given a session errors, When the error event fires, Then an `error` notification is delivered and it bypasses the duplicate-suppression cooldown.
|
|
187
|
+
- [ ] Given a session that never went busy, When it goes idle, Then no notification is sent.
|
|
188
|
+
- [ ] Given a subagent session or a cartographer session, When it goes idle, Then it is suppressed.
|
|
189
|
+
- [ ] Given the notify helper exits non-zero or writes to stderr, When the result is evaluated, Then the delivery is reported as failed rather than sent.
|
|
190
|
+
|
|
191
|
+
### US-12: Notification when a question is asked
|
|
192
|
+
**As a** user, **I want** to be notified when opencode is blocked waiting on my input, **so that** a pending prompt is never silently blocking a session.
|
|
193
|
+
|
|
194
|
+
**Story ID(s) for tests:** US-12
|
|
195
|
+
**Acceptance criteria:**
|
|
196
|
+
- [ ] Given a question is asked, When the event fires, Then a `question` notification is delivered that includes the question's text.
|
|
197
|
+
- [ ] Given a session has a parked question, When the idle event that follows fires, Then no duplicate "finished" notification is sent.
|
|
198
|
+
- [ ] Given a parked question is replied to, rejected, or the session errors, When that event fires, Then the pending entry is cleared and the next real completion notifies again.
|
|
199
|
+
- [ ] Given a question and a completion occur inside the same cooldown window, When both are evaluated, Then they are not deduplicated against each other.
|
|
200
|
+
|
|
201
|
+
### US-25: Notification when opencode asks for permission
|
|
202
|
+
**As a** user, **I want** to be notified when opencode is blocked asking me for permission — including permission to reach a directory outside the worktree — **so that** a silent permission prompt is never mistaken for a hung agent.
|
|
203
|
+
|
|
204
|
+
**Story ID(s) for tests:** US-25
|
|
205
|
+
**Acceptance criteria:**
|
|
206
|
+
- [ ] Given a permission is requested, When the `permission.asked` event fires, Then exactly one notification is delivered naming the permission type and the path or pattern being requested.
|
|
207
|
+
- [ ] Given opencode asks to reach a directory outside the worktree, When the prompt fires, Then the notification names that directory, resolved from `metadata.parentDir`/`metadata.filepath` and with the trailing glob trimmed, because a glob is not something the user can judge.
|
|
208
|
+
- [ ] Given the permission is granted, denied, or dismissed, When `permission.replied` fires, Then no notification is sent — the prompt was the thing worth interrupting for, not the answer — and only the parked-session block is cleared.
|
|
209
|
+
- [ ] Given a session is parked on a permission prompt, When the idle event fires, Then no "finished" notification is sent, because nothing finished.
|
|
210
|
+
- [ ] Given the plugin observes a permission prompt, When it handles the event, Then it is a read-only observer: the payload is not mutated and no `permission.ask` hook is registered, because that hook's `output.status` is the permission decision itself.
|
|
211
|
+
- [ ] Given a `permission` notification is raised, When it is evaluated against the cooldown, Then it bypasses it, since an unannounced block is the failure this story exists to remove.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Epic E — Remote hosts over SSH
|
|
216
|
+
|
|
217
|
+
### US-13: Notification reaches the local desktop from a remote host
|
|
218
|
+
**As a** user working on a remote machine over SSH, **I want** notifications from remote sessions delivered to my local desktop, **so that** remote work still notifies me.
|
|
219
|
+
|
|
220
|
+
**Story ID(s) for tests:** US-13
|
|
221
|
+
**Acceptance criteria:**
|
|
222
|
+
- [ ] Given a notification is raised on a remote host, When it is delivered, Then the local desktop shows it.
|
|
223
|
+
- [ ] Given the remote host has no desktop session of its own, When the notification is delivered, Then the username and bus path come from the local-user marker, never from the remote `id -u`/`id -un`.
|
|
224
|
+
- [ ] Given the notification travels to the forced-command receiver, When it arrives, Then its urgency and icon are validated against a whitelist before `notify-send` is invoked.
|
|
225
|
+
- [ ] Given the notification is sent, When it crosses the hop, Then it is passed as a single self-delimiting argument and never interpolated into a shell string (see US-18 for why it cannot be four).
|
|
226
|
+
- [ ] Given the session outlives the terminal it was started from, When the tunnel's original owner disconnects, Then notifications still arrive because the tunnel is a supervised unit rather than a login side effect.
|
|
227
|
+
|
|
228
|
+
### US-14: Tunnel degrades safely
|
|
229
|
+
**As a** user, **I want** the tunnel to fail silently when unreachable rather than blocking or erroring, **so that** a flaky connection never breaks my session.
|
|
230
|
+
|
|
231
|
+
**Story ID(s) for tests:** US-14
|
|
232
|
+
**Acceptance criteria:**
|
|
233
|
+
- [ ] Given the remote host is unreachable, When a notification is attempted, Then it fails without blocking or throwing out of the hook.
|
|
234
|
+
- [ ] Given the notification helper is missing, When the plugin looks for it, Then it warns once and continues working.
|
|
235
|
+
- [ ] Given the sync script runs many ssh calls while a tunnel is live, When each call is made, Then forwardings are cleared on that call so it does not compete for the tunnel port.
|
|
236
|
+
- [ ] Given a notification arrives at the receiver with an argument that is not whitelisted, When it is validated, Then it is rejected rather than forwarded to `notify-send`.
|
|
237
|
+
|
|
238
|
+
### US-18: Notification content survives the SSH hop intact
|
|
239
|
+
**As a** user on a remote host, **I want** the notification's text to arrive on my local desktop whole, **so that** a hop which fails quietly is not also a hop that delivers nothing.
|
|
240
|
+
|
|
241
|
+
**Story ID(s) for tests:** US-18
|
|
242
|
+
**Acceptance criteria:**
|
|
243
|
+
- [ ] Given the notification crosses the hop, When it is passed, Then it is one newline-delimited argument, because ssh concatenates everything after the host into a single command string and starts the forced command with no positional parameters.
|
|
244
|
+
- [ ] Given the forced command is started, When the payload arrives, Then it is read from `$SSH_ORIGINAL_COMMAND` and never from `$1`–`$4`, which are always empty over ssh.
|
|
245
|
+
- [ ] Given a body containing spaces, punctuation or glob characters, When it arrives at `notify-send`, Then it is byte-identical to what the remote sent.
|
|
246
|
+
- [ ] Given a payload containing shell metacharacters (`$(…)`, backticks, `;`, `&&`) or extra fields, When the receiver handles it, Then it is parsed as data only: nothing is executed and the argument list passed to `notify-send` is unchanged.
|
|
247
|
+
- [ ] Given a payload that cannot be understood at all, When it arrives, Then the receiver still exits 0 and pops up what it received, rather than failing the hop.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Epic F — Adoption and safety
|
|
252
|
+
|
|
253
|
+
### US-15: One-line install with sane defaults
|
|
254
|
+
**As a** new user, **I want** installing LaCode to require one config line and no manual setup, **so that** I get value immediately.
|
|
255
|
+
|
|
256
|
+
**Story ID(s) for tests:** US-15
|
|
257
|
+
**Acceptance criteria:**
|
|
258
|
+
- [ ] Given a fresh machine with no LaCode config, When the install runs, Then one config line is sufficient and no further manual step is required.
|
|
259
|
+
- [ ] Given no configuration is present at all, When LaCode boots, Then it falls back to shipped defaults and still functions.
|
|
260
|
+
- [ ] Given the plugin package is installed, When its peer dependency is unmet, Then the requirement is declared rather than silently duplicated inside the package.
|
|
261
|
+
|
|
262
|
+
### US-16: Merges, never clobbers
|
|
263
|
+
**As a** user with an existing opencode config, **I want** LaCode to add its agents/commands only where absent and never overwrite my own definitions, **so that** installing it cannot break my setup.
|
|
264
|
+
|
|
265
|
+
**Story ID(s) for tests:** US-16
|
|
266
|
+
**Acceptance criteria:**
|
|
267
|
+
- [ ] Given an agent of the same name already exists in the user's config, When LaCode merges, Then the user's definition is left untouched.
|
|
268
|
+
- [ ] Given a command of the same name already exists, When LaCode merges, Then the user's definition is left untouched.
|
|
269
|
+
- [ ] Given a merge adds an agent, When it is written back, Then every pre-existing key in the file survives the round trip unchanged.
|
|
270
|
+
- [ ] Given merging runs twice in a row, When the second run completes, Then the result is identical to the first (idempotent).
|
|
271
|
+
|
|
272
|
+
### US-17: Single off switch
|
|
273
|
+
**As a** user, **I want** one option to disable LaCode's config injection entirely, **so that** I can recover instantly if something misbehaves.
|
|
274
|
+
|
|
275
|
+
**Story ID(s) for tests:** US-17
|
|
276
|
+
**Acceptance criteria:**
|
|
277
|
+
- [ ] Given the off switch is set, When LaCode boots, Then no agent, command, or rule is injected into the user's config.
|
|
278
|
+
- [ ] Given the off switch is set, When the bootstrap runs, Then it makes no writes at all.
|
|
279
|
+
- [ ] Given the off switch is unset, When LaCode boots, Then injection behaves exactly as before — disabling is the only behavioural difference.
|
|
280
|
+
|
|
281
|
+
### US-24: Tool grants are documented, not injected, because a plugin cannot set them
|
|
282
|
+
**As a** user who keeps a global `"tools"` block, **I want** to know that per-agent tool grants must be declared in my own config, **so that** browser automation is not silently denied on the agents that need it.
|
|
283
|
+
|
|
284
|
+
The pain: LaCode once shipped a `tools: { "playwright_*": true }` grant on its four exploration and implementation tiers, with a comment claiming it rescued anyone who had globally disabled Playwright. It did not. opencode folds an agent's `tools` into its permission list **at config-load time**, inside the schema decode that runs before any plugin is called; a plugin's `config` hook mutates the already-decoded object, so the fold never re-runs and the injected value is read by nothing. The grant was dead code asserting a rescue it never performed. The fix is not to re-inject it — it is to tell the truth about it and document the one-line fix the user can apply themselves.
|
|
285
|
+
|
|
286
|
+
**Story ID(s) for tests:** US-24
|
|
287
|
+
**Acceptance criteria:**
|
|
288
|
+
- [ ] Given the config hook runs on a config that globally disables a tool namespace, When the agent set is injected, Then no injected agent carries a `tools` field at all — not on the four exploration and implementation tiers, and not on any hidden pool variant.
|
|
289
|
+
- [ ] Given a config with a global `"tools"` block, When LaCode is asked to inject, Then every other agent field it does own — `permission`, `prompt`, `description`, `temperature`, `mode`, `hidden` — is still written, so the removal is scoped to the one field the host folds before the plugin runs.
|
|
290
|
+
- [ ] Given the README, When a reader looks for what the plugin injects, Then the tool-grant limitation is stated plainly, above the injection list rather than buried under it, and a minimal copy-pasteable example of declaring the grants in the user's own config is shown.
|
|
291
|
+
- [ ] Given a reader who has never seen the source, When they read the README section, Then the load-time-folding reason is given in one sentence, so the grant is not re-added on the belief that it is merely missing.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Epic G — Context and cost (a maintained map instead of cold exploration)
|
|
296
|
+
|
|
297
|
+
The pain: every task opened by exploring the repository from scratch. The same
|
|
298
|
+
directory layout, the same entry points and the same layering were rediscovered
|
|
299
|
+
— and re-paid for — on every run, and nothing written down survived the session
|
|
300
|
+
that learned it. LaCode keeps a five-file codemap at `.opencode/context/` and
|
|
301
|
+
makes agents read it before they explore, then keeps it current on its own.
|
|
302
|
+
|
|
303
|
+
### US-19: Curated map replaces cold exploration
|
|
304
|
+
**As a** user, **I want** agents to start from a maintained map of the codebase, **so that** each task spends fewer tokens on re-discovering structure that is already written down.
|
|
305
|
+
|
|
306
|
+
**Story ID(s) for tests:** US-19
|
|
307
|
+
**Acceptance criteria:**
|
|
308
|
+
- [ ] Given a config that defines none of LaCode's agents, When the config hook runs, Then a `context-manager` subagent is injected, and it carries no model of its own, because the pool is the only thing that decides which model runs it.
|
|
309
|
+
- [ ] Given the injected agent set, When the slot variants are built, Then a hidden `context-manager-<slot>` variant exists for every slot the pool can hand out, so a cartography run borrows shared capacity instead of a dedicated model.
|
|
310
|
+
- [ ] 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.
|
|
311
|
+
- [ ] Given the injected `context-manager`, 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".
|
|
312
|
+
|
|
313
|
+
### US-20: The map stays current without being asked
|
|
314
|
+
**As a** user, **I want** the map refreshed automatically after a session that edited code, **so that** agents are never misled by stale documentation.
|
|
315
|
+
|
|
316
|
+
**Story ID(s) for tests:** US-20
|
|
317
|
+
**Acceptance criteria:**
|
|
318
|
+
- [ ] Given a file inside the project was edited, When the root session next goes idle, Then a `context-manager` session is created and prompted with the edited paths listed, rather than nothing happening.
|
|
319
|
+
- [ ] Given `.opencode/context/` does not exist, When the automatic run fires, Then it is told to bootstrap the map; given that the directory exists, Then it is told to make an incremental update.
|
|
320
|
+
- [ ] Given an idle event with no pending edit, or one from a subagent session, or one following an edit inside the map itself or inside a build directory, Then no cartographer is spawned.
|
|
321
|
+
- [ ] Given an automatic run borrowed a pool slot, When it completes, Then the slot is released and the cartographer session going idle does not spawn a second run.
|
|
322
|
+
- [ ] Given an automatic run that changed nothing on disk, When it reports, Then it says the map was not changed rather than reporting success, so a cartographer that was blocked from writing is distinguishable from one that had nothing to do.
|
|
323
|
+
- [ ] Given an automatic run that failed, When it reports, Then the paths it was given are queued again, so a failed run does not strand the work.
|
|
324
|
+
|
|
325
|
+
### US-21: One writer keeps the map coherent
|
|
326
|
+
**As a** user, **I want** exactly one agent allowed to write the map, **so that** concurrent sessions cannot fight over it or produce contradictory versions.
|
|
327
|
+
|
|
328
|
+
**Story ID(s) for tests:** US-21
|
|
329
|
+
|
|
330
|
+
Enforcement is split deliberately, and the split matters: **what the permission table enforces** is that `context-manager` can write the map and that *no other agent can*; **what the prompt enforces** is that every other agent knows to report map-relevant findings instead of making them. The permission half is the load-bearing one — it holds regardless of what the model decides to do — but it cannot tell an agent what it *should* have done, so the instruction is not redundant and is not going away.
|
|
331
|
+
|
|
332
|
+
**Acceptance criteria:**
|
|
333
|
+
- [ ] Given the injected agent set, When every agent's `edit` permission is evaluated against a path inside `.opencode/context/`, Then `context-manager` is the only agent whose winning rule for that path is `allow`, and every agent that may otherwise write files carries a `deny` on both spellings of the map path — so the map is closed to non-cartographers by permission, not only by instruction.
|
|
334
|
+
- [ ] Given an agent's `edit` rules are evaluated the way opencode evaluates them, When the narrow map `deny` is declared before a broad `"*": "allow"`, Then the broad rule wins and the agent is reported as a second writer; so the deny is required to be the LAST entry, and the shipped table must be declared in that order.
|
|
335
|
+
- [ ] Given the `context-manager` permission rule, When it is evaluated for a normally-rooted project and for a project rooted at `/`, Then both spellings of the map path are allowed, because opencode evaluates file permissions relative to the project root.
|
|
336
|
+
- [ ] Given a config whose one-writer rule allows neither spelling, When the writer rule is verified, Then it reports which rules it saw, that the map is not writable, and that both forms are required — instead of leaving a map that silently never updates.
|
|
337
|
+
- [ ] Given a config in which a non-cartographer agent would be granted the map, When the writer rule is verified, Then it reports that agent by name together with the rule that wins for it, and the report is silent once the deny is narrowed and placed last.
|
|
338
|
+
- [ ] Given an agent other than `context-manager` finishes work, When the standing rule reaches it, Then it is told never to edit the map itself and to report what belongs in it instead, so that a denied agent knows what to do rather than only that it was refused.
|
|
339
|
+
- [ ] Given `edit` is denied for the map, When the same agents read the map, Then reading is unaffected: the rule narrows the `edit` tool only, so the map stays readable by exactly the agents that are told to check it.
|
|
340
|
+
|
|
341
|
+
### US-22: On-demand map operations
|
|
342
|
+
**As a** user, **I want** `/context-init`, `/context-update` and `/context-review`, **so that** I can bootstrap, refresh, or audit the map whenever I don't trust it.
|
|
343
|
+
|
|
344
|
+
**Story ID(s) for tests:** US-22
|
|
345
|
+
**Acceptance criteria:**
|
|
346
|
+
- [ ] Given a config that defines none of LaCode's commands, When the config hook runs, Then `/context-init`, `/context-update` and `/context-review` are injected, each targeting `context-manager` as a subtask.
|
|
347
|
+
- [ ] Given an injected command template, When opencode substitutes `$ARGUMENTS`, Then `/context-update` carries the user's focus text in the placeholder's place and the procedure around it is unchanged.
|
|
348
|
+
- [ ] Given the three commands have been injected, When their templates are inspected, Then each names the procedure it delegates to (bootstrap, incremental update, or review) and none carries an unsubstituted placeholder other than `/context-update`'s single `$ARGUMENTS`.
|
|
349
|
+
- [ ] Given the user's config already defines one of the three commands, When the config hook runs, Then that command is left byte-identical while the other two are still added.
|
|
350
|
+
- [ ] Given the commands are injected, When each one's target agent is resolved against the same config object, Then all three resolve to an agent that actually exists there — a command must never point at a missing agent.
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## Coverage
|
|
355
|
+
|
|
356
|
+
Every story below has at least one behavioural test in `tests/stories/`, and
|
|
357
|
+
`tests/stories/coverage.test.mjs` enforces that mechanically in both directions:
|
|
358
|
+
it fails if a story here has no test, and it fails if a test names a story this
|
|
359
|
+
document does not define. The acceptance criteria above are unchanged; only this
|
|
360
|
+
table was filled in.
|
|
361
|
+
|
|
362
|
+
| Story | Epic | Test file |
|
|
363
|
+
| --- | --- | --- |
|
|
364
|
+
| US-1 | A | `tests/stories/a-cost-control.test.mjs` |
|
|
365
|
+
| US-2 | A | `tests/stories/a-cost-control.test.mjs` |
|
|
366
|
+
| US-3 | A | `tests/stories/a-cost-control.test.mjs` (harness: `tests/benchmark/run.mjs`) |
|
|
367
|
+
| US-23 | A | `tests/stories/a-cost-control.test.mjs` |
|
|
368
|
+
| US-4 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-limits-test.mjs` |
|
|
369
|
+
| US-5 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-test.mjs`, `tests/pool-models-test.mjs` |
|
|
370
|
+
| US-6 | B | `tests/stories/b-provider-resilience.test.mjs` |
|
|
371
|
+
| US-7 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-models-test.mjs` |
|
|
372
|
+
| US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
|
|
373
|
+
| US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
|
|
374
|
+
| US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
|
|
375
|
+
| US-11 | D | `tests/stories/d-notifications.test.mjs`, `tests/notify-test.mjs` |
|
|
376
|
+
| US-12 | D | `tests/stories/d-notifications.test.mjs`, `tests/notify-test.mjs` |
|
|
377
|
+
| US-25 | D | `tests/stories/d-notifications.test.mjs` |
|
|
378
|
+
| US-13 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/notify-test.mjs`, `tests/oc-test.mjs` |
|
|
379
|
+
| US-14 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
|
|
380
|
+
| US-18 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
|
|
381
|
+
| US-15 | F | `tests/stories/f-adoption-safety.test.mjs` |
|
|
382
|
+
| US-16 | F | `tests/stories/f-adoption-safety.test.mjs` |
|
|
383
|
+
| US-17 | F | `tests/stories/f-adoption-safety.test.mjs` |
|
|
384
|
+
| US-24 | F | `tests/stories/f-adoption-safety.test.mjs` |
|
|
385
|
+
| US-19 | G | `tests/stories/g-context-management.test.mjs` |
|
|
386
|
+
| US-20 | G | `tests/stories/g-context-management.test.mjs` |
|
|
387
|
+
| US-21 | G | `tests/stories/g-context-management.test.mjs`, `tests/permission-test.mjs` |
|
|
388
|
+
| US-22 | G | `tests/stories/g-context-management.test.mjs` |
|
|
389
|
+
|
|
390
|
+
The nine suites under `tests/` are unit-style and are kept as they are; the
|
|
391
|
+
`tests/stories/` suites are the behavioural layer, written one epic per file with
|
|
392
|
+
every test name carrying its story id. Run everything with `npm test`.
|
|
393
|
+
|
|
394
|
+
**No story is uncovered.** The previously uncovered set — US-1, US-2, US-3,
|
|
395
|
+
US-6, and all of epic F — now has tests, and the coverage guard fails the build
|
|
396
|
+
if any of them loses one.
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lawaty/lacode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "lawaty",
|
|
9
|
+
"url": "https://github.com/lawaty"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/lawaty/lacode.git"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/lawaty/lacode#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/lawaty/lacode/issues"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"opencode",
|
|
21
|
+
"opencode-plugin",
|
|
22
|
+
"subagents",
|
|
23
|
+
"cost-optimization",
|
|
24
|
+
"model-pool",
|
|
25
|
+
"notifications"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.18"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./dist/index.js",
|
|
32
|
+
"./pool": "./dist/pool.js",
|
|
33
|
+
"./agents": "./dist/agents.js",
|
|
34
|
+
"./commands": "./dist/commands.js",
|
|
35
|
+
"./tunnel": "./dist/tunnel.js",
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"bin": {
|
|
39
|
+
"lacode": "./dist/lacode.js"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"presets",
|
|
44
|
+
"LICENSE",
|
|
45
|
+
"README.md",
|
|
46
|
+
"docs"
|
|
47
|
+
],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "node scripts/build.mjs",
|
|
50
|
+
"prepack": "npm run build",
|
|
51
|
+
"test": "node --test tests/",
|
|
52
|
+
"benchmark": "node tests/benchmark/run.mjs"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"esbuild": "^0.28.2"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@opencode-ai/plugin": "1.18.34"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Pool presets
|
|
2
|
+
|
|
3
|
+
A preset is the pool's slot list: which models your pooled subagents share, and
|
|
4
|
+
how many concurrent sessions each one gets. It is one JSON file.
|
|
5
|
+
|
|
6
|
+
## Resolution chain
|
|
7
|
+
|
|
8
|
+
LaCode reads the first source that yields usable slots, in this order:
|
|
9
|
+
|
|
10
|
+
| # | Source | Where |
|
|
11
|
+
|---|--------|-------|
|
|
12
|
+
| 1 | plugin options | `["@lawaty/lacode", { "models": { "slots": [...] } }]` |
|
|
13
|
+
| 2 | user config | `~/.config/lacode/pool.json` |
|
|
14
|
+
| 3 | bundled preset | `presets/free-tier.json` |
|
|
15
|
+
| 4 | built-in fallback | hardcoded in `src/lib/pool.ts`, so routing always works |
|
|
16
|
+
|
|
17
|
+
The same logic lives in `resolveModels()` in `src/lib/pool.ts`, and it is the
|
|
18
|
+
single place it is implemented.
|
|
19
|
+
|
|
20
|
+
Missing, unreadable, or structurally broken falls **through to the next link**
|
|
21
|
+
and logs one warning. Nothing here ever throws into opencode — a pool that cannot
|
|
22
|
+
route takes every parallel spawn in the process down with it. Source 2's parent
|
|
23
|
+
directory (`~/.config/lacode/`) is created at plugin startup so you can drop a
|
|
24
|
+
file in without `mkdir` first.
|
|
25
|
+
|
|
26
|
+
Sources 1 and 2 win outright: if they parse, the bundled preset is never read.
|
|
27
|
+
Source 3 is a default, not a floor.
|
|
28
|
+
|
|
29
|
+
Nothing is hot-reloaded. Change a preset, then restart every running opencode
|
|
30
|
+
process.
|
|
31
|
+
|
|
32
|
+
## Schema
|
|
33
|
+
|
|
34
|
+
```jsonc
|
|
35
|
+
{
|
|
36
|
+
"slots": [
|
|
37
|
+
{ "model": "provider/model-id", "weight": 3 },
|
|
38
|
+
{ "model": "provider/another-id" } // weight defaults to 1
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Field | Required | Meaning |
|
|
44
|
+
|-------|----------|---------|
|
|
45
|
+
| `model` | yes | `provider/model-id`, split on the **first** slash only |
|
|
46
|
+
| `weight` | no | soft ceiling on concurrent claims, and priority. Absent = 1 |
|
|
47
|
+
|
|
48
|
+
`$comment` is a JSON-legal array of strings and is ignored by the loader; the
|
|
49
|
+
bundled preset uses it to explain itself in place.
|
|
50
|
+
|
|
51
|
+
## Validation — structure only
|
|
52
|
+
|
|
53
|
+
The loader checks shape and nothing else:
|
|
54
|
+
|
|
55
|
+
- `slots` must be a non-empty array
|
|
56
|
+
- each `model` must contain a `/`
|
|
57
|
+
- models must be distinct across slots
|
|
58
|
+
- `weight` must be a number in `(0, 100]`; a bad one **warns and uses 1** rather
|
|
59
|
+
than dropping the slot, because losing a model to a typo in a count costs real
|
|
60
|
+
capacity
|
|
61
|
+
- at most `MAX_SLOTS` (4) slots; extra entries are **dropped with a warning**,
|
|
62
|
+
because one hidden variant agent per base per slot exists and a fifth slot
|
|
63
|
+
would route at an agent that does not exist
|
|
64
|
+
- `tier` is accepted as a deprecated alias for `weight` (`primary` → 2,
|
|
65
|
+
`overflow` → 1) and warns
|
|
66
|
+
|
|
67
|
+
**Cost is never validated.** Any provider/model-id string is accepted, including
|
|
68
|
+
paid and proprietary models. There is no free-tier allowlist anywhere in the
|
|
69
|
+
package — a preset of frontier models is exactly as valid as the bundled one. The
|
|
70
|
+
bundled preset is built from 0/0-cost models because that is a good default, not
|
|
71
|
+
because the pool requires it.
|
|
72
|
+
|
|
73
|
+
## Weight, precisely
|
|
74
|
+
|
|
75
|
+
A weight is a count of concurrent sessions, used as both a ceiling and a
|
|
76
|
+
priority:
|
|
77
|
+
|
|
78
|
+
1. the **highest-weight** slot still under its own ceiling takes the next spawn
|
|
79
|
+
2. so slot 1 fills to its number before slot 2 sees any work at all
|
|
80
|
+
3. once **every** slot is at its ceiling, overflow goes to the least loaded slot —
|
|
81
|
+
a burst past the summed ceilings degrades to even spreading and never blocks
|
|
82
|
+
|
|
83
|
+
Equal weights give a plain round robin in list order. List order is the
|
|
84
|
+
tie-break, so put the models you want busiest first and treat the bottom of the
|
|
85
|
+
list as reserve capacity.
|
|
86
|
+
|
|
87
|
+
Spreading at least two providers across the slots is worth doing deliberately: a
|
|
88
|
+
provider-wide rate limit takes out every slot on that provider at once.
|
|
89
|
+
|
|
90
|
+
## Writing your own
|
|
91
|
+
|
|
92
|
+
Copy `free-tier.json`, change the slots, and put it at
|
|
93
|
+
`~/.config/lacode/pool.json` — or skip the file entirely and pass `models`
|
|
94
|
+
inline from `opencode.jsonc` when you want the list version-controlled beside
|
|
95
|
+
the rest of your config:
|
|
96
|
+
|
|
97
|
+
```jsonc
|
|
98
|
+
{
|
|
99
|
+
"plugin": [
|
|
100
|
+
["@lawaty/lacode", {
|
|
101
|
+
"models": {
|
|
102
|
+
"slots": [
|
|
103
|
+
{ "model": "anthropic/claude-sonnet-4-5", "weight": 2 },
|
|
104
|
+
{ "model": "opencode/big-pickle", "weight": 1 }
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
}]
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Whichever provider a slot names must be configured in your own opencode config,
|
|
113
|
+
or the model cannot resolve. The pool appends the model id to that provider's
|
|
114
|
+
`whitelist` if it has one — the whitelist is an allowlist, so opencode deletes
|
|
115
|
+
the model from the provider without it and the agent silently resolves to
|
|
116
|
+
nothing — but it cannot invent a provider, and it never writes a whitelist you
|
|
117
|
+
did not already have: an absent one already allows every model the provider
|
|
118
|
+
offers.
|
|
119
|
+
|
|
120
|
+
Check what it did with the `pool_status` tool: it prints the resolved source,
|
|
121
|
+
each slot with its weight and live load, and which slot the next spawn takes.
|