@lawaty/lacode 0.1.5 → 0.1.7
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/README.md +13 -0
- package/dist/agents.js +80 -22
- package/dist/index.js +338 -46
- package/dist/lacode.js +206 -4
- package/dist/pool.js +42 -35
- package/docs/USER-STORIES.md +20 -0
- package/package.json +1 -1
- package/presets/README.md +2 -2
- package/presets/free-tier.json +4 -4
package/README.md
CHANGED
|
@@ -283,6 +283,19 @@ option is optional:
|
|
|
283
283
|
| `models` | — | An inline slot list. Highest-priority source. |
|
|
284
284
|
| `modelsFile` | — | An explicit slot file; short-circuits the chain below. |
|
|
285
285
|
| `reapMs` | `30000` | Hang-reaper interval. |
|
|
286
|
+
| `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. |
|
|
287
|
+
|
|
288
|
+
### Staying up to date
|
|
289
|
+
|
|
290
|
+
opencode's plugin cache is **write-once**: it resolves `@lawaty/lacode` to an
|
|
291
|
+
exact version once and never re-resolves — not on restart, not with
|
|
292
|
+
`opencode plugin -g -f`, not with a range pin, not with a tagged config spec. So
|
|
293
|
+
LaCode keeps itself current instead (US-27): at startup it compares its own
|
|
294
|
+
version to npm's `latest` (at most once per ~6h, silent when offline) and, when
|
|
295
|
+
behind, reinstalls its own cache entry in the background using the opencode
|
|
296
|
+
binary — no npm or bun required. The running session keeps its loaded code; the
|
|
297
|
+
new version takes effect on the next start, and the log says so. Run
|
|
298
|
+
`lacode update` to do it on demand, or `lacode update --check` to just report.
|
|
286
299
|
|
|
287
300
|
### Pool preset resolution
|
|
288
301
|
|
package/dist/agents.js
CHANGED
|
@@ -1,25 +1,18 @@
|
|
|
1
1
|
// src/lib/pool.ts
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { join as join2 } from "node:path";
|
|
5
|
+
|
|
6
|
+
// src/lib/package-root.ts
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
4
8
|
import { dirname, join } from "node:path";
|
|
5
9
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
var
|
|
7
|
-
var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
|
|
8
|
-
var ORPHAN_TMP_MS = 60 * 1e3;
|
|
9
|
-
var CATALOG_TTL_MS = 10 * 60 * 1e3;
|
|
10
|
-
var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
|
|
11
|
-
var STUCK_IDLE_MS = 3 * 60 * 1e3;
|
|
12
|
-
var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
13
|
-
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
14
|
-
var MAX_SLOTS = 4;
|
|
15
|
-
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
16
|
-
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
17
|
-
var STRIKE_DECAY_MS = 30 * 60 * 1e3;
|
|
10
|
+
var PACKAGE_NAME = "@lawaty/lacode";
|
|
18
11
|
var findPackageRoot = (start) => {
|
|
19
12
|
let dir = start;
|
|
20
13
|
for (let up = 0; up < 6; up++) {
|
|
21
14
|
try {
|
|
22
|
-
if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name ===
|
|
15
|
+
if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === PACKAGE_NAME) return dir;
|
|
23
16
|
} catch {
|
|
24
17
|
}
|
|
25
18
|
const parent = dirname(dir);
|
|
@@ -29,11 +22,25 @@ var findPackageRoot = (start) => {
|
|
|
29
22
|
return join(start, "..", "..");
|
|
30
23
|
};
|
|
31
24
|
var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
var
|
|
35
|
-
var
|
|
36
|
-
var
|
|
25
|
+
|
|
26
|
+
// src/lib/pool.ts
|
|
27
|
+
var CLAIM_TTL_MS = 10 * 60 * 1e3;
|
|
28
|
+
var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
|
|
29
|
+
var ORPHAN_TMP_MS = 60 * 1e3;
|
|
30
|
+
var CATALOG_TTL_MS = 10 * 60 * 1e3;
|
|
31
|
+
var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
|
|
32
|
+
var STUCK_IDLE_MS = 3 * 60 * 1e3;
|
|
33
|
+
var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
34
|
+
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
35
|
+
var MAX_SLOTS = 4;
|
|
36
|
+
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
37
|
+
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
38
|
+
var STRIKE_DECAY_MS = 30 * 60 * 1e3;
|
|
39
|
+
var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
|
|
40
|
+
var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
|
|
41
|
+
var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
|
|
42
|
+
var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
|
|
43
|
+
var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
|
|
37
44
|
|
|
38
45
|
// src/agents.ts
|
|
39
46
|
var POOLED_BASES = ["explore", "implement", "context"];
|
|
@@ -58,13 +65,45 @@ Every question of the form "what does this file do", "where is X handled", or
|
|
|
58
65
|
("read src/auth.ts", "grep for 'migration' in *.py", "list the src/
|
|
59
66
|
directory"), one batch per question. Ask for the exact lines or
|
|
60
67
|
file:line references you need, not a survey.
|
|
61
|
-
- **Implementation \u2192 @implement**,
|
|
62
|
-
steps, the files
|
|
63
|
-
|
|
68
|
+
- **Implementation \u2192 @implement**, one decided unit at a time: the exact
|
|
69
|
+
steps, the exact files, the design you have already chosen, and the command
|
|
70
|
+
that proves it. Never one whole refactor handed over in a single piece \u2014
|
|
71
|
+
see \`# Task size\` below.
|
|
64
72
|
|
|
65
73
|
The agents you spawn are the pooled \`explore\` / \`implement\` bases \u2014
|
|
66
74
|
spawn the bare name and let the pool route, never a \`-1\`/\`-2\` slot variant.
|
|
67
75
|
|
|
76
|
+
# Task size
|
|
77
|
+
|
|
78
|
+
\`@implement\` runs on a cheap pooled model. It can carry out a decision; it
|
|
79
|
+
cannot make one. So what you hand it is always a small ask \u2014 never a mid or
|
|
80
|
+
large task.
|
|
81
|
+
|
|
82
|
+
A small ask is one change, one concern, already decided by you. Before handing
|
|
83
|
+
it over you should have explored, through \`@explore\`, far enough to know what
|
|
84
|
+
the change is and to have chosen the design: which class or value object, which
|
|
85
|
+
members, what becomes a \`static\`, what gets deleted. \`@implement\` executes that
|
|
86
|
+
choice. It never invents one.
|
|
87
|
+
|
|
88
|
+
It does not have to arrive fully pre-digested. Letting it find the call sites,
|
|
89
|
+
read the code around them and work out which files it touches is normal and
|
|
90
|
+
expected \u2014 that is exploration an agent should be free to do. What it must never
|
|
91
|
+
be handed is a problem to solve.
|
|
92
|
+
|
|
93
|
+
Too small is fine and costs little. Too large is the failure: "convert the
|
|
94
|
+
decode modules into named units, decide which are dead, keep the output
|
|
95
|
+
byte-identical, verify conformance and report" is not a task, it is the whole
|
|
96
|
+
project. A cheap agent handed it will improvise a design, skip the verification
|
|
97
|
+
you never spelled out, and return something you cannot trust.
|
|
98
|
+
|
|
99
|
+
When the work does not divide into small asks it is not implementation work
|
|
100
|
+
yet \u2014 send it to \`@plan\`, or keep it yourself.
|
|
101
|
+
|
|
102
|
+
Split into one ask per task, spawn independent asks together in a single
|
|
103
|
+
message so they run in parallel, and sequence only what truly depends on what.
|
|
104
|
+
If a returned ask turns out to need a decision you did not make, you make the
|
|
105
|
+
call and hand out a corrected ask.
|
|
106
|
+
|
|
68
107
|
# What stays yours
|
|
69
108
|
|
|
70
109
|
\`edit\`, \`write\`, and \`bash\` are available to you. Use them to apply what a
|
|
@@ -275,6 +314,25 @@ write, and run code/bash to make the plan's changes real. Work from the plan
|
|
|
275
314
|
as given \u2014 if it's ambiguous or you believe it's wrong, say so explicitly
|
|
276
315
|
rather than silently improvising a different approach.
|
|
277
316
|
|
|
317
|
+
# Before you start
|
|
318
|
+
|
|
319
|
+
The ask you receive is a small one, and it is already decided: the change is
|
|
320
|
+
specified and the design is chosen. Work from it as given rather than silently
|
|
321
|
+
substituting an approach of your own.
|
|
322
|
+
|
|
323
|
+
Expect to do your own locating. Finding the call sites, reading what you touch
|
|
324
|
+
and working out which files are involved is normal work, not a sign of a bad
|
|
325
|
+
brief \u2014 do it without asking.
|
|
326
|
+
|
|
327
|
+
What is not yours to do is decide. If the ask asks you to choose an approach,
|
|
328
|
+
pick the right abstraction, settle a judgement call, or work out what the
|
|
329
|
+
requirement even is, stop and report that back instead of improvising. A
|
|
330
|
+
confident wrong design costs more than no change at all.
|
|
331
|
+
|
|
332
|
+
Size works the same way. A small ask that turns out to be bigger than it looked
|
|
333
|
+
is a signal to come back and say so: complete the part that is decided, and
|
|
334
|
+
report precisely what else needs deciding.
|
|
335
|
+
|
|
278
336
|
Make changes incrementally where possible, and verify your own work (run
|
|
279
337
|
tests/build/lint if available) before considering a step done.
|
|
280
338
|
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
// src/lib/pool.ts
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { join as join2 } from "node:path";
|
|
5
|
+
|
|
6
|
+
// src/lib/package-root.ts
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
4
8
|
import { dirname, join } from "node:path";
|
|
5
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
+
var PACKAGE_NAME = "@lawaty/lacode";
|
|
11
|
+
var findPackageRoot = (start) => {
|
|
12
|
+
let dir = start;
|
|
13
|
+
for (let up = 0; up < 6; up++) {
|
|
14
|
+
try {
|
|
15
|
+
if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === PACKAGE_NAME) return dir;
|
|
16
|
+
} catch {
|
|
17
|
+
}
|
|
18
|
+
const parent = dirname(dir);
|
|
19
|
+
if (parent === dir) break;
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
return join(start, "..", "..");
|
|
23
|
+
};
|
|
24
|
+
var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
|
|
25
|
+
|
|
26
|
+
// src/lib/pool.ts
|
|
6
27
|
var CLAIM_TTL_MS = 10 * 60 * 1e3;
|
|
7
28
|
var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
|
|
8
29
|
var ORPHAN_TMP_MS = 60 * 1e3;
|
|
@@ -17,30 +38,16 @@ var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
|
17
38
|
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
18
39
|
var STRIKE_DECAY_MS = 30 * 60 * 1e3;
|
|
19
40
|
var BASES = ["explore", "implement", "context"];
|
|
20
|
-
var
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
} catch {
|
|
26
|
-
}
|
|
27
|
-
const parent = dirname(dir);
|
|
28
|
-
if (parent === dir) break;
|
|
29
|
-
dir = parent;
|
|
30
|
-
}
|
|
31
|
-
return join(start, "..", "..");
|
|
32
|
-
};
|
|
33
|
-
var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
|
|
34
|
-
var PRESETS_DIR = join(PACKAGE_ROOT, "presets");
|
|
35
|
-
var DEFAULT_PRESET = join(PRESETS_DIR, "free-tier.json");
|
|
36
|
-
var USER_CONFIG_DIR = join(homedir(), ".config", "lacode");
|
|
37
|
-
var USER_MODELS_PATH = join(USER_CONFIG_DIR, "pool.json");
|
|
38
|
-
var USER_MODELS_TXT = join(USER_CONFIG_DIR, "pool.txt");
|
|
41
|
+
var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
|
|
42
|
+
var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
|
|
43
|
+
var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
|
|
44
|
+
var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
|
|
45
|
+
var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
|
|
39
46
|
var FALLBACK_SLOTS = [
|
|
40
47
|
{ index: 1, model: "opencode/space-bunny-free", weight: 3 },
|
|
41
|
-
{ index: 2, model: "
|
|
48
|
+
{ index: 2, model: "opencode-go/step-5-preview-free", weight: 2 },
|
|
42
49
|
{ index: 3, model: "opencode/big-pickle", weight: 1 },
|
|
43
|
-
{ index: 4, model: "
|
|
50
|
+
{ index: 4, model: "opencode/step-5-preview-free", weight: 1 }
|
|
44
51
|
];
|
|
45
52
|
function parseSlots(parsed, origin) {
|
|
46
53
|
const problems = [];
|
|
@@ -89,7 +96,7 @@ function parseSlotText(text, origin) {
|
|
|
89
96
|
}
|
|
90
97
|
var readJSON = (path4) => {
|
|
91
98
|
try {
|
|
92
|
-
return { ok: true, value: JSON.parse(
|
|
99
|
+
return { ok: true, value: JSON.parse(readFileSync2(path4, "utf8")) };
|
|
93
100
|
} catch (error) {
|
|
94
101
|
return { ok: false, error: String(error) };
|
|
95
102
|
}
|
|
@@ -105,7 +112,7 @@ function readOptional(path4, origin, problems) {
|
|
|
105
112
|
function readTextOptional(path4, origin, problems) {
|
|
106
113
|
if (!existsSync(path4)) return void 0;
|
|
107
114
|
try {
|
|
108
|
-
return
|
|
115
|
+
return readFileSync2(path4, "utf8");
|
|
109
116
|
} catch (error) {
|
|
110
117
|
problems.push(`${origin} unreadable (${String(error)}); trying the next source`);
|
|
111
118
|
return void 0;
|
|
@@ -147,7 +154,7 @@ function resolveModels(input = {}) {
|
|
|
147
154
|
function readSlotFile(path4) {
|
|
148
155
|
let text;
|
|
149
156
|
try {
|
|
150
|
-
text =
|
|
157
|
+
text = readFileSync2(path4, "utf8");
|
|
151
158
|
} catch (error) {
|
|
152
159
|
return { slots: FALLBACK_SLOTS, problems: [`${path4} unreadable (${String(error)}); using built-in defaults`] };
|
|
153
160
|
}
|
|
@@ -215,7 +222,7 @@ var retryAfterMs = (headers) => {
|
|
|
215
222
|
};
|
|
216
223
|
function state(opts = {}) {
|
|
217
224
|
const id = opts.id ?? String(process.pid);
|
|
218
|
-
const dir = opts.dir ??
|
|
225
|
+
const dir = opts.dir ?? join2(homedir(), ".local", "share", "opencode", "agent-pool");
|
|
219
226
|
const modelsFile = opts.modelsFile;
|
|
220
227
|
const models = opts.models;
|
|
221
228
|
const key = /* @__PURE__ */ Symbol.for(`agent-pool:state:${id}:${dir}:${modelsFile ?? ""}:${models === void 0 ? "" : "inline"}`);
|
|
@@ -264,7 +271,7 @@ function init(s, onWarn) {
|
|
|
264
271
|
mkdirSync(s.dir, { recursive: true });
|
|
265
272
|
s.dirReady = true;
|
|
266
273
|
try {
|
|
267
|
-
unlinkSync(
|
|
274
|
+
unlinkSync(join2(s.dir, selfFile(s.id)));
|
|
268
275
|
} catch {
|
|
269
276
|
}
|
|
270
277
|
} catch {
|
|
@@ -281,8 +288,8 @@ function publish(s) {
|
|
|
281
288
|
const payload = {};
|
|
282
289
|
for (const [key, claim] of s.own) if (now - claim.t <= CLAIM_TTL_MS) payload[key] = claim;
|
|
283
290
|
try {
|
|
284
|
-
writeFileSync(
|
|
285
|
-
renameSync(
|
|
291
|
+
writeFileSync(join2(s.dir, `${selfFile(s.id)}.tmp`), JSON.stringify(payload));
|
|
292
|
+
renameSync(join2(s.dir, `${selfFile(s.id)}.tmp`), join2(s.dir, selfFile(s.id)));
|
|
286
293
|
} catch {
|
|
287
294
|
s.dirReady = false;
|
|
288
295
|
}
|
|
@@ -297,7 +304,7 @@ function readSiblings(s) {
|
|
|
297
304
|
}
|
|
298
305
|
const now = Date.now();
|
|
299
306
|
for (const entry of entries) {
|
|
300
|
-
const path4 =
|
|
307
|
+
const path4 = join2(s.dir, entry);
|
|
301
308
|
let age;
|
|
302
309
|
try {
|
|
303
310
|
age = now - statSync(path4).mtimeMs;
|
|
@@ -321,7 +328,7 @@ function readSiblings(s) {
|
|
|
321
328
|
continue;
|
|
322
329
|
}
|
|
323
330
|
try {
|
|
324
|
-
const parsed = JSON.parse(
|
|
331
|
+
const parsed = JSON.parse(readFileSync2(path4, "utf8"));
|
|
325
332
|
const claims = Object.values(parsed).filter(
|
|
326
333
|
(c) => c && typeof c.v === "string" && typeof c.m === "string" && now - c.t <= CLAIM_TTL_MS
|
|
327
334
|
);
|
|
@@ -342,9 +349,9 @@ function readLimits(s) {
|
|
|
342
349
|
const now = Date.now();
|
|
343
350
|
for (const entry of entries) {
|
|
344
351
|
if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
|
|
345
|
-
const path4 =
|
|
352
|
+
const path4 = join2(s.dir, entry);
|
|
346
353
|
try {
|
|
347
|
-
const strike = JSON.parse(
|
|
354
|
+
const strike = JSON.parse(readFileSync2(path4, "utf8"));
|
|
348
355
|
if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
|
|
349
356
|
if (strike.until > now) cooling.set(strike.model, strike);
|
|
350
357
|
else unlinkSync(path4);
|
|
@@ -366,7 +373,7 @@ function recordStrike(s, model, reason, stated) {
|
|
|
366
373
|
const backoff = Math.min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * 2 ** (strikes - 1));
|
|
367
374
|
const strike = { model, until: now + (stated ?? backoff), strikes, reason, last: now };
|
|
368
375
|
s.cooling.set(model, strike);
|
|
369
|
-
const path4 =
|
|
376
|
+
const path4 = join2(s.dir, limitFile(model));
|
|
370
377
|
try {
|
|
371
378
|
writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
|
|
372
379
|
renameSync(`${path4}.tmp`, path4);
|
|
@@ -382,7 +389,7 @@ function coolUnavailable(s, model) {
|
|
|
382
389
|
if (prior && prior.until > until) return prior;
|
|
383
390
|
const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
|
|
384
391
|
s.cooling.set(model, strike);
|
|
385
|
-
const path4 =
|
|
392
|
+
const path4 = join2(s.dir, limitFile(model));
|
|
386
393
|
try {
|
|
387
394
|
writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
|
|
388
395
|
renameSync(`${path4}.tmp`, path4);
|
|
@@ -512,13 +519,45 @@ Every question of the form "what does this file do", "where is X handled", or
|
|
|
512
519
|
("read src/auth.ts", "grep for 'migration' in *.py", "list the src/
|
|
513
520
|
directory"), one batch per question. Ask for the exact lines or
|
|
514
521
|
file:line references you need, not a survey.
|
|
515
|
-
- **Implementation \u2192 @implement**,
|
|
516
|
-
steps, the files
|
|
517
|
-
|
|
522
|
+
- **Implementation \u2192 @implement**, one decided unit at a time: the exact
|
|
523
|
+
steps, the exact files, the design you have already chosen, and the command
|
|
524
|
+
that proves it. Never one whole refactor handed over in a single piece \u2014
|
|
525
|
+
see \`# Task size\` below.
|
|
518
526
|
|
|
519
527
|
The agents you spawn are the pooled \`explore\` / \`implement\` bases \u2014
|
|
520
528
|
spawn the bare name and let the pool route, never a \`-1\`/\`-2\` slot variant.
|
|
521
529
|
|
|
530
|
+
# Task size
|
|
531
|
+
|
|
532
|
+
\`@implement\` runs on a cheap pooled model. It can carry out a decision; it
|
|
533
|
+
cannot make one. So what you hand it is always a small ask \u2014 never a mid or
|
|
534
|
+
large task.
|
|
535
|
+
|
|
536
|
+
A small ask is one change, one concern, already decided by you. Before handing
|
|
537
|
+
it over you should have explored, through \`@explore\`, far enough to know what
|
|
538
|
+
the change is and to have chosen the design: which class or value object, which
|
|
539
|
+
members, what becomes a \`static\`, what gets deleted. \`@implement\` executes that
|
|
540
|
+
choice. It never invents one.
|
|
541
|
+
|
|
542
|
+
It does not have to arrive fully pre-digested. Letting it find the call sites,
|
|
543
|
+
read the code around them and work out which files it touches is normal and
|
|
544
|
+
expected \u2014 that is exploration an agent should be free to do. What it must never
|
|
545
|
+
be handed is a problem to solve.
|
|
546
|
+
|
|
547
|
+
Too small is fine and costs little. Too large is the failure: "convert the
|
|
548
|
+
decode modules into named units, decide which are dead, keep the output
|
|
549
|
+
byte-identical, verify conformance and report" is not a task, it is the whole
|
|
550
|
+
project. A cheap agent handed it will improvise a design, skip the verification
|
|
551
|
+
you never spelled out, and return something you cannot trust.
|
|
552
|
+
|
|
553
|
+
When the work does not divide into small asks it is not implementation work
|
|
554
|
+
yet \u2014 send it to \`@plan\`, or keep it yourself.
|
|
555
|
+
|
|
556
|
+
Split into one ask per task, spawn independent asks together in a single
|
|
557
|
+
message so they run in parallel, and sequence only what truly depends on what.
|
|
558
|
+
If a returned ask turns out to need a decision you did not make, you make the
|
|
559
|
+
call and hand out a corrected ask.
|
|
560
|
+
|
|
522
561
|
# What stays yours
|
|
523
562
|
|
|
524
563
|
\`edit\`, \`write\`, and \`bash\` are available to you. Use them to apply what a
|
|
@@ -729,6 +768,25 @@ write, and run code/bash to make the plan's changes real. Work from the plan
|
|
|
729
768
|
as given \u2014 if it's ambiguous or you believe it's wrong, say so explicitly
|
|
730
769
|
rather than silently improvising a different approach.
|
|
731
770
|
|
|
771
|
+
# Before you start
|
|
772
|
+
|
|
773
|
+
The ask you receive is a small one, and it is already decided: the change is
|
|
774
|
+
specified and the design is chosen. Work from it as given rather than silently
|
|
775
|
+
substituting an approach of your own.
|
|
776
|
+
|
|
777
|
+
Expect to do your own locating. Finding the call sites, reading what you touch
|
|
778
|
+
and working out which files are involved is normal work, not a sign of a bad
|
|
779
|
+
brief \u2014 do it without asking.
|
|
780
|
+
|
|
781
|
+
What is not yours to do is decide. If the ask asks you to choose an approach,
|
|
782
|
+
pick the right abstraction, settle a judgement call, or work out what the
|
|
783
|
+
requirement even is, stop and report that back instead of improvising. A
|
|
784
|
+
confident wrong design costs more than no change at all.
|
|
785
|
+
|
|
786
|
+
Size works the same way. A small ask that turns out to be bigger than it looked
|
|
787
|
+
is a signal to come back and say so: complete the part that is decided, and
|
|
788
|
+
report precisely what else needs deciding.
|
|
789
|
+
|
|
732
790
|
Make changes incrementally where possible, and verify your own work (run
|
|
733
791
|
tests/build/lint if available) before considering a step done.
|
|
734
792
|
|
|
@@ -1443,7 +1501,7 @@ import { existsSync as existsSync2, readdirSync as readdirSync2, statSync as sta
|
|
|
1443
1501
|
import * as path2 from "node:path";
|
|
1444
1502
|
|
|
1445
1503
|
// src/lib/writer-rule.ts
|
|
1446
|
-
import { readFileSync as
|
|
1504
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
1447
1505
|
import * as path from "node:path";
|
|
1448
1506
|
var CONTEXT_DIR = path.join(".opencode", "context");
|
|
1449
1507
|
var WRITER = "context";
|
|
@@ -1516,7 +1574,7 @@ var evaluatedForm = (mapDir, projectRoot) => {
|
|
|
1516
1574
|
var verifyWriterRule = (configPath, mapDir, projectRoot) => {
|
|
1517
1575
|
let raw;
|
|
1518
1576
|
try {
|
|
1519
|
-
raw =
|
|
1577
|
+
raw = readFileSync3(configPath, "utf8");
|
|
1520
1578
|
} catch {
|
|
1521
1579
|
return void 0;
|
|
1522
1580
|
}
|
|
@@ -1604,11 +1662,11 @@ var changedSince = (before, dir) => {
|
|
|
1604
1662
|
for (const name of before.keys()) if (!after.has(name)) changed++;
|
|
1605
1663
|
return changed;
|
|
1606
1664
|
};
|
|
1607
|
-
var contextAutoUpdateHooks = (async ({ client, directory }) => {
|
|
1665
|
+
var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
1608
1666
|
const pending = /* @__PURE__ */ new Set();
|
|
1609
1667
|
const owned = /* @__PURE__ */ new Map();
|
|
1610
1668
|
let lastRun = 0;
|
|
1611
|
-
const state2 = state();
|
|
1669
|
+
const state2 = state(options);
|
|
1612
1670
|
const log = (level, message, extra) => client.app.log({ body: { service: SERVICE2, level, message, extra } }).catch(() => {
|
|
1613
1671
|
});
|
|
1614
1672
|
const root = path2.resolve(directory);
|
|
@@ -1937,14 +1995,14 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
|
|
|
1937
1995
|
const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
|
|
1938
1996
|
return info;
|
|
1939
1997
|
};
|
|
1940
|
-
const deliverViaHelper = (title, message, urgency) => new Promise((
|
|
1998
|
+
const deliverViaHelper = (title, message, urgency) => new Promise((resolve3) => {
|
|
1941
1999
|
const helper = findHelper();
|
|
1942
2000
|
if (!helper) {
|
|
1943
2001
|
if (!warnedHelper) {
|
|
1944
2002
|
warnedHelper = true;
|
|
1945
2003
|
void log("warn", "no oc-notify helper found; run 'bin/oc-sync' to install one");
|
|
1946
2004
|
}
|
|
1947
|
-
|
|
2005
|
+
resolve3({ ok: false, detail: "no oc-notify helper found" });
|
|
1948
2006
|
return;
|
|
1949
2007
|
}
|
|
1950
2008
|
const child = spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal"), {
|
|
@@ -1957,7 +2015,7 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
|
|
|
1957
2015
|
});
|
|
1958
2016
|
child.on("error", (error) => {
|
|
1959
2017
|
void log("warn", `notification helper failed: ${String(error)}`, { title });
|
|
1960
|
-
|
|
2018
|
+
resolve3({ ok: false, detail: String(error) });
|
|
1961
2019
|
});
|
|
1962
2020
|
child.on("exit", (code) => {
|
|
1963
2021
|
const noise = stderr.trim();
|
|
@@ -1968,7 +2026,7 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
|
|
|
1968
2026
|
message
|
|
1969
2027
|
});
|
|
1970
2028
|
}
|
|
1971
|
-
|
|
2029
|
+
resolve3({ ok, detail: noise || void 0 });
|
|
1972
2030
|
});
|
|
1973
2031
|
child.unref();
|
|
1974
2032
|
});
|
|
@@ -2093,6 +2151,238 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
|
|
|
2093
2151
|
};
|
|
2094
2152
|
});
|
|
2095
2153
|
|
|
2154
|
+
// src/lib/selfupdate.ts
|
|
2155
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2156
|
+
import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2, writeSync } from "node:fs";
|
|
2157
|
+
import { homedir as homedir2 } from "node:os";
|
|
2158
|
+
import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
|
|
2159
|
+
var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
2160
|
+
var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
|
|
2161
|
+
var FETCH_TIMEOUT_MS = 4e3;
|
|
2162
|
+
var INSTALL_TIMEOUT_MS = 12e4;
|
|
2163
|
+
var LOCK_STALE_MS = 10 * 60 * 1e3;
|
|
2164
|
+
var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
|
|
2165
|
+
var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
|
|
2166
|
+
var STATE_DIR = join6(homedir2(), ".local", "share", "opencode", "lacode");
|
|
2167
|
+
var STATE_PATH = join6(STATE_DIR, "selfupdate.json");
|
|
2168
|
+
var LOCK_PATH = join6(STATE_DIR, "selfupdate.lock");
|
|
2169
|
+
function compareSemver(a, b) {
|
|
2170
|
+
const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
2171
|
+
const left = parse(a);
|
|
2172
|
+
const right = parse(b);
|
|
2173
|
+
for (let i = 0; i < 3; i++) {
|
|
2174
|
+
const l = left[i] ?? 0;
|
|
2175
|
+
const r = right[i] ?? 0;
|
|
2176
|
+
if (l !== r) return l > r ? 1 : -1;
|
|
2177
|
+
}
|
|
2178
|
+
return 0;
|
|
2179
|
+
}
|
|
2180
|
+
function readInstalledVersion(packageRoot) {
|
|
2181
|
+
try {
|
|
2182
|
+
const manifest = JSON.parse(readFileSync4(join6(packageRoot, "package.json"), "utf8"));
|
|
2183
|
+
if (manifest.name !== PACKAGE_NAME) return void 0;
|
|
2184
|
+
return typeof manifest.version === "string" ? manifest.version : void 0;
|
|
2185
|
+
} catch {
|
|
2186
|
+
return void 0;
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
function resolveCacheRoot(packageRoot, exists = (p) => {
|
|
2190
|
+
try {
|
|
2191
|
+
return statSync3(p).isFile();
|
|
2192
|
+
} catch {
|
|
2193
|
+
return false;
|
|
2194
|
+
}
|
|
2195
|
+
}) {
|
|
2196
|
+
const tail = join6("node_modules", "@lawaty", "lacode");
|
|
2197
|
+
if (!packageRoot.endsWith(tail)) return void 0;
|
|
2198
|
+
const cacheRoot = resolve2(packageRoot, "..", "..", "..");
|
|
2199
|
+
if (!exists(join6(cacheRoot, "package.json"))) return void 0;
|
|
2200
|
+
return cacheRoot;
|
|
2201
|
+
}
|
|
2202
|
+
function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
|
|
2203
|
+
try {
|
|
2204
|
+
return statSync3(p).isFile();
|
|
2205
|
+
} catch {
|
|
2206
|
+
return false;
|
|
2207
|
+
}
|
|
2208
|
+
}) {
|
|
2209
|
+
if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
|
|
2210
|
+
for (const dir of (env.PATH ?? "").split(":")) {
|
|
2211
|
+
if (!dir) continue;
|
|
2212
|
+
const candidate = join6(dir, "opencode");
|
|
2213
|
+
if (exists(candidate)) return candidate;
|
|
2214
|
+
}
|
|
2215
|
+
const fallback = join6(homedir2(), ".opencode", "bin", "opencode");
|
|
2216
|
+
return exists(fallback) ? fallback : void 0;
|
|
2217
|
+
}
|
|
2218
|
+
async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
2219
|
+
const controller = new AbortController();
|
|
2220
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2221
|
+
try {
|
|
2222
|
+
const response = await fetchImpl(REGISTRY_LATEST, { signal: controller.signal });
|
|
2223
|
+
if (!response.ok) return void 0;
|
|
2224
|
+
const body = await response.json();
|
|
2225
|
+
return typeof body.version === "string" ? body.version : void 0;
|
|
2226
|
+
} catch {
|
|
2227
|
+
return void 0;
|
|
2228
|
+
} finally {
|
|
2229
|
+
clearTimeout(timer);
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
function readState(path4 = STATE_PATH) {
|
|
2233
|
+
try {
|
|
2234
|
+
return JSON.parse(readFileSync4(path4, "utf8"));
|
|
2235
|
+
} catch {
|
|
2236
|
+
return {};
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
function writeState(state2, path4 = STATE_PATH) {
|
|
2240
|
+
try {
|
|
2241
|
+
mkdirSync2(dirname3(path4), { recursive: true });
|
|
2242
|
+
writeFileSync2(path4, JSON.stringify(state2));
|
|
2243
|
+
} catch {
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
var isDue = (state2, now, interval = CHECK_INTERVAL_MS) => typeof state2.checkedAt !== "number" || now - state2.checkedAt >= interval;
|
|
2247
|
+
function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
|
|
2248
|
+
try {
|
|
2249
|
+
mkdirSync2(dirname3(path4), { recursive: true });
|
|
2250
|
+
const fd = openSync(path4, "wx");
|
|
2251
|
+
writeSync(fd, String(process.pid));
|
|
2252
|
+
closeSync(fd);
|
|
2253
|
+
return () => {
|
|
2254
|
+
try {
|
|
2255
|
+
unlinkSync2(path4);
|
|
2256
|
+
} catch {
|
|
2257
|
+
}
|
|
2258
|
+
};
|
|
2259
|
+
} catch {
|
|
2260
|
+
try {
|
|
2261
|
+
if (now - statSync3(path4).mtimeMs > LOCK_STALE_MS) unlinkSync2(path4);
|
|
2262
|
+
} catch {
|
|
2263
|
+
}
|
|
2264
|
+
return void 0;
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
async function checkForUpdate(opts) {
|
|
2268
|
+
const installed = readInstalledVersion(opts.packageRoot);
|
|
2269
|
+
const latest = await fetchLatest(opts.fetchImpl);
|
|
2270
|
+
return {
|
|
2271
|
+
installed,
|
|
2272
|
+
latest,
|
|
2273
|
+
newer: Boolean(installed && latest && compareSemver(latest, installed) > 0),
|
|
2274
|
+
cacheRoot: resolveCacheRoot(opts.packageRoot, opts.exists),
|
|
2275
|
+
bin: resolveOpencodeBin(opts.execPath, opts.env, opts.exists)
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
async function refreshCache(opts) {
|
|
2279
|
+
const { cacheRoot, bin, latest } = opts;
|
|
2280
|
+
const spawnImpl = opts.spawnImpl ?? spawn2;
|
|
2281
|
+
const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
|
|
2282
|
+
const fresh = join6(cacheRoot, "node_modules", "@lawaty", "lacode");
|
|
2283
|
+
const aside = `${cacheRoot}.stale-${Date.now()}`;
|
|
2284
|
+
try {
|
|
2285
|
+
rmSync(aside, { recursive: true, force: true });
|
|
2286
|
+
renameSync2(cacheRoot, aside);
|
|
2287
|
+
} catch (error) {
|
|
2288
|
+
return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
|
|
2289
|
+
}
|
|
2290
|
+
const exited = await new Promise((settle) => {
|
|
2291
|
+
let done = false;
|
|
2292
|
+
const finish = (value) => {
|
|
2293
|
+
if (done) return;
|
|
2294
|
+
done = true;
|
|
2295
|
+
clearTimeout(timer);
|
|
2296
|
+
settle(value);
|
|
2297
|
+
};
|
|
2298
|
+
const child = spawnImpl(bin, ["plugin", PACKAGE_NAME, "-g"], {
|
|
2299
|
+
env: { ...process.env, [CHILD_ENV]: "1" },
|
|
2300
|
+
stdio: "ignore"
|
|
2301
|
+
});
|
|
2302
|
+
const timer = setTimeout(() => {
|
|
2303
|
+
try {
|
|
2304
|
+
child.kill("SIGKILL");
|
|
2305
|
+
} catch {
|
|
2306
|
+
}
|
|
2307
|
+
finish(false);
|
|
2308
|
+
}, timeoutMs);
|
|
2309
|
+
child.on("error", () => finish(false));
|
|
2310
|
+
child.on("exit", (code) => finish(code === 0));
|
|
2311
|
+
});
|
|
2312
|
+
const installed = readInstalledVersion(fresh);
|
|
2313
|
+
const ok = exited && installed === latest;
|
|
2314
|
+
if (ok) {
|
|
2315
|
+
try {
|
|
2316
|
+
rmSync(aside, { recursive: true, force: true });
|
|
2317
|
+
} catch {
|
|
2318
|
+
}
|
|
2319
|
+
} else {
|
|
2320
|
+
try {
|
|
2321
|
+
renameSync2(aside, cacheRoot);
|
|
2322
|
+
} catch {
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
return {
|
|
2326
|
+
ok,
|
|
2327
|
+
detail: ok ? `installed ${installed}` : `installer exited ${exited ? "0" : "non-zero"}, cache now ${installed ?? "missing"}`
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
async function maybeSelfUpdate(opts) {
|
|
2331
|
+
const env = opts.env ?? process.env;
|
|
2332
|
+
if (opts.enabled === false || env[CHILD_ENV] === "1" || env[OPT_OUT_ENV] === "1") {
|
|
2333
|
+
return { action: "disabled" };
|
|
2334
|
+
}
|
|
2335
|
+
const now = (opts.now ?? Date.now)();
|
|
2336
|
+
const statePath = opts.statePath ?? STATE_PATH;
|
|
2337
|
+
const state2 = readState(statePath);
|
|
2338
|
+
if (!isDue(state2, now, opts.intervalMs ?? CHECK_INTERVAL_MS)) return { action: "skipped", detail: "checked recently" };
|
|
2339
|
+
const release2 = acquireLock(opts.lockPath, now);
|
|
2340
|
+
if (!release2) return { action: "skipped", detail: "another process is updating" };
|
|
2341
|
+
try {
|
|
2342
|
+
const check = await checkForUpdate(opts);
|
|
2343
|
+
if (!check.installed) return { action: "skipped", detail: "no installed version" };
|
|
2344
|
+
if (!check.latest) {
|
|
2345
|
+
writeState({ ...state2, checkedAt: now }, statePath);
|
|
2346
|
+
return { action: "skipped", detail: "registry unreachable" };
|
|
2347
|
+
}
|
|
2348
|
+
if (!check.newer) {
|
|
2349
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
2350
|
+
return { action: "current", detail: `${check.installed} is current` };
|
|
2351
|
+
}
|
|
2352
|
+
if (!check.cacheRoot) {
|
|
2353
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
2354
|
+
return { action: "skipped", detail: "not an opencode cache install" };
|
|
2355
|
+
}
|
|
2356
|
+
if (!check.bin) {
|
|
2357
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
2358
|
+
opts.log?.("warn", `LaCode ${check.latest} is available but no opencode binary was found to install it`);
|
|
2359
|
+
return { action: "failed", detail: "no opencode binary" };
|
|
2360
|
+
}
|
|
2361
|
+
const result = await refreshCache({ cacheRoot: check.cacheRoot, bin: check.bin, latest: check.latest, spawnImpl: opts.spawnImpl });
|
|
2362
|
+
if (result.ok) {
|
|
2363
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest, updatedTo: check.latest }, statePath);
|
|
2364
|
+
opts.log?.("info", `LaCode updated ${check.installed} -> ${check.latest}; restart opencode to apply`);
|
|
2365
|
+
return { action: "updated", detail: `${check.installed} -> ${check.latest}` };
|
|
2366
|
+
}
|
|
2367
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
2368
|
+
opts.log?.("warn", `LaCode update to ${check.latest} failed: ${result.detail}`);
|
|
2369
|
+
return { action: "failed", detail: result.detail };
|
|
2370
|
+
} finally {
|
|
2371
|
+
release2();
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// src/plugins/selfupdate.ts
|
|
2376
|
+
async function selfUpdateHooks(input, options) {
|
|
2377
|
+
const opts = options ?? {};
|
|
2378
|
+
if (opts.enabled === false || opts.selfUpdate === false) return {};
|
|
2379
|
+
const log = (level, message, extra) => input.client.app.log({ body: { service: "lacode", level, message, extra } }).catch(() => {
|
|
2380
|
+
});
|
|
2381
|
+
void maybeSelfUpdate({ packageRoot: PACKAGE_ROOT, enabled: opts.enabled, log }).catch(() => {
|
|
2382
|
+
});
|
|
2383
|
+
return {};
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2096
2386
|
// src/index.ts
|
|
2097
2387
|
var inertHooks = {
|
|
2098
2388
|
config: async () => {
|
|
@@ -2119,6 +2409,7 @@ var LaCode = (async (input, options) => {
|
|
|
2119
2409
|
const pool = await settled("agent-pool", () => agentPoolHooks(input, options)) ?? inertHooks;
|
|
2120
2410
|
const autoupdate = await settled("context-autoupdate", () => contextAutoUpdateHooks(input, options)) ?? inertHooks;
|
|
2121
2411
|
const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
|
|
2412
|
+
const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
|
|
2122
2413
|
const config = async (cfg) => {
|
|
2123
2414
|
try {
|
|
2124
2415
|
const injected = applyAgents(cfg, agentSet);
|
|
@@ -2159,6 +2450,7 @@ var LaCode = (async (input, options) => {
|
|
|
2159
2450
|
await pool.dispose?.();
|
|
2160
2451
|
await autoupdate.dispose?.();
|
|
2161
2452
|
await notifier.dispose?.();
|
|
2453
|
+
await selfupdate.dispose?.();
|
|
2162
2454
|
}
|
|
2163
2455
|
};
|
|
2164
2456
|
});
|
package/dist/lacode.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// bin/lacode
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
5
|
+
import { existsSync, readFileSync as readFileSync3 } from "node:fs";
|
|
6
6
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
7
7
|
|
|
8
8
|
// src/tunnel/index.ts
|
|
@@ -189,6 +189,167 @@ function describe(env = process.env, platform = process.platform) {
|
|
|
189
189
|
};
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
// src/lib/package-root.ts
|
|
193
|
+
import { readFileSync } from "node:fs";
|
|
194
|
+
import { dirname, join as join2 } from "node:path";
|
|
195
|
+
import { fileURLToPath } from "node:url";
|
|
196
|
+
var PACKAGE_NAME = "@lawaty/lacode";
|
|
197
|
+
var findPackageRoot = (start) => {
|
|
198
|
+
let dir = start;
|
|
199
|
+
for (let up = 0; up < 6; up++) {
|
|
200
|
+
try {
|
|
201
|
+
if (JSON.parse(readFileSync(join2(dir, "package.json"), "utf8")).name === PACKAGE_NAME) return dir;
|
|
202
|
+
} catch {
|
|
203
|
+
}
|
|
204
|
+
const parent = dirname(dir);
|
|
205
|
+
if (parent === dir) break;
|
|
206
|
+
dir = parent;
|
|
207
|
+
}
|
|
208
|
+
return join2(start, "..", "..");
|
|
209
|
+
};
|
|
210
|
+
var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
|
|
211
|
+
|
|
212
|
+
// src/lib/selfupdate.ts
|
|
213
|
+
import { spawn } from "node:child_process";
|
|
214
|
+
import { closeSync, mkdirSync, openSync, readFileSync as readFileSync2, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
215
|
+
import { homedir as homedir2 } from "node:os";
|
|
216
|
+
import { dirname as dirname2, join as join3, resolve } from "node:path";
|
|
217
|
+
var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
218
|
+
var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
|
|
219
|
+
var FETCH_TIMEOUT_MS = 4e3;
|
|
220
|
+
var INSTALL_TIMEOUT_MS = 12e4;
|
|
221
|
+
var LOCK_STALE_MS = 10 * 60 * 1e3;
|
|
222
|
+
var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
|
|
223
|
+
var STATE_DIR = join3(homedir2(), ".local", "share", "opencode", "lacode");
|
|
224
|
+
var STATE_PATH = join3(STATE_DIR, "selfupdate.json");
|
|
225
|
+
var LOCK_PATH = join3(STATE_DIR, "selfupdate.lock");
|
|
226
|
+
function compareSemver(a, b) {
|
|
227
|
+
const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
228
|
+
const left = parse(a);
|
|
229
|
+
const right = parse(b);
|
|
230
|
+
for (let i = 0; i < 3; i++) {
|
|
231
|
+
const l = left[i] ?? 0;
|
|
232
|
+
const r = right[i] ?? 0;
|
|
233
|
+
if (l !== r) return l > r ? 1 : -1;
|
|
234
|
+
}
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
function readInstalledVersion(packageRoot) {
|
|
238
|
+
try {
|
|
239
|
+
const manifest = JSON.parse(readFileSync2(join3(packageRoot, "package.json"), "utf8"));
|
|
240
|
+
if (manifest.name !== PACKAGE_NAME) return void 0;
|
|
241
|
+
return typeof manifest.version === "string" ? manifest.version : void 0;
|
|
242
|
+
} catch {
|
|
243
|
+
return void 0;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function resolveCacheRoot(packageRoot, exists = (p) => {
|
|
247
|
+
try {
|
|
248
|
+
return statSync(p).isFile();
|
|
249
|
+
} catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}) {
|
|
253
|
+
const tail = join3("node_modules", "@lawaty", "lacode");
|
|
254
|
+
if (!packageRoot.endsWith(tail)) return void 0;
|
|
255
|
+
const cacheRoot = resolve(packageRoot, "..", "..", "..");
|
|
256
|
+
if (!exists(join3(cacheRoot, "package.json"))) return void 0;
|
|
257
|
+
return cacheRoot;
|
|
258
|
+
}
|
|
259
|
+
function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
|
|
260
|
+
try {
|
|
261
|
+
return statSync(p).isFile();
|
|
262
|
+
} catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}) {
|
|
266
|
+
if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
|
|
267
|
+
for (const dir of (env.PATH ?? "").split(":")) {
|
|
268
|
+
if (!dir) continue;
|
|
269
|
+
const candidate = join3(dir, "opencode");
|
|
270
|
+
if (exists(candidate)) return candidate;
|
|
271
|
+
}
|
|
272
|
+
const fallback = join3(homedir2(), ".opencode", "bin", "opencode");
|
|
273
|
+
return exists(fallback) ? fallback : void 0;
|
|
274
|
+
}
|
|
275
|
+
async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
276
|
+
const controller = new AbortController();
|
|
277
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
278
|
+
try {
|
|
279
|
+
const response = await fetchImpl(REGISTRY_LATEST, { signal: controller.signal });
|
|
280
|
+
if (!response.ok) return void 0;
|
|
281
|
+
const body = await response.json();
|
|
282
|
+
return typeof body.version === "string" ? body.version : void 0;
|
|
283
|
+
} catch {
|
|
284
|
+
return void 0;
|
|
285
|
+
} finally {
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async function checkForUpdate(opts) {
|
|
290
|
+
const installed = readInstalledVersion(opts.packageRoot);
|
|
291
|
+
const latest = await fetchLatest(opts.fetchImpl);
|
|
292
|
+
return {
|
|
293
|
+
installed,
|
|
294
|
+
latest,
|
|
295
|
+
newer: Boolean(installed && latest && compareSemver(latest, installed) > 0),
|
|
296
|
+
cacheRoot: resolveCacheRoot(opts.packageRoot, opts.exists),
|
|
297
|
+
bin: resolveOpencodeBin(opts.execPath, opts.env, opts.exists)
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
async function refreshCache(opts) {
|
|
301
|
+
const { cacheRoot, bin, latest } = opts;
|
|
302
|
+
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
303
|
+
const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
|
|
304
|
+
const fresh = join3(cacheRoot, "node_modules", "@lawaty", "lacode");
|
|
305
|
+
const aside = `${cacheRoot}.stale-${Date.now()}`;
|
|
306
|
+
try {
|
|
307
|
+
rmSync(aside, { recursive: true, force: true });
|
|
308
|
+
renameSync(cacheRoot, aside);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
|
|
311
|
+
}
|
|
312
|
+
const exited = await new Promise((settle) => {
|
|
313
|
+
let done = false;
|
|
314
|
+
const finish = (value) => {
|
|
315
|
+
if (done) return;
|
|
316
|
+
done = true;
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
settle(value);
|
|
319
|
+
};
|
|
320
|
+
const child = spawnImpl(bin, ["plugin", PACKAGE_NAME, "-g"], {
|
|
321
|
+
env: { ...process.env, [CHILD_ENV]: "1" },
|
|
322
|
+
stdio: "ignore"
|
|
323
|
+
});
|
|
324
|
+
const timer = setTimeout(() => {
|
|
325
|
+
try {
|
|
326
|
+
child.kill("SIGKILL");
|
|
327
|
+
} catch {
|
|
328
|
+
}
|
|
329
|
+
finish(false);
|
|
330
|
+
}, timeoutMs);
|
|
331
|
+
child.on("error", () => finish(false));
|
|
332
|
+
child.on("exit", (code) => finish(code === 0));
|
|
333
|
+
});
|
|
334
|
+
const installed = readInstalledVersion(fresh);
|
|
335
|
+
const ok = exited && installed === latest;
|
|
336
|
+
if (ok) {
|
|
337
|
+
try {
|
|
338
|
+
rmSync(aside, { recursive: true, force: true });
|
|
339
|
+
} catch {
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
try {
|
|
343
|
+
renameSync(aside, cacheRoot);
|
|
344
|
+
} catch {
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
ok,
|
|
349
|
+
detail: ok ? `installed ${installed}` : `installer exited ${exited ? "0" : "non-zero"}, cache now ${installed ?? "missing"}`
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
192
353
|
// bin/lacode
|
|
193
354
|
if (!process.features?.typescript) {
|
|
194
355
|
if (process.env.LACODE_STRIP_TYPECHECKED === "1") {
|
|
@@ -198,7 +359,7 @@ if (!process.features?.typescript) {
|
|
|
198
359
|
const { spawnSync: spawnSync3 } = await import("node:child_process");
|
|
199
360
|
const result = spawnSync3(
|
|
200
361
|
process.execPath,
|
|
201
|
-
["--experimental-strip-types", "--no-warnings",
|
|
362
|
+
["--experimental-strip-types", "--no-warnings", fileURLToPath2(import.meta.url), ...process.argv.slice(2)],
|
|
202
363
|
{ stdio: "inherit", env: { ...process.env, LACODE_STRIP_TYPECHECKED: "1" } }
|
|
203
364
|
);
|
|
204
365
|
process.exit(result.status ?? 1);
|
|
@@ -216,6 +377,13 @@ var USAGE = `lacode \u2014 portable notifications and remote exec for LaCode
|
|
|
216
377
|
lacode doctor
|
|
217
378
|
Print what the environment resolves to. Diagnostics only.
|
|
218
379
|
|
|
380
|
+
lacode update [--check]
|
|
381
|
+
Bring this machine's installed LaCode up to the latest published
|
|
382
|
+
version. opencode's plugin cache never re-resolves on its own, so
|
|
383
|
+
this is the manual half of the automatic check the plugin runs at
|
|
384
|
+
startup. --check only reports; it installs nothing. The new version
|
|
385
|
+
takes effect on the next opencode start.
|
|
386
|
+
|
|
219
387
|
Environment:
|
|
220
388
|
OC_LOCAL_USER desktop user the tunnel authenticates as
|
|
221
389
|
OC_TUNNEL_PORT reverse-forward listener port (default 10022)
|
|
@@ -269,6 +437,40 @@ switch (command) {
|
|
|
269
437
|
process.exit(0);
|
|
270
438
|
break;
|
|
271
439
|
}
|
|
440
|
+
case "update": {
|
|
441
|
+
const checkOnly = argv.includes("--check");
|
|
442
|
+
const check = await checkForUpdate({ packageRoot: PACKAGE_ROOT });
|
|
443
|
+
if (!check.installed) {
|
|
444
|
+
console.error("lacode update: could not read the installed version (run from an installed package).");
|
|
445
|
+
process.exit(1);
|
|
446
|
+
}
|
|
447
|
+
if (!check.latest) {
|
|
448
|
+
console.error("lacode update: could not reach the npm registry; check your connection and retry.");
|
|
449
|
+
process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
if (!check.newer) {
|
|
452
|
+
console.log(`lacode ${check.installed} is up to date (latest ${check.latest}).`);
|
|
453
|
+
process.exit(0);
|
|
454
|
+
}
|
|
455
|
+
console.log(`lacode ${check.installed} -> ${check.latest} available.`);
|
|
456
|
+
if (checkOnly) process.exit(0);
|
|
457
|
+
if (!check.cacheRoot) {
|
|
458
|
+
console.error("This is not an opencode cache install; update it with your package manager instead.");
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
if (!check.bin) {
|
|
462
|
+
console.error("lacode update: no opencode binary found to perform the install.");
|
|
463
|
+
process.exit(1);
|
|
464
|
+
}
|
|
465
|
+
const result = await refreshCache({ cacheRoot: check.cacheRoot, bin: check.bin, latest: check.latest });
|
|
466
|
+
if (!result.ok) {
|
|
467
|
+
console.error(`lacode update: install failed (${result.detail}).`);
|
|
468
|
+
process.exit(1);
|
|
469
|
+
}
|
|
470
|
+
console.log(`Updated to ${check.latest}. Restart opencode to apply.`);
|
|
471
|
+
process.exit(0);
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
272
474
|
case "receive": {
|
|
273
475
|
const request = parsePayload(process.env.SSH_ORIGINAL_COMMAND ?? "");
|
|
274
476
|
const result = sendLocal(request);
|
|
@@ -292,7 +494,7 @@ switch (command) {
|
|
|
292
494
|
}
|
|
293
495
|
console.log(`Generated ${key}`);
|
|
294
496
|
}
|
|
295
|
-
const pubkey = existsSync(pub) ?
|
|
497
|
+
const pubkey = existsSync(pub) ? readFileSync3(pub, "utf8").trim() : "(public key missing)";
|
|
296
498
|
const self = process.argv[1] ?? "lacode";
|
|
297
499
|
console.log("\nOn the REMOTE host (where opencode runs), export:");
|
|
298
500
|
console.log(` OC_LOCAL_USER=${user || "<desktop-user>"}`);
|
package/dist/pool.js
CHANGED
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
// src/lib/pool.ts
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { join as join2 } from "node:path";
|
|
5
|
+
|
|
6
|
+
// src/lib/package-root.ts
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
4
8
|
import { dirname, join } from "node:path";
|
|
5
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
+
var PACKAGE_NAME = "@lawaty/lacode";
|
|
11
|
+
var findPackageRoot = (start) => {
|
|
12
|
+
let dir = start;
|
|
13
|
+
for (let up = 0; up < 6; up++) {
|
|
14
|
+
try {
|
|
15
|
+
if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === PACKAGE_NAME) return dir;
|
|
16
|
+
} catch {
|
|
17
|
+
}
|
|
18
|
+
const parent = dirname(dir);
|
|
19
|
+
if (parent === dir) break;
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
return join(start, "..", "..");
|
|
23
|
+
};
|
|
24
|
+
var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
|
|
25
|
+
|
|
26
|
+
// src/lib/pool.ts
|
|
6
27
|
var CLAIM_TTL_MS = 10 * 60 * 1e3;
|
|
7
28
|
var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
|
|
8
29
|
var ORPHAN_TMP_MS = 60 * 1e3;
|
|
@@ -17,30 +38,16 @@ var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
|
17
38
|
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
18
39
|
var STRIKE_DECAY_MS = 30 * 60 * 1e3;
|
|
19
40
|
var BASES = ["explore", "implement", "context"];
|
|
20
|
-
var
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
} catch {
|
|
26
|
-
}
|
|
27
|
-
const parent = dirname(dir);
|
|
28
|
-
if (parent === dir) break;
|
|
29
|
-
dir = parent;
|
|
30
|
-
}
|
|
31
|
-
return join(start, "..", "..");
|
|
32
|
-
};
|
|
33
|
-
var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
|
|
34
|
-
var PRESETS_DIR = join(PACKAGE_ROOT, "presets");
|
|
35
|
-
var DEFAULT_PRESET = join(PRESETS_DIR, "free-tier.json");
|
|
36
|
-
var USER_CONFIG_DIR = join(homedir(), ".config", "lacode");
|
|
37
|
-
var USER_MODELS_PATH = join(USER_CONFIG_DIR, "pool.json");
|
|
38
|
-
var USER_MODELS_TXT = join(USER_CONFIG_DIR, "pool.txt");
|
|
41
|
+
var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
|
|
42
|
+
var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
|
|
43
|
+
var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
|
|
44
|
+
var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
|
|
45
|
+
var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
|
|
39
46
|
var FALLBACK_SLOTS = [
|
|
40
47
|
{ index: 1, model: "opencode/space-bunny-free", weight: 3 },
|
|
41
|
-
{ index: 2, model: "
|
|
48
|
+
{ index: 2, model: "opencode-go/step-5-preview-free", weight: 2 },
|
|
42
49
|
{ index: 3, model: "opencode/big-pickle", weight: 1 },
|
|
43
|
-
{ index: 4, model: "
|
|
50
|
+
{ index: 4, model: "opencode/step-5-preview-free", weight: 1 }
|
|
44
51
|
];
|
|
45
52
|
function parseSlots(parsed, origin) {
|
|
46
53
|
const problems = [];
|
|
@@ -89,7 +96,7 @@ function parseSlotText(text, origin) {
|
|
|
89
96
|
}
|
|
90
97
|
var readJSON = (path) => {
|
|
91
98
|
try {
|
|
92
|
-
return { ok: true, value: JSON.parse(
|
|
99
|
+
return { ok: true, value: JSON.parse(readFileSync2(path, "utf8")) };
|
|
93
100
|
} catch (error) {
|
|
94
101
|
return { ok: false, error: String(error) };
|
|
95
102
|
}
|
|
@@ -105,7 +112,7 @@ function readOptional(path, origin, problems) {
|
|
|
105
112
|
function readTextOptional(path, origin, problems) {
|
|
106
113
|
if (!existsSync(path)) return void 0;
|
|
107
114
|
try {
|
|
108
|
-
return
|
|
115
|
+
return readFileSync2(path, "utf8");
|
|
109
116
|
} catch (error) {
|
|
110
117
|
problems.push(`${origin} unreadable (${String(error)}); trying the next source`);
|
|
111
118
|
return void 0;
|
|
@@ -147,7 +154,7 @@ function resolveModels(input = {}) {
|
|
|
147
154
|
function readSlotFile(path) {
|
|
148
155
|
let text;
|
|
149
156
|
try {
|
|
150
|
-
text =
|
|
157
|
+
text = readFileSync2(path, "utf8");
|
|
151
158
|
} catch (error) {
|
|
152
159
|
return { slots: FALLBACK_SLOTS, problems: [`${path} unreadable (${String(error)}); using built-in defaults`] };
|
|
153
160
|
}
|
|
@@ -215,7 +222,7 @@ var retryAfterMs = (headers) => {
|
|
|
215
222
|
};
|
|
216
223
|
function state(opts = {}) {
|
|
217
224
|
const id = opts.id ?? String(process.pid);
|
|
218
|
-
const dir = opts.dir ??
|
|
225
|
+
const dir = opts.dir ?? join2(homedir(), ".local", "share", "opencode", "agent-pool");
|
|
219
226
|
const modelsFile = opts.modelsFile;
|
|
220
227
|
const models = opts.models;
|
|
221
228
|
const key = /* @__PURE__ */ Symbol.for(`agent-pool:state:${id}:${dir}:${modelsFile ?? ""}:${models === void 0 ? "" : "inline"}`);
|
|
@@ -264,7 +271,7 @@ function init(s, onWarn) {
|
|
|
264
271
|
mkdirSync(s.dir, { recursive: true });
|
|
265
272
|
s.dirReady = true;
|
|
266
273
|
try {
|
|
267
|
-
unlinkSync(
|
|
274
|
+
unlinkSync(join2(s.dir, selfFile(s.id)));
|
|
268
275
|
} catch {
|
|
269
276
|
}
|
|
270
277
|
} catch {
|
|
@@ -281,8 +288,8 @@ function publish(s) {
|
|
|
281
288
|
const payload = {};
|
|
282
289
|
for (const [key, claim] of s.own) if (now - claim.t <= CLAIM_TTL_MS) payload[key] = claim;
|
|
283
290
|
try {
|
|
284
|
-
writeFileSync(
|
|
285
|
-
renameSync(
|
|
291
|
+
writeFileSync(join2(s.dir, `${selfFile(s.id)}.tmp`), JSON.stringify(payload));
|
|
292
|
+
renameSync(join2(s.dir, `${selfFile(s.id)}.tmp`), join2(s.dir, selfFile(s.id)));
|
|
286
293
|
} catch {
|
|
287
294
|
s.dirReady = false;
|
|
288
295
|
}
|
|
@@ -297,7 +304,7 @@ function readSiblings(s) {
|
|
|
297
304
|
}
|
|
298
305
|
const now = Date.now();
|
|
299
306
|
for (const entry of entries) {
|
|
300
|
-
const path =
|
|
307
|
+
const path = join2(s.dir, entry);
|
|
301
308
|
let age;
|
|
302
309
|
try {
|
|
303
310
|
age = now - statSync(path).mtimeMs;
|
|
@@ -321,7 +328,7 @@ function readSiblings(s) {
|
|
|
321
328
|
continue;
|
|
322
329
|
}
|
|
323
330
|
try {
|
|
324
|
-
const parsed = JSON.parse(
|
|
331
|
+
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
325
332
|
const claims = Object.values(parsed).filter(
|
|
326
333
|
(c) => c && typeof c.v === "string" && typeof c.m === "string" && now - c.t <= CLAIM_TTL_MS
|
|
327
334
|
);
|
|
@@ -342,9 +349,9 @@ function readLimits(s) {
|
|
|
342
349
|
const now = Date.now();
|
|
343
350
|
for (const entry of entries) {
|
|
344
351
|
if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
|
|
345
|
-
const path =
|
|
352
|
+
const path = join2(s.dir, entry);
|
|
346
353
|
try {
|
|
347
|
-
const strike = JSON.parse(
|
|
354
|
+
const strike = JSON.parse(readFileSync2(path, "utf8"));
|
|
348
355
|
if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
|
|
349
356
|
if (strike.until > now) cooling.set(strike.model, strike);
|
|
350
357
|
else unlinkSync(path);
|
|
@@ -366,7 +373,7 @@ function recordStrike(s, model, reason, stated) {
|
|
|
366
373
|
const backoff = Math.min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * 2 ** (strikes - 1));
|
|
367
374
|
const strike = { model, until: now + (stated ?? backoff), strikes, reason, last: now };
|
|
368
375
|
s.cooling.set(model, strike);
|
|
369
|
-
const path =
|
|
376
|
+
const path = join2(s.dir, limitFile(model));
|
|
370
377
|
try {
|
|
371
378
|
writeFileSync(`${path}.tmp`, JSON.stringify(strike));
|
|
372
379
|
renameSync(`${path}.tmp`, path);
|
|
@@ -382,7 +389,7 @@ function coolUnavailable(s, model) {
|
|
|
382
389
|
if (prior && prior.until > until) return prior;
|
|
383
390
|
const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
|
|
384
391
|
s.cooling.set(model, strike);
|
|
385
|
-
const path =
|
|
392
|
+
const path = join2(s.dir, limitFile(model));
|
|
386
393
|
try {
|
|
387
394
|
writeFileSync(`${path}.tmp`, JSON.stringify(strike));
|
|
388
395
|
renameSync(`${path}.tmp`, path);
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -317,6 +317,25 @@ The pain: LaCode once shipped a `tools: { "playwright_*": true }` grant on its f
|
|
|
317
317
|
|
|
318
318
|
---
|
|
319
319
|
|
|
320
|
+
### US-27: The package keeps itself current, without npm and without being asked
|
|
321
|
+
**As a** user who installed LaCode once, **I want** each new release to reach my machine automatically, **so that** I never have to learn that opencode freezes the plugin version and hand-clear a cache to upgrade.
|
|
322
|
+
|
|
323
|
+
The pain: opencode's plugin cache is **write-once**. It resolves `@lawaty/lacode` to an exact version once and never re-resolves — not on restart, not with `opencode plugin -g -f`, not with a range pin, not with a tagged config spec (all four verified against opencode 1.18.34). A published release therefore reaches a client only when something installs it into that cache again, and nothing in opencode ever does. The plugin has to do it itself, and it may assume only one tool: the opencode binary the code already runs inside.
|
|
324
|
+
|
|
325
|
+
**Story ID(s) for tests:** US-27
|
|
326
|
+
**Acceptance criteria:**
|
|
327
|
+
- [ ] Given a client runs the plugin and the registry reports a newer version, When the check runs, Then the plugin reinstalls its own cache entry in the background and logs one line naming the old and new version, and startup is never blocked on the registry.
|
|
328
|
+
- [ ] Given the check ran recently, When the plugin loads again within the interval, Then the registry is consulted at most once per interval, so a session start never waits on npm and npm is not hammered.
|
|
329
|
+
- [ ] Given a machine with neither npm nor bun, When an update runs, Then it is performed by the opencode binary alone, because that is the one tool every client is guaranteed to have.
|
|
330
|
+
- [ ] Given an update is installed, When the running session continues, Then it keeps its loaded code and the new version takes effect on the next start, and the log line says exactly that.
|
|
331
|
+
- [ ] Given two opencode processes start together and both find an update, When they race, Then a cross-process lock ensures only one performs the install.
|
|
332
|
+
- [ ] Given `selfUpdate: false` in the plugin options or `LACODE_NO_SELFUPDATE=1` in the environment, When the plugin loads, Then it performs no check at all.
|
|
333
|
+
- [ ] Given the registry is unreachable, When the check runs, Then it is silent, records nothing that would suppress a later retry forever, and never throws into opencode.
|
|
334
|
+
- [ ] Given a plain source checkout rather than an opencode cache install, When the check runs, Then it does not attempt to reinstall, so a development tree is never overwritten.
|
|
335
|
+
- [ ] Given `lacode update` or `lacode update --check`, When it is run, Then it reports (or installs) the update for this machine and exits non-zero only on a real failure — a missing registry, no cache to update, or a failed install.
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
320
339
|
## Epic G — Context and cost (a maintained map instead of cold exploration)
|
|
321
340
|
|
|
322
341
|
The pain: every task opened by exploring the repository from scratch. The same
|
|
@@ -408,6 +427,7 @@ table was filled in.
|
|
|
408
427
|
| US-16 | F | `tests/stories/f-adoption-safety.test.mjs` |
|
|
409
428
|
| US-17 | F | `tests/stories/f-adoption-safety.test.mjs` |
|
|
410
429
|
| US-24 | F | `tests/stories/f-adoption-safety.test.mjs` |
|
|
430
|
+
| US-27 | F | `tests/stories/h-self-update.test.mjs` |
|
|
411
431
|
| US-19 | G | `tests/stories/g-context-management.test.mjs` |
|
|
412
432
|
| US-20 | G | `tests/stories/g-context-management.test.mjs` |
|
|
413
433
|
| US-21 | G | `tests/stories/g-context-management.test.mjs`, `tests/permission-test.mjs` |
|
package/package.json
CHANGED
package/presets/README.md
CHANGED
|
@@ -36,10 +36,10 @@ in place, no JSON and no objects:
|
|
|
36
36
|
|
|
37
37
|
```text
|
|
38
38
|
# provider/model-id weight
|
|
39
|
-
opencode-go/
|
|
39
|
+
opencode-go/step-5-preview-free 3
|
|
40
40
|
opencode/space-bunny-free 2
|
|
41
41
|
opencode/big-pickle 1
|
|
42
|
-
opencode/
|
|
42
|
+
opencode/step-5-preview-free 1
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
`#` starts a comment (whole-line or trailing), blank lines are ignored, and the
|
package/presets/free-tier.json
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
" 3. once EVERY slot is at its ceiling, the overflow goes to the least loaded slot,",
|
|
21
21
|
" so a burst past the summed ceilings degrades to even spreading and never blocks",
|
|
22
22
|
"",
|
|
23
|
-
"With this file:
|
|
24
|
-
"takes 2, then big-pickle and
|
|
23
|
+
"With this file: space-bunny takes 3 sessions to itself, then step-5-preview",
|
|
24
|
+
"(opencode-go) takes 2, then big-pickle and step-5-preview (Zen) take 1 each -- sequence 1,1,1,2,2,3,4,...",
|
|
25
25
|
"Equal weights give a plain round robin in list order. 2,2,1,1 reproduces the old",
|
|
26
26
|
"primary/overflow cycle exactly, which is why the old tier values still work.",
|
|
27
27
|
"",
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
],
|
|
54
54
|
"slots": [
|
|
55
55
|
{ "model": "opencode/space-bunny-free", "weight": 3 },
|
|
56
|
-
{ "model": "opencode-go/
|
|
56
|
+
{ "model": "opencode-go/step-5-preview-free", "weight": 2 },
|
|
57
57
|
{ "model": "opencode/big-pickle", "weight": 1 },
|
|
58
|
-
{ "model": "opencode/
|
|
58
|
+
{ "model": "opencode/step-5-preview-free", "weight": 1 }
|
|
59
59
|
]
|
|
60
60
|
}
|