@lawaty/lacode 0.7.0 → 0.8.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
@@ -9,6 +9,142 @@ commit as the changelog entry it describes.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.8.0] — cooldowns are probed instead of trusted, and the instructions agent can read the map
13
+
14
+ ### Added
15
+
16
+ - **Benched models are now checked by using them.** A model that rate-limits or runs
17
+ out of quota is benched until its window ends, and that window is computed
18
+ backoff rather than a promise from the provider — a quota-shaped one reaches six
19
+ hours, long enough for a provider that recovered, or whose quota was reset by hand
20
+ in its own console, to stay excluded while every delegated spawn queues behind the
21
+ survivors. `pool_recheck` asks instead of guessing: one real request on that exact
22
+ model, with a one-word prompt and its own system string so the check costs
23
+ handfuls of tokens rather than shipping a subagent's instructions. A model that
24
+ answers is re-admitted at once — no restart, no reload, because `readLimits`
25
+ rebuilds the cooldown state from disk on every routing decision — and one that is
26
+ still limited keeps exactly the window it had. `src/lib/probe.ts` owns the probe;
27
+ it runs every fifteen minutes while anything is cooling (`probeMs`, `0` disables)
28
+ and on demand through the tool, one model at a time, silent when nothing changed
29
+ and reported as a toast plus a log line when something did.
30
+
31
+ Three rules keep a health check from becoming the thing it is checking for. A
32
+ probe's own failure is never counted as a strike, or repeated checks would drive
33
+ the window to the six-hour cap and no probe could ever pass; the only window it
34
+ may change is one the provider itself stated, which is the provider correcting a
35
+ guess. A probe's 429 never benches a whole provider — one request is evidence
36
+ about one request. And a probe is a root session like any other, so it is marked
37
+ in the shared pool state to keep the cartographer's idle trigger from starting a
38
+ context run on its way past.
39
+
40
+ ### Changed
41
+
42
+ - **A finished cartographer run leaves the session list.** opencode's list shows
43
+ root sessions, and the cartographer was created with no `parentID` — so every
44
+ automatic run landed in the user's main list and buried real work (112 of them
45
+ on one machine). Nesting it under the triggering session is unsafe:
46
+ `pool.bindChild` adopts a fresh child into any claim the parent holds without a
47
+ target, which is exactly what a root has while its own explore tasks are in
48
+ flight, and the reaper would abort the cartographer as though it were the hung
49
+ task. Archiving is unavailable in 1.18.34 — no archive method, and `session.list`
50
+ takes only `directory`. So the run now exports its transcript to
51
+ `~/.local/share/opencode/lacode/cartographer/` (last 20 kept, override with
52
+ `LACODE_TRANSCRIPT_DIR`) and deletes the session, on the failure path as well as
53
+ the success path, since a retrying cartographer is what fills a list fastest.
54
+ Its notifications were already suppressed.
55
+
56
+ - **A slot's concurrency ceiling is now twice its weight.** Weight still sets how
57
+ many turns a slot takes per weighted round-robin rotation, but the ceiling is
58
+ `2 × weight`, so one full rotation fills only half the pool and the run is walked
59
+ twice before every slot is full. `pool_status` prints the ceiling as `load=n/2w`,
60
+ and a weight-3 slot may now hold 6 concurrent sessions.
61
+
62
+ - **The `instructions` agent can read the context map.** Its `read` grant was
63
+ scoped to `AGENTS.md` alone, so an `/agent-instructions` directive was applied
64
+ without sight of what the project has already settled — the agent could restate
65
+ a fact `conventions.md` records, or contradict one it had never read. The map is
66
+ now readable through both path spellings (`.opencode/context/**` and
67
+ `*/.opencode/context/**`), declared after the broad deny so the grant is not
68
+ inert. The **write** grant is unchanged and still reaches `AGENTS.md` only: the
69
+ map stays read-only to this agent, `context` remains its only writer, and the
70
+ writer guard — which reads `edit` alone — needs no new exemption.
71
+
72
+ ### Fixed
73
+
74
+ - **The pool no longer grows without bound.** The per-session `activity` and
75
+ `children` maps were written on every message event and every `session.created`
76
+ and never pruned, so a long-running server accumulated an entry per session for
77
+ the life of the process. Both are now swept by the reaper and on every spawn, and
78
+ the notifier's per-session bookkeeping (`active` / `titles`) is capped at 500.
79
+
80
+ - **The routing log no longer prints a slot variant's name.** It read
81
+ `routed explore -> explore-2`, which is read by the very agent being routed and
82
+ hands it a valid-looking agent name in the exact place it decides what to spawn.
83
+ The refusals that followed were the model copying those names back —
84
+ `implement-4` 23 times, `implement-2` 20, `explore-2` 12, roughly one per routed
85
+ spawn. The line now names the slot and the model, which is the same diagnostic
86
+ value with nothing for the model to echo. The refusal itself is unchanged.
87
+
88
+ - **The notification helper spawn is bounded.** Question, permission and error
89
+ outcomes bypass the notification cooldown on purpose — a session blocked on a
90
+ prompt the user was never told about is the silent stall this plugin exists to
91
+ prevent — so a failure storm could start one detached helper per event, each
92
+ `bin/oc-notify` → `bin/oc` → an ssh attempt, with nothing reaping them. The
93
+ bound is on the transport, not on the decision: at most 4 helpers in flight,
94
+ and one that has not answered in 30s is killed. Which notifications fire is
95
+ unchanged.
96
+
97
+ - **A provider-stated reset can no longer bench a model for days.** A stated
98
+ window ("Resets in 35min") is still trusted over the computed backoff, but the
99
+ parse was unbounded, so one malformed upstream number removed a slot from
100
+ rotation indefinitely: an `account_rate_limit` carrying `retry in 329917s`
101
+ (~3.8 days) cooled a model for **91.6 hours**, long past the window it was
102
+ waiting for, and every delegated spawn queued behind the survivors. A stated
103
+ window is now discarded past `STATED_COOLDOWN_MAX_MS` (6h, the quota cap) so
104
+ the caller falls back to the ordinary strike backoff instead of trusting a
105
+ number it cannot believe. A limit already on disk is unaffected — delete its
106
+ `limit.<model>.json` to re-admit the model now.
107
+
108
+ - **`"Resets in 2hr 17min"` is no longer read as 2h00.** The unit alternations
109
+ were ordered `h|hr|hour`, so `2hr 17min` matched the bare `h`, left `r 17min`
110
+ unparsed and silently dropped the minutes — a window too *short* by exactly the
111
+ part it discarded, despite the docstring claiming that spelling was handled.
112
+ Alternations are now longest-first (`hour|hr|h`, `minute|min|m`, `second|sec|s`).
113
+
114
+ - **The OmniRoute sticky-assignment table is bounded.** It is keyed by session and
115
+ only a *failure* deleted an entry, so it grew with every session the process ever
116
+ served rather than with the sessions in flight. It is now capped at 500,
117
+ evicting oldest-first, and a `select()` that returns an existing assignment
118
+ re-inserts it — previously a long-running session kept its original position and
119
+ was evicted as "oldest" while still actively in use.
120
+
121
+ - **A pinned agent whose model is cooling now says so.** `basic` and `expert`
122
+ hard-code a model and are not pooled, so a spawn of either falls through routing
123
+ with nothing logged: when that provider was benched the escalation agent simply
124
+ read as "cancelled". The pool now warns once, naming the model, the reason and
125
+ the fact that only `explore`/`implement`/`context` can fail over.
126
+
127
+ - **The shared state directory now cleans itself without needing traffic.** Dead
128
+ claims files and lapsed cooldowns were reclaimed only as a side effect of
129
+ `snapshot()`, which runs when something spawns — so an idle machine, or one
130
+ whose process was killed before its next spawn, carried a dead process's claims
131
+ file and every lapsed cooldown indefinitely, and those files are read on every
132
+ routing decision. `sweepStateDir` applies the same policy on an hourly clock,
133
+ and once at startup so the previous run's debris goes immediately. It never
134
+ touches this process's own claims file, `cursor.json`, a running cooldown, or a
135
+ lapsed cooldown still inside its strike-decay window (deleting that would reset
136
+ the strike count and restart the backoff at 1).
137
+
138
+ ## [0.7.1] — /agent-instructions creates AGENTS.md on first use
139
+
140
+ ### Changed
141
+
142
+ - **`/agent-instructions` creates `AGENTS.md` on first use.** The hidden
143
+ `instructions` agent now creates the project's root `AGENTS.md` when it does
144
+ not exist, seeded with the instruction you pass, instead of refusing. Nothing
145
+ is created at startup and no placeholder content is written — the file appears
146
+ only when you run the command, and holds only what you asked for.
147
+
12
148
  ## [0.7.0] — standing instructions move to the project AGENTS.md
