@lawaty/lacode 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +128 -1
- package/README.md +19 -2
- package/dist/agents.js +30 -3
- package/dist/commands.js +4 -2
- package/dist/index.js +491 -36
- package/dist/pool.js +149 -8
- package/docs/OMNIROUTE.md +17 -15
- package/docs/USER-STORIES.md +2 -2
- package/package.json +1 -1
- package/presets/README.md +4 -4
- package/presets/free-tier.json +3 -2
package/dist/index.js
CHANGED
|
@@ -34,12 +34,15 @@ var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
|
|
|
34
34
|
var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
35
35
|
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
36
36
|
var MAX_WEIGHT = 100;
|
|
37
|
+
var SLOT_CEILING_FACTOR = 2;
|
|
38
|
+
var slotCeiling = (slot) => slot.weight * SLOT_CEILING_FACTOR;
|
|
37
39
|
var MAX_SLOTS = 4;
|
|
38
40
|
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
39
41
|
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
40
42
|
var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
|
|
41
43
|
var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
|
|
42
44
|
var STRIKE_DECAY_MS = 30 * 60 * 1e3;
|
|
45
|
+
var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
|
|
43
46
|
var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
|
|
44
47
|
var BASES = ["explore", "implement", "context"];
|
|
45
48
|
var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
|
|
@@ -207,12 +210,13 @@ var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
|
|
|
207
210
|
var TRANSIENT_GATEWAY_PATTERNS = [/queue budget/i, /exceeded queue/i, /aborted due to timeout/i, /timeout/i];
|
|
208
211
|
var isTransientGatewayText = (text) => TRANSIENT_GATEWAY_PATTERNS.some((re) => re.test(text));
|
|
209
212
|
var statedCooldown = (text) => {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
const bounded = (ms) => Number.isFinite(ms) && ms > 0 && ms <= STATED_COOLDOWN_MAX_MS ? ms : void 0;
|
|
214
|
+
const hm = /resets?\s+in\s+(\d+)\s*(?:hour|hr|h)s?\s*(\d+)?\s*(?:minute|min|m)?s?/i.exec(text);
|
|
215
|
+
if (hm) return bounded((Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3);
|
|
216
|
+
const m = /resets?\s+in\s+(\d+)\s*(?:minute|min|m)s?/i.exec(text);
|
|
217
|
+
if (m) return bounded(Number(m[1]) * 60 * 1e3);
|
|
218
|
+
const s = /retry[\s-]?in\s+(\d+)\s*(?:second|sec|s)s?/i.exec(text);
|
|
219
|
+
if (s) return bounded(Number(s[1]) * 1e3);
|
|
216
220
|
return void 0;
|
|
217
221
|
};
|
|
218
222
|
var retryAfterMs = (headers) => {
|
|
@@ -246,10 +250,14 @@ function state(opts = {}) {
|
|
|
246
250
|
cooling: /* @__PURE__ */ new Map(),
|
|
247
251
|
strikes: /* @__PURE__ */ new Map(),
|
|
248
252
|
activity: /* @__PURE__ */ new Map(),
|
|
253
|
+
probes: /* @__PURE__ */ new Set(),
|
|
254
|
+
probing: false,
|
|
255
|
+
lastProbe: 0,
|
|
249
256
|
children: /* @__PURE__ */ new Map(),
|
|
250
257
|
reaped: 0,
|
|
251
258
|
reaping: false,
|
|
252
259
|
lastSweep: 0,
|
|
260
|
+
lastDirSweep: 0,
|
|
253
261
|
id,
|
|
254
262
|
dir,
|
|
255
263
|
modelsFile,
|
|
@@ -286,10 +294,88 @@ function init(s, onWarn) {
|
|
|
286
294
|
onWarn(`claim dir ${s.dir} unusable; pool load tracked in memory only`);
|
|
287
295
|
}
|
|
288
296
|
}
|
|
297
|
+
var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
|
|
298
|
+
function sweepStateDir(s) {
|
|
299
|
+
const removed = [];
|
|
300
|
+
if (!s.dirReady) return removed;
|
|
301
|
+
let entries;
|
|
302
|
+
try {
|
|
303
|
+
entries = readdirSync(s.dir);
|
|
304
|
+
} catch {
|
|
305
|
+
return removed;
|
|
306
|
+
}
|
|
307
|
+
const now = Date.now();
|
|
308
|
+
const drop = (name) => {
|
|
309
|
+
try {
|
|
310
|
+
unlinkSync(join2(s.dir, name));
|
|
311
|
+
removed.push(name);
|
|
312
|
+
} catch {
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
for (const entry of entries) {
|
|
316
|
+
let age;
|
|
317
|
+
try {
|
|
318
|
+
age = now - statSync(join2(s.dir, entry)).mtimeMs;
|
|
319
|
+
} catch {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (entry.endsWith(".tmp")) {
|
|
323
|
+
if (age > ORPHAN_TMP_MS) drop(entry);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const claim = /^claims\.(.+)\.json$/.exec(entry);
|
|
327
|
+
if (claim) {
|
|
328
|
+
if (claim[1] === s.id) continue;
|
|
329
|
+
if (/^\d+$/.test(claim[1]) && !isPidAlive(Number(claim[1]))) {
|
|
330
|
+
drop(entry);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (age > DEAD_FILE_MS) drop(entry);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (entry.startsWith("limit.") && entry.endsWith(".json")) {
|
|
337
|
+
try {
|
|
338
|
+
const strike = JSON.parse(readFileSync2(join2(s.dir, entry), "utf8"));
|
|
339
|
+
if (typeof strike.until !== "number") {
|
|
340
|
+
drop(entry);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (strike.until > now) continue;
|
|
344
|
+
if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) continue;
|
|
345
|
+
drop(entry);
|
|
346
|
+
} catch {
|
|
347
|
+
drop(entry);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return removed;
|
|
352
|
+
}
|
|
289
353
|
function pruneOwn(s) {
|
|
290
354
|
const now = Date.now();
|
|
291
355
|
for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) > CLAIM_TTL_MS) s.own.delete(key);
|
|
292
356
|
}
|
|
357
|
+
function pruneActivity(s, now) {
|
|
358
|
+
if (s.activity.size === 0) return;
|
|
359
|
+
const live = /* @__PURE__ */ new Set();
|
|
360
|
+
for (const claim of s.own.values()) if (claim.g) live.add(claim.g);
|
|
361
|
+
for (const [id, seen] of s.activity) {
|
|
362
|
+
if (live.has(id)) continue;
|
|
363
|
+
if (now - seen > CLAIM_TTL_MS) s.activity.delete(id);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function pruneChildren(s, now) {
|
|
367
|
+
if (s.children.size === 0) return;
|
|
368
|
+
for (const [parent, ring] of s.children) {
|
|
369
|
+
const fresh = ring.filter((child) => now - child.t <= CHILD_MEMORY_MS);
|
|
370
|
+
if (fresh.length === 0) s.children.delete(parent);
|
|
371
|
+
else if (fresh.length !== ring.length) s.children.set(parent, fresh);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function pruneSessions(s) {
|
|
375
|
+
const now = Date.now();
|
|
376
|
+
pruneActivity(s, now);
|
|
377
|
+
pruneChildren(s, now);
|
|
378
|
+
}
|
|
293
379
|
function publish(s) {
|
|
294
380
|
if (!s.dirReady) return;
|
|
295
381
|
const now = Date.now();
|
|
@@ -435,6 +521,50 @@ function coolUnavailable(s, model) {
|
|
|
435
521
|
}
|
|
436
522
|
return strike;
|
|
437
523
|
}
|
|
524
|
+
function clearCooldowns(s, models) {
|
|
525
|
+
const cleared = [];
|
|
526
|
+
const now = Date.now();
|
|
527
|
+
const wanted = models && models.length > 0 ? new Set(models) : void 0;
|
|
528
|
+
let entries;
|
|
529
|
+
try {
|
|
530
|
+
entries = readdirSync(s.dir);
|
|
531
|
+
} catch {
|
|
532
|
+
return cleared;
|
|
533
|
+
}
|
|
534
|
+
for (const entry of entries) {
|
|
535
|
+
if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
|
|
536
|
+
const path4 = join2(s.dir, entry);
|
|
537
|
+
let strike;
|
|
538
|
+
try {
|
|
539
|
+
const parsed = JSON.parse(readFileSync2(path4, "utf8"));
|
|
540
|
+
if (parsed && typeof parsed.model === "string") strike = parsed;
|
|
541
|
+
} catch {
|
|
542
|
+
strike = void 0;
|
|
543
|
+
}
|
|
544
|
+
if (wanted && (strike === void 0 || !wanted.has(strike.model))) continue;
|
|
545
|
+
try {
|
|
546
|
+
unlinkSync(path4);
|
|
547
|
+
} catch {
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (strike === void 0) {
|
|
551
|
+
cleared.push({ model: entry, reason: "unreadable cooldown file", strikes: 0, secondsLeft: 0 });
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
s.cooling.delete(strike.model);
|
|
555
|
+
s.strikes.delete(strike.model);
|
|
556
|
+
cleared.push({
|
|
557
|
+
model: strike.model,
|
|
558
|
+
reason: typeof strike.reason === "string" ? strike.reason : "unknown",
|
|
559
|
+
strikes: typeof strike.strikes === "number" ? strike.strikes : 0,
|
|
560
|
+
secondsLeft: typeof strike.until === "number" ? Math.max(0, Math.ceil((strike.until - now) / 1e3)) : 0
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
return cleared;
|
|
564
|
+
}
|
|
565
|
+
var markProbe = (s, sessionID) => s.probes.add(sessionID);
|
|
566
|
+
var unmarkProbe = (s, sessionID) => s.probes.delete(sessionID);
|
|
567
|
+
var isProbe = (s, sessionID) => s.probes.has(sessionID);
|
|
438
568
|
function refreshCatalog(s, serverUrl) {
|
|
439
569
|
if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
|
|
440
570
|
s.catalogAt = Date.now();
|
|
@@ -457,6 +587,7 @@ function isAvailable(s, model, cooling) {
|
|
|
457
587
|
}
|
|
458
588
|
function snapshot(s) {
|
|
459
589
|
pruneOwn(s);
|
|
590
|
+
pruneSessions(s);
|
|
460
591
|
const siblings = readSiblings(s);
|
|
461
592
|
const cooling = readLimits(s);
|
|
462
593
|
const counts = /* @__PURE__ */ new Map();
|
|
@@ -466,7 +597,7 @@ function snapshot(s) {
|
|
|
466
597
|
return { counts, cooling, siblings, own: s.own, dirReady: s.dirReady };
|
|
467
598
|
}
|
|
468
599
|
var loadOf = (counts, model) => counts.get(model) ?? 0;
|
|
469
|
-
var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slot
|
|
600
|
+
var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
|
|
470
601
|
function roundRobinSequence(slots) {
|
|
471
602
|
const seq = [];
|
|
472
603
|
for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
|
|
@@ -1319,20 +1450,46 @@ on that.
|
|
|
1319
1450
|
write only what the client asked for.
|
|
1320
1451
|
- Say plainly in your report that you created the file.
|
|
1321
1452
|
|
|
1453
|
+
# Before you write
|
|
1454
|
+
|
|
1455
|
+
Read \`.opencode/context/\` first when it is there \u2014 \`architecture.md\`,
|
|
1456
|
+
\`conventions.md\` or \`contexts.md\`, whichever bears on the directive \u2014 so you do
|
|
1457
|
+
not record a fact the project has already settled, and do not contradict one you
|
|
1458
|
+
do not know yet. Read only what is relevant, and carry on if it is missing.
|
|
1459
|
+
|
|
1460
|
+
That map is READ-ONLY to you: the \`context\` agent is its only writer. If the
|
|
1461
|
+
directive contradicts it, say so plainly in your report and still write what the
|
|
1462
|
+
client asked for. Never edit the map to agree with \`AGENTS.md\` \u2014 the two are
|
|
1463
|
+
different files with different jobs, and this one is yours alone.
|
|
1464
|
+
|
|
1322
1465
|
# Reporting
|
|
1323
1466
|
|
|
1324
1467
|
Report briefly what you changed and where. Do not edit any other file.`,
|
|
1325
1468
|
permission: {
|
|
1326
|
-
// Read
|
|
1327
|
-
//
|
|
1469
|
+
// Read reaches AGENTS.md and the context map, and nothing else.
|
|
1470
|
+
//
|
|
1471
|
+
// The map is here because the directive is about how work is DONE in this
|
|
1472
|
+
// project and the map is the maintained record of what the project IS: reading
|
|
1473
|
+
// it is how this agent avoids recording a fact the code already settles, and
|
|
1474
|
+
// avoids contradicting one it does not know yet. It stays READ-ONLY — `edit`
|
|
1475
|
+
// below still denies the map, so this grant cannot turn the one-writer rule
|
|
1476
|
+
// into a two-writer rule, and the writer guard does not have to care about it
|
|
1477
|
+
// (it reads `edit` alone).
|
|
1478
|
+
//
|
|
1479
|
+
// Both spellings are required, and every allow must sit AFTER the broad deny:
|
|
1480
|
+
// LAST match wins, so an allow written before "*": "deny" is silently inert.
|
|
1328
1481
|
read: {
|
|
1329
1482
|
"*": "deny",
|
|
1330
1483
|
"AGENTS.md": "allow",
|
|
1331
|
-
"*/AGENTS.md": "allow"
|
|
1484
|
+
"*/AGENTS.md": "allow",
|
|
1485
|
+
"*/.opencode/context/**": "allow",
|
|
1486
|
+
".opencode/context/**": "allow"
|
|
1332
1487
|
},
|
|
1333
1488
|
glob: "deny",
|
|
1334
1489
|
grep: "deny",
|
|
1335
1490
|
list: "deny",
|
|
1491
|
+
// Still exactly one writable path. The map is readable (see above) and stays
|
|
1492
|
+
// unwritable: `context` is its only writer, and this agent is not it.
|
|
1336
1493
|
edit: {
|
|
1337
1494
|
"*": "deny",
|
|
1338
1495
|
"AGENTS.md": "allow",
|
|
@@ -1468,7 +1625,8 @@ Follow the rules for the file in your system prompt, then report what you change
|
|
|
1468
1625
|
Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
|
|
1469
1626
|
|
|
1470
1627
|
1. Pool status \u2014 call the \`pool_status\` tool. Report the slot list, each
|
|
1471
|
-
slot's load against its weight
|
|
1628
|
+
slot's load against its ceiling (twice the slot's weight), and which models
|
|
1629
|
+
are cooling.
|
|
1472
1630
|
|
|
1473
1631
|
2. OmniRoute configured \u2014 inspect the \`@lawaty/lacode\` plugin options in the
|
|
1474
1632
|
user's opencode config for an \`omniroute\` block with \`enabled: true\`. If it
|
|
@@ -1505,7 +1663,7 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
|
|
|
1505
1663
|
2. Resolve the instruction above into a target slot table. These are the rules the loader enforces \u2014 never write a file that breaks one:
|
|
1506
1664
|
- one slot per line: \`<provider>/<model-id> [weight]\`
|
|
1507
1665
|
- at most ${MAX_SLOTS} slots, and a model may appear only once
|
|
1508
|
-
- weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the
|
|
1666
|
+
- weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's share of the rotation \u2014 how many turns it takes per cycle \u2014 and its concurrency ceiling is twice that. NOT a priority; pick order follows line order.
|
|
1509
1667
|
- \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
|
|
1510
1668
|
If the instruction names a model ambiguously, or would need more than ${MAX_SLOTS} slots, stop and ask rather than guessing.
|
|
1511
1669
|
|
|
@@ -1540,11 +1698,126 @@ function applyCommands(cfg, commands = COMMANDS) {
|
|
|
1540
1698
|
|
|
1541
1699
|
// src/plugins/agent-pool.ts
|
|
1542
1700
|
import { tool } from "@opencode-ai/plugin";
|
|
1701
|
+
|
|
1702
|
+
// src/lib/probe.ts
|
|
1703
|
+
var PROBE_SYSTEM = "You are a connection health check. Reply with the single word: ok";
|
|
1704
|
+
var PROBE_PROMPT = "ok";
|
|
1705
|
+
var PROBE_TIMEOUT_MS = 45e3;
|
|
1706
|
+
var failureText = (error) => {
|
|
1707
|
+
if (error === void 0 || error === null) return "";
|
|
1708
|
+
if (typeof error === "string") return error;
|
|
1709
|
+
try {
|
|
1710
|
+
return JSON.stringify(error) ?? String(error);
|
|
1711
|
+
} catch {
|
|
1712
|
+
return String(error);
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
var shortReason = (text) => {
|
|
1716
|
+
if (!text) return "failed";
|
|
1717
|
+
const matched = text.match(/rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded/i)?.[0];
|
|
1718
|
+
return matched ? matched.toLowerCase().replace(/[\s_]/g, "-") : isLimitText(text) ? "limit" : "failed";
|
|
1719
|
+
};
|
|
1720
|
+
var probeOne = async (client, opts, model, leftSeconds) => {
|
|
1721
|
+
const { state: s, directory } = opts;
|
|
1722
|
+
const { provider, id: modelID } = splitModel(model);
|
|
1723
|
+
const settle = (ok, reason, left) => ({
|
|
1724
|
+
model,
|
|
1725
|
+
ok,
|
|
1726
|
+
reason,
|
|
1727
|
+
hadSeconds: leftSeconds,
|
|
1728
|
+
leftSeconds: left
|
|
1729
|
+
});
|
|
1730
|
+
const created = await client.session.create({ body: { title: `pool probe (${model})` }, query: { directory } }).then((r) => r?.data?.id).catch(() => void 0);
|
|
1731
|
+
if (!created) return settle(false, "no probe session", leftSeconds);
|
|
1732
|
+
markProbe(s, created);
|
|
1733
|
+
let timer;
|
|
1734
|
+
try {
|
|
1735
|
+
const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
|
|
1736
|
+
const clock = opts.now ?? Date.now;
|
|
1737
|
+
let timedOut = false;
|
|
1738
|
+
const deadline = new Promise((resolve3) => {
|
|
1739
|
+
timer = setTimeout(() => {
|
|
1740
|
+
timedOut = true;
|
|
1741
|
+
void client.session.abort({ path: { id: created }, query: { directory } }).catch(() => {
|
|
1742
|
+
});
|
|
1743
|
+
resolve3(void 0);
|
|
1744
|
+
}, timeoutMs);
|
|
1745
|
+
});
|
|
1746
|
+
const answer = client.session.prompt({
|
|
1747
|
+
path: { id: created },
|
|
1748
|
+
query: { directory },
|
|
1749
|
+
body: {
|
|
1750
|
+
model: { providerID: provider, modelID },
|
|
1751
|
+
system: PROBE_SYSTEM,
|
|
1752
|
+
parts: [{ type: "text", text: PROBE_PROMPT }]
|
|
1753
|
+
}
|
|
1754
|
+
}).then(
|
|
1755
|
+
(result2) => result2,
|
|
1756
|
+
(error) => ({ error })
|
|
1757
|
+
);
|
|
1758
|
+
const result = await Promise.race([answer, deadline]);
|
|
1759
|
+
const failure = result?.error ?? result?.data?.info?.error;
|
|
1760
|
+
if (!timedOut && failure === void 0 && result?.data?.info !== void 0) {
|
|
1761
|
+
clearCooldowns(s, [model]);
|
|
1762
|
+
return settle(true, "answered", 0);
|
|
1763
|
+
}
|
|
1764
|
+
const text = failureText(failure);
|
|
1765
|
+
const stated = statedCooldown(text);
|
|
1766
|
+
if (stated !== void 0) recordStrike(s, model, shortReason(text), stated);
|
|
1767
|
+
const still = readLimits(s).get(model);
|
|
1768
|
+
return settle(
|
|
1769
|
+
false,
|
|
1770
|
+
timedOut ? `no answer in ${Math.round(timeoutMs / 1e3)}s` : shortReason(text),
|
|
1771
|
+
still && still.until > clock() ? Math.ceil((still.until - clock()) / 1e3) : 0
|
|
1772
|
+
);
|
|
1773
|
+
} finally {
|
|
1774
|
+
if (timer) clearTimeout(timer);
|
|
1775
|
+
const deleted = await client.session.delete({ path: { id: created }, query: { directory } }).then(() => true, () => false);
|
|
1776
|
+
if (deleted) unmarkProbe(s, created);
|
|
1777
|
+
}
|
|
1778
|
+
};
|
|
1779
|
+
async function probeCooling(client, opts) {
|
|
1780
|
+
const s = opts.state;
|
|
1781
|
+
const now = opts.now ?? Date.now;
|
|
1782
|
+
const empty = { outcomes: [], cleared: [], kept: [], changed: false };
|
|
1783
|
+
if (s.probing) return empty;
|
|
1784
|
+
const cooling = readLimits(s);
|
|
1785
|
+
const at = now();
|
|
1786
|
+
const wanted = opts.models && opts.models.length > 0 ? new Set(opts.models) : void 0;
|
|
1787
|
+
const candidates = [...cooling.values()].filter(
|
|
1788
|
+
(strike) => strike.until > at && (wanted === void 0 || wanted.has(strike.model))
|
|
1789
|
+
);
|
|
1790
|
+
if (candidates.length === 0) return empty;
|
|
1791
|
+
s.probing = true;
|
|
1792
|
+
const outcomes = [];
|
|
1793
|
+
try {
|
|
1794
|
+
for (const strike of candidates.sort((a, b) => a.until - b.until)) {
|
|
1795
|
+
outcomes.push(await probeOne(client, opts, strike.model, Math.ceil((strike.until - at) / 1e3)));
|
|
1796
|
+
}
|
|
1797
|
+
} finally {
|
|
1798
|
+
s.probing = false;
|
|
1799
|
+
s.lastProbe = Date.now();
|
|
1800
|
+
}
|
|
1801
|
+
const reAdmitted = outcomes.filter((o) => o.ok).map((o) => o.model);
|
|
1802
|
+
const stillLimited = outcomes.filter((o) => !o.ok);
|
|
1803
|
+
return {
|
|
1804
|
+
outcomes,
|
|
1805
|
+
cleared: reAdmitted,
|
|
1806
|
+
kept: stillLimited.map((o) => o.model),
|
|
1807
|
+
// A window the provider restated is a change too: the pool now waits a
|
|
1808
|
+
// different length than it did, and the user is the one who set that.
|
|
1809
|
+
changed: reAdmitted.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// src/plugins/agent-pool.ts
|
|
1543
1814
|
var SERVICE = "agent-pool";
|
|
1544
1815
|
var REAP_INTERVAL_MS = 3e4;
|
|
1545
1816
|
var HANG_FALLBACK_MS = 10 * 60 * 1e3;
|
|
1817
|
+
var PROBE_INTERVAL_MS = 15 * 60 * 1e3;
|
|
1546
1818
|
var ABORT_TIMEOUT_MS = 1e4;
|
|
1547
|
-
var
|
|
1819
|
+
var formatWindow = (seconds) => seconds < 60 ? `${seconds}s` : `${Math.ceil(seconds / 60)}m`;
|
|
1820
|
+
var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
1548
1821
|
const opts = options ?? {};
|
|
1549
1822
|
const s = state(opts);
|
|
1550
1823
|
ensureUserConfigDir();
|
|
@@ -1556,6 +1829,13 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1556
1829
|
await log("warn", message);
|
|
1557
1830
|
};
|
|
1558
1831
|
init(s, (m) => void warnOnce(m));
|
|
1832
|
+
const sweepDirIfDue = () => {
|
|
1833
|
+
if (Date.now() - s.lastDirSweep < STATE_SWEEP_INTERVAL_MS) return;
|
|
1834
|
+
s.lastDirSweep = Date.now();
|
|
1835
|
+
const removed = sweepStateDir(s);
|
|
1836
|
+
if (removed.length > 0) void log("info", `swept ${removed.length} stale pool file(s): ${removed.join(", ")}`);
|
|
1837
|
+
};
|
|
1838
|
+
sweepDirIfDue();
|
|
1559
1839
|
const abortSession = async (sessionID) => {
|
|
1560
1840
|
if (!serverUrl) return { ok: false, why: "no serverUrl: this process has no HTTP server to abort through" };
|
|
1561
1841
|
try {
|
|
@@ -1597,6 +1877,8 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1597
1877
|
s.reaping = true;
|
|
1598
1878
|
s.lastSweep = Date.now();
|
|
1599
1879
|
try {
|
|
1880
|
+
pruneSessions(s);
|
|
1881
|
+
sweepDirIfDue();
|
|
1600
1882
|
const stuck = findStuck(s);
|
|
1601
1883
|
if (stuck.length === 0) return;
|
|
1602
1884
|
const released = [];
|
|
@@ -1661,6 +1943,46 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1661
1943
|
"info",
|
|
1662
1944
|
`reaper armed: every ${Math.round(reapMs / 1e3)}s, aborts a claim older than ${Math.round(STUCK_MIN_AGE_MS / 6e4)}m whose target has been silent ${Math.round(STUCK_IDLE_MS / 6e4)}m, or any claim past the ${Math.round(MAX_CLAIM_AGE_MS / 6e4)}m ceiling however chatty`
|
|
1663
1945
|
);
|
|
1946
|
+
const EMPTY_REPORT = { outcomes: [], cleared: [], kept: [], changed: false };
|
|
1947
|
+
const reportTitle = (report) => report.outcomes.length === 0 ? "nothing cooling to probe" : `probed ${report.outcomes.length} cooling model(s), re-admitted ${report.cleared.length}`;
|
|
1948
|
+
const reportText = (report) => {
|
|
1949
|
+
if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
|
|
1950
|
+
return [
|
|
1951
|
+
...report.outcomes.map(
|
|
1952
|
+
(outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the rotation` : `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`
|
|
1953
|
+
),
|
|
1954
|
+
report.cleared.length > 0 ? `re-admitted: ${report.cleared.join(", ")}. They route again on the next pooled spawn \u2014 nothing needs restarting or reloading.` : "nothing was re-admitted; the pool is short exactly as much as it was."
|
|
1955
|
+
].join("\n");
|
|
1956
|
+
};
|
|
1957
|
+
const announce = async (report) => {
|
|
1958
|
+
if (!report.changed) return;
|
|
1959
|
+
const message = report.cleared.length > 0 ? `Re-admitted ${report.cleared.join(", ")} \u2014 they answer again.` : `${report.kept.length} cooling model(s) re-checked, still limited.`;
|
|
1960
|
+
await log("info", `pool probe: ${message}`, { cleared: report.cleared, kept: report.kept });
|
|
1961
|
+
try {
|
|
1962
|
+
await client.tui?.showToast?.({
|
|
1963
|
+
body: { title: "pool recheck", message, variant: report.cleared.length > 0 ? "success" : "info", duration: 8e3 }
|
|
1964
|
+
});
|
|
1965
|
+
} catch {
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
const runProbe = async (models) => {
|
|
1969
|
+
let report = EMPTY_REPORT;
|
|
1970
|
+
try {
|
|
1971
|
+
report = await probeCooling(client, { state: s, directory, models });
|
|
1972
|
+
} catch (error) {
|
|
1973
|
+
await log("error", `pool probe failed: ${String(error)}`);
|
|
1974
|
+
return EMPTY_REPORT;
|
|
1975
|
+
}
|
|
1976
|
+
await announce(report);
|
|
1977
|
+
return report;
|
|
1978
|
+
};
|
|
1979
|
+
const probeMs = opts.probeMs ?? PROBE_INTERVAL_MS;
|
|
1980
|
+
const probeTimer = probeMs > 0 ? setInterval(() => void runProbe(), probeMs) : void 0;
|
|
1981
|
+
probeTimer?.unref?.();
|
|
1982
|
+
void log(
|
|
1983
|
+
"info",
|
|
1984
|
+
probeMs > 0 ? `cooldown probe armed: every ${Math.round(probeMs / 6e4)}m, checks the models that are cooling by using them (one request each, one at a time); 0 to disable` : "cooldown probe disabled (probeMs: 0); use the pool_recheck tool to check on demand"
|
|
1985
|
+
);
|
|
1664
1986
|
return {
|
|
1665
1987
|
config: async (cfg) => {
|
|
1666
1988
|
const slots = ensureSlots(s, (m) => void warnOnce(m));
|
|
@@ -1712,6 +2034,10 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1712
2034
|
const reason2 = status2?.action?.reason ?? "";
|
|
1713
2035
|
if (status2?.type === "retry" && status2.action?.provider && (reason2 === "free_tier_limit" || isLimitText(reason2))) {
|
|
1714
2036
|
const provider = status2.action.provider;
|
|
2037
|
+
if (sessionID && isProbe(s, sessionID)) {
|
|
2038
|
+
await log("debug", `probe on provider ${provider} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
1715
2041
|
const until = typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
|
|
1716
2042
|
const cooled = coolProvider(s, provider, until, reason2 || "rate-limited");
|
|
1717
2043
|
if (cooled.length > 0) {
|
|
@@ -1746,6 +2072,10 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1746
2072
|
const transientGateway = gateway5xx || isTransientGatewayText(haystack);
|
|
1747
2073
|
if (!limited && !transientGateway) return;
|
|
1748
2074
|
const model = `${info.providerID ?? "?"}/${info.modelID ?? "?"}`;
|
|
2075
|
+
if (info.sessionID && isProbe(s, info.sessionID)) {
|
|
2076
|
+
await log("debug", `probe of ${model} hit a limit (${isLimitText(haystack) ? "limit" : "transient"}); leaving the cooldown to the probe`);
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
1749
2079
|
if (isUnavailableText(haystack) || !limited && transientGateway) {
|
|
1750
2080
|
const transient = coolUnavailable(s, model);
|
|
1751
2081
|
if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
|
|
@@ -1787,15 +2117,22 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1787
2117
|
}
|
|
1788
2118
|
acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
|
|
1789
2119
|
output.args.subagent_type = variant;
|
|
1790
|
-
await log("info", `routed ${requested}
|
|
2120
|
+
await log("info", `routed ${requested} to slot ${slot.index} on ${slot.model}`, {
|
|
1791
2121
|
slot: slot.index,
|
|
1792
2122
|
weight: slot.weight,
|
|
1793
2123
|
model: slot.model,
|
|
1794
|
-
ceiling: slot
|
|
2124
|
+
ceiling: slotCeiling(slot),
|
|
1795
2125
|
load: (snap.counts.get(slot.model) ?? 0) + 1
|
|
1796
2126
|
});
|
|
1797
2127
|
return;
|
|
1798
2128
|
}
|
|
2129
|
+
const pinned = s.models.get(requested);
|
|
2130
|
+
const bench = pinned ? snap.cooling.get(pinned) : void 0;
|
|
2131
|
+
if (pinned && bench) {
|
|
2132
|
+
await warnOnce(
|
|
2133
|
+
`${requested} runs ${pinned}, which is cooling (${bench.reason}, strike ${bench.strikes}) -- it cannot fail over, because only ${BASES.join("/")} are pooled. Drop the model line from ${requested} to route it through the pool, or wait out the cooldown.`
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
1799
2136
|
} catch (error) {
|
|
1800
2137
|
await log("error", `routing failed, spawn unchanged: ${String(error)}`);
|
|
1801
2138
|
}
|
|
@@ -1805,8 +2142,18 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1805
2142
|
release(s, claimKey(input.sessionID, input.callID));
|
|
1806
2143
|
},
|
|
1807
2144
|
tool: {
|
|
2145
|
+
pool_recheck: tool({
|
|
2146
|
+
description: "Probe the models the pool has benched and re-admit the ones that answer. Each probe is a real request on that exact model, so it settles what a cooldown can only guess: if the model replies, its cooldown is cleared and it is back in the rotation; if it is still limited, the existing cooldown stands. Call it after resetting a quota in the provider's own console, when the pool feels short of capacity, or to check now instead of waiting for the next automatic pass.",
|
|
2147
|
+
args: {
|
|
2148
|
+
models: tool.schema.array(tool.schema.string()).optional().describe("Model ids to probe, as provider/model-id. Omit to probe every model that is cooling.")
|
|
2149
|
+
},
|
|
2150
|
+
async execute(args) {
|
|
2151
|
+
const report = await runProbe(args?.models);
|
|
2152
|
+
return { title: `agent-pool: ${reportTitle(report)}`, output: reportText(report) };
|
|
2153
|
+
}
|
|
2154
|
+
}),
|
|
1808
2155
|
pool_status: tool({
|
|
1809
|
-
description: "Show live pool load across every opencode process, per free model and per agent type, each slot's load against its
|
|
2156
|
+
description: "Show live pool load across every opencode process, per free model and per agent type, each slot's load against its ceiling, the slot marked <- next that the next spawn would take, and which models are cooling after a rate limit or a stuck task. Check it before launching many parallel subagents.",
|
|
1810
2157
|
args: {},
|
|
1811
2158
|
async execute() {
|
|
1812
2159
|
refreshCatalog(s, serverUrl);
|
|
@@ -1835,7 +2182,7 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1835
2182
|
const marker = slot.index === next.index ? " <- next" : "";
|
|
1836
2183
|
const load = entry?.total ?? 0;
|
|
1837
2184
|
const atCeiling = !underCeiling(slot, snap.counts);
|
|
1838
|
-
return ` slot ${slot.index} w${slot.weight} ${slot.model} load=${load}/${slot
|
|
2185
|
+
return ` slot ${slot.index} w${slot.weight} ${slot.model} load=${load}/${slotCeiling(slot)}${atCeiling ? " FULL" : ""}${marker} [${users}${notes.length ? "; " + notes.join("; ") : ""}]`;
|
|
1839
2186
|
};
|
|
1840
2187
|
const total = [...snap.counts.values()].reduce((sum, n) => sum + n, 0);
|
|
1841
2188
|
const coolingNow = [...snap.cooling.entries()].filter(([, st]) => st.until > now);
|
|
@@ -1857,6 +2204,10 @@ round robin in slot order, each slot taking as many turns as its weight says, so
|
|
|
1857
2204
|
`hung past ${Math.round(STUCK_MIN_AGE_MS / 6e4)}min with ${Math.round(STUCK_IDLE_MS / 6e4)}min silence: ${hung} (auto-aborted); reaped total: ${s.reaped}.`,
|
|
1858
2205
|
`overran past ${Math.round(MAX_CLAIM_AGE_MS / 6e4)}min: ${overran} (auto-aborted).`,
|
|
1859
2206
|
`watchdog alive: last sweep ${s.lastSweep ? `${Math.max(0, Math.round((now - s.lastSweep) / 1e3))}s ago` : "never"} (every ${Math.round(reapMs / 1e3)}s).`,
|
|
2207
|
+
// When the cooled models were last USED rather than inferred, because
|
|
2208
|
+
// "still cooling" and "nobody has actually asked it lately" are
|
|
2209
|
+
// different situations and this is the line that tells them apart.
|
|
2210
|
+
probeMs > 0 ? `cooldown probe: last ${s.lastProbe ? `${Math.max(0, Math.round((now - s.lastProbe) / 1e3))}s ago` : "never (nothing has been cooling)"}, every ${Math.round(probeMs / 6e4)}m; call the pool_recheck tool to ask now.` : "cooldown probe: off (probeMs: 0); call the pool_recheck tool to ask now.",
|
|
1860
2211
|
coolingNow.length === 0 ? "no models cooling down." : `cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
|
|
1861
2212
|
].join("\n")
|
|
1862
2213
|
};
|
|
@@ -1865,14 +2216,18 @@ round robin in slot order, each slot taking as many turns as its weight says, so
|
|
|
1865
2216
|
},
|
|
1866
2217
|
dispose: async () => {
|
|
1867
2218
|
clearInterval(timer);
|
|
2219
|
+
if (probeTimer) clearInterval(probeTimer);
|
|
1868
2220
|
s.own.clear();
|
|
2221
|
+
s.activity.clear();
|
|
2222
|
+
s.children.clear();
|
|
1869
2223
|
publish(s);
|
|
1870
2224
|
}
|
|
1871
2225
|
};
|
|
1872
2226
|
});
|
|
1873
2227
|
|
|
1874
2228
|
// src/plugins/context-autoupdate.ts
|
|
1875
|
-
import { existsSync as existsSync2, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
|
|
2229
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2230
|
+
import { homedir as homedir2 } from "node:os";
|
|
1876
2231
|
import * as path2 from "node:path";
|
|
1877
2232
|
|
|
1878
2233
|
// src/lib/jsonc.ts
|
|
@@ -2028,8 +2383,24 @@ var IGNORED_SEGMENTS = /* @__PURE__ */ new Set([
|
|
|
2028
2383
|
".next",
|
|
2029
2384
|
".turbo",
|
|
2030
2385
|
".cache",
|
|
2031
|
-
".git"
|
|
2386
|
+
".git",
|
|
2387
|
+
// Generated payloads. These are OUTPUT, not source: a single decoded corpus or
|
|
2388
|
+
// derived report in one of these is routinely tens of megabytes, and an agent
|
|
2389
|
+
// that samples one with `head`/`cat` produces a tool output large enough to
|
|
2390
|
+
// be worth recording on its own.
|
|
2391
|
+
"derived_outputs",
|
|
2392
|
+
"derived",
|
|
2393
|
+
"artifacts",
|
|
2394
|
+
"corpus",
|
|
2395
|
+
"corpora"
|
|
2032
2396
|
]);
|
|
2397
|
+
var NO_BULK_READS = [
|
|
2398
|
+
"Do not read generated, vendored or corpus payloads: `node_modules/`, `dist/`,",
|
|
2399
|
+
"`build/`, `out/`, `*.min.js`, `derived_outputs/`, `*.bytes`, and large `.json`/`.csv`",
|
|
2400
|
+
"corpora. They are megabytes each and describe no architecture. Read the source",
|
|
2401
|
+
"that PRODUCES or CONSUMES them instead. If a payload must be sampled, use",
|
|
2402
|
+
"`head -c 2000` or `wc -c` \u2014 never `cat` or a bare `grep -r` across one."
|
|
2403
|
+
].join(" ");
|
|
2033
2404
|
var toPosix2 = (value) => value.split(path2.sep).join("/");
|
|
2034
2405
|
var findProjectConfig = () => {
|
|
2035
2406
|
let dir = import.meta.dir ?? path2.dirname(new URL(import.meta.url).pathname);
|
|
@@ -2064,6 +2435,33 @@ var changedSince = (before, dir) => {
|
|
|
2064
2435
|
for (const name of before.keys()) if (!after.has(name)) changed++;
|
|
2065
2436
|
return changed;
|
|
2066
2437
|
};
|
|
2438
|
+
var RETAIN_TRANSCRIPTS = 20;
|
|
2439
|
+
var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(homedir2(), ".local", "share", "opencode", "lacode", "cartographer");
|
|
2440
|
+
var retireSession = async (client, directory, sessionID, mode) => {
|
|
2441
|
+
try {
|
|
2442
|
+
const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
|
|
2443
|
+
const dir = transcriptDir();
|
|
2444
|
+
mkdirSync2(dir, { recursive: true });
|
|
2445
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2446
|
+
const file = path2.join(dir, `${stamp}-${mode}.json`);
|
|
2447
|
+
writeFileSync2(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
|
|
2448
|
+
pruneTranscripts(dir);
|
|
2449
|
+
await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
|
|
2450
|
+
} catch {
|
|
2451
|
+
}
|
|
2452
|
+
};
|
|
2453
|
+
var pruneTranscripts = (dir) => {
|
|
2454
|
+
try {
|
|
2455
|
+
const entries = readdirSync2(dir).filter((name) => name.endsWith(".json")).sort();
|
|
2456
|
+
for (const name of entries.slice(0, Math.max(0, entries.length - RETAIN_TRANSCRIPTS))) {
|
|
2457
|
+
try {
|
|
2458
|
+
unlinkSync2(path2.join(dir, name));
|
|
2459
|
+
} catch {
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
} catch {
|
|
2463
|
+
}
|
|
2464
|
+
};
|
|
2067
2465
|
var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
2068
2466
|
const pending = /* @__PURE__ */ new Set();
|
|
2069
2467
|
const owned = /* @__PURE__ */ new Map();
|
|
@@ -2097,6 +2495,8 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2097
2495
|
"This session touched these paths (starting context, not an exhaustive list):",
|
|
2098
2496
|
listed(files),
|
|
2099
2497
|
"",
|
|
2498
|
+
NO_BULK_READS,
|
|
2499
|
+
"",
|
|
2100
2500
|
"Follow the bootstrap procedure in your system prompt: progressive exploration, then write the five context files within their size targets. When the project already has architecture or decision documentation, link to it instead of duplicating it. Write conclusions, not inventories."
|
|
2101
2501
|
].join("\n") : [
|
|
2102
2502
|
"Incremental context update. Project root: " + directory,
|
|
@@ -2104,6 +2504,8 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2104
2504
|
"This session touched these paths (start from these and their immediate architectural neighborhood):",
|
|
2105
2505
|
listed(files),
|
|
2106
2506
|
"",
|
|
2507
|
+
NO_BULK_READS,
|
|
2508
|
+
"",
|
|
2107
2509
|
'Follow the incremental-update procedure in your system prompt. Update a context file only if this work introduced or revealed information that will materially help future agents: a new boundary or module, a new or moved canonical entry point, a changed workflow, a new convention, an architectural decision, a new dependency between subsystems, a renamed or moved subsystem, or a previously undocumented invariant. Do not edit documentation merely because source files changed. If nothing material changed, make no edits and report "no update needed".'
|
|
2108
2510
|
].join("\n");
|
|
2109
2511
|
return {
|
|
@@ -2119,6 +2521,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2119
2521
|
lastRun = Date.now();
|
|
2120
2522
|
return;
|
|
2121
2523
|
}
|
|
2524
|
+
if (isProbe(state2, sessionID)) return;
|
|
2122
2525
|
const now = Date.now();
|
|
2123
2526
|
for (const [id, startedAt] of owned) if (now - startedAt > OWNED_TTL_MS) owned.delete(id);
|
|
2124
2527
|
if (owned.size > 0) return;
|
|
@@ -2163,6 +2566,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2163
2566
|
body: { agent, parts: [{ type: "text", text: compose(mode, files) }] }
|
|
2164
2567
|
}).then(async (result) => {
|
|
2165
2568
|
release(state2, claimKey2);
|
|
2569
|
+
await retireSession(client, directory, target, mode);
|
|
2166
2570
|
const failure = result.error ?? result.data?.info?.error;
|
|
2167
2571
|
if (failure) {
|
|
2168
2572
|
requeue();
|
|
@@ -2186,6 +2590,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2186
2590
|
}
|
|
2187
2591
|
}).catch(async (error) => {
|
|
2188
2592
|
release(state2, claimKey2);
|
|
2593
|
+
await retireSession(client, directory, target, mode);
|
|
2189
2594
|
requeue();
|
|
2190
2595
|
await log("error", `auto ${mode} failed: ${String(error)}`);
|
|
2191
2596
|
});
|
|
@@ -2372,6 +2777,9 @@ var Notifier = class {
|
|
|
2372
2777
|
// src/plugins/notify.ts
|
|
2373
2778
|
var SERVICE3 = "notify";
|
|
2374
2779
|
var COOLDOWN_MS2 = 5e3;
|
|
2780
|
+
var MAX_INFLIGHT_HELPERS = 4;
|
|
2781
|
+
var HELPER_TIMEOUT_MS = 3e4;
|
|
2782
|
+
var MAX_TRACKED_SESSIONS = 500;
|
|
2375
2783
|
var SOUND_ENV = "LACODE_SOUND";
|
|
2376
2784
|
var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
|
|
2377
2785
|
var findHelper = () => {
|
|
@@ -2388,6 +2796,8 @@ var findHelper = () => {
|
|
|
2388
2796
|
return void 0;
|
|
2389
2797
|
};
|
|
2390
2798
|
var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
2799
|
+
const maxInflight = deps.maxInflightHelpers ?? MAX_INFLIGHT_HELPERS;
|
|
2800
|
+
const helperTimeoutMs = deps.helperTimeoutMs ?? HELPER_TIMEOUT_MS;
|
|
2391
2801
|
const opts = options ?? {};
|
|
2392
2802
|
const soundEnv = (process.env[SOUND_ENV] ?? "").trim().toLowerCase();
|
|
2393
2803
|
const soundOn = !SOUND_OFF_VALUES.includes(soundEnv) && opts.sound !== false;
|
|
@@ -2397,6 +2807,15 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2397
2807
|
const pending = /* @__PURE__ */ new Set();
|
|
2398
2808
|
const permissions = /* @__PURE__ */ new Set();
|
|
2399
2809
|
let warnedHelper = false;
|
|
2810
|
+
let inflight = 0;
|
|
2811
|
+
const rememberActive = (sessionID) => {
|
|
2812
|
+
active.add(sessionID);
|
|
2813
|
+
while (active.size > MAX_TRACKED_SESSIONS) active.delete(active.values().next().value);
|
|
2814
|
+
};
|
|
2815
|
+
const rememberTitle = (id, title) => {
|
|
2816
|
+
titles.set(id, title);
|
|
2817
|
+
while (titles.size > MAX_TRACKED_SESSIONS) titles.delete(titles.keys().next().value);
|
|
2818
|
+
};
|
|
2400
2819
|
const log = (level, message, extra) => client.app.log({ body: { service: SERVICE3, level, message, extra } }).catch(() => {
|
|
2401
2820
|
});
|
|
2402
2821
|
const fetchInfo = async (sessionID) => {
|
|
@@ -2404,6 +2823,11 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2404
2823
|
return info;
|
|
2405
2824
|
};
|
|
2406
2825
|
const deliverViaHelper = (title, message, urgency, sound) => new Promise((resolve3) => {
|
|
2826
|
+
if (inflight >= maxInflight) {
|
|
2827
|
+
void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
|
|
2828
|
+
resolve3({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2407
2831
|
const helper = findHelper();
|
|
2408
2832
|
if (!helper) {
|
|
2409
2833
|
if (!warnedHelper) {
|
|
@@ -2417,13 +2841,32 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2417
2841
|
stdio: ["ignore", "ignore", "pipe"],
|
|
2418
2842
|
detached: true
|
|
2419
2843
|
});
|
|
2844
|
+
inflight++;
|
|
2845
|
+
let settled = false;
|
|
2846
|
+
let timer;
|
|
2847
|
+
const finish = (result) => {
|
|
2848
|
+
if (settled) return;
|
|
2849
|
+
settled = true;
|
|
2850
|
+
inflight--;
|
|
2851
|
+
if (timer) clearTimeout(timer);
|
|
2852
|
+
resolve3(result);
|
|
2853
|
+
};
|
|
2854
|
+
timer = setTimeout(() => {
|
|
2855
|
+
try {
|
|
2856
|
+
child.kill("SIGKILL");
|
|
2857
|
+
} catch {
|
|
2858
|
+
}
|
|
2859
|
+
void log("warn", `notification helper timed out after ${helperTimeoutMs}ms`, { title });
|
|
2860
|
+
finish({ ok: false, detail: `helper timed out after ${helperTimeoutMs}ms` });
|
|
2861
|
+
}, helperTimeoutMs);
|
|
2862
|
+
timer.unref?.();
|
|
2420
2863
|
let stderr = "";
|
|
2421
2864
|
child.stderr?.on("data", (chunk) => {
|
|
2422
2865
|
if (stderr.length < 400) stderr += String(chunk);
|
|
2423
2866
|
});
|
|
2424
2867
|
child.on("error", (error) => {
|
|
2425
2868
|
void log("warn", `notification helper failed: ${String(error)}`, { title });
|
|
2426
|
-
|
|
2869
|
+
finish({ ok: false, detail: String(error) });
|
|
2427
2870
|
});
|
|
2428
2871
|
child.on("exit", (code) => {
|
|
2429
2872
|
const noise = stderr.trim();
|
|
@@ -2434,7 +2877,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2434
2877
|
message
|
|
2435
2878
|
});
|
|
2436
2879
|
}
|
|
2437
|
-
|
|
2880
|
+
finish({ ok, detail: noise || void 0 });
|
|
2438
2881
|
});
|
|
2439
2882
|
child.unref();
|
|
2440
2883
|
});
|
|
@@ -2489,12 +2932,12 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2489
2932
|
try {
|
|
2490
2933
|
if (event.type === "session.status") {
|
|
2491
2934
|
const { sessionID: sessionID2, status } = event.properties;
|
|
2492
|
-
if (status.type === "busy" && sessionID2)
|
|
2935
|
+
if (status.type === "busy" && sessionID2) rememberActive(sessionID2);
|
|
2493
2936
|
return;
|
|
2494
2937
|
}
|
|
2495
2938
|
if (event.type === "session.updated") {
|
|
2496
2939
|
const info = event.properties.info;
|
|
2497
|
-
if (info?.id)
|
|
2940
|
+
if (info?.id) rememberTitle(info.id, info.title);
|
|
2498
2941
|
return;
|
|
2499
2942
|
}
|
|
2500
2943
|
if (event.type === "question.asked") {
|
|
@@ -2502,7 +2945,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2502
2945
|
const sessionID2 = properties?.sessionID;
|
|
2503
2946
|
if (!sessionID2) return;
|
|
2504
2947
|
pending.add(sessionID2);
|
|
2505
|
-
|
|
2948
|
+
rememberActive(sessionID2);
|
|
2506
2949
|
const headline = questionHeadline(properties.questions);
|
|
2507
2950
|
void announce(sessionID2, "question", headline || void 0);
|
|
2508
2951
|
return;
|
|
@@ -2517,7 +2960,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2517
2960
|
const sessionID2 = properties?.sessionID;
|
|
2518
2961
|
if (!sessionID2) return;
|
|
2519
2962
|
permissions.add(sessionID2);
|
|
2520
|
-
|
|
2963
|
+
rememberActive(sessionID2);
|
|
2521
2964
|
void announce(sessionID2, "permission", permissionHeadline(properties) || void 0);
|
|
2522
2965
|
return;
|
|
2523
2966
|
}
|
|
@@ -2730,6 +3173,12 @@ function createSelector(config, deps = {}) {
|
|
|
2730
3173
|
const now = deps.now ?? Date.now;
|
|
2731
3174
|
const stats = /* @__PURE__ */ new Map();
|
|
2732
3175
|
const sticky = /* @__PURE__ */ new Map();
|
|
3176
|
+
const MAX_STICKY = 500;
|
|
3177
|
+
const rememberSticky = (sessionID, modelID) => {
|
|
3178
|
+
sticky.delete(sessionID);
|
|
3179
|
+
sticky.set(sessionID, modelID);
|
|
3180
|
+
while (sticky.size > MAX_STICKY) sticky.delete(sticky.keys().next().value);
|
|
3181
|
+
};
|
|
2733
3182
|
let cands = [];
|
|
2734
3183
|
const ensure = (modelID) => {
|
|
2735
3184
|
let stat = stats.get(modelID);
|
|
@@ -2766,7 +3215,10 @@ function createSelector(config, deps = {}) {
|
|
|
2766
3215
|
if (list.length === 0) return null;
|
|
2767
3216
|
if (config.stickyPerSession) {
|
|
2768
3217
|
const held = sticky.get(sessionID);
|
|
2769
|
-
if (held !== void 0 && list.includes(held) && !cooling(held))
|
|
3218
|
+
if (held !== void 0 && list.includes(held) && !cooling(held)) {
|
|
3219
|
+
rememberSticky(sessionID, held);
|
|
3220
|
+
return held;
|
|
3221
|
+
}
|
|
2770
3222
|
}
|
|
2771
3223
|
let bestID = null;
|
|
2772
3224
|
let bestStat = null;
|
|
@@ -2781,7 +3233,7 @@ function createSelector(config, deps = {}) {
|
|
|
2781
3233
|
}
|
|
2782
3234
|
}
|
|
2783
3235
|
if (bestID === null) return null;
|
|
2784
|
-
if (config.stickyPerSession)
|
|
3236
|
+
if (config.stickyPerSession) rememberSticky(sessionID, bestID);
|
|
2785
3237
|
return bestID;
|
|
2786
3238
|
},
|
|
2787
3239
|
stickyFor(sessionID) {
|
|
@@ -2813,6 +3265,9 @@ function createSelector(config, deps = {}) {
|
|
|
2813
3265
|
const out = {};
|
|
2814
3266
|
for (const [id, stat] of stats) out[id] = { ...stat };
|
|
2815
3267
|
return out;
|
|
3268
|
+
},
|
|
3269
|
+
stickySize() {
|
|
3270
|
+
return sticky.size;
|
|
2816
3271
|
}
|
|
2817
3272
|
};
|
|
2818
3273
|
}
|
|
@@ -3032,8 +3487,8 @@ var omniRouteHooks = async (input, options, deps = {}) => {
|
|
|
3032
3487
|
|
|
3033
3488
|
// src/lib/selfupdate.ts
|
|
3034
3489
|
import { spawn as spawn2 } from "node:child_process";
|
|
3035
|
-
import { closeSync, mkdirSync as
|
|
3036
|
-
import { homedir as
|
|
3490
|
+
import { closeSync, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3, writeSync } from "node:fs";
|
|
3491
|
+
import { homedir as homedir3 } from "node:os";
|
|
3037
3492
|
import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
|
|
3038
3493
|
var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
3039
3494
|
var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
|
|
@@ -3043,7 +3498,7 @@ var LOCK_STALE_MS = 10 * 60 * 1e3;
|
|
|
3043
3498
|
var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
|
|
3044
3499
|
var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
|
|
3045
3500
|
var inTestRunner = (env = process.env) => typeof env.NODE_TEST_CONTEXT === "string" && env.NODE_TEST_CONTEXT.length > 0;
|
|
3046
|
-
var STATE_DIR = join6(
|
|
3501
|
+
var STATE_DIR = join6(homedir3(), ".local", "share", "opencode", "lacode");
|
|
3047
3502
|
var STATE_PATH = join6(STATE_DIR, "selfupdate.json");
|
|
3048
3503
|
var LOCK_PATH = join6(STATE_DIR, "selfupdate.lock");
|
|
3049
3504
|
function compareSemver(a, b) {
|
|
@@ -3100,7 +3555,7 @@ function resolveOpencodeBin(execPath = process.execPath, env = process.env, exis
|
|
|
3100
3555
|
const candidate = join6(dir, "opencode");
|
|
3101
3556
|
if (exists(candidate)) return candidate;
|
|
3102
3557
|
}
|
|
3103
|
-
const fallback = join6(
|
|
3558
|
+
const fallback = join6(homedir3(), ".opencode", "bin", "opencode");
|
|
3104
3559
|
return exists(fallback) ? fallback : void 0;
|
|
3105
3560
|
}
|
|
3106
3561
|
async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
@@ -3126,8 +3581,8 @@ function readState(path4 = STATE_PATH) {
|
|
|
3126
3581
|
}
|
|
3127
3582
|
function writeState(state2, path4 = STATE_PATH) {
|
|
3128
3583
|
try {
|
|
3129
|
-
|
|
3130
|
-
|
|
3584
|
+
mkdirSync3(dirname3(path4), { recursive: true });
|
|
3585
|
+
writeFileSync3(path4, JSON.stringify(state2));
|
|
3131
3586
|
} catch {
|
|
3132
3587
|
}
|
|
3133
3588
|
}
|
|
@@ -3147,13 +3602,13 @@ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
|
|
|
3147
3602
|
closeSync(fd);
|
|
3148
3603
|
return () => {
|
|
3149
3604
|
try {
|
|
3150
|
-
|
|
3605
|
+
unlinkSync3(path4);
|
|
3151
3606
|
} catch {
|
|
3152
3607
|
}
|
|
3153
3608
|
};
|
|
3154
3609
|
};
|
|
3155
3610
|
try {
|
|
3156
|
-
|
|
3611
|
+
mkdirSync3(dirname3(path4), { recursive: true });
|
|
3157
3612
|
return claim();
|
|
3158
3613
|
} catch {
|
|
3159
3614
|
let orphaned = false;
|
|
@@ -3172,7 +3627,7 @@ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
|
|
|
3172
3627
|
}
|
|
3173
3628
|
if (!orphaned) return void 0;
|
|
3174
3629
|
try {
|
|
3175
|
-
|
|
3630
|
+
unlinkSync3(path4);
|
|
3176
3631
|
} catch {
|
|
3177
3632
|
return void 0;
|
|
3178
3633
|
}
|