@lawaty/lacode 0.4.1 → 0.4.2

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/README.md CHANGED
@@ -27,7 +27,9 @@ is also on disk at `node_modules/@lawaty/lacode/CHANGELOG.md`.
27
27
  | SSH notification tunnel | Work on a remote host raises its notification on that host, which has no desktop. |
28
28
  | Repository context map | Every task starts by exploring your codebase cold, and every agent re-discovers the same layout, the same entry points and the same boundaries — paid for again on every run. |
29
29
 
30
- ## Install
30
+ ## Getting started
31
+
32
+ ### 1. Install
31
33
 
32
34
  Add one line to your opencode config — `~/.config/opencode/opencode.json`, or
33
35
  the `.jsonc` beside it:
@@ -38,228 +40,60 @@ the `.jsonc` beside it:
38
40
 
39
41
  **That is the entire install.** opencode resolves an npm plugin specifier itself
40
42
  and installs it at startup, so there is no `npm install` to run first and no
41
- directory to create. Do not run `npm i -g @lawaty/lacode`: a global npm install
42
- is never consulted by opencode, so it changes nothing about whether the plugin
43
+ directory to create. Do not run `npm i -g @lawaty/lacode`: a global npm install is
44
+ never consulted by opencode, so it changes nothing about whether the plugin
43
45
  loads.
44
46
 
