@lawaty/lacode 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +139 -1
- package/README.md +19 -2
- package/dist/agents.js +35 -5
- package/dist/commands.js +4 -2
- package/dist/index.js +496 -38
- package/dist/pool.js +149 -8
- package/docs/OMNIROUTE.md +17 -15
- package/docs/USER-STORIES.md +2 -2
- package/package.json +1 -1
- package/presets/README.md +4 -4
- package/presets/free-tier.json +3 -2
package/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);
|
|
@@ -1313,23 +1444,52 @@ on that.
|
|
|
1313
1444
|
tidy, reorder or delete content the client did not ask you to touch.
|
|
1314
1445
|
- Prefer current-state wording: state how things are, not how they changed.
|
|
1315
1446
|
- Keep the file concise. If it is growing past roughly 150 lines, say so.
|
|
1316
|
-
- If \`AGENTS.md\` does not exist,
|
|
1317
|
-
|
|
1447
|
+
- If \`AGENTS.md\` does not exist, create it and record the client's instruction in
|
|
1448
|
+
it \u2014 that instruction is the file's first content.
|
|
1449
|
+
- Do NOT add placeholder comments, empty template headings, or example text;
|
|
1450
|
+
write only what the client asked for.
|
|
1451
|
+
- Say plainly in your report that you created the file.
|
|
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.
|
|
1318
1464
|
|
|
1319
1465
|
# Reporting
|
|
1320
1466
|
|
|
1321
1467
|
Report briefly what you changed and where. Do not edit any other file.`,
|
|
1322
1468
|
permission: {
|
|
1323
|
-
// Read
|
|
1324
|
-
//
|
|
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.
|
|
1325
1481
|
read: {
|
|
1326
1482
|
"*": "deny",
|
|
1327
1483
|
"AGENTS.md": "allow",
|
|
1328
|
-
"*/AGENTS.md": "allow"
|
|
1484
|
+
"*/AGENTS.md": "allow",
|
|
1485
|
+
"*/.opencode/context/**": "allow",
|
|
1486
|
+
".opencode/context/**": "allow"
|
|
1329
1487
|
},
|
|
1330
1488
|
glob: "deny",
|
|
1331
1489
|
grep: "deny",
|
|
1332
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.
|
|
1333
1493
|
edit: {
|
|
1334
1494
|
"*": "deny",
|
|
1335
1495
|
"AGENTS.md": "allow",
|
|
@@ -1465,7 +1625,8 @@ Follow the rules for the file in your system prompt, then report what you change
|
|
|
1465
1625
|
Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
|
|
1466
1626
|
|
|
1467
1627
|
1. Pool status \u2014 call the \`pool_status\` tool. Report the slot list, each
|
|
1468
|
-
slot's load against its weight
|
|
1628
|
+
slot's load against its ceiling (twice the slot's weight), and which models
|
|
1629
|
+
are cooling.
|
|
1469
1630
|
|
|
1470
1631
|
2. OmniRoute configured \u2014 inspect the \`@lawaty/lacode\` plugin options in the
|
|
1471
1632
|
user's opencode config for an \`omniroute\` block with \`enabled: true\`. If it
|
|
@@ -1502,7 +1663,7 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
|
|
|
1502
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:
|
|
1503
1664
|
- one slot per line: \`<provider>/<model-id> [weight]\`
|
|
1504
1665
|
- at most ${MAX_SLOTS} slots, and a model may appear only once
|
|
1505
|
-
- 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.
|
|
1506
1667
|
- \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
|
|
1507
1668
|
If the instruction names a model ambiguously, or would need more than ${MAX_SLOTS} slots, stop and ask rather than guessing.
|
|
1508
1669
|
|
|
@@ -1537,11 +1698,126 @@ function applyCommands(cfg, commands = COMMANDS) {
|
|
|
1537
1698
|
|
|
1538
1699
|
// src/plugins/agent-pool.ts
|
|
1539
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
|
|
1540
1814
|
var SERVICE = "agent-pool";
|
|
1541
1815
|
var REAP_INTERVAL_MS = 3e4;
|
|
1542
1816
|
var HANG_FALLBACK_MS = 10 * 60 * 1e3;
|
|
1817
|
+
var PROBE_INTERVAL_MS = 15 * 60 * 1e3;
|
|
1543
1818
|
var ABORT_TIMEOUT_MS = 1e4;
|
|
1544
|
-
var
|
|
1819
|
+
var formatWindow = (seconds) => seconds < 60 ? `${seconds}s` : `${Math.ceil(seconds / 60)}m`;
|
|
1820
|
+
var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
1545
1821
|
const opts = options ?? {};
|
|
1546
1822
|
const s = state(opts);
|
|
1547
1823
|
ensureUserConfigDir();
|
|
@@ -1553,6 +1829,13 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1553
1829
|
await log("warn", message);
|
|
1554
1830
|
};
|
|
1555
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();
|
|
1556
1839
|
const abortSession = async (sessionID) => {
|
|
1557
1840
|
if (!serverUrl) return { ok: false, why: "no serverUrl: this process has no HTTP server to abort through" };
|
|
1558
1841
|
try {
|
|
@@ -1594,6 +1877,8 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1594
1877
|
s.reaping = true;
|
|
1595
1878
|
s.lastSweep = Date.now();
|
|
1596
1879
|
try {
|
|
1880
|
+
pruneSessions(s);
|
|
1881
|
+
sweepDirIfDue();
|
|
1597
1882
|
const stuck = findStuck(s);
|
|
1598
1883
|
if (stuck.length === 0) return;
|
|
1599
1884
|
const released = [];
|
|
@@ -1658,6 +1943,46 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1658
1943
|
"info",
|
|
1659
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`
|
|
1660
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
|
+
);
|
|
1661
1986
|
return {
|
|
1662
1987
|
config: async (cfg) => {
|
|
1663
1988
|
const slots = ensureSlots(s, (m) => void warnOnce(m));
|
|
@@ -1709,6 +2034,10 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1709
2034
|
const reason2 = status2?.action?.reason ?? "";
|
|
1710
2035
|
if (status2?.type === "retry" && status2.action?.provider && (reason2 === "free_tier_limit" || isLimitText(reason2))) {
|
|
1711
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
|
+
}
|
|
1712
2041
|
const until = typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
|
|
1713
2042
|
const cooled = coolProvider(s, provider, until, reason2 || "rate-limited");
|
|
1714
2043
|
if (cooled.length > 0) {
|
|
@@ -1743,6 +2072,10 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1743
2072
|
const transientGateway = gateway5xx || isTransientGatewayText(haystack);
|
|
1744
2073
|
if (!limited && !transientGateway) return;
|
|
1745
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
|
+
}
|
|
1746
2079
|
if (isUnavailableText(haystack) || !limited && transientGateway) {
|
|
1747
2080
|
const transient = coolUnavailable(s, model);
|
|
1748
2081
|
if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
|
|
@@ -1784,15 +2117,22 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1784
2117
|
}
|
|
1785
2118
|
acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
|
|
1786
2119
|
output.args.subagent_type = variant;
|
|
1787
|
-
await log("info", `routed ${requested}
|
|
2120
|
+
await log("info", `routed ${requested} to slot ${slot.index} on ${slot.model}`, {
|
|
1788
2121
|
slot: slot.index,
|
|
1789
2122
|
weight: slot.weight,
|
|
1790
2123
|
model: slot.model,
|
|
1791
|
-
ceiling: slot
|
|
2124
|
+
ceiling: slotCeiling(slot),
|
|
1792
2125
|
load: (snap.counts.get(slot.model) ?? 0) + 1
|
|
1793
2126
|
});
|
|
1794
2127
|
return;
|
|
1795
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
|
+
}
|
|
1796
2136
|
} catch (error) {
|
|
1797
2137
|
await log("error", `routing failed, spawn unchanged: ${String(error)}`);
|
|
1798
2138
|
}
|
|
@@ -1802,8 +2142,18 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1802
2142
|
release(s, claimKey(input.sessionID, input.callID));
|
|
1803
2143
|
},
|
|
1804
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
|
+
}),
|
|
1805
2155
|
pool_status: tool({
|
|
1806
|
-
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.",
|
|
1807
2157
|
args: {},
|
|
1808
2158
|
async execute() {
|
|
1809
2159
|
refreshCatalog(s, serverUrl);
|
|
@@ -1832,7 +2182,7 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
|
|
|
1832
2182
|
const marker = slot.index === next.index ? " <- next" : "";
|
|
1833
2183
|
const load = entry?.total ?? 0;
|
|
1834
2184
|
const atCeiling = !underCeiling(slot, snap.counts);
|
|
1835
|
-
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("; ") : ""}]`;
|
|
1836
2186
|
};
|
|
1837
2187
|
const total = [...snap.counts.values()].reduce((sum, n) => sum + n, 0);
|
|
1838
2188
|
const coolingNow = [...snap.cooling.entries()].filter(([, st]) => st.until > now);
|
|
@@ -1854,6 +2204,10 @@ round robin in slot order, each slot taking as many turns as its weight says, so
|
|
|
1854
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}.`,
|
|
1855
2205
|
`overran past ${Math.round(MAX_CLAIM_AGE_MS / 6e4)}min: ${overran} (auto-aborted).`,
|
|
1856
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.",
|
|
1857
2211
|
coolingNow.length === 0 ? "no models cooling down." : `cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
|
|
1858
2212
|
].join("\n")
|
|
1859
2213
|
};
|
|
@@ -1862,14 +2216,18 @@ round robin in slot order, each slot taking as many turns as its weight says, so
|
|
|
1862
2216
|
},
|
|
1863
2217
|
dispose: async () => {
|
|
1864
2218
|
clearInterval(timer);
|
|
2219
|
+
if (probeTimer) clearInterval(probeTimer);
|
|
1865
2220
|
s.own.clear();
|
|
2221
|
+
s.activity.clear();
|
|
2222
|
+
s.children.clear();
|
|
1866
2223
|
publish(s);
|
|
1867
2224
|
}
|
|
1868
2225
|
};
|
|
1869
2226
|
});
|
|
1870
2227
|
|
|
1871
2228
|
// src/plugins/context-autoupdate.ts
|
|
1872
|
-
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";
|
|
1873
2231
|
import * as path2 from "node:path";
|
|
1874
2232
|
|
|
1875
2233
|
// src/lib/jsonc.ts
|
|
@@ -2025,8 +2383,24 @@ var IGNORED_SEGMENTS = /* @__PURE__ */ new Set([
|
|
|
2025
2383
|
".next",
|
|
2026
2384
|
".turbo",
|
|
2027
2385
|
".cache",
|
|
2028
|
-
".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"
|
|
2029
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(" ");
|
|
2030
2404
|
var toPosix2 = (value) => value.split(path2.sep).join("/");
|
|
2031
2405
|
var findProjectConfig = () => {
|
|
2032
2406
|
let dir = import.meta.dir ?? path2.dirname(new URL(import.meta.url).pathname);
|
|
@@ -2061,6 +2435,33 @@ var changedSince = (before, dir) => {
|
|
|
2061
2435
|
for (const name of before.keys()) if (!after.has(name)) changed++;
|
|
2062
2436
|
return changed;
|
|
2063
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
|
+
};
|
|
2064
2465
|
var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
2065
2466
|
const pending = /* @__PURE__ */ new Set();
|
|
2066
2467
|
const owned = /* @__PURE__ */ new Map();
|
|
@@ -2094,6 +2495,8 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2094
2495
|
"This session touched these paths (starting context, not an exhaustive list):",
|
|
2095
2496
|
listed(files),
|
|
2096
2497
|
"",
|
|
2498
|
+
NO_BULK_READS,
|
|
2499
|
+
"",
|
|
2097
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."
|
|
2098
2501
|
].join("\n") : [
|
|
2099
2502
|
"Incremental context update. Project root: " + directory,
|
|
@@ -2101,6 +2504,8 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2101
2504
|
"This session touched these paths (start from these and their immediate architectural neighborhood):",
|
|
2102
2505
|
listed(files),
|
|
2103
2506
|
"",
|
|
2507
|
+
NO_BULK_READS,
|
|
2508
|
+
"",
|
|
2104
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".'
|
|
2105
2510
|
].join("\n");
|
|
2106
2511
|
return {
|
|
@@ -2116,6 +2521,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2116
2521
|
lastRun = Date.now();
|
|
2117
2522
|
return;
|
|
2118
2523
|
}
|
|
2524
|
+
if (isProbe(state2, sessionID)) return;
|
|
2119
2525
|
const now = Date.now();
|
|
2120
2526
|
for (const [id, startedAt] of owned) if (now - startedAt > OWNED_TTL_MS) owned.delete(id);
|
|
2121
2527
|
if (owned.size > 0) return;
|
|
@@ -2160,6 +2566,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2160
2566
|
body: { agent, parts: [{ type: "text", text: compose(mode, files) }] }
|
|
2161
2567
|
}).then(async (result) => {
|
|
2162
2568
|
release(state2, claimKey2);
|
|
2569
|
+
await retireSession(client, directory, target, mode);
|
|
2163
2570
|
const failure = result.error ?? result.data?.info?.error;
|
|
2164
2571
|
if (failure) {
|
|
2165
2572
|
requeue();
|
|
@@ -2183,6 +2590,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2183
2590
|
}
|
|
2184
2591
|
}).catch(async (error) => {
|
|
2185
2592
|
release(state2, claimKey2);
|
|
2593
|
+
await retireSession(client, directory, target, mode);
|
|
2186
2594
|
requeue();
|
|
2187
2595
|
await log("error", `auto ${mode} failed: ${String(error)}`);
|
|
2188
2596
|
});
|
|
@@ -2369,6 +2777,9 @@ var Notifier = class {
|
|
|
2369
2777
|
// src/plugins/notify.ts
|
|
2370
2778
|
var SERVICE3 = "notify";
|
|
2371
2779
|
var COOLDOWN_MS2 = 5e3;
|
|
2780
|
+
var MAX_INFLIGHT_HELPERS = 4;
|
|
2781
|
+
var HELPER_TIMEOUT_MS = 3e4;
|
|
2782
|
+
var MAX_TRACKED_SESSIONS = 500;
|
|
2372
2783
|
var SOUND_ENV = "LACODE_SOUND";
|
|
2373
2784
|
var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
|
|
2374
2785
|
var findHelper = () => {
|
|
@@ -2385,6 +2796,8 @@ var findHelper = () => {
|
|
|
2385
2796
|
return void 0;
|
|
2386
2797
|
};
|
|
2387
2798
|
var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
2799
|
+
const maxInflight = deps.maxInflightHelpers ?? MAX_INFLIGHT_HELPERS;
|
|
2800
|
+
const helperTimeoutMs = deps.helperTimeoutMs ?? HELPER_TIMEOUT_MS;
|
|
2388
2801
|
const opts = options ?? {};
|
|
2389
2802
|
const soundEnv = (process.env[SOUND_ENV] ?? "").trim().toLowerCase();
|
|
2390
2803
|
const soundOn = !SOUND_OFF_VALUES.includes(soundEnv) && opts.sound !== false;
|
|
@@ -2394,6 +2807,15 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2394
2807
|
const pending = /* @__PURE__ */ new Set();
|
|
2395
2808
|
const permissions = /* @__PURE__ */ new Set();
|
|
2396
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
|
+
};
|
|
2397
2819
|
const log = (level, message, extra) => client.app.log({ body: { service: SERVICE3, level, message, extra } }).catch(() => {
|
|
2398
2820
|
});
|
|
2399
2821
|
const fetchInfo = async (sessionID) => {
|
|
@@ -2401,6 +2823,11 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2401
2823
|
return info;
|
|
2402
2824
|
};
|
|
2403
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
|
+
}
|
|
2404
2831
|
const helper = findHelper();
|
|
2405
2832
|
if (!helper) {
|
|
2406
2833
|
if (!warnedHelper) {
|
|
@@ -2414,13 +2841,32 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2414
2841
|
stdio: ["ignore", "ignore", "pipe"],
|
|
2415
2842
|
detached: true
|
|
2416
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?.();
|
|
2417
2863
|
let stderr = "";
|
|
2418
2864
|
child.stderr?.on("data", (chunk) => {
|
|
2419
2865
|
if (stderr.length < 400) stderr += String(chunk);
|
|
2420
2866
|
});
|
|
2421
2867
|
child.on("error", (error) => {
|
|
2422
2868
|
void log("warn", `notification helper failed: ${String(error)}`, { title });
|
|
2423
|
-
|
|
2869
|
+
finish({ ok: false, detail: String(error) });
|
|
2424
2870
|
});
|
|
2425
2871
|
child.on("exit", (code) => {
|
|
2426
2872
|
const noise = stderr.trim();
|
|
@@ -2431,7 +2877,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2431
2877
|
message
|
|
2432
2878
|
});
|
|
2433
2879
|
}
|
|
2434
|
-
|
|
2880
|
+
finish({ ok, detail: noise || void 0 });
|
|
2435
2881
|
});
|
|
2436
2882
|
child.unref();
|
|
2437
2883
|
});
|
|
@@ -2486,12 +2932,12 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2486
2932
|
try {
|
|
2487
2933
|
if (event.type === "session.status") {
|
|
2488
2934
|
const { sessionID: sessionID2, status } = event.properties;
|
|
2489
|
-
if (status.type === "busy" && sessionID2)
|
|
2935
|
+
if (status.type === "busy" && sessionID2) rememberActive(sessionID2);
|
|
2490
2936
|
return;
|
|
2491
2937
|
}
|
|
2492
2938
|
if (event.type === "session.updated") {
|
|
2493
2939
|
const info = event.properties.info;
|
|
2494
|
-
if (info?.id)
|
|
2940
|
+
if (info?.id) rememberTitle(info.id, info.title);
|
|
2495
2941
|
return;
|
|
2496
2942
|
}
|
|
2497
2943
|
if (event.type === "question.asked") {
|
|
@@ -2499,7 +2945,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2499
2945
|
const sessionID2 = properties?.sessionID;
|
|
2500
2946
|
if (!sessionID2) return;
|
|
2501
2947
|
pending.add(sessionID2);
|
|
2502
|
-
|
|
2948
|
+
rememberActive(sessionID2);
|
|
2503
2949
|
const headline = questionHeadline(properties.questions);
|
|
2504
2950
|
void announce(sessionID2, "question", headline || void 0);
|
|
2505
2951
|
return;
|
|
@@ -2514,7 +2960,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2514
2960
|
const sessionID2 = properties?.sessionID;
|
|
2515
2961
|
if (!sessionID2) return;
|
|
2516
2962
|
permissions.add(sessionID2);
|
|
2517
|
-
|
|
2963
|
+
rememberActive(sessionID2);
|
|
2518
2964
|
void announce(sessionID2, "permission", permissionHeadline(properties) || void 0);
|
|
2519
2965
|
return;
|
|
2520
2966
|
}
|
|
@@ -2727,6 +3173,12 @@ function createSelector(config, deps = {}) {
|
|
|
2727
3173
|
const now = deps.now ?? Date.now;
|
|
2728
3174
|
const stats = /* @__PURE__ */ new Map();
|
|
2729
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
|
+
};
|
|
2730
3182
|
let cands = [];
|
|
2731
3183
|
const ensure = (modelID) => {
|
|
2732
3184
|
let stat = stats.get(modelID);
|
|
@@ -2763,7 +3215,10 @@ function createSelector(config, deps = {}) {
|
|
|
2763
3215
|
if (list.length === 0) return null;
|
|
2764
3216
|
if (config.stickyPerSession) {
|
|
2765
3217
|
const held = sticky.get(sessionID);
|
|
2766
|
-
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
|
+
}
|
|
2767
3222
|
}
|
|
2768
3223
|
let bestID = null;
|
|
2769
3224
|
let bestStat = null;
|
|
@@ -2778,7 +3233,7 @@ function createSelector(config, deps = {}) {
|
|
|
2778
3233
|
}
|
|
2779
3234
|
}
|
|
2780
3235
|
if (bestID === null) return null;
|
|
2781
|
-
if (config.stickyPerSession)
|
|
3236
|
+
if (config.stickyPerSession) rememberSticky(sessionID, bestID);
|
|
2782
3237
|
return bestID;
|
|
2783
3238
|
},
|
|
2784
3239
|
stickyFor(sessionID) {
|
|
@@ -2810,6 +3265,9 @@ function createSelector(config, deps = {}) {
|
|
|
2810
3265
|
const out = {};
|
|
2811
3266
|
for (const [id, stat] of stats) out[id] = { ...stat };
|
|
2812
3267
|
return out;
|
|
3268
|
+
},
|
|
3269
|
+
stickySize() {
|
|
3270
|
+
return sticky.size;
|
|
2813
3271
|
}
|
|
2814
3272
|
};
|
|
2815
3273
|
}
|
|
@@ -3029,8 +3487,8 @@ var omniRouteHooks = async (input, options, deps = {}) => {
|
|
|
3029
3487
|
|
|
3030
3488
|
// src/lib/selfupdate.ts
|
|
3031
3489
|
import { spawn as spawn2 } from "node:child_process";
|
|
3032
|
-
import { closeSync, mkdirSync as
|
|
3033
|
-
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";
|
|
3034
3492
|
import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
|
|
3035
3493
|
var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
3036
3494
|
var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
|
|
@@ -3040,7 +3498,7 @@ var LOCK_STALE_MS = 10 * 60 * 1e3;
|
|
|
3040
3498
|
var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
|
|
3041
3499
|
var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
|
|
3042
3500
|
var inTestRunner = (env = process.env) => typeof env.NODE_TEST_CONTEXT === "string" && env.NODE_TEST_CONTEXT.length > 0;
|
|
3043
|
-
var STATE_DIR = join6(
|
|
3501
|
+
var STATE_DIR = join6(homedir3(), ".local", "share", "opencode", "lacode");
|
|
3044
3502
|
var STATE_PATH = join6(STATE_DIR, "selfupdate.json");
|
|
3045
3503
|
var LOCK_PATH = join6(STATE_DIR, "selfupdate.lock");
|
|
3046
3504
|
function compareSemver(a, b) {
|
|
@@ -3097,7 +3555,7 @@ function resolveOpencodeBin(execPath = process.execPath, env = process.env, exis
|
|
|
3097
3555
|
const candidate = join6(dir, "opencode");
|
|
3098
3556
|
if (exists(candidate)) return candidate;
|
|
3099
3557
|
}
|
|
3100
|
-
const fallback = join6(
|
|
3558
|
+
const fallback = join6(homedir3(), ".opencode", "bin", "opencode");
|
|
3101
3559
|
return exists(fallback) ? fallback : void 0;
|
|
3102
3560
|
}
|
|
3103
3561
|
async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
@@ -3123,8 +3581,8 @@ function readState(path4 = STATE_PATH) {
|
|
|
3123
3581
|
}
|
|
3124
3582
|
function writeState(state2, path4 = STATE_PATH) {
|
|
3125
3583
|
try {
|
|
3126
|
-
|
|
3127
|
-
|
|
3584
|
+
mkdirSync3(dirname3(path4), { recursive: true });
|
|
3585
|
+
writeFileSync3(path4, JSON.stringify(state2));
|
|
3128
3586
|
} catch {
|
|
3129
3587
|
}
|
|
3130
3588
|
}
|
|
@@ -3144,13 +3602,13 @@ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
|
|
|
3144
3602
|
closeSync(fd);
|
|
3145
3603
|
return () => {
|
|
3146
3604
|
try {
|
|
3147
|
-
|
|
3605
|
+
unlinkSync3(path4);
|
|
3148
3606
|
} catch {
|
|
3149
3607
|
}
|
|
3150
3608
|
};
|
|
3151
3609
|
};
|
|
3152
3610
|
try {
|
|
3153
|
-
|
|
3611
|
+
mkdirSync3(dirname3(path4), { recursive: true });
|
|
3154
3612
|
return claim();
|
|
3155
3613
|
} catch {
|
|
3156
3614
|
let orphaned = false;
|
|
@@ -3169,7 +3627,7 @@ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
|
|
|
3169
3627
|
}
|
|
3170
3628
|
if (!orphaned) return void 0;
|
|
3171
3629
|
try {
|
|
3172
|
-
|
|
3630
|
+
unlinkSync3(path4);
|
|
3173
3631
|
} catch {
|
|
3174
3632
|
return void 0;
|
|
3175
3633
|
}
|