@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 ADDED
@@ -0,0 +1,32 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lawaty
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ ## Attribution
26
+
27
+ This project is MIT licensed. The MIT terms above require only that the
28
+ copyright notice travel with the software — nothing more. As a courtesy,
29
+ though, please keep lawaty credited on derivative work and credit the
30
+ original project when forking:
31
+
32
+ Based on work by lawaty — https://github.com/lawaty/lacode
package/README.md CHANGED
@@ -1,3 +1,416 @@
1
- # Temporary Holding Version
1
+ # LaCode
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Ultimate opencode setup for Go users
4
+
5
+ LaCode is an opencode plugin. It routes delegated work onto a weighted pool of
6
+ cheap models instead of the expensive one, reaps subagents that hang without
7
+ telling anyone, keeps a maintained map of your repository so agents stop
8
+ exploring it cold on every task, and tells you when a parallel session finishes
9
+ or is waiting on you — including sessions running on remote hosts over SSH,
10
+ where the desktop notification would otherwise die on a machine that has no
11
+ desktop.
12
+
13
+ Install it with one config line. It adds agents and commands it finds missing,
14
+ and it never overwrites one you already defined.
15
+
16
+ ## Why
17
+
18
+ | Capability | The problem it solves |
19
+ | --- | --- |
20
+ | Weighted model pool | An expensive main model burns through its quota doing file listing. |
21
+ | Per-model cooldowns and failover | A provider rate-limits mid-session and the whole thing hard-fails, while other providers sit idle. |
22
+ | Hang reaper | A wedged subagent holds its slot forever, so the session stalls with no error and no timeout. |
23
+ | Desktop notifications | With several sessions open you cannot see which one finished, or which is blocked on a question. |
24
+ | SSH notification tunnel | Work on a remote host raises its notification on that host, which has no desktop. |
25
+ | 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. |
26
+
27
+ ## Install
28
+
29
+ Add one line to your opencode config — `~/.config/opencode/opencode.json`, or
30
+ the `.jsonc` beside it:
31
+
32
+ ```json
33
+ { "plugin": ["@lawaty/lacode"] }
34
+ ```
35
+
36
+ **That is the entire install.** opencode resolves an npm plugin specifier itself
37
+ and installs it at startup, so there is no `npm install` to run first and no
38
+ directory to create. Do not run `npm i -g @lawaty/lacode`: a global npm install
39
+ is never consulted by opencode, so it changes nothing about whether the plugin
40
+ loads.
41
+
42
+ To install it explicitly instead — or to install it once for every project on
43
+ the machine rather than per config — use the CLI, which does the same thing:
44
+
45
+ ```bash
46
+ opencode plugin @lawaty/lacode # this project
47
+ opencode plugin @lawaty/lacode -g # global, for every project on the machine
48
+ ```
49
+
50
+ **Restart opencode afterwards.** Nothing here hot-reloads: the `plugin` array is
51
+ read at startup, so without a restart the change does not take effect and the
52
+ agent set will look stale for no visible reason.
53
+
54
+ ### Where it is installed
55
+
56
+ opencode installs npm plugins itself, into one directory per specifier under
57
+ its cache — not into your project's `node_modules`, and not from anywhere a
58
+ global npm install put them:
59
+
60
+ ```
61
+ $XDG_CACHE_HOME/opencode/packages/<specifier>/node_modules/
62
+ # ~/.cache/opencode/packages/@lawaty/lacode@latest/node_modules/ by default
63
+ ```
64
+
65
+ `$XDG_CACHE_HOME` defaults to `~/.cache`; set it if you keep a cache elsewhere.
66
+ To inspect what a specifier actually resolved to, list its directory under
67
+ `~/.cache/opencode/packages/`. An empty directory with no `node_modules` inside
68
+ it means the install failed — almost always an unpublished or misspelled package
69
+ — and opencode starts normally **without** the plugin.
70
+
71
+ ### Verifying the install
72
+
73
+ A plugin that fails to install is silent: there is no error, opencode starts,
74
+ and it simply does not have the agents. Check for them:
75
+
76
+ ```bash
77
+ opencode agent list | grep -E '^(explore-fast|implement-fast|explore-deep|implement-deep)'
78
+ ```
79
+
80
+ A working install prints `explore-fast`, `implement-fast`, `explore-deep` and
81
+ `implement-deep`, each with the hidden `-1`..`-4` pool variants alongside. If the
82
+ grep returns nothing but opencode's built-ins, the plugin did not load: check the
83
+ cache directory above, and check that `plugin` is in the config file opencode
84
+ actually reads. Run this in the same shell environment opencode runs in — an
85
+ `agent list` under a different `HOME` or `XDG_CONFIG_HOME` tests a different
86
+ machine.
87
+
88
+ ### The `lacode` CLI
89
+
90
+ The package also ships the `lacode` CLI (`notify`, `forward`, `doctor`) — see
91
+ [Remote hosts](#remote-hosts). The plugin install does **not** put it on your
92
+ `PATH`; run it from the package's `node_modules/.bin`, or install just the CLI
93
+ globally with `npm i -g @lawaty/lacode` if you want the bare command. (That
94
+ global install is for the command only — it is still never what makes the plugin
95
+ load.)
96
+
97
+ ### What the install gives you
98
+
99
+ Two things are worth knowing about what npm actually hands you:
100
+
101
+ - **`@opencode-ai/plugin` is a peer dependency.** It is not vendored into this
102
+ package and never will be: a second copy of the SDK inside the tarball would
103
+ mean opencode and the plugin holding two different module instances, and
104
+ instance identity is what plugin state hangs on. npm installs the peer for you;
105
+ if you install with a tool that does not, install `@opencode-ai/plugin` at the
106
+ same version yourself.
107
+ - **You get compiled JavaScript, not TypeScript sources.** The tarball ships
108
+ `dist/`, built from `src/` at pack time (`prepack` runs the build, so a stale
109
+ `dist/` cannot be published). This is not a preference: node strips types
110
+ natively from 22.18 but **refuses to for files under `node_modules`**, so a
111
+ package that shipped `.ts` worked fine in a checkout and failed on install —
112
+ `lacode --help` died with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. A
113
+ published package cannot depend on its consumer's loader being lenient.
114
+
115
+ ## Configuration
116
+
117
+ Plugin options are a tuple — the package name, then an options object. Every
118
+ option is optional:
119
+
120
+ ```jsonc
121
+ {
122
+ "plugin": [
123
+ ["@lawaty/lacode", {
124
+ "enabled": true,
125
+ "models": {
126
+ "slots": [
127
+ { "model": "anthropic/claude-sonnet-4-5", "weight": 2 },
128
+ { "model": "opencode/big-pickle", "weight": 1 }
129
+ ]
130
+ }
131
+ }]
132
+ ]
133
+ }
134
+ ```
135
+
136
+ | Option | Default | Meaning |
137
+ | --- | --- | --- |
138
+ | `enabled` | `true` | The single off switch. `false` injects nothing at all and does no writes. |
139
+ | `models` | — | An inline slot list. Highest-priority source. |
140
+ | `modelsFile` | — | An explicit slot file; short-circuits the chain below. |
141
+ | `deepModel` | — | A `provider/model-id` pinned onto `explore-deep` and `implement-deep`. |
142
+ | `reapMs` | `30000` | Hang-reaper interval. |
143
+
144
+ ### Pool preset resolution
145
+
146
+ The slot list is the first of these that yields usable slots:
147
+
148
+ 1. plugin options — `models` in the tuple above
149
+ 2. `~/.config/lacode/pool.json` (its parent directory is created for you)
150
+ 3. the bundled `presets/free-tier.json`
151
+ 4. a built-in fallback, so routing always works
152
+
153
+ A missing, unreadable or structurally broken source falls through to the next and
154
+ logs one warning; nothing here throws into opencode. Nothing hot-reloads —
155
+ change a preset, then restart every opencode process.
156
+
157
+ **Any model list is accepted, including paid ones** (US-7). There is no free-tier
158
+ allowlist anywhere in the package, and no cost check of any kind: only the
159
+ structure is validated. The bundled preset is built from free models because
160
+ that is a sensible default, not because the pool requires it.
161
+
162
+ ### Which models run which agent
163
+
164
+ Only the **pool** writes a model onto a pooled agent, from whichever source wins
165
+ above. The pooled bases (`explore-fast`, `implement-fast`, `context-manager`)
166
+ and their hidden `-1`..`-4` variants therefore carry no model of their own: a
167
+ model written next to an agent would be a second source of truth that silently
168
+ wins whenever routing falls through. Spawn a base and let the pool route it.
169
+
170
+ `explore-deep` and `implement-deep` are **not** pooled — they are the deliberate
171
+ escalation above a pooled tier — so nothing routes them and nothing writes their
172
+ model. Left alone they fall through to your default model, which turns "I am
173
+ stuck, escalate" into an unannounced jump to the most expensive model in your
174
+ setup. Set `deepModel` to pin them:
175
+
176
+ ```jsonc
177
+ "plugin": [["@lawaty/lacode", { "deepModel": "your-provider/your-deep-model" }]]
178
+ ```
179
+
180
+ It is an option and not a literal in the package because which model to escalate
181
+ onto is a property of your providers, not of LaCode. If you keep a `whitelist`
182
+ on that provider, LaCode appends the pinned model to it for you: the whitelist is
183
+ an allowlist, so opencode drops any model a provider offers that it does not
184
+ name, and an unlisted pin would resolve to nothing at all. If you have no
185
+ whitelist there, none is written — an absent one already allows every model. With
186
+ `deepModel` unset, no model is written and the deep tiers use your
187
+ default model — the behaviour of earlier versions.
188
+
189
+ ### ⚠️ Per-agent tool grants must be in your own config
190
+
191
+ **LaCode cannot grant tools to its agents, and this is not an oversight you can
192
+ work around by configuring it.** If you keep a global `"tools"` block in your
193
+ config — for example `"tools": { "playwright_*": false }` — then the four
194
+ exploration and implementation tiers (`explore-fast`, `explore-deep`,
195
+ `implement-fast`, `implement-deep`, plus their hidden `-1`..`-4` pool variants)
196
+ lose browser access, and **nothing tells them why**: the tool call is simply
197
+ refused mid-task. `context-manager` gets no grant either, which is correct — it
198
+ writes the context map and has no business in a browser.
199
+
200
+ opencode folds an agent's `tools` into its permission list **at config-load
201
+ time**, while decoding the config — before any plugin runs. A plugin's `config`
202
+ hook mutates the already-decoded config, so the fold never re-runs and anything
203
+ it puts in `agent.tools` is inert. (Verified on opencode 1.18.34/1.18.35 with
204
+ `opencode debug agent explore-fast`: a grant written in your own config shows two
205
+ `playwright_*` entries — `deny` then `allow`, last one winning — while the same
206
+ grant injected by the plugin shows only the `deny`.) LaCode's other injected
207
+ fields are not affected; `permission`, `prompt`, `model`, `description`,
208
+ `temperature`, `mode` and `hidden` are all read after the plugin hook runs.
209
+
210
+ **So if you use a global `"tools"` block, declare the grants yourself:**
211
+
212
+ ```jsonc
213
+ {
214
+ "tools": { "playwright_*": false },
215
+ "agent": {
216
+ "explore-fast": { "tools": { "playwright_*": true } },
217
+ "explore-deep": { "tools": { "playwright_*": true } },
218
+ "implement-fast": { "tools": { "playwright_*": true } },
219
+ "implement-deep": { "tools": { "playwright_*": true } }
220
+ }
221
+ }
222
+ ```
223
+
224
+ The hidden pool variants (`explore-fast-1`, `implement-fast-2`, …) inherit
225
+ nothing here — add a line per variant you actually spawn, or drop the global
226
+ deny and scope it to the agents that should not have browser access instead.
227
+
228
+ Nothing hot-reloads: after editing your config, restart every running opencode
229
+ process.
230
+
231
+ ### What gets injected
232
+
233
+ **19 agent keys**: the 7 bases `build`, `plan`, `explore-fast`, `explore-deep`,
234
+ `implement-fast`, `implement-deep`, `context-manager`, plus one hidden
235
+ `<base>-1`..`-4` pool variant for each of the 3 pooled bases.
236
+
237
+ `build` and `plan` exist in opencode as built-ins, so on a stock config those
238
+ two keys are already taken and merge-without-clobber keeps the built-in — the
239
+ LaCode definitions of `build` and `plan` are what you get if you remove the
240
+ built-ins of those names first.
241
+
242
+ **Five keys are deliberately not injected.** `basic` is a byte-for-byte
243
+ duplicate of `plan` apart from a pinned model, and `plan` is injected instead.
244
+ `expert` pins a specific model and carries a personal benchmark note in its
245
+ description — a package cannot ship someone else's provider, quota or benchmark
246
+ results — though its escalation ladder still resolves, because the two deep
247
+ tiers it escalates to *are* injected. And `explore`, `general` and `implement`
248
+ are only `{ "disable": true }` stubs in the author's own config: turning off
249
+ opencode's built-in agents is a decision about one person's configuration, not
250
+ a default a package should impose on a stranger.
251
+
252
+ One **field** is deliberately not injected either: `tools`, for the
253
+ load-time-folding reason explained above. Nothing else the plugin writes into
254
+ an agent is subject to it.
255
+
256
+ **Installation merges, it does not clobber** (US-16). An agent, command or
257
+ instruction is added only when that key is absent from your config. A
258
+ user-defined agent of the same name is left byte-identical — not patched, not
259
+ partially merged. Skips are counted and logged once.
260
+
261
+ **`enabled: false` turns all of it off** (US-17). No pool model rewriting, no
262
+ agent injection, no command injection, no reaper timer armed, no
263
+ `~/.config/lacode` directory created.
264
+
265
+ These three are specified in [docs/USER-STORIES.md](docs/USER-STORIES.md) as
266
+ US-7, US-16 and US-17, and each has tests.
267
+
268
+ ## Context map
269
+
270
+ Agents are stateless between tasks, and exploring your repository is the most
271
+ expensive part of any task — so every agent pays for it again, re-deriving the
272
+ same layout, entry points and boundaries from scratch. LaCode keeps a short,
273
+ maintained map of the repository in `.opencode/context/` (five files:
274
+ `architecture`, `contexts`, `conventions`, `workflows`, `decisions`) and injects
275
+ a standing rule that every agent reads the relevant file before exploring and
276
+ starts from the canonical entry points it lists. The map holds conclusions, not
277
+ inventories: it answers "what should I look at first?" and nothing else, which
278
+ is what makes reading it cheaper than the exploration it replaces.
279
+
280
+ It keeps itself current. When a session that edited code goes idle, LaCode
281
+ starts a `context-manager` run that borrows a slot from the same pool every
282
+ other subagent uses and updates the map from the changed paths and their
283
+ architectural neighbourhood — defaulting to *no edit*, since a changed file is
284
+ not a changed fact. Only that one agent ever writes the map; everything else
285
+ reports what belongs in it, so concurrent sessions cannot contradict each other.
286
+ You can also drive it by hand, which is the move when you do not trust what you
287
+ are reading:
288
+
289
+ ```bash
290
+ /context-init # build the map, or verify and refresh an existing one
291
+ /context-update # incremental refresh; accepts a focus, e.g. /context-update src/payments
292
+ /context-review # audit it for staleness, contradictions, duplication and bloat
293
+ ```
294
+
295
+ The map is local and untracked — it describes your checkout on your machine, so
296
+ it is not committed and not synced. If it is missing, agents check, find
297
+ nothing and carry on as before. What this buys is tokens not spent
298
+ re-discovering structure; as with everything else here, no savings percentage is
299
+ published, because none has been independently measured. The mechanism, the
300
+ permission rule that makes one writer possible, and what the log lines mean are
301
+ in [docs/CONTEXT.md](docs/CONTEXT.md).
302
+
303
+ ## Cost
304
+
305
+ The mechanism is specific and small: bulk exploration, implementation and
306
+ repository cartography run on pooled cheap or free subagents instead of the main
307
+ model. There are **two separate effects** here, and they are worth telling apart
308
+ because only one of them is about money per token:
309
+
310
+ 1. **The tokens are spent on a cheaper model.** A read, a grep, or a mechanical
311
+ edit performed by a near-free subagent costs per-token approximately nothing,
312
+ where the same call on your main model does not. This is the effect people
313
+ usually mean when they say the pool saves money.
314
+ 2. **The main model's context is never filled with file contents it does not
315
+ need.** A delegated explorer returns a conclusion and a `file:line`, not
316
+ forty tool calls' worth of source. The expensive model spends its attention
317
+ on design work instead of on reconstructing a directory tree, and its context
318
+ window — a hard, non-renewable budget — is spent on reasoning rather than on
319
+ raw file text.
320
+
321
+ The second is the larger of the two, and it is not a cost-per-token effect at
322
+ all: it is a capacity effect. It holds even when the pooled model is not free,
323
+ and it is why the pool is scoped to *mechanical* work. Work that needs real
324
+ judgement belongs to the main model regardless of what the pool is running,
325
+ because pulling a difficult conclusion back through a subagent costs more
326
+ context on the far side than reading the file directly would have.
327
+
328
+ That scoping is why the bundled preset is free: pooled work is work you would
329
+ not have wanted the main model to do anyway. **Free is the shipped default, not
330
+ an enforced restriction.** The pool is open — it accepts any model you give it,
331
+ free, cheap or expensive, and never inspects, prices, warns about, filters or
332
+ rewrites one. The only per-model rule is structural: a slot must be written
333
+ `provider/model-id` so it is addressable at all. That default lives in exactly one
334
+ place, `presets/free-tier.json`, where you can read it and change it (US-7, US-23).
335
+
336
+ Agents start from the maintained context map above rather than exploring cold, so
337
+ the structure of your codebase is written down once instead of re-derived on
338
+ every task. Load is counted per model across every agent type and every process,
339
+ so a slot cannot hide behind its own dedicated model while another idles, and a
340
+ model that just rate-limited one project is skipped by all of them.
341
+
342
+ **No savings percentage is published here, because none has been independently
343
+ measured.** What the mechanism is worth in money depends entirely on the token
344
+ mix of your tasks and on the models you compare the pooled run against. A figure
345
+ from someone else's workload is not a claim about yours.
346
+
347
+ The harness exists so you can produce your own number:
348
+
349
+ ```bash
350
+ npm run benchmark # offline simulation from the pinned price snapshot
351
+ npm run benchmark -- --real # runs both sides for real and reads back the tokens
352
+ ```
353
+
354
+ Both print tokens and cost for the same scenario run two ways, and both refuse
355
+ to emit a number they cannot derive — an unpriced model reports `unknown`, never
356
+ `0`. How the two modes differ is in [docs/BENCHMARKS.md](docs/BENCHMARKS.md),
357
+ which explains the harness and deliberately contains no results.
358
+
359
+ ## Remote hosts
360
+
361
+ Notifications raised on a remote host need a way back to your desktop. The
362
+ reverse SSH forward is what carries them:
363
+
364
+ ```bash
365
+ lacode notify "build finished" "4 files changed" # local desktop, or forwarded if remote
366
+ lacode doctor # what the environment resolves to
367
+ ```
368
+
369
+ `lacode notify` is fire-and-forget and always exits 0 — an unreachable desktop
370
+ must never be able to fail the process that tried to notify you. `lacode forward
371
+ HOST -- COMMAND` runs a command on another host over ssh and exits with its
372
+ status.
373
+
374
+ Configuration, the payload format, the receiver contract and holding the forward
375
+ open are in [docs/TUNNEL.md](docs/TUNNEL.md).
376
+
377
+ ## User stories
378
+
379
+ Twenty-three stories across seven epics, each written as a behavioural contract
380
+ with acceptance criteria a test can assert mechanically — the pains behind them,
381
+ the Given/When/Then criteria, and the story-to-test mapping are in
382
+ [docs/USER-STORIES.md](docs/USER-STORIES.md).
383
+
384
+ ## Development
385
+
386
+ ```bash
387
+ npm run build # esbuild -> dist/ (what the tarball ships)
388
+ npm test # node --test tests/ — offline, no network, no running server
389
+ npm run benchmark # the cost harness; see docs/BENCHMARKS.md
390
+ ```
391
+
392
+ Node ≥ 22.18.
393
+
394
+ `npm run build` compiles each entry point in `package.json` (`exports` and
395
+ `bin`) into `dist/` with esbuild, one bundle per entry point, so nothing in
396
+ `src/` has to be renamed to satisfy node's ESM resolver. `@opencode-ai/plugin`
397
+ and the node builtins are external — see the header of `scripts/build.mjs` for
398
+ why a peer dependency must never be inlined. `dist/` is generated and
399
+ gitignored; `prepack` builds it, so `npm pack` cannot produce a stale tarball.
400
+
401
+ The sources are TypeScript and the tests import `src/` directly (node strips the
402
+ types outside `node_modules`, which is exactly where the tests live), so the
403
+ loop stays: edit `src/`, run `npm test`. Only the packaging fixtures build, and
404
+ they call the same `scripts/build.mjs` the tarball does.
405
+
406
+ `tests/stories/` holds the story suites and `tests/stories/coverage.test.mjs`
407
+ is the guard: it reads the `### US-N:` headings out of `docs/USER-STORIES.md`
408
+ and the `[US-N]` markers out of the test names and fails if they disagree, in
409
+ either direction. **A test that covers a story must have the id at the start of
410
+ its name** — `test("[US-7] a paid model is accepted")` — or CI's guard fails it.
411
+ A story with no test is as much a failure as a test naming a story that does not
412
+ exist.
413
+
414
+ ## License
415
+
416
+ MIT licensed. Based on work by [lawaty](https://github.com/lawaty/lacode).