45
- It is also the form to keep as your default: if your opencode does not load the
46
- bare specifier, add to this line rather than replacing it. The
47
- [procedure below](#if-the-agents-are-missing) says when that is, and
48
- [which versions this is known on](#which-opencode-versions-this-is-known-on)
49
- says what is actually verified.
50
-
51
- To install it explicitly instead — or to install it once for every project on
52
- the machine rather than per config — use the CLI, which does the same thing:
47
+ To install it for every project on the machine rather than per config, let the
48
+ CLI do the same thing:
53
49
 
54
50
  ```bash
55
- opencode plugin @lawaty/lacode # this project
56
- opencode plugin @lawaty/lacode -g # global, for every project on the machine
51
+ opencode plugin @lawaty/lacode -g
57
52
  ```
58
53
 
59
- **Restart opencode afterwards.** Nothing here hot-reloads: the `plugin` array is
60
- read at startup, so without a restart the change does not take effect and the
61
- agent set will look stale for no visible reason.
54
+ ### 2. Restart opencode
62
55
 
63
- ### Where it is installed
56
+ **Nothing here hot-reloads.** The `plugin` array is read at startup, so without a
57
+ restart the change does not take effect and the agent set will look stale for no
58
+ visible reason.
64
59
 
65
- opencode installs npm plugins itself, into one directory per specifier under its
66
- cache — not into your project's `node_modules`, and not from anywhere a global
67
- npm install put them. Ask opencode where that cache is instead of assuming,
68
- because where it lives differs by platform:
60
+ ### 3. Check it loaded
69
61
 
70
62
  ```bash
71
- opencode debug paths
72
- ```
73
-
74
- It prints the directories opencode is using on this machine — config, data, log
75
- and the rest. Find the **cache** line and append
76
- `/packages/<specifier>/node_modules/` to the path it reports. On Linux with no
77
- `XDG_CACHE_HOME` set, that comes out as, for example:
78
-
79
- ```
80
- ~/.cache/opencode/packages/@lawaty/lacode@latest/node_modules/
63
+ opencode agent list | grep -E '^(explore|implement|context)'
81
64
  ```
82
65
 
83
- **That last line is an illustration of one platform's layout, not a path to
84
- copy.** Where the same cache root sits on another operating system is not
85
- something this document should guess at — read it from `opencode debug paths`
86
- and use what it prints.
66
+ You should see `explore`, `implement` and `context`, each with hidden `-1`..`-4`
67
+ pool variants alongside. That is the whole install, and you are done.
87
68
 
88
- To inspect what a specifier actually resolved to, list its directory under that
89
- cache root. An **empty** directory with no `node_modules` inside it means the
90
- install failed — almost always an unpublished or misspelled package — and
91
- opencode starts normally **without** the plugin. A **populated** directory there
92
- means the install succeeded and tells you nothing at all about whether the
93
- plugin was loaded; that is the next section, and telling those two apart is the
94
- whole reason for reading it.
69
+ **If that prints nothing, the plugin did not load** — and opencode will not tell
70
+ you: it starts normally, logs nothing, and the agents are simply absent. A
71
+ plugin that fails to load is silent by design of the host, so this check is the
72
+ only way to know. The
73
+ [troubleshooting guide](docs/INSTALL.md#if-the-agents-are-missing) walks the
74
+ procedure.
95
75
 
96
- ### If the agents are missing
76
+ ### 4. Use it
97
77
 
98
- A plugin that fails to load is silent: opencode starts normally, prints nothing,
99
- logs nothing, and the agents are simply not there. Work down this. Every step
100
- below is written to hold whichever opencode version you are running — none of
101
- them depends on knowing the answer in advance.
78
+ Nothing to turn on. Ask for something that delegates, and the pool takes it from
79
+ there:
102
80
 
103
- **1 — Write the bare specifier and restart.**
104
-
105
- ```json
106
- { "plugin": ["@lawaty/lacode"] }
81
+ ```
82
+ > use the explore agent to find where auth tokens are validated
107
83
  ```
108
84
 
109
- Nothing here hot-reloads, so the restart is part of the step, not a footnote.
110
-
111
- **2 — Check whether the plugin loaded.**
85
+ To see what the pool is doing underneath:
112
86
 
113
87
  ```bash
114
- opencode agent list | grep -E '^(explore|implement|context)'
88
+ /lacode-doctor # slots, model resolution, OmniRoute — report only, changes nothing
89
+ lacode version # which LaCode is actually running, and whether opencode loads that one
115
90
  ```
116
91
 
117
- A working install prints `explore`, `implement` and `context`, each with the
118
- hidden `-1`..`-4` pool variants alongside. If the
119
- grep returns nothing and the list holds only opencode's own built-ins, the plugin
120
- did not load. Run it in the same shell environment opencode runs in — an
121
- `agent list` under a different `HOME` or `XDG_CONFIG_HOME` tests a different
122
- machine. **If the agents are listed, stop here: the one line above is the whole
123
- install**, and nothing further in this section applies to you.
124
-
125
- **3 — Decide which failure you are looking at.** Inspect the package directory
126
- under the cache root from
127
- [Where it is installed](#where-it-is-installed), and also check that `plugin`
128
- really is in the config file opencode reads.
129
-
130
- - **Empty** (no `node_modules` inside): the install failed — check the package
131
- name, and that it is published. There is nothing to load yet.
132
- - **Populated**: the install worked and the loader dropped the plugin. Go to
133
- step 4. These two look identical from the outside, which is why this step
134
- exists.
135
-
136
- **4 — If the install worked and the agents are still missing, point the loader
137
- at the built file.** Add a second entry; leave the bare name in place:
138
-
139
- ```json
140
- {
141
- "plugin": [
142
- "@lawaty/lacode",
143
- "file:///ABSOLUTE-PATH-FROM-debug-paths/packages/@lawaty/lacode@latest/node_modules/@lawaty/lacode/dist/index.js"
144
- ]
145
- }
146
- ```
92
+ In a repository with no `.opencode/context/` yet, the first session builds it in
93
+ the background; `/context-init` forces it, and [Context map](#context-map)
94
+ explains what it is for.
147
95
 
148
- `ABSOLUTE-PATH-FROM-debug-paths` is a placeholder to be replaced, not a
149
- directory: take the cache path `opencode debug paths` reports for **your**
150
- machine and put it in front of `/packages/…`. It must be an absolute path with
151
- `$HOME` already expanded and no `~` left in it. Re-derive it after a cache wipe
152
- or a reinstall — the directory is keyed by specifier, so it moves when the
153
- version does.
154
-
155
- Why keep both entries instead of swapping the bare name out:
156
-
157
- - The bare name is what makes opencode install the package into its cache, and
158
- therefore what creates the path the `file://` entry points at.
159
- - The `file://` entry is what actually loads the plugin.
160
- - They **cannot double-inject** on opencode 1.18.35, where the bare name
161
- contributes nothing at all: the pair loads the plugin exactly once. On any
162
- other version this is untested, which is precisely why step 4 is something you
163
- take *because* step 2 showed the agents missing, and never on the expectation
164
- that it would help.
165
-
166
- **5 — Restart, and expect two restarts after a cache wipe.** The first start
167
- installs the package and creates the path the `file://` entry points at, but a
168
- path that does not exist at startup is a path that cannot be loaded in that same
169
- run. The second start finds it and loads the plugin. After that it is steady
170
- state.
171
-
172
- This package also publishes `main` and an `exports["./server"]` entry pointing at
173
- the same built file, because those are two of the shapes a plugin loader
174
- resolving an entry point consults. They are forward-compatibility metadata and
175
- they cost nothing — but they do **not** fix this on 1.18.35: adding either one to
176
- the *installed* cache copy was tested there and the bare specifier still loaded
177
- nothing. No other version has been observed where they help either. Use the
178
- `file://` entry.
179
-
180
- ### Which opencode versions this is known on
181
-
182
- Exactly one version has been measured, and the procedure above is deliberately
183
- not built around that fact. Treat this table as the evidence it is, which is one
184
- row wide.
185
-
186
- | opencode version | bare spec loads? | `file://` needed? | evidence |
187
- | --- | --- | --- | --- |
188
- | 1.18.35 | No — installs the package, then silently drops it | Yes | **Verified** on 1.18.35: a populated cache directory, zero agents injected, no error and no log line; the `file://` entry loads the full agent set. |
189
- | Any other version | **Unknown** | **Unknown** | **Untested.** No other opencode version has been run against this package in either direction. |
190
-
191
- Every command quoted on this page was run against 1.18.35. **If your version is
192
- not the one measured here, follow the procedure above rather than reading this
193
- table as coverage of your build**: each step stands on its own, and step 2 tells
194
- you which situation you are in before any version-specific advice is relevant.
195
-
196
- ### macOS
197
-
198
- The plugin runs on macOS. It is plain JavaScript with no native dependency, and
199
- what the package publishes is the built JavaScript rather than TypeScript
200
- sources, so nothing about loading it is Linux-only. Use the procedure above
201
- unchanged.
202
-
203
- Three things differ, and all three are worth reading rather than assuming:
204
-
205
- - **Paths.** Do not copy a cache path out of this document — the example under
206
- [Where it is installed](#where-it-is-installed) is one platform's layout. Run
207
- `opencode debug paths` and build the `file://` entry from what it reports.
208
- This page deliberately does not name a macOS cache root: where opencode keeps
209
- it on a Mac has not been established here, and a plausible-looking guess is
210
- worse than an instruction to look.
211
- - **Notifications** are delivered through `osascript`, or through
212
- `terminal-notifier` when `osascript` is not installed, rather than through the
213
- Linux `notify-send` (US-26). `lacode doctor` reports which channel your machine
214
- would actually use.
215
- - **The repository's shell scripts are Linux-only by design**, and are not in the
216
- published package. They drive one person's SSH topology, so each is on the side
217
- it belongs to:
218
-
219
- - **Remote hosts are plain npm clients.** A remote host installs the plugin the
220
- same way (`opencode plugin @lawaty/lacode -g`); there is nothing to rsync. The
221
- notification hop is a direct ssh (`ControlMaster` + `ControlPersist`) set up
222
- once with `lacode tunnel setup` — no reverse forward, no listening port, no
223
- systemd unit. See [Remote hosts](#remote-hosts) and docs/TUNNEL.md.
224
- - The repo still carries the author's personal, Linux-only tooling from the older
225
- reverse-forward setup (`bin/oc`, `bin/oc-notify`, `bin/oc-notify-receiver`,
226
- `bin/oc-sync`). None of it is in the published package, and none of it is needed
227
- for the npm-client path above.
228
-
229
- **The test suite runs on macOS.** It is written to pass on a Mac as well as on
230
- Linux, so "it works on a Mac" is a supported claim rather than an intention. The
231
- few checks that exercise the Linux-only shell scripts above skip themselves on a
232
- machine that cannot run them, naming the reason — a bash 3.2 with no `mapfile`,
233
- or no GNU `timeout` — rather than passing silently or failing confusingly.
234
-
235
- ### The `lacode` CLI
236
-
237
- The package also ships the `lacode` CLI (`notify`, `forward`, `doctor`, `receive`, `tunnel setup`) — see
238
- [Remote hosts](#remote-hosts). The plugin install does **not** put it on your
239
- `PATH`; run it from the package's `node_modules/.bin`, or install just the CLI
240
- globally with `npm i -g @lawaty/lacode` if you want the bare command. (That
241
- global install is for the command only — it is still never what makes the plugin
242
- load.)
243
-
244
- ### What the install gives you
245
-
246
- Two things are worth knowing about what npm actually hands you:
247
-
248
- - **`@opencode-ai/plugin` is a peer dependency.** It is not vendored into this
249
- package and never will be: a second copy of the SDK inside the tarball would
250
- mean opencode and the plugin holding two different module instances, and
251
- instance identity is what plugin state hangs on. npm installs the peer for you;
252
- if you install with a tool that does not, install `@opencode-ai/plugin` at the
253
- same version yourself.
254
- - **You get compiled JavaScript, not TypeScript sources.** The tarball ships
255
- `dist/`, built from `src/` at pack time (`prepack` runs the build, so a stale
256
- `dist/` cannot be published). This is not a preference: node strips types
257
- natively from 22.18 but **refuses to for files under `node_modules`**, so a
258
- package that shipped `.ts` worked fine in a checkout and failed on install —
259
- `lacode --help` died with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. A
260
- published package cannot depend on its consumer's loader being lenient.
261
-
262
- ## Configuration
96
+ ## Configure
263
97
 
264
98
  Plugin options are a tuple — the package name, then an options object. Every
265
99
  option is optional:
@@ -284,11 +118,11 @@ option is optional:
284
118
  | --- | --- | --- |
285
119
  | `enabled` | `true` | The single off switch. `false` injects nothing at all and does no writes. |
286
120
  | `models` | — | An inline slot list. Highest-priority source. |
287
- | `modelsFile` | — | An explicit slot file; short-circuits the chain below. |
121
+ | `modelsFile` | — | An explicit slot file; short-circuits the [resolution chain](docs/CONFIGURATION.md#pool-preset-resolution). |
288
122
  | `reapMs` | `30000` | Hang-reaper interval. |
289
123
  | `selfUpdate` | `true` | Check for a newer release at most once per ~6h and install it in the background; it applies on the next opencode start. `false` — or `LACODE_NO_SELFUPDATE=1` — turns the check off. |
290
124
  | `sound` | `true` | Play a short tone with each notification, one per outcome, so you can tell a finished session from a question or a permission prompt without reading the popup. It is opt-out: `false` — or `LACODE_SOUND=0` (`false`, `off`, `no` work too, and beat the option) — turns the sound off, and anything else, a typo included, leaves it on. |
291
- | `omniroute` | — | Optional [OmniRoute](#configuring-omniroute) gateway integration. Absent or `{ enabled: false }` changes nothing. |
125
+ | `omniroute` | — | Optional [OmniRoute](docs/OMNIROUTE.md) gateway integration. Absent or `{ enabled: false }` changes nothing. |
292
126
 
293
127
  ### Staying up to date
294
128
 
@@ -302,338 +136,55 @@ binary — no npm or bun required. The running session keeps its loaded code; th
302
136
  new version takes effect on the next start, and the log says so. Run
303
137
  `lacode update` to do it on demand, or `lacode update --check` to just report.
304
138
 
305
- ### Pool preset resolution
306
-
307
- The slot list is the first of these that yields usable slots:
308
-
309
- 1. plugin options — `models` in the tuple above
310
- 2. `~/.config/lacode/pool.json` (its parent directory is created for you)
311
- 3. the bundled `presets/free-tier.json`
312
- 4. a built-in fallback, so routing always works
313
-
314
- A missing, unreadable or structurally broken source falls through to the next and
315
- logs one warning; nothing here throws into opencode. Nothing hot-reloads —
316
- change a preset, then restart every opencode process.
317
-
318
- **Any model list is accepted, including paid ones** (US-7). There is no free-tier
319
- allowlist anywhere in the package, and no cost check of any kind: only the
320
- structure is validated. The bundled preset is built from free models because
321
- that is a sensible default, not because the pool requires it.
322
-
323
- ### Configuring OmniRoute
324
-
325
- [OmniRoute](https://github.com/diegosouzapw/OmniRoute) is a local
326
- OpenAI-compatible AI gateway (default `http://localhost:20128/v1`) that routes a
327
- request across many providers with fallback and "combos". LaCode can register it
328
- as an opencode provider for you. It is **off by default**: if you do not set
329
- `omniroute.enabled: true`, nothing below happens and LaCode makes no request.
330
-
331
- **Setting it up on a client machine.** Four steps, in this order:
332
-
333
- 1. Install OmniRoute, then make it stay up: run `omniroute autostart enable`
334
- once. That writes a systemd user unit in a headless session, or an XDG
335
- autostart entry in a graphical one, and OmniRoute supervises itself from
336
- there on. Its gateway listens on `http://localhost:20128/v1`. The gateway's
337
- lifecycle is **the client's responsibility**: LaCode never starts, supervises
338
- or stops it, it only probes it. The gateway must therefore already be
339
- running **before** you start opencode — one that is down at startup is
340
- disabled for that run, and the integration is per-run.
341
- 2. Add the plugin block to your opencode config:
342
- `["@lawaty/lacode", { "omniroute": { "enabled": true, "apiKey": "<your omniroute key>", "combo": "auto", "models": [] } }]`
343
- `auto` is the zero-config default, but on a free tier it often resolves to a
344
- slow chain. [A tested fast combo](#a-tested-fast-combo-free-mix) below is
345
- ordered for latency and is worth applying on a new client.
346
- 3. Optionally, to route pooled work through it, make `omniroute/auto 3` the
347
- **first** line of `~/.config/lacode/pool.txt`. `pool.txt` is optional user
348
- config; the bundled default preset does **not** include OmniRoute.
349
- 4. **Restart opencode.** Nothing here hot-reloads.
350
-
351
- With `models: []` and `combo: "auto"`, the plugin registers exactly one model,
352
- `omniroute/auto`, which then appears in the model picker for any agent — no
353
- hand-maintained model list.
354
-
355
- The full option set, for reference:
356
-
357
- ```jsonc
358
- {
359
- "plugin": [
360
- ["@lawaty/lacode", {
361
- "omniroute": {
362
- "enabled": true,
363
- "baseURL": "http://localhost:20128/v1",
364
- "apiKey": "sk_omniroute", // optional; OMNIROUTE_API_KEY overrides it
365
- "combo": "static-best-free", // optional: let OmniRoute do the routing
366
- "models": ["cc/claude-sonnet-4-6", "gemini-3-flash"], // hard allowlist
367
- "tiers": {
368
- "primary": ["cc/claude-sonnet-4-6"],
369
- "backup": ["gemini-3-flash"]
370
- },
371
- "stickyPerSession": true,
372
- "healthCheck": true,
373
- "timeoutMs": 5000
374
- }
375
- }]
376
- ]
377
- }
378
- ```
139
+ ### Which LaCode is running
379
140
 
380
- | Field | Default | Meaning |
381
- | --- | --- | --- |
382
- | `enabled` | `false` | Turns the integration on. |
383
- | `baseURL` | `http://localhost:20128/v1` | Gateway base URL, normalised to exactly one `/v1`. |
384
- | `apiKey` | — | Bearer key. **`OMNIROUTE_API_KEY` overrides this value.** The key is never logged. |
385
- | `combo` | — | An OmniRoute combo id (or `auto`) to expose as a selectable model even though the gateway does not list it. Exposed first. |
386
- | `models` | — | A **hard allowlist**: only these ids are ever exposed, even if the gateway lists more. |
387
- | `tiers` | — | `{ "primary": [...], "backup": [...] }` preference order over models the allowlist already permits. |
388
- | `stickyPerSession` | `true` | Keep a session on one model while that model is healthy. |
389
- | `healthCheck` | `true` | Probe `GET /models` at startup; a failed check disables the integration for that run only. |
390
- | `timeoutMs` | `5000` | Budget for LaCode's own probe and model-cache calls to the gateway. It is not written into the provider options, so it does not bound real traffic — opencode's own default applies there. |
391
-
392
- An enabled but invalid block (bad `baseURL`, a non-array `models`, …) is rejected
393
- as a whole: LaCode logs one warning naming the problem and continues exactly as
394
- if the integration were off. It never crashes opencode.
395
-
396
- **What it registers.** On the first `config` pass LaCode health-checks the
397
- gateway, reads `GET /models`, filters it through your `models` allowlist (or
398
- exposes everything the gateway lists when no allowlist is set), and writes a
399
- `provider.omniroute` entry — `npm: "@ai-sdk/openai-compatible"` with your
400
- `baseURL` / `apiKey` — so `omniroute/<model-id>` becomes selectable. If you
401
- already have a `provider.omniroute` block, LaCode leaves it completely untouched
402
- and warns; it also never adds a `whitelist`. Because opencode loads
403
- `@ai-sdk/openai-compatible` itself, no extra dependency is installed.
404
-
405
- **Combo vs. allowlist.** Set `combo` and OmniRoute does the fallback for you
406
- (let it route): the combo leads the exposed models, so opencode can hand a failed
407
- request to the gateway's own fallback chain. Without a combo, LaCode exposes the
408
- allowlist ordered by `tiers` (primary first). Candidate order is `filterModels`
409
- (combo first, then the hard allowlist) followed by `orderByTier`, and that is
410
- all — LaCode picks which model is exposed and in what order; it does not measure
411
- latency or error rate at runtime, and it does not skip a model because it
412
- failed.
413
-
414
- **Using OmniRoute in the pool.** The integration only *registers* the provider —
415
- it never touches your pool, and it does not place a slot for you. To route pooled
416
- work (exploration, implementation, cartography) through the gateway, list it in
417
- your pool like any other model:
418
-
419
- ```
420
- # ~/.config/lacode/pool.txt
421
- omniroute/auto 3
422
- opencode/space-bunny-free 2
423
- opencode-go/step-5-preview-free 1
424
- opencode/big-pickle 1
425
- ```
426
-
427
- `omniroute/auto` then takes slot 1 (weight 3) and drains pooled mechanical work
428
- before it reaches the bundled opencode/opencode-go ("zen") models. Any
429
- `omniroute/<id>` works, provided the id is exposed — by `combo`/`models`, or the
430
- whole catalog when neither is set. Because the plugin writes the pool nothing,
431
- the only thing that changes when the gateway is down is that `omniroute/<id>`
432
- fails; every other slot keeps working. See
433
- [docs/USER-STORIES.md](docs/USER-STORIES.md) US-28.
434
-
435
- ### A tested fast combo: `free-mix`
436
-
437
- `combo: "auto"` is the zero-configuration default, but `auto` inherits whatever
438
- the gateway chooses, and on a busy free tier that is frequently the slowest
439
- chain available. The combo below was **measured** against a live free-tier
440
- gateway and is ordered for latency rather than for breadth. Apply it during the
441
- "Setting it up on a client machine" steps above and pooled work is usable
442
- straight away, instead of starting from a gateway that queues for half a minute
443
- per call.
444
-
445
- ```jsonc
446
- {
447
- "name": "free-mix",
448
- // Order decides. Do NOT use "weighted" here — weights let the gateway
449
- // re-shuffle a model that is returning 429 into first position again.
450
- "strategy": "priority",
451
- "models": [
452
- { "model": "gemini/gemini-3.1-flash-lite", "providerId": "gemini" },
453
- { "model": "gemini/gemini-3.8-flash", "providerId": "gemini" },
454
- { "model": "gemini/gemini-3.7-flash", "providerId": "gemini" },
455
- { "model": "gemini/gemini-3-flash-preview", "providerId": "gemini" },
456
- { "model": "openrouter/nvidia/nemotron-3-super-120b-a12b:free", "providerId": "openrouter" },
457
- { "model": "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free", "providerId": "openrouter" },
458
- { "model": "openrouter/nvidia/nemotron-3.5-lightning:free", "providerId": "openrouter" },
459
- { "model": "openrouter/cohere/north-mini-code:free", "providerId": "openrouter" },
460
- { "model": "openrouter/poolside/laguna-s-2.1:free", "providerId": "openrouter" },
461
- { "model": "openrouter/dots-studio/dots-3-note-preview:free", "providerId": "openrouter" }
462
- ]
463
- }
464
- ```
465
-
466
- Why this order, from 859 calls over a single day against that gateway:
467
-
468
- | Model | Observed behaviour | Position |
469
- | --- | --- | --- |
470
- | `gemini/gemini-3.1-flash-lite` | **165/172 succeeded (96%)**, ~4.5s median | **first** |
471
- | `gemini-3.8-flash` / `3.7-flash` / `3-flash-preview` | succeed intermittently, but **burn exactly 30.0s** on a `503` queue-budget timeout when they do not | next |
472
- | `openrouter/*:free` | **~90% `429`**, but fails *fast* (~1.8s) and occasionally succeeds | last, as a cheap final fallback |
473
- | `gemini-2.5-flash`, `gemini-2.5-flash-lite` | **`404` on every attempt** — dead ids | **omitted entirely** |
474
-
475
- The ordering matters because a combo pays for *every* step it walks past. Put the
476
- 96%-success model first and the common case is a single hop; the 30s models sit
477
- behind it where they cost nothing, and the 429s sit last where they give up in
478
- under two seconds.
479
-
480
- Measured on the same gateway, same day, `max_tokens: 10`:
481
-
482
- | | Before | After |
483
- | --- | --- | --- |
484
- | `free-mix` response | 42.7s / 41.8s / 34.7s | 4.1s / 3.1s / 3.2s / 4.0s / 3.2s |
485
- | Median | **~41.8s** | **~3.2s** |
486
- | Provider time burned on calls >25s | **56.9 of 102.4 min (56%)** | — |
487
-
488
- Before the change, the real call sequence was a `429` after 2.7s, then a `503`
489
- after 30.0s, then the actual work in 5.7s — roughly **33 seconds of dead time in
490
- front of every successful request**, and a p90 of 100s when three 30s steps
491
- chained.
492
-
493
- These free-tier endpoints change availability constantly, so treat the ordering
494
- as a **starting point to re-measure, not a permanent truth**. Re-check it with
495
- OmniRoute's own call logs before assuming it still holds:
141
+ `lacode version` answers it — and the answer is often not the version number you
142
+ expected, because the plugin entry in your config decides which copy is loaded:
496
143
 
497
144
  ```bash
498
- # one timed request through the gateway
499
- curl -o /dev/null -w '%{http_code} %{time_starttransfer}s\n' \
500
- -X POST http://localhost:20128/v1/chat/completions \
501
- -H "Authorization: Bearer $OMNIROUTE_API_KEY" -H 'Content-Type: application/json' \
502
- -d '{"model":"free-mix","messages":[{"role":"user","content":"Say OK"}],"max_tokens":10}'
503
-
504
- # per-model status + duration, straight from OmniRoute's logs
505
- python3 - <<'PY'
506
- import json, glob, collections
507
- d = "/home/<user>/.omniroute/call_logs/<YYYY-MM-DD>"
508
- g = collections.defaultdict(collections.Counter)
509
- for f in glob.glob(d + "/*.json"):
510
- try: s = json.load(open(f))["summary"]
511
- except Exception: continue
512
- g[s.get("comboStepId")][s.get("status")] += 1
513
- for k, v in sorted(g.items(), key=lambda x: str(x[0])):
514
- print(f"{str(k)[:44]:44} {dict(v)}")
515
- PY
145
+ lacode version
516
146
  ```
517
147
 
518
- Apply it with the bundled CLI (`PUT /api/combos/<id>` updates in place, so the
519
- combo is never briefly missing):
520
-
521
- ```bash
522
- omniroute api combos get-api-combos-id- --id <combo-id> --json > free-mix.json
523
- # edit name/strategy/models in free-mix.json, then:
524
- omniroute api combos put-api-combos-id- --id <combo-id> --body @free-mix.json
525
148
  ```
526
-
527
- **A note on mid-session switching.** opencode 1.18.34 exposes no hook to change
528
- an agent's main model mid-request, so LaCode cannot swap models inside a running
529
- session; use an OmniRoute `combo` (or `auto`) if you want per-request fallback.
530
- LaCode's selection engine decides which models are exposed and in what order, and
531
- its cooldown/backoff rules are unit-tested.
532
-
533
- **Getting a key.** Open the OmniRoute dashboard, go to API Manager / API Keys and
534
- copy a key into `OMNIROUTE_API_KEY` (recommended) or `omniroute.apiKey`. On a
535
- local instance with `REQUIRE_API_KEY=false`, the literal placeholder
536
- `sk_omniroute` is accepted.
537
-
538
- **Troubleshooting**
539
-
540
- | Symptom | Cause | Fix |
541
- | --- | --- | --- |
542
- | `omniroute health check failed` at startup | Gateway not running, wrong `baseURL`, or a startup race | Start OmniRoute on `http://localhost:20128` and restart opencode; `omniroute autostart enable` makes that durable across restarts. The integration is disabled for that run only, not permanently. |
543
- | `omniroute health check failed` at startup, then a repeating `cooling omniroute/<id> (hang)` and `Model not found` churn on one slot | OmniRoute was started *after* opencode, and `pool.txt` names an `omniroute/<id>` slot | The integration is off for that run, but the pool still routes to the slot: the request hangs, the reaper cools it for 600s, and the cycle repeats. Start OmniRoute, then restart opencode. |
544
- | `ProviderModelNotFoundError: Model not found` on an id the gateway serves | The id is a valid OmniRoute route but is not in the plugin's `models` allowlist, so the plugin never exposed it | Add the id to `models`, or use `combo: "auto"` with `omniroute/auto`. A valid gateway route is not automatically an exposed model. |
545
- | `no usable models` | The gateway returned an empty list, or every id was filtered out by `models` | `curl -H "Authorization: Bearer $OMNIROUTE_API_KEY" http://localhost:20128/v1/models` and check `models`/`combo` against the returned ids. |
546
- | `401` / `Invalid API key` | `REQUIRE_API_KEY=true` and the key is missing or wrong | Create a key in the dashboard, export `OMNIROUTE_API_KEY`, or set `REQUIRE_API_KEY=false` locally and use `sk_omniroute`. |
547
- | `404` on a URL containing `/v1/v1/` | A `baseURL` that already carried `/v1` was given twice | LaCode normalises this, so it should not come from LaCode; check a hand-written `provider.omniroute` block, which LaCode leaves untouched. |
548
- | Provider registered but models are missing | You already had `provider.omniroute` | Remove it and let LaCode write it, or add the models yourself — LaCode never modifies an existing provider. |
549
- | Pooled work still runs on the bundled models | The gateway was unhealthy/disabled at startup, or you have no `omniroute/…` slot in your pool | Check the startup log for `omniroute health check failed` / `no usable models`, then add `omniroute/<model>` to `pool.txt` (or `pool.json`) to route pooled work through the gateway. |
550
-
551
- ### Which models run which agent
552
-
553
- Only the **pool** writes a model onto a pooled agent, from whichever source wins
554
- above. The pooled bases (`explore`, `implement`, `context`)
555
- and their hidden `-1`..`-4` variants therefore carry no model of their own: a
556
- model written next to an agent would be a second source of truth that silently
557
- wins whenever routing falls through. Spawn a base and let the pool route it.
558
-
559
- ### ⚠️ Per-agent tool grants must be in your own config
560
-
561
- **LaCode cannot grant tools to its agents, and this is not an oversight you can
562
- work around by configuring it.** If you keep a global `"tools"` block in your
563
- config — for example `"tools": { "playwright_*": false }` — then the pooled
564
- exploration and implementation bases (`explore` and `implement`, plus their
565
- hidden `-1`..`-4` pool variants) lose browser access, and **nothing tells them why**: the tool call is simply
566
- refused mid-task. `context` gets no grant either, which is correct — it
567
- writes the context map and has no business in a browser.
568
-
569
- opencode folds an agent's `tools` into its permission list **at config-load
570
- time**, while decoding the config — before any plugin runs. A plugin's `config`
571
- hook mutates the already-decoded config, so the fold never re-runs and anything
572
- it puts in `agent.tools` is inert. (Verified on opencode 1.18.34/1.18.35 with
573
- `opencode debug agent explore`: a grant written in your own config shows two
574
- `playwright_*` entries — `deny` then `allow`, last one winning — while the same
575
- grant injected by the plugin shows only the `deny`.) LaCode's other injected
576
- fields are not affected; `permission`, `prompt`, `model`, `description`,
577
- `temperature`, `mode` and `hidden` are all read after the plugin hook runs.
578
-
579
- **So if you use a global `"tools"` block, declare the grants yourself:**
580
-
581
- ```jsonc
582
- {
583
- "tools": { "playwright_*": false },
584
- "agent": {
585
- "explore": { "tools": { "playwright_*": true } },
586
- "implement": { "tools": { "playwright_*": true } }
587
- }
588
- }
149
+ version 0.4.1
150
+ root /home/you/lacode
151
+ source checkout
152
+ commit 9446c88 (dirty)
153
+ dist current
154
+ config /home/you/.config/opencode/opencode.jsonc
155
+ plugin file:///home/you/lacode/dist/index.js
156
+ this copy yes — opencode loads this copy
589
157
  ```
590
158
 
591
- The hidden pool variants (`explore-1`, `implement-2`, …) inherit
592
- nothing here — add a line per variant, or drop the global
593
- deny and scope it to the agents that should not have browser access instead.
594
- Note that you cannot *spawn* a variant by name: the pool refuses
595
- `explore-1` and friends and points you at the bare base, so per-variant
596
- permissions only ever apply to a spawn the plugin routed itself.
597
-
598
- Nothing hot-reloads: after editing your config, restart every running opencode
599
- process.
600
-
601
- ### What gets injected
602
-
603
- **17 agent keys**: the 5 bases `build`, `plan`, `explore`, `implement`,
604
- `context`, plus one hidden
605
- `<base>-1`..`-4` pool variant for each of the 3 pooled bases.
606
-
607
- `build` and `plan` exist in opencode as built-ins, so on a stock config those
608
- two keys are already taken and merge-without-clobber keeps the built-in — the
609
- LaCode definitions of `build` and `plan` are what you get if you remove the
610
- built-ins of those names first.
611
-
612
- **Five keys are deliberately not injected.** `basic` is a byte-for-byte
613
- duplicate of `plan` apart from a pinned model, and `plan` is injected instead.
614
- `expert` pins a specific model and carries a personal benchmark note in its
615
- description — a package cannot ship someone else's provider, quota or benchmark
616
- results — though its escalation ladder still resolves, because the two deep
617
- tiers it escalates to *are* injected. And `explore`, `general` and `implement`
618
- are only `{ "disable": true }` stubs in the author's own config: turning off
619
- opencode's built-in agents is a decision about one person's configuration, not
620
- a default a package should impose on a stranger.
621
-
622
- One **field** is deliberately not injected either: `tools`, for the
623
- load-time-folding reason explained above. Nothing else the plugin writes into
624
- an agent is subject to it.
625
-
626
- **Installation merges, it does not clobber** (US-16). An agent, command or
627
- instruction is added only when that key is absent from your config. A
628
- user-defined agent of the same name is left byte-identical — not patched, not
629
- partially merged. Skips are counted and logged once.
630
-
631
- **`enabled: false` turns all of it off** (US-17). No pool model rewriting, no
632
- agent injection, no command injection, no reaper timer armed, no
633
- `~/.config/lacode` directory created.
634
-
635
- These three are specified in [docs/USER-STORIES.md](docs/USER-STORIES.md) as
636
- US-7, US-16 and US-17, and each has tests.
159
+ `npm ls -g @lawaty/lacode` answers a different question. It reports the package
160
+ opencode resolves *by name*, which is not necessarily the one running: a config
161
+ may load the plugin by absolute path, in which case a stale global install can
162
+ sit there reporting a version nothing uses. So this command reads your actual
163
+ `plugin` array and says whether it resolves to the copy speaking.
164
+
165
+ `source` distinguishes a git checkout from an npm install, `commit` and the
166
+ dirty flag name what the code was built from, and `dist` is the trap worth
167
+ knowing about — opencode loads `dist/index.js` while you edit and pull `src/`,
168
+ so a `git pull` can leave you running yesterday's build with nothing failing.
169
+ When it reports `stale`, rebuild with `npm run build`. No network is touched;
170
+ `lacode update --check` is what asks npm whether something newer exists.
171
+
172
+ ## What you get
173
+
174
+ Three agents, pooled and rotated: `explore`, `implement` and `context`. You spawn
175
+ the base name and the pool picks a cheap model slot for it. Behind each base sits
176
+ up to four hidden variants — one per slot — and a spawn of the bare name is the
177
+ only thing that works: naming a variant lets the caller choose the provider
178
+ directly, which is exactly what the rotation and the per-slot ceiling exist to
179
+ prevent, so it is refused.
180
+
181
+ Four commands: `/context-init`, `/context-update`, `/context-review` and
182
+ `/lacode-doctor`. Everything is added **only when your config does not already
183
+ define it** — an agent or command you wrote yourself is left byte-identical,
184
+ never patched or partially merged. `{"enabled": false}` turns the whole thing off
185
+ without leaving a trace. The exact injection list, and the three keys that are
186
+ deliberately *not* injected, are in
187
+ [docs/CONFIGURATION.md](docs/CONFIGURATION.md#what-gets-injected).
637
188
 
638
189
  ## Context map
639
190
 
@@ -673,25 +224,32 @@ in [docs/CONTEXT.md](docs/CONTEXT.md).
673
224
 
674
225
  ## Managerial directions
675
226
 
676
- You can optionally add `manager.md` to `.opencode/context/`. This file is owned
677
- by you alone and is never written, edited, pruned, or promoted by any agent. It
678
- outranks the five generated files on focus and settled decisions, but it is
679
- secondary to the source code: if the code disagrees, the code wins and the
680
- conflict is reported back to you.
681
-
682
- The file is per-clone, not committed, and entirely optional: if it is missing,
683
- agents behave exactly as before. When present, it should contain these three
684
- sections:
685
-
686
- - `## Stage`: A snapshot of your current work-in-progress. Rewrite this section
687
- rather than appending to it to keep it concise. If this file passes roughly 150
688
- lines, run `/context-review`.
689
- - `## Confirmed`: Facts and decisions you have personally ratified. Treat these
690
- as settled, but not as permission to skip reading the code.
691
- - `## Instructions`: Standing managerial directions for all agents.
692
-
693
- No agent may write this file at all: the context agent's edit permission denies
694
- `manager.md` explicitly, so ratification can only ever come from your hand.
227
+ The plugin creates `manager.md` in `.opencode/context/` for you, and never
228
+ overwrites it once it exists. It is your file: it records what you want done
229
+ and how you want it approached — what is being worked on, what comes next,
230
+ what you have settled, and what every agent should keep in mind. It says
231
+ nothing about where code lives, so it can never contradict the code, and
232
+ within that domain it outranks the five generated files and any agent's
233
+ inference about what matters right now.
234
+
235
+ The file is per-clone and not committed, so it is yours rather than shared
236
+ team state. It has no fixed schema — the headings below are a convenience.
237
+ Add your own; every heading in it is treated as direction.
238
+
239
+ - `## Current`: what is being worked on right now. Rewrite it rather than
240
+ appending, so it does not become a running history. If the file passes
241
+ roughly 150 lines, run `/context-review`.
242
+ - `## Next`: what comes next, in order.
243
+ - `## Confirmed`: what you have personally ratified. Treat these as settled
244
+ decisions, not as permission to skip reading the code.
245
+ - `## Instructions`: standing direction beyond the current task — priorities,
246
+ things to care about, constraints that are not obvious from the code.
247
+
248
+ **Who may write it.** The context agent is explicitly permission-denied from
249
+ touching this path, so the automatic post-session run can never alter it. An
250
+ agent working in your live session may write it only when you explicitly ask
251
+ it to, and only what you asked for — never on its own initiative, and never
252
+ to "keep it up to date".
695
253
 
696
254
  ## Cost
697
255
 
@@ -767,12 +325,22 @@ status.
767
325
  Configuration, the payload format, the receiver contract and holding the forward
768
326
  open are in [docs/TUNNEL.md](docs/TUNNEL.md).
769
327
 
770
- ## User stories
328
+ ## Further reading
771
329
 
772
- Twenty-nine stories across seven epics, each written as a behavioural contract
773
- with acceptance criteria a test can assert mechanically — the pains behind them,
774
- the Given/When/Then criteria, and the story-to-test mapping are in
775
- [docs/USER-STORIES.md](docs/USER-STORIES.md).
330
+ Optional. Nothing above needs any of it — this is where the detail lives once you
331
+ want it.
332
+
333
+ | | |
334
+ | --- | --- |
335
+ | [Install and troubleshooting](docs/INSTALL.md) | Where opencode puts the package, what to do when the agents are missing, the version matrix, macOS differences, and the `lacode` CLI. |
336
+ | [Configuration reference](docs/CONFIGURATION.md) | Every plugin option, how the pool resolves its slots, per-agent tool grants, and exactly what gets written into your config. |
337
+ | [OmniRoute gateway](docs/OMNIROUTE.md) | The optional local gateway integration: setup, full option set, a latency-measured free combo, and its troubleshooting table. |
338
+ | [Context map](docs/CONTEXT.md) | The mechanism behind `.opencode/context/` — the one-writer permission rule, the five files, and what the log lines mean. |
339
+ | [Remote hosts and notifications](docs/TUNNEL.md) | The SSH notification hop, payload format, receiver contract, and holding the forward open. |
340
+ | [Benchmarks](docs/BENCHMARKS.md) | How the cost harness works, and why it deliberately publishes no savings number. |
341
+ | [User stories](docs/USER-STORIES.md) | The behavioural contracts behind all of the above, written so a test can assert them. |
342
+ | [Operations runbook](OPERATIONS.md) | Maintainer-facing: what runs where, what needs a restart, how the pieces are distributed across machines. |
343
+ | [Changelog](CHANGELOG.md) | What changed in each release. |
776
344
 
777
345
  ## Development
778
346
 
@@ -807,4 +375,4 @@ exist.
807
375
 
808
376
  ## License
809
377
 
810
- MIT licensed. Based on work by [lawaty](https://github.com/lawaty/lacode).
378
+ MIT licensed. Based on work by [lawaty](https://github.com/lawaty/lacode).