@lawaty/lacode 0.10.0 → 0.11.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/CHANGELOG.md CHANGED
@@ -6,7 +6,180 @@ All notable changes to LaCode are recorded here. The format follows
6
6
 
7
7
  Versions come from git release commits; the npm version is bumped in the same
8
8
  commit as the changelog entry it describes.
9
- ## [Unreleased]
9
+ ## [0.11.0] — the old sessions and the old event rows are swept away on their own, and a pool edit takes effect without a restart
10
+
11
+ ### Added
12
+
13
+ - **A pool edit is picked up live, and a model the provider has withdrawn no
14
+ longer takes its slot down with it.** The slot table used to be read once per
15
+ process, so editing `pool.txt`, `pool.json` or the bundled preset did nothing
16
+ until a restart — and a slot whose model id had been retired failed every
17
+ spawn routed to it. The plugin now watches the three file sources (`watchMs`,
18
+ default 1s; `watchDebounceMs`, default 250ms; both configurable, `watchMs: 0`
19
+ disables). When the resolved table actually changes it reassigns `s.slots`,
20
+ re-binds the variant agents and provider whitelists from the same config
21
+ object, and validates every slot with one real request — cooling or not. A
22
+ provider answering "Model not found" is now a first-class outcome:
23
+ `pool_recheck` benches that model for the quota cap (`model-missing`) so
24
+ routing stops choosing it, and names it in the report. Given `models`,
25
+ `pool_recheck` validates those even when they are not cooling; omitting
26
+ `models` keeps the old, cheap "probe what is cooling" contract. The watcher is
27
+ closed in `dispose`, so a torn-down plugin stops polling.
28
+
29
+ ### Fixed
30
+
31
+ - **The bundled preset and the built-in fallback no longer name a withdrawn
32
+ model.** `opencode-go/minimax-m3.1-flash-free` is `deprecated` on models.dev
33
+ and is hidden from `opencode models`; it is replaced by
34
+ `opencode-go/beam-free`, which is active, free and in a different model family.
35
+ `tests/models-snapshot.json` was refreshed for the new slot.
36
+
37
+ - **A build no longer unloads the pool from a process that starts during it.**
38
+ `npm run build` deleted `dist/` before rebuilding, so an opencode process that
39
+ imported `file://…/dist/index.js` in that window failed to load LaCode at all
40
+ and ran every `task` spawn on the caller's own model — `explore` appeared to
41
+ ignore the pool. Each entry is now staged and renamed into place, and stale
42
+ files are removed only after the live ones are in place.
43
+
44
+ - **A task spawn with no `subagent_type` is reported instead of silently
45
+ bypassing the pool.** opencode runs the default subagent, which carries no
46
+ pool model, so it inherits the caller's model; the hook cannot route it after
47
+ the fact, but it now logs a warning naming the fix (`subagent_type: "explore"`
48
+ or `"implement"`). An `@explore` mention or a direct session prompt has the
49
+ same limitation: the plugin API exposes the model read-only, so it cannot be
50
+ re-routed there either.
51
+
52
+ - **A provider limit the pool did not recognise left the task hanging until the
53
+ watchdog found it.** opencode reports a limit in two shapes: an `action` block
54
+ naming the provider and a reason, or — for every provider whose error body
55
+ opencode does not know — a retry status carrying nothing but the provider's
56
+ own words. The pool only read the first shape, and only when the reason
57
+ happened to be one of its own, so a limit that said "limit reached" was not a
58
+ limit to it: nothing was cooled, the session kept retrying the refused model,
59
+ and the task waited out the watchdog's minutes before anything killed it.
60
+ Worse, the retry went back to the same slot, because nothing had been put on
61
+ cooldown for the pool to route around.
62
+
63
+ The limit vocabulary now reads the provider's own phrasing — "limit reached",
64
+ "daily limit reached", "usage limit exceeded" — and a retry status is read for
65
+ its message as well as for its reason. The context family is deliberately
66
+ excluded: "context limit reached" is a prompt that is too long, not a
67
+ provider that has run out of allowance, and benching a model for it would take
68
+ a healthy provider out of rotation over the user's own prompt.
69
+
70
+ A limit that names its provider still benches the whole provider. A limit that
71
+ does not name a provider cools only the model the session was running on — the
72
+ one the refusal is evidence about — and either way the stalled session is
73
+ aborted at once, so the caller retries onto a slot that answers instead of
74
+ waiting for the watchdog.
75
+
76
+ - **A transient outage no longer benches a model as if it had run out of
77
+ allowance.** "Endpoint is unavailable" is a provider that is briefly down, not
78
+ one that has spent its quota: it lifts on its own. Reported in a retry
79
+ status, it took the quota path — fifteen minutes off the rotation, filed
80
+ under "rate-limited" — and it counted as a strike, so the next real limit on
81
+ that model started at strike two and got a thirty-minute window. An outage now
82
+ takes the flat one-minute window the assistant-error path has always given
83
+ it, and it is never counted as a strike: the model is benched, the session is
84
+ still killed, and the quota window that follows starts where it should.
85
+
86
+ - **Two processes could raise the same notification.** A notification is claimed
87
+ with an exclusive create, so only the process that wins the claim raises it,
88
+ and a claim older than its window may be taken over so a holder that died
89
+ mid-popup cannot silence every later one. The takeover deleted the stale claim
90
+ and then created its own — but the delete was unconditional and never
91
+ re-checked that the file it removed was still the stale claim it had judged a
92
+ moment earlier. Two processes that both saw the same stale claim both reached
93
+ the takeover: the first replaced it with a fresh claim, the second deleted
94
+ that fresh claim and made its own, and both returned "won", so both notified.
95
+ The takeover is now a compare-and-swap: an exclusive takeover lock, an
96
+ immediate refusal for whoever did not get it, and a re-read of the claim
97
+ inside the lock, so the loser leaves a live holder's fresh claim untouched.
98
+ The staleness rule itself is unchanged — a fresh claim is never stolen and a
99
+ stale one is still reclaimed.
100
+
101
+ - **A `busy` session counted as you answering a prompt.** The five-second floor
102
+ before a notification is raised exists so it does not land while you are
103
+ already at the keyboard, and it is meant to be reset by real interaction.
104
+ `session.status: busy` was counted as that interaction — but `busy` is raised
105
+ whenever the session starts working, including when opencode resumes on its
106
+ own, so the floor was refreshed without you touching anything and the popup
107
+ you were owed could be suppressed. The signal is now a `message.updated` with
108
+ the user's own role on it, plus the reply events for permissions and
109
+ questions; `busy` no longer counts.
110
+
111
+ - **A tunnel unit could be installed with a preflight it cannot run.** The unit
112
+ writer put `ExecStartPre=<preflight>` in unconditionally, so on a host where
113
+ the preflight was never installed systemd failed the unit and the tunnel never
114
+ came up — and the up-to-date check kept the broken unit because it demanded
115
+ the line. The line is now written only when the preflight is installed, which
116
+ degrades to "no preflight" rather than "no tunnel", and an already-broken unit
117
+ is repaired on the next run. The up-to-date check no longer requires the line.
118
+
119
+ - **A strike for an unavailable provider is filed where it belongs.** An outage
120
+ reported through the strike path could still be counted as a quota strike and
121
+ handed the long window, because only one of the two call sites delegated to
122
+ the outage handler. Both now do, and a strike never counts an outage. A limit
123
+ that names a provider is also no longer overridden by the model the session
124
+ happened to be running on.
125
+
126
+ - **Two acceptance criteria were corrected to match the shipped behaviour.**
127
+ US-6 criterion 4 stated the "a full slot is not the next one" rule without its
128
+ exception, so it contradicted the overflow path; it now says a full slot is
129
+ next only when no other slot is available and under its ceiling. US-19
130
+ criterion 3 stated a standing rule as if the product injected it, when it now
131
+ lives in the repo-level `AGENTS.md`.
132
+
133
+ ### Added
134
+
135
+ - **The limit vocabulary is read from one place.** `pool.limitReason()` names
136
+ the limit a text reports, and the pool, the prober and the fail-over path all
137
+ take the reason from it, so a log line and a cooldown file never spell the
138
+ same fault two ways.
139
+
140
+ - **US-31 — a limit that hangs is killed, and the retry lands on a slot that
141
+ answers.** The contract is written down as a user story with acceptance
142
+ criteria in `docs/USER-STORIES.md`, and every criterion has a test in
143
+ `tests/stories/`.
144
+
145
+ - **Two retention sweeps take the old rows away before they become the whole
146
+ database.** `scripts/retire-old-sessions.mjs` archives then removes sessions
147
+ past a window (30 days, 24 hours of grace) and
148
+ `scripts/prune-old-events.mjs` deletes old transcript rows from `event`
149
+ (7 days, 1 hour of grace), in chunks, ordering by id — the id carries the
150
+ timestamp, so the primary-key index turns "older than" into a range scan
151
+ instead of a full table walk. Both refuse to touch a row that something still
152
+ depends on: a session an `event_sequence` cursor owns, a session bound to a
153
+ workspace, and anything inside the grace window. Neither ever removes or
154
+ resets a cursor. A session is written to a JSON archive before it is deleted,
155
+ and the archive is the recovery path if the window was wrong.
156
+
157
+ Both run by hand with `--dry-run`, support `--limit`, and the event prune also
158
+ takes `--vacuum`; a window larger than the id's own era is clamped, so a
159
+ mistyped number cannot be read as "delete everything". Each ships a systemd
160
+ service and timer (daily; `Persistent=true`, with a randomized delay on the
161
+ event prune so it does not run inside the session sweep's write window) and
162
+ appends one JSONL line per real run to a log under the state directory.
163
+
164
+ - **US-32 and US-33 document the sweeps, and the coverage guard reads the root
165
+ suites.** Both have acceptance criteria in `docs/USER-STORIES.md` and story
166
+ markers on their tests. The guard now scans the root-level `tests/*-test.mjs`
167
+ suites as well as `tests/stories/`, so a story whose tests legitimately live
168
+ outside the story directory is counted instead of reported uncovered.
169
+
170
+ - **Every acceptance criterion that could not be falsified now has a test that
171
+ fails when the behaviour regresses.** The audit found criteria whose tests
172
+ would have passed for the opposite behaviour: the least-loaded slot under a
173
+ full cooldown, a ceiling turn being skipped rather than consumed, a full slot
174
+ left unmarked, a macOS prompt carrying no buttons, a question carrying none,
175
+ a missing sound player costing only the sound, the bus path coming from the
176
+ local user rather than the remote, the sound playing where the notice is
177
+ rendered and never on the headless host, the self-update CLI, its argv and its
178
+ state file, cost control with no price information, the cartographer carrying
179
+ no model of its own, the map being reported rather than edited, an OmniRoute
180
+ gateway leaving a user's pool alone, and the notification doc leading with the
181
+ shipped path. Each new test was shown to fail by breaking the behaviour it
182
+ covers.
10
183
 
11
184
  ## [0.10.0] — a notification waits for you instead of timing out, and a permission can be answered from the popup
12
185
 
package/README.md CHANGED
@@ -58,9 +58,11 @@ opencode plugin @lawaty/lacode -g
58
58
 
59
59
  ### 2. Restart opencode
60
60
 
61
- **Nothing here hot-reloads.** The `plugin` array is read at startup, so without a
62
- restart the change does not take effect and the agent set will look stale for no
63
- visible reason.
61
+ **Plugin code is not hot-reloaded.** The `plugin` array is read at startup, so
62
+ without a restart the change does not take effect and the agent set will look
63
+ stale for no visible reason. (The pool's slot files are the exception: editing
64
+ `pool.txt`, `pool.json` or the bundled preset is picked up live — see
65
+ [Configure](#configure).)
64
66
 
65
67
  ### 3. Check it loaded
66
68
 
@@ -91,6 +93,7 @@ To see what the pool is doing underneath:
91
93
 
92
94
  ```bash
93
95
  /lacode-doctor # slots, model resolution, OmniRoute — report only, changes nothing
96
+ /pool-recheck # probe the benched models now; re-admit the ones that answer
94
97
  lacode version # which LaCode is actually running, and whether opencode loads that one
95
98
  ```
96
99
 
@@ -192,15 +195,18 @@ it. Those windows are computed backoff rather than a promise from the provider,
192
195
  and a quota-shaped one can run for hours — long enough for a provider that has
193
196
  since recovered, or whose quota you reset in its own console, to stay excluded
194
197
  while every delegated spawn queues behind the survivors. So LaCode asks instead
195
- of guessing: every fifteen minutes, and on demand via the `pool_recheck` tool, it
196
- probes each benched model with one real request on that exact model. A model that
197
- answers is re-admitted immediately — nothing restarts, nothing reloads — and a
198
- model that is still limited keeps exactly the window it had, because a check must
199
- never be the thing that extends the wait. A change is reported as a toast and a
200
- log line; a pass that finds nothing changed says nothing at all.
201
-
202
- Six commands: `/context-init`, `/context-update`, `/context-review`,
203
- `/lacode-doctor`, `/agent-instructions` and `/pool`. Everything is added **only
198
+ of guessing: every fifteen minutes, on demand via the `pool_recheck` tool, and
199
+ the moment a pool file changes (where it validates every slot, cooling or not),
200
+ it probes each benched model with one real request on that exact model. A model
201
+ that answers is re-admitted immediately — nothing restarts, nothing reloads — and
202
+ a model that is still limited keeps exactly the window it had, because a check
203
+ must never be the thing that extends the wait. A model the provider no longer
204
+ serves at all is benched for the quota cap and named in the report. A change is
205
+ reported as a toast and a log line; a pass that finds nothing changed says
206
+ nothing at all.
207
+
208
+ Seven commands: `/context-init`, `/context-update`, `/context-review`,
209
+ `/lacode-doctor`, `/agent-instructions`, `/pool` and `/pool-recheck`. Everything is added **only
204
210
  when your config does not already
205
211
  define it** — an agent or command you wrote yourself is left byte-identical,
206
212
  never patched or partially merged. `{"enabled": false}` turns the whole thing off
@@ -236,7 +242,8 @@ are reading:
236
242
  /context-review # audit it for staleness, contradictions, duplication and bloat
237
243
  /lacode-doctor # check the pool, model resolution and the OmniRoute gateway. Report only
238
244
  /agent-instructions # apply a client instruction to the project's AGENTS.md — client-only, see below
239
- /pool # rewrite the pool slot table in ~/.config/lacode/pool.txt; restart to apply
245
+ /pool # rewrite the pool slot table in ~/.config/lacode/pool.txt; picked up automatically
246
+ /pool-recheck # probe the pool now; accepts an optional provider/model list
240
247
  ```
241
248
 
242
249
  The map is local and untracked — it describes your checkout on your machine, so
package/dist/commands.js CHANGED
@@ -168,7 +168,7 @@ Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
168
168
  Change nothing. Print a final one-line summary of failures.`
169
169
  },
170
170
  "pool": {
171
- description: "Modify this machine's LaCode pool from a short instruction \u2014 the shared free-model slot table in ~/.config/lacode/pool.txt. Restarts opencode afterwards.",
171
+ description: "Modify this machine's LaCode pool from a short instruction \u2014 the shared free-model slot table in ~/.config/lacode/pool.txt. Picked up live; no restart.",
172
172
  template: `Apply this change to the LaCode pool on this machine:
173
173
 
174
174
  $ARGUMENTS
@@ -186,16 +186,27 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
186
186
 
187
187
  3. Write the WHOLE file, not a patch. This file is an override, not an edit target: any slot you drop disappears, and slot numbers are line positions, so a partial write silently renumbers the rotation. Keep the existing comment header. If the write fails on a missing directory, create \`~/.config/lacode\` first and retry.
188
188
 
189
- 4. Report the before \u2192 after slot table, the resolved path, and this reminder in substance: nothing hot-reloads, so every running opencode process must be restarted before the new pool takes effect.
189
+ 4. Report the before \u2192 after slot table and the resolved path. The running process watches the pool files: it reloads the table, re-binds the variant agents and validates every slot, so no restart is needed. If a slot names a model the provider does not serve, it will be benched and named in the log and toast \u2014 report that instead of claiming the swap succeeded.
190
190
 
191
191
  Two traps that fail silently, so check and state them explicitly:
192
192
  - A slot's provider must already exist in the user's opencode config, or that slot cannot resolve at all. If the instruction adds a model on a provider that is not configured, say so.
193
193
  - If a slot model is \`omniroute/<id>\`, that id must also appear in the plugin's \`omniroute.models\` allowlist (or be the \`combo\` id, which needs no entry) \u2014 otherwise the pool resolves and every spawn fails with \`ProviderModelNotFoundError\`.
194
194
  - A provider \`whitelist\` is an allowlist, and the plugin appends your model to it at config time. Nothing for you to do there.
195
195
 
196
- If the instruction is to RESET or REVERT rather than to change models, delete \`${USER_MODELS_TXT}\` instead, and report that the pool falls back to the bundled preset \u2014 with the same restart reminder.
196
+ If the instruction is to RESET or REVERT rather than to change models, delete \`${USER_MODELS_TXT}\` instead, and report that the pool falls back to the bundled preset \u2014 the running process picks that up automatically too.
197
197
 
198
198
  Change nothing else. In particular do not touch \`presets/free-tier.json\`, \`~/.config/lacode/pool.json\`, \`FALLBACK_SLOTS\` in \`src/lib/pool.ts\`, or \`tests/models-snapshot.json\`: this command owns the per-machine override and nothing else.`
199
+ },
200
+ "pool-recheck": {
201
+ description: "Probe the pool now \u2014 re-admit benched models that answer, keep the ones still limited, and bench a model the provider no longer serves.",
202
+ template: `Probe the LaCode pool now and report the result. Run inline; do not spawn a subagent and do not edit the pool.
203
+
204
+ $ARGUMENTS
205
+
206
+ - If the text above names one or more models, call the \`pool_recheck\` tool with those ids in \`models\` (provider/model-id).
207
+ - Otherwise call \`pool_recheck\` with no arguments; it probes every model that is currently cooling.
208
+
209
+ Report the tool's output as it came back, then add one short line naming what changed: re-admitted, still limited, or benched because the provider does not serve the model. If a model was benched as \`model-missing\`, say so and name the replacement in \`~/.config/lacode/pool.txt\` \u2014 but change nothing yourself.`
199
210
  }
200
211
  };
201
212
  function applyCommands(cfg, commands = COMMANDS) {