@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 +139 -1
- package/README.md +19 -2
- package/dist/agents.js +35 -5
- package/dist/commands.js +4 -2
- package/dist/index.js +496 -38
- package/dist/pool.js +149 -8
- package/docs/OMNIROUTE.md +17 -15
- package/docs/USER-STORIES.md +2 -2
- package/package.json +1 -1
- package/presets/README.md +4 -4
- package/presets/free-tier.json +3 -2
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.
|
|
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,
|
|
751
|
-
|
|
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
|
|
758
|
-
//
|
|
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
|
|
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
|
|
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
|
|