13
149
 
14
150
  ### Changed
@@ -235,7 +371,9 @@ experimental; `latest` remains 0.4.2.
235
371
 
236
372
  - First published release: pooled subagents over a shared set of models.
237
373
 
238
- [Unreleased]: https://github.com/lawaty/lacode/compare/v0.7.0...HEAD
374
+ [Unreleased]: https://github.com/lawaty/lacode/compare/v0.8.0...HEAD
375
+ [0.8.0]: https://github.com/lawaty/lacode/compare/v0.7.1...v0.8.0
376
+ [0.7.1]: https://github.com/lawaty/lacode/compare/v0.7.0...v0.7.1
239
377
  [0.7.0]: https://github.com/lawaty/lacode/compare/v0.6.0...v0.7.0
240
378
  [0.6.0]: https://github.com/lawaty/lacode/compare/v0.5.0...v0.6.0
241
379
  [0.5.0]: https://github.com/lawaty/lacode/compare/v0.4.2...v0.5.0
package/README.md CHANGED
@@ -125,6 +125,7 @@ option is optional:
125
125
  | `models` | — | An inline slot list. Highest-priority source. |
126
126
  | `modelsFile` | — | An explicit slot file; short-circuits the [resolution chain](docs/CONFIGURATION.md#pool-preset-resolution). |
127
127
  | `reapMs` | `30000` | Hang-reaper interval. |
128
+ | `probeMs` | `900000` | How often benched models are checked by actually using them. `0` turns the periodic check off and leaves the `pool_recheck` tool as the only way to ask. |
128
129
  | `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. |
129
130
  | `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. |
130
131
  | `omniroute` | — | Optional [OmniRoute](docs/OMNIROUTE.md) gateway integration. Absent or `{ enabled: false }` changes nothing. |
@@ -185,6 +186,19 @@ only thing that works: naming a variant lets the caller choose the provider
185
186
  directly, which is exactly what the rotation and the per-slot ceiling exist to
186
187
  prevent, so it is refused.
187
188
 
189
+ A model that rate-limits or runs out of quota is benched and taken out of the
190
+ rotation until its window ends, so one sick provider does not take the pool with
191
+ it. Those windows are computed backoff rather than a promise from the provider,
192
+ and a quota-shaped one can run for hours — long enough for a provider that has
193
+ since recovered, or whose quota you reset in its own console, to stay excluded
194
+ 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
+
188
202
  Six commands: `/context-init`, `/context-update`, `/context-review`,
189
203
  `/lacode-doctor`, `/agent-instructions` and `/pool`. Everything is added **only
190
204
  when your config does not already
@@ -258,8 +272,11 @@ is the hidden `instructions` agent, reachable solely through the
258
272
  instruction you have given to `AGENTS.md`, then reports what it changed. It is
259
273
  a normal agent turn, not a dead append: tell it what you want and review the
260
274
  result. The `instructions` agent is the one agent permitted to edit that file,
261
- its grant is scoped to `AGENTS.md` alone, and no other agent can spawn it —
262
- the command is the only route in.
275
+ its write grant is scoped to `AGENTS.md` alone, and no other agent can spawn it —
276
+ the command is the only route in. It may also **read** `.opencode/context/`, so a
277
+ directive does not restate a fact the map has already settled or contradict one it
278
+ has not read; the map stays read-only to it, and the `context` agent remains its
279
+ only writer.
263
280
 
264
281
  ## Cost
265
282
 
package/dist/agents.js CHANGED
@@ -44,6 +44,7 @@ var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
44
44
  var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
45
45
  var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
46
46
  var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
47
+ var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
47
48
  var CHILD_MEMORY_MS = 30 * 6e4;
48
49
 
49
50
  // src/lib/agent-instructions.ts
@@ -747,23 +748,52 @@ on that.
747
748
  tidy, reorder or delete content the client did not ask you to touch.
748
749
  - Prefer current-state wording: state how things are, not how they changed.
749
750
  - Keep the file concise. If it is growing past roughly 150 lines, say so.
750
- - If \`AGENTS.md\` does not exist, stop and report that there is nothing to edit.
751
- Do not create it.
751
+ - If \`AGENTS.md\` does not exist, create it and record the client's instruction in
752
+ it \u2014 that instruction is the file's first content.
753
+ - Do NOT add placeholder comments, empty template headings, or example text;
754
+ write only what the client asked for.
755
+ - Say plainly in your report that you created the file.
756
+
757
+ # Before you write
758
+
759
+ Read \`.opencode/context/\` first when it is there \u2014 \`architecture.md\`,
760
+ \`conventions.md\` or \`contexts.md\`, whichever bears on the directive \u2014 so you do
761
+ not record a fact the project has already settled, and do not contradict one you
762
+ do not know yet. Read only what is relevant, and carry on if it is missing.
763
+
764
+ That map is READ-ONLY to you: the \`context\` agent is its only writer. If the
765
+ directive contradicts it, say so plainly in your report and still write what the
766
+ client asked for. Never edit the map to agree with \`AGENTS.md\` \u2014 the two are
767
+ different files with different jobs, and this one is yours alone.
752
768
 
753
769
  # Reporting
754
770
 
755
771
  Report briefly what you changed and where. Do not edit any other file.`,
756
772
  permission: {
757
- // Read is scoped to the one file too: the directive is about AGENTS.md, and
758
- // nothing else is any of this agent's business.
773
+ // Read reaches AGENTS.md and the context map, and nothing else.
774
+ //
775
+ // The map is here because the directive is about how work is DONE in this
776
+ // project and the map is the maintained record of what the project IS: reading
777
+ // it is how this agent avoids recording a fact the code already settles, and
778
+ // avoids contradicting one it does not know yet. It stays READ-ONLY — `edit`
779
+ // below still denies the map, so this grant cannot turn the one-writer rule
780
+ // into a two-writer rule, and the writer guard does not have to care about it
781
+ // (it reads `edit` alone).
782
+ //
783
+ // Both spellings are required, and every allow must sit AFTER the broad deny:
784
+ // LAST match wins, so an allow written before "*": "deny" is silently inert.
759
785
  read: {
760
786
  "*": "deny",
761
787
  "AGENTS.md": "allow",
762
- "*/AGENTS.md": "allow"
788
+ "*/AGENTS.md": "allow",
789
+ "*/.opencode/context/**": "allow",
790
+ ".opencode/context/**": "allow"
763
791
  },
764
792
  glob: "deny",
765
793
  grep: "deny",
766
794
  list: "deny",
795
+ // Still exactly one writable path. The map is readable (see above) and stays
796
+ // unwritable: `context` is its only writer, and this agent is not it.
767
797
  edit: {
768
798
  "*": "deny",
769
799
  "AGENTS.md": "allow",
package/dist/commands.js CHANGED
@@ -49,6 +49,7 @@ var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
49
49
  var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
50
50
  var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
51
51
  var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
52
+ var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
52
53
  var CHILD_MEMORY_MS = 30 * 6e4;
53
54
 
54
55
  // src/commands.ts
@@ -138,7 +139,8 @@ Follow the rules for the file in your system prompt, then report what you change
138
139
  Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
139
140
 
140
141
  1. Pool status \u2014 call the \`pool_status\` tool. Report the slot list, each
141
- slot's load against its weight ceiling, and which models are cooling.
142
+ slot's load against its ceiling (twice the slot's weight), and which models
143
+ are cooling.
142
144
 
143
145
  2. OmniRoute configured \u2014 inspect the \`@lawaty/lacode\` plugin options in the
144
146
  user's opencode config for an \`omniroute\` block with \`enabled: true\`. If it
@@ -175,7 +177,7 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
175
177
  2. Resolve the instruction above into a target slot table. These are the rules the loader enforces \u2014 never write a file that breaks one:
176
178
  - one slot per line: \`<provider>/<model-id> [weight]\`
177
179
  - at most ${MAX_SLOTS} slots, and a model may appear only once
178
- - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the soft ceiling on concurrent claims and the slot's share of the rotation \u2014 NOT a priority; pick order follows line order.
180
+ - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's share of the rotation \u2014 how many turns it takes per cycle \u2014 and its concurrency ceiling is twice that. NOT a priority; pick order follows line order.
179
181
  - \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
180
182
  If the instruction names a model ambiguously, or would need more than ${MAX_SLOTS} slots, stop and ask rather than guessing.
181
183