@lawaty/lacode 2.3.0-beta.2 → 2.3.0-beta.4
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 +27 -0
- package/README.md +12 -4
- package/dist/index.js +70 -24
- package/dist/tui.js +114 -13
- package/docs/CONFIGURATION.md +10 -2
- package/docs/USER-STORIES.md +7 -5
- package/package.json +6 -3
- package/scripts/refresh-pool.mjs +110 -29
- package/scripts/score-models.mjs +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,29 @@ commit as the changelog entry it describes.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.3.0-beta.4] — status never benches, structure is the contract
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Pool: only a hard outage (a gating `go`/`free` service reporting `down`) with a structurally intact pool may skip a refresh scan. A mere `degraded` never skips, because models routinely answer while the status page says degraded. Paid-tier (`payg`) and roll-up (`inference`) components can no longer freeze the free pool.
|
|
17
|
+
- Pool: the fail-closed guard now enforces structure, not just count. A selection that drops the opencode-go head or the opencode tail is refused even at full count, and the keep threshold is anchored to the contract pool size (10) instead of the current file, so repeated writes can no longer ratchet the pool down. `--force-write` overrides the guard for a genuinely changed catalogue.
|
|
18
|
+
- Pool: per-request probe deadline is 12 seconds (was 45), so a flapping provider stretches a scan far less; `--timeout MS` still overrides it.
|
|
19
|
+
- Pool: a failed scorer run or a refused write now schedules a 5-minute recheck like every other failed run, instead of waiting for the next 6-hour slot.
|
|
20
|
+
|
|
21
|
+
## [2.3.0-beta.3] — persist autonomy modes in global config
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Modes: YOLO/YKB modes now persist in the lacode plugin options inside the global `opencode.jsonc` (single config file, comment-preserving edits).
|
|
26
|
+
|
|
27
|
+
### Removed
|
|
28
|
+
|
|
29
|
+
- Modes: removed `~/.config/lacode/modes.json` and `LACODE_MODES_FILE` seeding.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Build: externalized the `jsonc-parser` runtime dependency.
|
|
34
|
+
|
|
12
35
|
## [2.3.0-beta.2] — degenerate refreshes never shrink the pool
|
|
13
36
|
|
|
14
37
|
### Added
|
|
@@ -1226,6 +1249,10 @@ experimental; `latest` remains 0.4.2.
|
|
|
1226
1249
|
[Unreleased]: https://github.com/lawaty/lacode/compare/v2.1.0-beta.0...HEAD
|
|
1227
1250
|
[2.1.0-beta.0]: https://github.com/lawaty/lacode/compare/v2.0.0-beta.0...v2.1.0-beta.0
|
|
1228
1251
|
[2.0.0-beta.0]: https://github.com/lawaty/lacode/compare/v1.3.1...v2.0.0-beta.0
|
|
1252
|
+
[2.3.0-beta.3]: https://github.com/lawaty/lacode/compare/v2.3.0-beta.2...v2.3.0-beta.3
|
|
1253
|
+
[2.3.0-beta.2]: https://github.com/lawaty/lacode/compare/v2.3.0-beta.1...v2.3.0-beta.2
|
|
1254
|
+
[2.3.0-beta.1]: https://github.com/lawaty/lacode/compare/v2.3.0-beta.0...v2.3.0-beta.1
|
|
1255
|
+
[2.3.0-beta.0]: https://github.com/lawaty/lacode/releases/tag/v2.3.0-beta.0
|
|
1229
1256
|
[1.3.1]: https://github.com/lawaty/lacode/releases/tag/v1.3.1
|
|
1230
1257
|
[1.3.0-beta.9]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.9
|
|
1231
1258
|
[1.3.0-beta.8]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.8
|
package/README.md
CHANGED
|
@@ -156,15 +156,15 @@ option is optional:
|
|
|
156
156
|
| `feedbackHint` | `true` | Show the one-time feedback note on the first interactive run. `false` — or `LACODE_NO_FEEDBACK_HINT=1` — turns it off. The flag lives in `~/.local/share/opencode/lacode/feedback.json`; nothing is sent anywhere. |
|
|
157
157
|
| `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. |
|
|
158
158
|
| `omniroute` | — | Optional [OmniRoute](docs/OMNIROUTE.md) gateway integration. Absent or `{ enabled: false }` changes nothing. |
|
|
159
|
-
| `yolo` | `false` |
|
|
160
|
-
| `ykb` | `false` |
|
|
159
|
+
| `yolo` | `false` | YOLO mode: permission requests that would ask are auto-approved. Explicit `deny` rules still win. The TUI writes this option when you run `/yolo`. |
|
|
160
|
+
| `ykb` | `false` | YKB (You Know Better) mode: the agent is told not to ask, and questions that still appear are answered with the recommended option. The TUI writes this option when you run `/ykb`. |
|
|
161
161
|
| `watchMs` | `1000` | How often the pool files are checked for changes. `0` disables live reload. |
|
|
162
162
|
| `watchDebounceMs` | `250` | Collapse a burst of editor writes into one reload. |
|
|
163
163
|
| `imagePaste` | — | Image attachments: `maxBytes` (4 MiB), `maxImages` (10) and `dir`. |
|
|
164
164
|
|
|
165
165
|
## Modes
|
|
166
166
|
|
|
167
|
-
Two runtime modes
|
|
167
|
+
Two runtime modes live on the lacode plugin entry and can be toggled from the TUI:
|
|
168
168
|
|
|
169
169
|
| Command | Effect |
|
|
170
170
|
| --- | --- |
|
|
@@ -172,7 +172,15 @@ Two runtime modes can be seeded from plugin options and toggled from the TUI:
|
|
|
172
172
|
| `/ykb` | Toggle YKB (You Know Better). The agent is told not to ask questions and to continue with its recommendation. A question that still appears is answered with its `(Recommended)` option, or its first option when none is marked. |
|
|
173
173
|
| `/modes` | Show both switches. |
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
```jsonc
|
|
176
|
+
{
|
|
177
|
+
"plugin": [
|
|
178
|
+
["@lawaty/lacode", { "yolo": true, "ykb": false }]
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The toggles edit the `yolo` / `ykb` options on the lacode plugin entry in your global opencode config (`~/.config/opencode/opencode.jsonc`, or `$XDG_CONFIG_HOME/opencode/opencode.jsonc`), preserving comments. The server reads that file on the next decision — no restart. Turning a mode on also answers requests that are already pending. The lacode plugin entry must already exist in the global config; a project-only entry cannot be toggled. A free-text question with no options is left for the user, since there is no option to choose.
|
|
176
184
|
|
|
177
185
|
### Staying up to date
|
|
178
186
|
|
package/dist/index.js
CHANGED
|
@@ -2282,9 +2282,9 @@ var STATE_DIR = join3(homedir2(), ".local", "share", "opencode", "lacode");
|
|
|
2282
2282
|
var STATE_PATH = join3(STATE_DIR, "selfupdate.json");
|
|
2283
2283
|
var LOCK_PATH = join3(STATE_DIR, "selfupdate.lock");
|
|
2284
2284
|
function compareSemver(a, b) {
|
|
2285
|
-
const
|
|
2286
|
-
const left =
|
|
2287
|
-
const right =
|
|
2285
|
+
const parse2 = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
2286
|
+
const left = parse2(a);
|
|
2287
|
+
const right = parse2(b);
|
|
2288
2288
|
for (let i = 0; i < 3; i++) {
|
|
2289
2289
|
const l = left[i] ?? 0;
|
|
2290
2290
|
const r = right[i] ?? 0;
|
|
@@ -6167,35 +6167,82 @@ async function imagePasteHooks(input, options, deps) {
|
|
|
6167
6167
|
}
|
|
6168
6168
|
}
|
|
6169
6169
|
|
|
6170
|
-
// src/lib/
|
|
6170
|
+
// src/lib/opencode-config.ts
|
|
6171
6171
|
import { existsSync as existsSync5, mkdirSync as mkdirSync9, readFileSync as readFileSync10, renameSync as renameSync7, writeFileSync as writeFileSync10 } from "node:fs";
|
|
6172
6172
|
import { homedir as homedir8 } from "node:os";
|
|
6173
6173
|
import { dirname as dirname6, join as join12 } from "node:path";
|
|
6174
|
-
|
|
6174
|
+
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
|
|
6175
|
+
function globalOpencodeConfigPath() {
|
|
6176
|
+
const base = process.env.XDG_CONFIG_HOME ?? join12(homedir8(), ".config");
|
|
6177
|
+
const jsonc = join12(base, "opencode", "opencode.jsonc");
|
|
6178
|
+
return existsSync5(jsonc) ? jsonc : join12(base, "opencode", "opencode.json");
|
|
6179
|
+
}
|
|
6180
|
+
var OpenCodeConfigError = class extends Error {
|
|
6181
|
+
constructor(message) {
|
|
6182
|
+
super(message);
|
|
6183
|
+
this.name = "OpenCodeConfigError";
|
|
6184
|
+
}
|
|
6185
|
+
};
|
|
6186
|
+
function isPlainObject(value) {
|
|
6187
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
6188
|
+
}
|
|
6189
|
+
function readOpencodeConfig(path4 = globalOpencodeConfigPath()) {
|
|
6190
|
+
if (!existsSync5(path4)) return {};
|
|
6191
|
+
const text = readFileSync10(path4, "utf8");
|
|
6192
|
+
const errors = [];
|
|
6193
|
+
const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
|
|
6194
|
+
if (errors.length > 0) {
|
|
6195
|
+
const detail = errors.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`).join(", ");
|
|
6196
|
+
throw new OpenCodeConfigError(`Failed to parse ${path4}: ${detail}`);
|
|
6197
|
+
}
|
|
6198
|
+
if (!isPlainObject(parsed)) {
|
|
6199
|
+
throw new OpenCodeConfigError(`Config root must be an object: ${path4}`);
|
|
6200
|
+
}
|
|
6201
|
+
return parsed;
|
|
6202
|
+
}
|
|
6203
|
+
function pluginName(entry) {
|
|
6204
|
+
if (typeof entry === "string") return entry;
|
|
6205
|
+
if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
|
|
6206
|
+
return void 0;
|
|
6207
|
+
}
|
|
6208
|
+
function matchesLacode(name) {
|
|
6209
|
+
if (name === "@lawaty/lacode") return true;
|
|
6210
|
+
const segments = name.split(/[\\/]/);
|
|
6211
|
+
return segments.some((s) => s.toLowerCase() === "lacode");
|
|
6212
|
+
}
|
|
6213
|
+
function findLacodePlugin(config) {
|
|
6214
|
+
const plugins = config.plugin;
|
|
6215
|
+
if (!Array.isArray(plugins)) return void 0;
|
|
6216
|
+
for (let index = 0; index < plugins.length; index++) {
|
|
6217
|
+
const entry = plugins[index];
|
|
6218
|
+
const name = pluginName(entry);
|
|
6219
|
+
if (!name || !matchesLacode(name)) continue;
|
|
6220
|
+
const options = Array.isArray(entry) && isPlainObject(entry[1]) ? entry[1] : void 0;
|
|
6221
|
+
return { index, name, options };
|
|
6222
|
+
}
|
|
6223
|
+
return void 0;
|
|
6224
|
+
}
|
|
6225
|
+
function readModeOptions(config = readOpencodeConfig()) {
|
|
6226
|
+
const entry = findLacodePlugin(config);
|
|
6227
|
+
const options = entry?.options;
|
|
6228
|
+
const yolo = typeof options?.yolo === "boolean" ? options.yolo : void 0;
|
|
6229
|
+
const ykb = typeof options?.ykb === "boolean" ? options.ykb : void 0;
|
|
6230
|
+
return { yolo, ykb };
|
|
6231
|
+
}
|
|
6232
|
+
|
|
6233
|
+
// src/lib/modes.ts
|
|
6175
6234
|
var YKB_INSTRUCTION = "YKB MODE (You Know Better) is ON. Do not ask the user questions, request clarification, or wait for confirmation. When a decision is needed, choose the option you would recommend, state the choice briefly, and continue until the task is complete. Stop only if you are genuinely blocked by a missing credential, an unrecoverable error, or a permission that cannot be granted.";
|
|
6176
|
-
var bool = (value, fallback) => typeof value === "boolean" ? value : fallback;
|
|
6177
6235
|
function readModes(defaults = {}) {
|
|
6178
|
-
const fallback = { yolo: defaults.yolo === true, ykb: defaults.ykb === true };
|
|
6179
6236
|
try {
|
|
6180
|
-
const
|
|
6181
|
-
return {
|
|
6237
|
+
const options = readModeOptions();
|
|
6238
|
+
return {
|
|
6239
|
+
yolo: options.yolo ?? defaults.yolo === true,
|
|
6240
|
+
ykb: options.ykb ?? defaults.ykb === true
|
|
6241
|
+
};
|
|
6182
6242
|
} catch {
|
|
6183
|
-
return
|
|
6243
|
+
return { yolo: defaults.yolo === true, ykb: defaults.ykb === true };
|
|
6184
6244
|
}
|
|
6185
6245
|
}
|
|
6186
|
-
function writeModes(modes) {
|
|
6187
|
-
const path4 = MODES_FILE;
|
|
6188
|
-
mkdirSync9(dirname6(path4), { recursive: true });
|
|
6189
|
-
const tmp = `${path4}.tmp`;
|
|
6190
|
-
writeFileSync10(tmp, `${JSON.stringify({ yolo: modes.yolo, ykb: modes.ykb }, null, 2)}
|
|
6191
|
-
`);
|
|
6192
|
-
renameSync7(tmp, path4);
|
|
6193
|
-
}
|
|
6194
|
-
function seedModes(defaults) {
|
|
6195
|
-
if (existsSync5(MODES_FILE)) return;
|
|
6196
|
-
if (defaults.yolo !== true && defaults.ykb !== true) return;
|
|
6197
|
-
writeModes({ yolo: defaults.yolo === true, ykb: defaults.ykb === true });
|
|
6198
|
-
}
|
|
6199
6246
|
function recommendedAnswer(question) {
|
|
6200
6247
|
const q = question;
|
|
6201
6248
|
const options = (Array.isArray(q?.options) ? q.options : []).filter(
|
|
@@ -6214,7 +6261,6 @@ async function modesHooks(input, options) {
|
|
|
6214
6261
|
const defaults = {};
|
|
6215
6262
|
if (typeof opts.yolo === "boolean") defaults.yolo = opts.yolo;
|
|
6216
6263
|
if (typeof opts.ykb === "boolean") defaults.ykb = opts.ykb;
|
|
6217
|
-
seedModes(defaults);
|
|
6218
6264
|
const state2 = () => readModes(defaults);
|
|
6219
6265
|
const warn2 = (message) => {
|
|
6220
6266
|
input.client.app.log({ body: { service: "modes", level: "warn", message } }).catch(() => {
|
package/dist/tui.js
CHANGED
|
@@ -943,28 +943,129 @@ var plugin = {
|
|
|
943
943
|
};
|
|
944
944
|
var timeline_default = plugin;
|
|
945
945
|
|
|
946
|
-
// src/lib/
|
|
946
|
+
// src/lib/opencode-config.ts
|
|
947
947
|
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
|
|
948
948
|
import { homedir as homedir3 } from "node:os";
|
|
949
949
|
import { dirname, join as join4 } from "node:path";
|
|
950
|
-
|
|
951
|
-
|
|
950
|
+
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
|
|
951
|
+
function globalOpencodeConfigPath() {
|
|
952
|
+
const base = process.env.XDG_CONFIG_HOME ?? join4(homedir3(), ".config");
|
|
953
|
+
const jsonc = join4(base, "opencode", "opencode.jsonc");
|
|
954
|
+
return existsSync2(jsonc) ? jsonc : join4(base, "opencode", "opencode.json");
|
|
955
|
+
}
|
|
956
|
+
var OpenCodeConfigError = class extends Error {
|
|
957
|
+
constructor(message) {
|
|
958
|
+
super(message);
|
|
959
|
+
this.name = "OpenCodeConfigError";
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
function isPlainObject(value) {
|
|
963
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
964
|
+
}
|
|
965
|
+
function readOpencodeConfig(path = globalOpencodeConfigPath()) {
|
|
966
|
+
if (!existsSync2(path)) return {};
|
|
967
|
+
const text = readFileSync3(path, "utf8");
|
|
968
|
+
const errors = [];
|
|
969
|
+
const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
|
|
970
|
+
if (errors.length > 0) {
|
|
971
|
+
const detail = errors.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`).join(", ");
|
|
972
|
+
throw new OpenCodeConfigError(`Failed to parse ${path}: ${detail}`);
|
|
973
|
+
}
|
|
974
|
+
if (!isPlainObject(parsed)) {
|
|
975
|
+
throw new OpenCodeConfigError(`Config root must be an object: ${path}`);
|
|
976
|
+
}
|
|
977
|
+
return parsed;
|
|
978
|
+
}
|
|
979
|
+
function pluginName(entry) {
|
|
980
|
+
if (typeof entry === "string") return entry;
|
|
981
|
+
if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
|
|
982
|
+
return void 0;
|
|
983
|
+
}
|
|
984
|
+
function matchesLacode(name) {
|
|
985
|
+
if (name === "@lawaty/lacode") return true;
|
|
986
|
+
const segments = name.split(/[\\/]/);
|
|
987
|
+
return segments.some((s) => s.toLowerCase() === "lacode");
|
|
988
|
+
}
|
|
989
|
+
function findLacodePlugin(config) {
|
|
990
|
+
const plugins = config.plugin;
|
|
991
|
+
if (!Array.isArray(plugins)) return void 0;
|
|
992
|
+
for (let index = 0; index < plugins.length; index++) {
|
|
993
|
+
const entry = plugins[index];
|
|
994
|
+
const name = pluginName(entry);
|
|
995
|
+
if (!name || !matchesLacode(name)) continue;
|
|
996
|
+
const options = Array.isArray(entry) && isPlainObject(entry[1]) ? entry[1] : void 0;
|
|
997
|
+
return { index, name, options };
|
|
998
|
+
}
|
|
999
|
+
return void 0;
|
|
1000
|
+
}
|
|
1001
|
+
function readModeOptions(config = readOpencodeConfig()) {
|
|
1002
|
+
const entry = findLacodePlugin(config);
|
|
1003
|
+
const options = entry?.options;
|
|
1004
|
+
const yolo = typeof options?.yolo === "boolean" ? options.yolo : void 0;
|
|
1005
|
+
const ykb = typeof options?.ykb === "boolean" ? options.ykb : void 0;
|
|
1006
|
+
return { yolo, ykb };
|
|
1007
|
+
}
|
|
1008
|
+
function writeModeOptions(patch, path = globalOpencodeConfigPath()) {
|
|
1009
|
+
if (!existsSync2(path)) {
|
|
1010
|
+
throw new OpenCodeConfigError(`Cannot write mode options: config does not exist: ${path}`);
|
|
1011
|
+
}
|
|
1012
|
+
let text = readFileSync3(path, "utf8");
|
|
1013
|
+
const errors = [];
|
|
1014
|
+
const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
|
|
1015
|
+
if (errors.length > 0) {
|
|
1016
|
+
const detail = errors.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`).join(", ");
|
|
1017
|
+
throw new OpenCodeConfigError(`Failed to parse ${path}: ${detail}`);
|
|
1018
|
+
}
|
|
1019
|
+
if (!isPlainObject(parsed)) {
|
|
1020
|
+
throw new OpenCodeConfigError(`Config root must be an object: ${path}`);
|
|
1021
|
+
}
|
|
1022
|
+
const entry = findLacodePlugin(parsed);
|
|
1023
|
+
if (!entry) {
|
|
1024
|
+
throw new OpenCodeConfigError(`lacode plugin entry not found in ${path}`);
|
|
1025
|
+
}
|
|
1026
|
+
const formattingOptions = { insertSpaces: true, tabSize: 2, eol: "\n" };
|
|
1027
|
+
const pluginEntry = parsed.plugin[entry.index];
|
|
1028
|
+
if (typeof pluginEntry === "string") {
|
|
1029
|
+
const obj = {};
|
|
1030
|
+
if (patch.yolo !== void 0) obj.yolo = patch.yolo;
|
|
1031
|
+
if (patch.ykb !== void 0) obj.ykb = patch.ykb;
|
|
1032
|
+
const edits = modify(text, ["plugin", entry.index], [entry.name, obj], { formattingOptions });
|
|
1033
|
+
text = applyEdits(text, edits);
|
|
1034
|
+
} else if (Array.isArray(pluginEntry) && !isPlainObject(pluginEntry[1])) {
|
|
1035
|
+
const obj = {};
|
|
1036
|
+
if (patch.yolo !== void 0) obj.yolo = patch.yolo;
|
|
1037
|
+
if (patch.ykb !== void 0) obj.ykb = patch.ykb;
|
|
1038
|
+
const edits = modify(text, ["plugin", entry.index, 1], obj, { formattingOptions });
|
|
1039
|
+
text = applyEdits(text, edits);
|
|
1040
|
+
} else {
|
|
1041
|
+
const keys = ["yolo", "ykb"];
|
|
1042
|
+
for (const key of keys) {
|
|
1043
|
+
const value = patch[key];
|
|
1044
|
+
if (value === void 0) continue;
|
|
1045
|
+
const edits = modify(text, ["plugin", entry.index, 1, key], value, { formattingOptions });
|
|
1046
|
+
text = applyEdits(text, edits);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1050
|
+
const tmp = `${path}.tmp`;
|
|
1051
|
+
writeFileSync(tmp, text);
|
|
1052
|
+
renameSync(tmp, path);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// src/lib/modes.ts
|
|
952
1056
|
function readModes(defaults = {}) {
|
|
953
|
-
const fallback = { yolo: defaults.yolo === true, ykb: defaults.ykb === true };
|
|
954
1057
|
try {
|
|
955
|
-
const
|
|
956
|
-
return {
|
|
1058
|
+
const options = readModeOptions();
|
|
1059
|
+
return {
|
|
1060
|
+
yolo: options.yolo ?? defaults.yolo === true,
|
|
1061
|
+
ykb: options.ykb ?? defaults.ykb === true
|
|
1062
|
+
};
|
|
957
1063
|
} catch {
|
|
958
|
-
return
|
|
1064
|
+
return { yolo: defaults.yolo === true, ykb: defaults.ykb === true };
|
|
959
1065
|
}
|
|
960
1066
|
}
|
|
961
1067
|
function writeModes(modes) {
|
|
962
|
-
|
|
963
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
964
|
-
const tmp = `${path}.tmp`;
|
|
965
|
-
writeFileSync(tmp, `${JSON.stringify({ yolo: modes.yolo, ykb: modes.ykb }, null, 2)}
|
|
966
|
-
`);
|
|
967
|
-
renameSync(tmp, path);
|
|
1068
|
+
writeModeOptions({ yolo: modes.yolo, ykb: modes.ykb });
|
|
968
1069
|
}
|
|
969
1070
|
function toggleMode(name, defaults = {}) {
|
|
970
1071
|
const current = readModes(defaults);
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -52,7 +52,7 @@ restart.
|
|
|
52
52
|
|
|
53
53
|
## Modes
|
|
54
54
|
|
|
55
|
-
Two runtime modes
|
|
55
|
+
Two runtime modes live on the lacode plugin entry and can be toggled from the TUI:
|
|
56
56
|
|
|
57
57
|
| Command | Effect |
|
|
58
58
|
| --- | --- |
|
|
@@ -60,7 +60,15 @@ Two runtime modes can be seeded from plugin options and toggled from the TUI:
|
|
|
60
60
|
| `/ykb` | Toggle YKB (You Know Better). The agent is told not to ask questions and to continue with its recommendation. A question that still appears is answered with its `(Recommended)` option, or its first option when none is marked. |
|
|
61
61
|
| `/modes` | Show both switches. |
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
```jsonc
|
|
64
|
+
{
|
|
65
|
+
"plugin": [
|
|
66
|
+
["@lawaty/lacode", { "yolo": true, "ykb": false }]
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The toggles edit the `yolo` / `ykb` options on the lacode plugin entry in your global opencode config (`~/.config/opencode/opencode.jsonc`, or `$XDG_CONFIG_HOME/opencode/opencode.jsonc`), preserving comments. The server reads that file on the next decision — no restart. Turning a mode on also answers requests that are already pending. The lacode plugin entry must already exist in the global config; a project-only entry cannot be toggled. A free-text question with no options is left for the user, since there is no option to choose.
|
|
64
72
|
|
|
65
73
|
|
|
66
74
|
## Which models run which agent
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -194,7 +194,9 @@ A refresh once replaced a healthy ten-slot pool with a two-slot scan because not
|
|
|
194
194
|
**Story ID(s) for tests:** US-40
|
|
195
195
|
**Acceptance criteria:**
|
|
196
196
|
- [ ] Given a healthy pool and a scan that returns fewer slots than half of it (minimum four), When the refresh finishes, Then the previous pool file is left untouched, the refusal is logged, and a recheck is scheduled in 5 minutes.
|
|
197
|
-
- [ ] Given
|
|
197
|
+
- [ ] Given a scan that drops the opencode-go head or the opencode tail the current pool has, When the refresh finishes, Then the previous pool is kept even at full count, because the structure is the contract.
|
|
198
|
+
- [ ] Given opencode's serving path reports a hard outage on the live status and the current pool is structurally intact, When the refresh runs, Then no scan is started, the pool is kept, and a recheck is scheduled in 5 minutes.
|
|
199
|
+
- [ ] Given the status reports degraded (latency, elevated errors) rather than down, When the refresh runs, Then the scan proceeds as normal, because models routinely answer while the page says degraded.
|
|
198
200
|
- [ ] Given any failed run with a stated reset hours out, When the retry is scheduled, Then the recheck still happens within 5 minutes; a sooner provider reset is honoured.
|
|
199
201
|
- [ ] Given the degradation lifts, When the next 5-minute recheck runs, Then a full scan restores the pool instead of waiting for the next 6-hour slot.
|
|
200
202
|
- [ ] Given a thin or missing pool, When a fuller scan completes, Then it is always written — the guard only ever blocks shrinking, never recovery.
|
|
@@ -569,15 +571,15 @@ A failed summarization used to clear the pending flag and never reschedule, so o
|
|
|
569
571
|
### US-37: YOLO mode approves permissions without stopping
|
|
570
572
|
**As a** user running a long autonomous task, **I want** permission prompts to be approved automatically while YOLO is on, **so that** the agent keeps working instead of waiting for routine approvals, while an explicit `deny` still protects me.
|
|
571
573
|
|
|
572
|
-
Permission prompts are the main thing that breaks a long autonomous run: every `ask` blocks the session until a human answers. YOLO flips that decision to `allow` at the permission hook, keeps an explicit `deny` authoritative, and answers any request that still reaches the event bus. The state
|
|
574
|
+
Permission prompts are the main thing that breaks a long autonomous run: every `ask` blocks the session until a human answers. YOLO flips that decision to `allow` at the permission hook, keeps an explicit `deny` authoritative, and answers any request that still reaches the event bus. The state lives in the `yolo` option on the lacode plugin entry in the global `opencode.jsonc`; the TUI writes it and the server reads it on the next decision, so enabling it also clears what is already pending.
|
|
573
575
|
|
|
574
576
|
**Story ID(s) for tests:** US-37
|
|
575
577
|
**Acceptance criteria:**
|
|
576
578
|
- [ ] Given YOLO is on, When the runtime's permission decision is `ask`, Then it becomes `allow` without a prompt, while an existing `deny` or `allow` is left unchanged.
|
|
577
579
|
- [ ] Given YOLO is off, When the runtime's permission decision is `ask`, Then it stays `ask` and still reaches the user.
|
|
578
580
|
- [ ] Given a `permission.asked` event still reaches the event bus while YOLO is on and carries a request id and a session id, When the handler runs, Then the plugin replies `once` for that request; an event missing either id is ignored.
|
|
579
|
-
- [ ] Given YOLO is toggled in the TUI, When the
|
|
580
|
-
- [ ] Given
|
|
581
|
+
- [ ] Given YOLO is toggled in the TUI, When the global `opencode.jsonc` changes, Then the next permission decision sees the new value without a restart, and requests already pending are answered.
|
|
582
|
+
- [ ] Given the TUI toggles a mode, When it writes the global `opencode.jsonc`, Then only the `yolo` / `ykb` options on the lacode plugin entry change, comments and formatting are preserved, and the config remains the single source of truth.
|
|
581
583
|
- [ ] Given the README and configuration reference, Then the `yolo` option and the `/yolo` command are documented alongside `/modes`.
|
|
582
584
|
|
|
583
585
|
### US-38: YKB mode chooses the recommendation instead of asking
|
|
@@ -654,6 +656,6 @@ The root-level suites under `tests/` are unit-style; the `tests/stories/` suites
|
|
|
654
656
|
the behavioural layer, written one epic per file with every test name carrying
|
|
655
657
|
its story id. Run everything with `npm test`.
|
|
656
658
|
|
|
657
|
-
40/40 stories covered by
|
|
659
|
+
40/40 stories covered by 377 test case(s); thinnest story has 1.
|
|
658
660
|
No story is untested. The scan covers the `tests/stories/` suites and the root-level
|
|
659
661
|
`tests/*-test.mjs` suites.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lawaty/lacode",
|
|
3
|
-
"version": "2.3.0-beta.
|
|
3
|
+
"version": "2.3.0-beta.4",
|
|
4
4
|
"description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@opencode-ai/plugin": "1.18.34",
|
|
71
|
-
"solid
|
|
72
|
-
"
|
|
71
|
+
"@opentui/solid": "*",
|
|
72
|
+
"solid-js": "*"
|
|
73
73
|
},
|
|
74
74
|
"peerDependenciesMeta": {
|
|
75
75
|
"solid-js": {
|
|
@@ -85,5 +85,8 @@
|
|
|
85
85
|
},
|
|
86
86
|
"publishConfig": {
|
|
87
87
|
"tag": "latest"
|
|
88
|
+
},
|
|
89
|
+
"dependencies": {
|
|
90
|
+
"jsonc-parser": "^3.3.1"
|
|
88
91
|
}
|
|
89
92
|
}
|
package/scripts/refresh-pool.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { homedir, userInfo } from "node:os"
|
|
|
24
24
|
import { dirname, join, resolve } from "node:path"
|
|
25
25
|
import { fileURLToPath } from "node:url"
|
|
26
26
|
import { fetchProviderStatus } from "./provider-status.mjs"
|
|
27
|
+
import { POOL_SIZE } from "./pool-selection.mjs"
|
|
27
28
|
|
|
28
29
|
const HOME = homedir()
|
|
29
30
|
export const DEFAULT_SCORES_PATH = join(HOME, ".config", "lacode", "model-scores.json")
|
|
@@ -209,23 +210,83 @@ export function poolSlotCount(text) {
|
|
|
209
210
|
return splitHeaderAndSlots(text).slots.filter(isSlotLine).length
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
/** The pool's structural contract: an opencode-go head and an opencode tail. */
|
|
214
|
+
export function poolHasHead(text) {
|
|
215
|
+
const slots = splitHeaderAndSlots(text).slots.filter(isSlotLine)
|
|
216
|
+
return slots.length > 0 && slots[0].split("#")[0].trim().startsWith("opencode-go/")
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function poolHasTail(text) {
|
|
220
|
+
const slots = splitHeaderAndSlots(text).slots.filter(isSlotLine)
|
|
221
|
+
if (slots.length === 0) return false
|
|
222
|
+
const last = slots[slots.length - 1].split("#")[0].trim()
|
|
223
|
+
return last.startsWith("opencode/") && /(^|\s)b:opencode(\s|$)/.test(last)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Pure scan-skip decision. Only a hard `down` on a gating opencode service may
|
|
228
|
+
* skip a scan — `degraded` never does, because models routinely answer while
|
|
229
|
+
* the page says degraded. A structurally broken pool is never skipped either:
|
|
230
|
+
* the scan is its repair path.
|
|
231
|
+
*/
|
|
232
|
+
export function shouldSkipScan({ providerStatus, currentPoolText }) {
|
|
233
|
+
if (!providerStatus?.ok || providerStatus.stale) {
|
|
234
|
+
return { skip: false, reason: "no trustworthy live signal; scanning" }
|
|
235
|
+
}
|
|
236
|
+
const down = Array.isArray(providerStatus.down) ? providerStatus.down : []
|
|
237
|
+
if (down.length === 0) {
|
|
238
|
+
return { skip: false, reason: "no hard outage on the live status; scanning" }
|
|
239
|
+
}
|
|
240
|
+
if (!poolHasHead(currentPoolText) || !poolHasTail(currentPoolText)) {
|
|
241
|
+
return { skip: false, reason: "pool structure already broken; scanning to repair it" }
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
skip: true,
|
|
245
|
+
reason: `opencode status reports down: ${down.join(", ")}; keeping the current pool and rechecking in ${DEGRADED_RETRY_S / 60} minutes`,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
212
249
|
/**
|
|
213
|
-
* Pure fail-closed guard. A refresh must never
|
|
214
|
-
* degenerate selection:
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
250
|
+
* Pure fail-closed guard. A refresh must never break the pool's structure or
|
|
251
|
+
* replace a working pool with a degenerate selection: the new pool must keep
|
|
252
|
+
* the opencode-go head and the opencode tail the current pool has, and hold at
|
|
253
|
+
* least half of POOL_SIZE. The baseline is the contract size, never the
|
|
254
|
+
* current file — a previously shrunk pool can always grow back, but no write
|
|
255
|
+
* may shrink or behead it. There is no previous pool on a first-ever run, so
|
|
256
|
+
* there is nothing to protect and the guard passes. `--force-write` is the
|
|
257
|
+
* human override for a genuinely changed catalogue.
|
|
218
258
|
*/
|
|
219
|
-
export function shouldKeepPool({ currentPoolText,
|
|
259
|
+
export function shouldKeepPool({ currentPoolText, newSlots }) {
|
|
220
260
|
const prev = poolSlotCount(currentPoolText ?? "")
|
|
221
261
|
if (prev <= 0) return { keep: false, prev, threshold: 0, reason: "no previous pool to protect" }
|
|
222
|
-
const
|
|
223
|
-
|
|
262
|
+
const slots = Array.isArray(newSlots) ? newSlots : []
|
|
263
|
+
const threshold = Math.max(MIN_POOL_SLOTS, Math.floor(POOL_SIZE / 2))
|
|
264
|
+
if (poolHasHead(currentPoolText) && !slots.some((line) => String(line).split("#")[0].trim().startsWith("opencode-go/"))) {
|
|
265
|
+
return {
|
|
266
|
+
keep: true,
|
|
267
|
+
prev,
|
|
268
|
+
threshold,
|
|
269
|
+
reason: `new selection has no opencode-go head slot while the previous pool has one; refusing to behead the pool`,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const tailKept = slots.some((line) => {
|
|
273
|
+
const stripped = String(line).split("#")[0].trim()
|
|
274
|
+
return stripped.startsWith("opencode/") && /(^|\s)b:opencode(\s|$)/.test(stripped)
|
|
275
|
+
})
|
|
276
|
+
if (poolHasTail(currentPoolText) && !tailKept) {
|
|
277
|
+
return {
|
|
278
|
+
keep: true,
|
|
279
|
+
prev,
|
|
280
|
+
threshold,
|
|
281
|
+
reason: `new selection has no opencode tail slot while the previous pool has one; refusing to dock the tail`,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (slots.length >= threshold) return { keep: false, prev, threshold, reason: "" }
|
|
224
285
|
return {
|
|
225
286
|
keep: true,
|
|
226
287
|
prev,
|
|
227
288
|
threshold,
|
|
228
|
-
reason: `new selection has ${
|
|
289
|
+
reason: `new selection has ${slots.length} usable slot(s), below the keep threshold of ${threshold} (contract pool size ${POOL_SIZE}); refusing to shrink the pool`,
|
|
229
290
|
}
|
|
230
291
|
}
|
|
231
292
|
|
|
@@ -383,15 +444,19 @@ function usage() {
|
|
|
383
444
|
--max N models per provider passed to the scorer (default: 50)
|
|
384
445
|
--scores PATH scores file (default: ${DEFAULT_SCORES_PATH})
|
|
385
446
|
--pool PATH pool file to read/compare/write (default: ${DEFAULT_POOL_PATH})
|
|
447
|
+
--force-write write the new selection even when the fail-closed guard would
|
|
448
|
+
keep the previous pool (human override for a changed catalogue)
|
|
386
449
|
-h, --help this message
|
|
387
450
|
|
|
388
451
|
Degradation behaviour: before scanning, the refresh checks
|
|
389
|
-
https://status.opencode.ai/api/status.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
the
|
|
393
|
-
|
|
394
|
-
|
|
452
|
+
https://status.opencode.ai/api/status. Only a hard outage (a gating service
|
|
453
|
+
reporting down) with a structurally intact pool skips the scan; a mere degraded
|
|
454
|
+
never does, because models routinely answer while the page says degraded. After
|
|
455
|
+
a scan, a selection that beheads the pool (no opencode-go head), docks the tail
|
|
456
|
+
(no opencode slot), or falls below half the contract pool size (minimum ${MIN_POOL_SLOTS}
|
|
457
|
+
slots) is never written; the previous pool is kept and a 5-minute recheck is
|
|
458
|
+
scheduled. A retry is always rechecked within 5 minutes while anything failed,
|
|
459
|
+
even when the stated reset is hours out.`)
|
|
395
460
|
}
|
|
396
461
|
|
|
397
462
|
async function main() {
|
|
@@ -404,6 +469,7 @@ async function main() {
|
|
|
404
469
|
const fromScores = argv.includes("--from-scores")
|
|
405
470
|
const printOnly = argv.includes("--print") || argv.includes("--dry-run")
|
|
406
471
|
const noRetry = argv.includes("--no-retry")
|
|
472
|
+
const forceWrite = argv.includes("--force-write")
|
|
407
473
|
const value = (flag, fallback) => {
|
|
408
474
|
const hit = argv.find((a) => a === flag || a.startsWith(`${flag}=`))
|
|
409
475
|
if (!hit) return fallback
|
|
@@ -423,23 +489,21 @@ async function main() {
|
|
|
423
489
|
|
|
424
490
|
const currentPoolText = readTextFile(poolPath)
|
|
425
491
|
|
|
426
|
-
// Live degradation signal.
|
|
427
|
-
//
|
|
428
|
-
// pool
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
let providerStatus = { ok: false, providers: [], services: [] }
|
|
492
|
+
// Live degradation signal. Only a hard `down` on opencode's serving path may
|
|
493
|
+
// skip the scan — and only when the current pool is structurally intact, so
|
|
494
|
+
// a broken pool always gets its repair scan. `degraded` never skips: models
|
|
495
|
+
// routinely answer while the page says degraded, and the write guard below
|
|
496
|
+
// protects the pool if the scan comes back degenerate.
|
|
497
|
+
let providerStatus = { ok: false, providers: [], services: [], down: [] }
|
|
432
498
|
if (!printOnly) {
|
|
433
499
|
try {
|
|
434
500
|
providerStatus = await fetchProviderStatus()
|
|
435
501
|
} catch (error) {
|
|
436
|
-
providerStatus = { ok: false, providers: [], services: [], reason: String(error?.message ?? error) }
|
|
502
|
+
providerStatus = { ok: false, providers: [], services: [], down: [], reason: String(error?.message ?? error) }
|
|
437
503
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
console.log(
|
|
441
|
-
`opencode status degraded (${detail}); keeping the current pool of ${poolSlotCount(currentPoolText)} slots and rechecking in ${DEGRADED_RETRY_S / 60} minutes`,
|
|
442
|
-
)
|
|
504
|
+
const skip = shouldSkipScan({ providerStatus, currentPoolText })
|
|
505
|
+
if (skip.skip) {
|
|
506
|
+
console.log(`${skip.reason}; keeping the current pool of ${poolSlotCount(currentPoolText)} slots`)
|
|
443
507
|
if (!noRetry) {
|
|
444
508
|
const scheduled = scheduleRetrySoon()
|
|
445
509
|
console.log(
|
|
@@ -457,6 +521,14 @@ async function main() {
|
|
|
457
521
|
runScorer(max)
|
|
458
522
|
} catch (error) {
|
|
459
523
|
console.error(`scorer failed; pool.txt not written: ${String(error?.message ?? error)}`)
|
|
524
|
+
if (!printOnly && !noRetry) {
|
|
525
|
+
const scheduled = scheduleRetrySoon()
|
|
526
|
+
console.log(
|
|
527
|
+
scheduled.scheduled
|
|
528
|
+
? `scheduled degraded recheck in ${scheduled.seconds}s`
|
|
529
|
+
: `degraded recheck not scheduled: ${scheduled.reason}`,
|
|
530
|
+
)
|
|
531
|
+
}
|
|
460
532
|
process.exitCode = 1
|
|
461
533
|
return
|
|
462
534
|
}
|
|
@@ -476,6 +548,14 @@ async function main() {
|
|
|
476
548
|
built = buildPool({ scores, currentPoolText, now: new Date() })
|
|
477
549
|
} catch (error) {
|
|
478
550
|
console.error(`refresh refused (fail closed): ${String(error?.message ?? error)}`)
|
|
551
|
+
if (!printOnly && !noRetry) {
|
|
552
|
+
const scheduled = scheduleRetrySoon()
|
|
553
|
+
console.log(
|
|
554
|
+
scheduled.scheduled
|
|
555
|
+
? `scheduled degraded recheck in ${scheduled.seconds}s`
|
|
556
|
+
: `degraded recheck not scheduled: ${scheduled.reason}`,
|
|
557
|
+
)
|
|
558
|
+
}
|
|
479
559
|
process.exitCode = 1
|
|
480
560
|
return
|
|
481
561
|
}
|
|
@@ -491,8 +571,9 @@ async function main() {
|
|
|
491
571
|
|
|
492
572
|
// Fail closed on a degenerate selection: keep the previous pool, say so
|
|
493
573
|
// loudly, and recheck in 5 minutes instead of waiting out a long reset.
|
|
494
|
-
|
|
495
|
-
|
|
574
|
+
// --force-write is the human override for a genuinely changed catalogue.
|
|
575
|
+
if (!printOnly && !forceWrite) {
|
|
576
|
+
const kept = shouldKeepPool({ currentPoolText, newSlots: built.slots })
|
|
496
577
|
if (kept.keep) {
|
|
497
578
|
console.error(`refresh refused (fail closed): ${kept.reason}`)
|
|
498
579
|
if (!noRetry) {
|
package/scripts/score-models.mjs
CHANGED
|
@@ -104,7 +104,7 @@ const value = (flag, fallback) => {
|
|
|
104
104
|
--min-context N exclude models whose context limit is below N (default: ${MIN_CONTEXT})
|
|
105
105
|
--min-score N do not rank models scoring below N (default: ${MIN_SCORE})
|
|
106
106
|
--free-only only probe catalogue cost 0/0 models on every provider
|
|
107
|
-
--timeout MS per-request deadline in ms (default:
|
|
107
|
+
--timeout MS per-request deadline in ms (default: 12000)
|
|
108
108
|
--delay MS pause between requests in ms (default: 300)
|
|
109
109
|
--budget-min N stop starting new probes after N minutes and write what was
|
|
110
110
|
measured; providers not scanned keep their previous rows
|
|
@@ -129,7 +129,7 @@ const providers = providerArg ? String(providerArg).split(",").map((s) => s.trim
|
|
|
129
129
|
const maxPerProvider = Math.max(1, Number(value("--max", "50")) || 50)
|
|
130
130
|
const minContext = Math.max(0, Number(value("--min-context", String(MIN_CONTEXT))) || MIN_CONTEXT)
|
|
131
131
|
const minScore = Math.max(0, Number(value("--min-score", String(MIN_SCORE))) || MIN_SCORE)
|
|
132
|
-
const timeoutMs = Math.max(1_000, Number(value("--timeout", "
|
|
132
|
+
const timeoutMs = Math.max(1_000, Number(value("--timeout", "12000")) || 12_000)
|
|
133
133
|
const delayMs = Math.max(0, Number(value("--delay", "300")) || 300)
|
|
134
134
|
const budgetMin = Math.max(1, Number(value("--budget-min", "30")) || 30)
|
|
135
135
|
const budgetMs = budgetMin * 60_000
|