@lawaty/lacode 0.10.0 → 1.0.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 +235 -2
- package/README.md +78 -37
- package/dist/agents.js +24 -16
- package/dist/commands.js +25 -6
- package/dist/index.js +1550 -566
- package/dist/lacode.js +211 -48
- package/dist/pool.js +55 -44
- package/dist/tunnel.js +185 -13
- package/docs/CONFIGURATION.md +23 -9
- package/docs/OMNIROUTE.md +1 -1
- package/docs/USER-STORIES.md +99 -21
- package/package.json +3 -3
- package/presets/README.md +26 -27
- package/presets/free-tier.json +26 -42
package/dist/index.js
CHANGED
|
@@ -33,6 +33,7 @@ var STUCK_IDLE_MS = 3 * 60 * 1e3;
|
|
|
33
33
|
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
|
+
var UNAVAILABLE_REASON = "endpoint-unavailable";
|
|
36
37
|
var MAX_WEIGHT = 100;
|
|
37
38
|
var SLOT_CEILING_FACTOR = 2;
|
|
38
39
|
var slotCeiling = (slot) => slot.weight * SLOT_CEILING_FACTOR;
|
|
@@ -44,6 +45,10 @@ var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
|
|
|
44
45
|
var STRIKE_DECAY_MS = 30 * 60 * 1e3;
|
|
45
46
|
var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
|
|
46
47
|
var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
|
|
48
|
+
var MODEL_MISSING_REASON = "model-missing";
|
|
49
|
+
var MODEL_MISSING_COOLDOWN_MS = QUOTA_COOLDOWN_MAX_MS;
|
|
50
|
+
var MODEL_MISSING_PATTERN = /model\s+(?:not\s+found|does\s+not\s+exist|is\s+not\s+available)|(?:unknown|no\s+such)\s+model/i;
|
|
51
|
+
var isModelMissingText = (text) => MODEL_MISSING_PATTERN.test(text);
|
|
47
52
|
var BASES = ["explore", "implement", "context"];
|
|
48
53
|
var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
|
|
49
54
|
var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
|
|
@@ -52,7 +57,7 @@ var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
|
|
|
52
57
|
var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
|
|
53
58
|
var FALLBACK_SLOTS = [
|
|
54
59
|
{ index: 1, model: "opencode/big-pickle", weight: 3 },
|
|
55
|
-
{ index: 2, model: "opencode-go/
|
|
60
|
+
{ index: 2, model: "opencode-go/beam-free", weight: 2 },
|
|
56
61
|
{ index: 3, model: "opencode/deepseek-v4.1-flash-free", weight: 1 },
|
|
57
62
|
{ index: 4, model: "opencode/glm-5.3-flash-free", weight: 1 }
|
|
58
63
|
];
|
|
@@ -205,7 +210,14 @@ var LIMIT_PATTERNS = [
|
|
|
205
210
|
/overloaded/i,
|
|
206
211
|
UNAVAILABLE_PATTERN
|
|
207
212
|
];
|
|
208
|
-
var
|
|
213
|
+
var LIMIT_REACHED_PATTERN = /\blimits?[\s_-]?(?:reached|exceeded)\b|\breached\s+(?:your|the|its)\s+limit\b/i;
|
|
214
|
+
var CONTEXT_LIMIT_PATTERN = /(?:context|token|length|window|prompt|char|character|byte|size)\s*(?:limit|length|size|window)/i;
|
|
215
|
+
var LIMIT_REASON_PATTERN = /rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded|limits?[\s_-]?(?:reached|exceeded)/i;
|
|
216
|
+
var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text)) || LIMIT_REACHED_PATTERN.test(text) && !CONTEXT_LIMIT_PATTERN.test(text);
|
|
217
|
+
var limitReason = (text) => {
|
|
218
|
+
if (!isLimitText(text)) return void 0;
|
|
219
|
+
return text.match(LIMIT_REASON_PATTERN)?.[0]?.toLowerCase().replace(/[\s_]/g, "-");
|
|
220
|
+
};
|
|
209
221
|
var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
|
|
210
222
|
var TRANSIENT_GATEWAY_PATTERNS = [/queue budget/i, /exceeded queue/i, /aborted due to timeout/i, /timeout/i];
|
|
211
223
|
var isTransientGatewayText = (text) => TRANSIENT_GATEWAY_PATTERNS.some((re) => re.test(text));
|
|
@@ -276,6 +288,21 @@ function ensureSlots(s, onWarn) {
|
|
|
276
288
|
for (const problem of problems) onWarn(problem);
|
|
277
289
|
return slots;
|
|
278
290
|
}
|
|
291
|
+
var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}`).join("|")}`;
|
|
292
|
+
function reloadSlots(s, onWarn) {
|
|
293
|
+
const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
|
|
294
|
+
const changed = slotSignature(origin, slots) !== slotSignature(s.origin, s.slots);
|
|
295
|
+
if (!changed) return { changed, slots, origin };
|
|
296
|
+
s.slots = slots;
|
|
297
|
+
s.origin = origin;
|
|
298
|
+
for (const problem of problems) onWarn(problem);
|
|
299
|
+
return { changed, slots, origin };
|
|
300
|
+
}
|
|
301
|
+
function slotSources(input = {}) {
|
|
302
|
+
if (input.models !== void 0) return [];
|
|
303
|
+
if (input.modelsFile) return [input.modelsFile];
|
|
304
|
+
return [USER_MODELS_TXT, USER_MODELS_PATH, DEFAULT_PRESET];
|
|
305
|
+
}
|
|
279
306
|
var claimKey = (sessionID, callID) => `${sessionID}:${callID}`;
|
|
280
307
|
var selfFile = (id) => `claims.${id}.json`;
|
|
281
308
|
var limitFile = (model) => `limit.${model.replace(/[^a-zA-Z0-9._-]/g, "__")}.json`;
|
|
@@ -465,11 +492,12 @@ function readLimits(s) {
|
|
|
465
492
|
try {
|
|
466
493
|
const strike = JSON.parse(readFileSync2(path4, "utf8"));
|
|
467
494
|
if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
|
|
495
|
+
const countsAsStrike = strike.reason !== UNAVAILABLE_REASON;
|
|
468
496
|
if (strike.until > now) {
|
|
469
497
|
cooling.set(strike.model, strike);
|
|
470
|
-
memory.set(strike.model, strike);
|
|
498
|
+
if (countsAsStrike) memory.set(strike.model, strike);
|
|
471
499
|
} else if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) {
|
|
472
|
-
memory.set(strike.model, strike);
|
|
500
|
+
if (countsAsStrike) memory.set(strike.model, strike);
|
|
473
501
|
} else {
|
|
474
502
|
unlinkSync(path4);
|
|
475
503
|
}
|
|
@@ -486,6 +514,7 @@ function readLimits(s) {
|
|
|
486
514
|
}
|
|
487
515
|
function recordStrike(s, model, reason, stated) {
|
|
488
516
|
if (!s.dirReady) return;
|
|
517
|
+
if (reason === UNAVAILABLE_REASON) return coolUnavailable(s, model);
|
|
489
518
|
const now = Date.now();
|
|
490
519
|
const prior = s.strikes.get(model);
|
|
491
520
|
const strikes = prior && now - prior.last < STRIKE_DECAY_MS ? prior.strikes + 1 : 1;
|
|
@@ -510,9 +539,8 @@ function coolUnavailable(s, model) {
|
|
|
510
539
|
const prior = s.cooling.get(model);
|
|
511
540
|
const until = now + UNAVAILABLE_COOLDOWN_MS;
|
|
512
541
|
if (prior && prior.until > until) return prior;
|
|
513
|
-
const strike = { model, until, strikes: 1, reason:
|
|
542
|
+
const strike = { model, until, strikes: 1, reason: UNAVAILABLE_REASON, last: now };
|
|
514
543
|
s.cooling.set(model, strike);
|
|
515
|
-
s.strikes.set(model, strike);
|
|
516
544
|
const path4 = join2(s.dir, limitFile(model));
|
|
517
545
|
try {
|
|
518
546
|
writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
|
|
@@ -598,48 +626,24 @@ function snapshot(s) {
|
|
|
598
626
|
}
|
|
599
627
|
var loadOf = (counts, model) => counts.get(model) ?? 0;
|
|
600
628
|
var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
|
|
601
|
-
function
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
function readCursor(s) {
|
|
608
|
-
try {
|
|
609
|
-
const parsed = JSON.parse(readFileSync2(join2(s.dir, CURSOR_FILE), "utf8"));
|
|
610
|
-
if (typeof parsed.seq === "number" && Number.isInteger(parsed.seq) && parsed.seq >= 0) return parsed.seq;
|
|
611
|
-
} catch {
|
|
629
|
+
function pickSlot(s, snap) {
|
|
630
|
+
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
631
|
+
const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
632
|
+
const pool = usable.length > 0 ? usable : slots;
|
|
633
|
+
for (const slot of usable) {
|
|
634
|
+
if (loadOf(snap.counts, slot.model) < slot.weight) return slot;
|
|
612
635
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
function writeCursor(s, seq) {
|
|
616
|
-
if (!s.dirReady) return;
|
|
617
|
-
const path4 = join2(s.dir, CURSOR_FILE);
|
|
618
|
-
try {
|
|
619
|
-
writeFileSync(`${path4}.tmp`, JSON.stringify({ seq }));
|
|
620
|
-
renameSync(`${path4}.tmp`, path4);
|
|
621
|
-
} catch {
|
|
636
|
+
for (const slot of usable) {
|
|
637
|
+
if (loadOf(snap.counts, slot.model) < slotCeiling(slot)) return slot;
|
|
622
638
|
}
|
|
639
|
+
return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
|
|
623
640
|
}
|
|
624
|
-
function
|
|
625
|
-
const advance = options?.advance !== false;
|
|
641
|
+
function poolPass(s, snap) {
|
|
626
642
|
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
627
|
-
const seq = roundRobinSequence(slots);
|
|
628
|
-
if (seq.length > 0) {
|
|
629
|
-
const byIndex = new Map(slots.map((slot) => [slot.index, slot]));
|
|
630
|
-
const start = readCursor(s) % seq.length;
|
|
631
|
-
for (let step = 0; step < seq.length; step++) {
|
|
632
|
-
const at = (start + step) % seq.length;
|
|
633
|
-
const slot = byIndex.get(seq[at]);
|
|
634
|
-
if (slot && isAvailable(s, slot.model, snap.cooling) && underCeiling(slot, snap.counts)) {
|
|
635
|
-
if (advance) writeCursor(s, at + 1);
|
|
636
|
-
return slot;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
643
|
const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
641
|
-
|
|
642
|
-
|
|
644
|
+
if (usable.some((slot) => loadOf(snap.counts, slot.model) < slot.weight)) return 1;
|
|
645
|
+
if (usable.some((slot) => loadOf(snap.counts, slot.model) < slotCeiling(slot))) return 2;
|
|
646
|
+
return "overflow";
|
|
643
647
|
}
|
|
644
648
|
function agentFor(base, slot) {
|
|
645
649
|
return `${base}-${slot}`;
|
|
@@ -774,9 +778,12 @@ Every question of the form "what does this file do", "where is X handled", or
|
|
|
774
778
|
that proves it. Never one whole refactor handed over in a single piece \u2014
|
|
775
779
|
see \`# Task size\` below.
|
|
776
780
|
|
|
777
|
-
Spawn the
|
|
778
|
-
|
|
779
|
-
|
|
781
|
+
Spawn the pooled base \u2014 \`explore\`, \`implement\` or \`context\`. The plugin decides
|
|
782
|
+
which model runs the work; do not try to choose one yourself.
|
|
783
|
+
|
|
784
|
+
\`expert\` is not part of the pool and is expensive: spawn it only when the user
|
|
785
|
+
explicitly asks for it. Never escalate to it on your own, and never as a
|
|
786
|
+
fallback when a pooled spawn fails.
|
|
780
787
|
|
|
781
788
|
# When the pool is unavailable
|
|
782
789
|
|
|
@@ -785,12 +792,12 @@ pool cannot serve a spawn \u2014 every slot cooling or exhausted, spawns repeate
|
|
|
785
792
|
failing, or a runtime message saying no slot is available \u2014 that is not a reason
|
|
786
793
|
to do the work yourself, and not a reason to stop and ask.
|
|
787
794
|
|
|
788
|
-
**Retry.** A
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
picked the wrong number.
|
|
795
|
+
**Retry.** A cooling model is transient and costs you nothing but a turn. Spawn
|
|
796
|
+
the same base again. If a spawn fails, do not switch to a different name \u2014 a
|
|
797
|
+
failure means the pool has a routing problem, not that you picked the wrong one.
|
|
792
798
|
|
|
793
|
-
Retry is always available
|
|
799
|
+
Retry is always available
|
|
800
|
+
and never wrong, which is why it is the default. But
|
|
794
801
|
"never wrong" is not "sufficient": looping silently against a pool with every
|
|
795
802
|
model cooling goes nowhere and burns the slot doing it. So separate the two
|
|
796
803
|
failures. **Transient** \u2014 a cooldown, a rate limit, one slot at its ceiling \u2014
|
|
@@ -798,7 +805,8 @@ means retry. **Persistent or structural** \u2014 every slot cooling, the same sp
|
|
|
798
805
|
failing repeatedly with the same error, a malformed model id \u2014 means stop
|
|
799
806
|
retrying and *report* it: say what failed (slot, model, error) and continue
|
|
800
807
|
with whatever else the task needs, or ask the user. Surface the failure; do not
|
|
801
|
-
spin on it and do not let it end the whole task.
|
|
808
|
+
spin on it and do not let it end the whole task. Do not escalate to \`expert\` as
|
|
809
|
+
a workaround; it is opt-in by the user.
|
|
802
810
|
|
|
803
811
|
**Never fall back to reading, grepping, exploring or implementing on your own.**
|
|
804
812
|
Do not "just quickly" inspect a file because a spawn failed. A failed spawn is
|
|
@@ -915,8 +923,11 @@ When you need to understand the codebase:
|
|
|
915
923
|
2. Invoke @explore with specific, focused instructions (e.g. "read src/auth.ts", "grep for 'migration' in *.py files", "list the src/ directory")
|
|
916
924
|
3. @explore will return its findings \u2014 you process them and plan accordingly
|
|
917
925
|
|
|
918
|
-
Spawn the
|
|
919
|
-
|
|
926
|
+
Spawn the pooled base (\`explore\`, \`implement\`, \`context\`); the plugin decides
|
|
927
|
+
which model runs the work. Do not try to choose one yourself.
|
|
928
|
+
|
|
929
|
+
\`expert\` is not part of the pool and is expensive: spawn it only when the user
|
|
930
|
+
explicitly asks for it.
|
|
920
931
|
|
|
921
932
|
If the pool cannot serve a spawn \u2014 every slot cooling or exhausted, or repeated
|
|
922
933
|
failures \u2014 do NOT fall back to reading or planning from your own inspection.
|
|
@@ -1244,7 +1255,7 @@ Trigger: \`.opencode/context/\` exists and work has just been done here.
|
|
|
1244
1255
|
- an architectural decision
|
|
1245
1256
|
- a new dependency between subsystems
|
|
1246
1257
|
- a renamed or relocated subsystem
|
|
1247
|
-
|
|
1258
|
+
- an invariant not yet recorded in the map
|
|
1248
1259
|
|
|
1249
1260
|
Do not update documentation merely because source files changed. Do not
|
|
1250
1261
|
rewrite files wholesale. Do not make timestamp-only edits. Do not add a fact
|
|
@@ -1550,7 +1561,7 @@ function buildAgentSet() {
|
|
|
1550
1561
|
for (let slot = 1; slot <= MAX_SLOTS; slot++) {
|
|
1551
1562
|
const variant = {
|
|
1552
1563
|
...def,
|
|
1553
|
-
description: `Hidden pool
|
|
1564
|
+
description: `Hidden pool slot ${slot} for ${base}. Its model is NOT set here -- LaCode injects it from the resolved pool preset at config time, so the preset is the only place a pool model is written down. Spawn '${base}' and let the pool route.`,
|
|
1554
1565
|
hidden: true,
|
|
1555
1566
|
// `mode: "primary"` is what keeps a variant OUT of the model's system
|
|
1556
1567
|
// prompt. `hidden` cannot do it.
|
|
@@ -1562,9 +1573,10 @@ function buildAgentSet() {
|
|
|
1562
1573
|
// and by default-agent resolution. A variant left as `mode: "subagent"`
|
|
1563
1574
|
// is therefore listed to the model under its own name -- "explore-1:
|
|
1564
1575
|
// Hidden pool variant explore-1" -- and the model copies that name
|
|
1565
|
-
// straight into its next spawn. The
|
|
1566
|
-
// naming
|
|
1567
|
-
//
|
|
1576
|
+
// straight into its next spawn. The task hook coerces a hand-named
|
|
1577
|
+
// variant back to its base, because naming one is how a caller would
|
|
1578
|
+
// pick the provider and skip ordered slot fill and the slot ceiling. The coercion is right; the
|
|
1579
|
+
// leak that provokes it is not.
|
|
1568
1580
|
//
|
|
1569
1581
|
// `mode` is the only field that filter respects, and setting it here is
|
|
1570
1582
|
// safe: the task tool never reads an agent's mode -- it resolves the
|
|
@@ -1638,7 +1650,7 @@ context file only if this work introduced or revealed information that will
|
|
|
1638
1650
|
materially help future agents \u2014 a new boundary or module, a new or moved
|
|
1639
1651
|
canonical entry point, a changed workflow, a new convention, an architectural
|
|
1640
1652
|
decision, a new dependency between subsystems, a renamed or moved subsystem,
|
|
1641
|
-
or
|
|
1653
|
+
or an invariant not yet recorded.
|
|
1642
1654
|
|
|
1643
1655
|
Do not edit documentation just because source files changed. No
|
|
1644
1656
|
timestamp-only edits. If nothing material changed, make no edits and report
|
|
@@ -1713,7 +1725,7 @@ Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
|
|
|
1713
1725
|
Change nothing. Print a final one-line summary of failures.`
|
|
1714
1726
|
},
|
|
1715
1727
|
"pool": {
|
|
1716
|
-
description: "Modify this machine's LaCode pool from a short instruction \u2014 the shared free-model slot table in ~/.config/lacode/pool.txt.
|
|
1728
|
+
description: "Modify this machine's LaCode pool from a short instruction \u2014 the shared free-model slot table in ~/.config/lacode/pool.txt. Picked up live; no restart.",
|
|
1717
1729
|
template: `Apply this change to the LaCode pool on this machine:
|
|
1718
1730
|
|
|
1719
1731
|
$ARGUMENTS
|
|
@@ -1725,22 +1737,41 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
|
|
|
1725
1737
|
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:
|
|
1726
1738
|
- one slot per line: \`<provider>/<model-id> [weight]\`
|
|
1727
1739
|
- at most ${MAX_SLOTS} slots, and a model may appear only once
|
|
1728
|
-
- weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's
|
|
1740
|
+
- weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's active-session budget for one pass \u2014 how many turns it takes per cycle \u2014 and its concurrency ceiling is twice that. NOT a priority; pick order follows line order.
|
|
1729
1741
|
- \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
|
|
1730
1742
|
If the instruction names a model ambiguously, or would need more than ${MAX_SLOTS} slots, stop and ask rather than guessing.
|
|
1731
1743
|
|
|
1732
|
-
3. Write the WHOLE file, not a patch. This file is an override, not an edit target: any slot you drop disappears, and slot numbers are line positions, so a partial write silently renumbers
|
|
1744
|
+
3. Write the WHOLE file, not a patch. This file is an override, not an edit target: any slot you drop disappears, and slot numbers are line positions, so a partial write silently renumbers slots. Keep the existing comment header. If the write fails on a missing directory, create \`~/.config/lacode\` first and retry.
|
|
1733
1745
|
|
|
1734
|
-
4. Report the before \u2192 after slot table
|
|
1746
|
+
4. Report the before \u2192 after slot table and the resolved path. The running process watches the pool files: it reloads the table, re-binds the variant agents and validates every slot, so no restart is needed. If a slot names a model the provider does not serve, it will be benched and named in the log and toast \u2014 report that instead of claiming the swap succeeded.
|
|
1735
1747
|
|
|
1736
1748
|
Two traps that fail silently, so check and state them explicitly:
|
|
1737
1749
|
- A slot's provider must already exist in the user's opencode config, or that slot cannot resolve at all. If the instruction adds a model on a provider that is not configured, say so.
|
|
1738
1750
|
- If a slot model is \`omniroute/<id>\`, that id must also appear in the plugin's \`omniroute.models\` allowlist (or be the \`combo\` id, which needs no entry) \u2014 otherwise the pool resolves and every spawn fails with \`ProviderModelNotFoundError\`.
|
|
1739
1751
|
- A provider \`whitelist\` is an allowlist, and the plugin appends your model to it at config time. Nothing for you to do there.
|
|
1740
1752
|
|
|
1741
|
-
If the instruction is to RESET or REVERT rather than to change models, delete \`${USER_MODELS_TXT}\` instead, and report that the pool falls back to the bundled preset \u2014
|
|
1753
|
+
If the instruction is to RESET or REVERT rather than to change models, delete \`${USER_MODELS_TXT}\` instead, and report that the pool falls back to the bundled preset \u2014 the running process picks that up automatically too.
|
|
1742
1754
|
|
|
1743
1755
|
Change nothing else. In particular do not touch \`presets/free-tier.json\`, \`~/.config/lacode/pool.json\`, \`FALLBACK_SLOTS\` in \`src/lib/pool.ts\`, or \`tests/models-snapshot.json\`: this command owns the per-machine override and nothing else.`
|
|
1756
|
+
},
|
|
1757
|
+
"pool-recheck": {
|
|
1758
|
+
description: "Probe the pool now \u2014 re-admit benched models that answer, keep the ones still limited, and bench a model the provider no longer serves.",
|
|
1759
|
+
template: `Probe the LaCode pool now and report the result. Run inline; do not spawn a subagent and do not edit the pool.
|
|
1760
|
+
|
|
1761
|
+
$ARGUMENTS
|
|
1762
|
+
|
|
1763
|
+
- If the text above names one or more models, call the \`pool_recheck\` tool with those ids in \`models\` (provider/model-id).
|
|
1764
|
+
- Otherwise call \`pool_recheck\` with no arguments; it probes every model that is currently cooling.
|
|
1765
|
+
|
|
1766
|
+
Report the tool's output as it came back, then add one short line naming what changed: re-admitted, still limited, or benched because the provider does not serve the model. If a model was benched as \`model-missing\`, say so and name the replacement in \`~/.config/lacode/pool.txt\` \u2014 but change nothing yourself.`
|
|
1767
|
+
},
|
|
1768
|
+
"image-in-cb": {
|
|
1769
|
+
description: "Attach the clipboard image to this message \u2014 macOS needs pngpaste, Linux needs wl-paste or xclip.",
|
|
1770
|
+
template: "$ARGUMENTS"
|
|
1771
|
+
},
|
|
1772
|
+
"image": {
|
|
1773
|
+
description: "Attach one or more image files to this message",
|
|
1774
|
+
template: "$ARGUMENTS"
|
|
1744
1775
|
}
|
|
1745
1776
|
};
|
|
1746
1777
|
function applyCommands(cfg, commands = COMMANDS) {
|
|
@@ -1758,125 +1789,427 @@ function applyCommands(cfg, commands = COMMANDS) {
|
|
|
1758
1789
|
return { added, kept };
|
|
1759
1790
|
}
|
|
1760
1791
|
|
|
1761
|
-
// src/
|
|
1762
|
-
import {
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
var
|
|
1767
|
-
var
|
|
1768
|
-
var
|
|
1769
|
-
|
|
1770
|
-
|
|
1792
|
+
// src/lib/selfupdate.ts
|
|
1793
|
+
import { spawn } from "node:child_process";
|
|
1794
|
+
import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync3, renameSync as renameSync2, rmSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2, writeSync } from "node:fs";
|
|
1795
|
+
import { homedir as homedir2 } from "node:os";
|
|
1796
|
+
import { dirname as dirname2, join as join3, resolve } from "node:path";
|
|
1797
|
+
var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
1798
|
+
var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
|
|
1799
|
+
var FETCH_TIMEOUT_MS = 4e3;
|
|
1800
|
+
var INSTALL_TIMEOUT_MS = 12e4;
|
|
1801
|
+
var LOCK_STALE_MS = 10 * 60 * 1e3;
|
|
1802
|
+
var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
|
|
1803
|
+
var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
|
|
1804
|
+
var inTestRunner = (env = process.env) => typeof env.NODE_TEST_CONTEXT === "string" && env.NODE_TEST_CONTEXT.length > 0;
|
|
1805
|
+
var STATE_DIR = join3(homedir2(), ".local", "share", "opencode", "lacode");
|
|
1806
|
+
var STATE_PATH = join3(STATE_DIR, "selfupdate.json");
|
|
1807
|
+
var LOCK_PATH = join3(STATE_DIR, "selfupdate.lock");
|
|
1808
|
+
function compareSemver(a, b) {
|
|
1809
|
+
const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
1810
|
+
const left = parse(a);
|
|
1811
|
+
const right = parse(b);
|
|
1812
|
+
for (let i = 0; i < 3; i++) {
|
|
1813
|
+
const l = left[i] ?? 0;
|
|
1814
|
+
const r = right[i] ?? 0;
|
|
1815
|
+
if (l !== r) return l > r ? 1 : -1;
|
|
1816
|
+
}
|
|
1817
|
+
return 0;
|
|
1818
|
+
}
|
|
1819
|
+
function readInstalledVersion(packageRoot, read = (path4) => {
|
|
1771
1820
|
try {
|
|
1772
|
-
return
|
|
1821
|
+
return readFileSync3(path4, "utf8");
|
|
1773
1822
|
} catch {
|
|
1774
|
-
return
|
|
1823
|
+
return void 0;
|
|
1775
1824
|
}
|
|
1776
|
-
}
|
|
1777
|
-
var shortReason = (text) => {
|
|
1778
|
-
if (!text) return "failed";
|
|
1779
|
-
const matched = text.match(/rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded/i)?.[0];
|
|
1780
|
-
return matched ? matched.toLowerCase().replace(/[\s_]/g, "-") : isLimitText(text) ? "limit" : "failed";
|
|
1781
|
-
};
|
|
1782
|
-
var probeOne = async (client, opts, model, leftSeconds) => {
|
|
1783
|
-
const { state: s, directory } = opts;
|
|
1784
|
-
const { provider, id: modelID } = splitModel(model);
|
|
1785
|
-
const settle = (ok, reason, left) => ({
|
|
1786
|
-
model,
|
|
1787
|
-
ok,
|
|
1788
|
-
reason,
|
|
1789
|
-
hadSeconds: leftSeconds,
|
|
1790
|
-
leftSeconds: left
|
|
1791
|
-
});
|
|
1792
|
-
const created = await client.session.create({ body: { title: `pool probe (${model})` }, query: { directory } }).then((r) => r?.data?.id).catch(() => void 0);
|
|
1793
|
-
if (!created) return settle(false, "no probe session", leftSeconds);
|
|
1794
|
-
markProbe(s, created);
|
|
1795
|
-
let timer;
|
|
1825
|
+
}) {
|
|
1796
1826
|
try {
|
|
1797
|
-
const
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
if (
|
|
1829
|
-
const
|
|
1830
|
-
return
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1827
|
+
const raw = read(join3(packageRoot, "package.json"));
|
|
1828
|
+
if (raw === void 0) return void 0;
|
|
1829
|
+
const manifest = JSON.parse(raw);
|
|
1830
|
+
if (manifest.name !== PACKAGE_NAME) return void 0;
|
|
1831
|
+
return typeof manifest.version === "string" ? manifest.version : void 0;
|
|
1832
|
+
} catch {
|
|
1833
|
+
return void 0;
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
function resolveCacheRoot(packageRoot, exists = (p) => {
|
|
1837
|
+
try {
|
|
1838
|
+
return statSync2(p).isFile();
|
|
1839
|
+
} catch {
|
|
1840
|
+
return false;
|
|
1841
|
+
}
|
|
1842
|
+
}) {
|
|
1843
|
+
const tail = join3("node_modules", "@lawaty", "lacode");
|
|
1844
|
+
if (!packageRoot.endsWith(tail)) return void 0;
|
|
1845
|
+
const cacheRoot = resolve(packageRoot, "..", "..", "..");
|
|
1846
|
+
if (!exists(join3(cacheRoot, "package.json"))) return void 0;
|
|
1847
|
+
return cacheRoot;
|
|
1848
|
+
}
|
|
1849
|
+
function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
|
|
1850
|
+
try {
|
|
1851
|
+
return statSync2(p).isFile();
|
|
1852
|
+
} catch {
|
|
1853
|
+
return false;
|
|
1854
|
+
}
|
|
1855
|
+
}) {
|
|
1856
|
+
if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
|
|
1857
|
+
for (const dir of (env.PATH ?? "").split(":")) {
|
|
1858
|
+
if (!dir) continue;
|
|
1859
|
+
const candidate = join3(dir, "opencode");
|
|
1860
|
+
if (exists(candidate)) return candidate;
|
|
1861
|
+
}
|
|
1862
|
+
const fallback = join3(homedir2(), ".opencode", "bin", "opencode");
|
|
1863
|
+
return exists(fallback) ? fallback : void 0;
|
|
1864
|
+
}
|
|
1865
|
+
async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
1866
|
+
const controller = new AbortController();
|
|
1867
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1868
|
+
try {
|
|
1869
|
+
const response = await fetchImpl(REGISTRY_LATEST, { signal: controller.signal });
|
|
1870
|
+
if (!response.ok) return void 0;
|
|
1871
|
+
const body = await response.json();
|
|
1872
|
+
return typeof body.version === "string" ? body.version : void 0;
|
|
1873
|
+
} catch {
|
|
1874
|
+
return void 0;
|
|
1835
1875
|
} finally {
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1876
|
+
clearTimeout(timer);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
function readState(path4 = STATE_PATH) {
|
|
1880
|
+
try {
|
|
1881
|
+
return JSON.parse(readFileSync3(path4, "utf8"));
|
|
1882
|
+
} catch {
|
|
1883
|
+
return {};
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
function writeState(state2, path4 = STATE_PATH) {
|
|
1887
|
+
try {
|
|
1888
|
+
mkdirSync2(dirname2(path4), { recursive: true });
|
|
1889
|
+
writeFileSync2(path4, JSON.stringify(state2));
|
|
1890
|
+
} catch {
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
var isDue = (state2, now, interval = CHECK_INTERVAL_MS) => typeof state2.checkedAt !== "number" || now - state2.checkedAt >= interval;
|
|
1894
|
+
var isProcessAlive = (pid) => {
|
|
1895
|
+
try {
|
|
1896
|
+
process.kill(pid, 0);
|
|
1897
|
+
return true;
|
|
1898
|
+
} catch (error) {
|
|
1899
|
+
return error.code === "EPERM";
|
|
1839
1900
|
}
|
|
1840
1901
|
};
|
|
1841
|
-
|
|
1842
|
-
const
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
s.probing = true;
|
|
1854
|
-
const outcomes = [];
|
|
1902
|
+
function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
|
|
1903
|
+
const claim = () => {
|
|
1904
|
+
const fd = openSync(path4, "wx");
|
|
1905
|
+
writeSync(fd, String(process.pid));
|
|
1906
|
+
closeSync(fd);
|
|
1907
|
+
return () => {
|
|
1908
|
+
try {
|
|
1909
|
+
unlinkSync2(path4);
|
|
1910
|
+
} catch {
|
|
1911
|
+
}
|
|
1912
|
+
};
|
|
1913
|
+
};
|
|
1855
1914
|
try {
|
|
1856
|
-
|
|
1857
|
-
|
|
1915
|
+
mkdirSync2(dirname2(path4), { recursive: true });
|
|
1916
|
+
return claim();
|
|
1917
|
+
} catch {
|
|
1918
|
+
let orphaned = false;
|
|
1919
|
+
try {
|
|
1920
|
+
const pid = Number.parseInt(readFileSync3(path4, "utf8").trim(), 10);
|
|
1921
|
+
orphaned = !Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid);
|
|
1922
|
+
} catch {
|
|
1923
|
+
orphaned = true;
|
|
1924
|
+
}
|
|
1925
|
+
if (!orphaned) {
|
|
1926
|
+
try {
|
|
1927
|
+
orphaned = now - statSync2(path4).mtimeMs > LOCK_STALE_MS;
|
|
1928
|
+
} catch {
|
|
1929
|
+
orphaned = true;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
if (!orphaned) return void 0;
|
|
1933
|
+
try {
|
|
1934
|
+
unlinkSync2(path4);
|
|
1935
|
+
} catch {
|
|
1936
|
+
return void 0;
|
|
1937
|
+
}
|
|
1938
|
+
try {
|
|
1939
|
+
return claim();
|
|
1940
|
+
} catch {
|
|
1941
|
+
return void 0;
|
|
1858
1942
|
}
|
|
1859
|
-
} finally {
|
|
1860
|
-
s.probing = false;
|
|
1861
|
-
s.lastProbe = Date.now();
|
|
1862
1943
|
}
|
|
1863
|
-
const reAdmitted = outcomes.filter((o) => o.ok).map((o) => o.model);
|
|
1864
|
-
const stillLimited = outcomes.filter((o) => !o.ok);
|
|
1865
|
-
return {
|
|
1866
|
-
outcomes,
|
|
1867
|
-
cleared: reAdmitted,
|
|
1868
|
-
kept: stillLimited.map((o) => o.model),
|
|
1869
|
-
// A window the provider restated is a change too: the pool now waits a
|
|
1870
|
-
// different length than it did, and the user is the one who set that.
|
|
1871
|
-
changed: reAdmitted.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
|
|
1872
|
-
};
|
|
1873
1944
|
}
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1945
|
+
async function checkForUpdate(opts) {
|
|
1946
|
+
const installed = readInstalledVersion(opts.packageRoot);
|
|
1947
|
+
const latest = await fetchLatest(opts.fetchImpl);
|
|
1948
|
+
return {
|
|
1949
|
+
installed,
|
|
1950
|
+
latest,
|
|
1951
|
+
newer: Boolean(installed && latest && compareSemver(latest, installed) > 0),
|
|
1952
|
+
cacheRoot: resolveCacheRoot(opts.packageRoot, opts.exists),
|
|
1953
|
+
bin: resolveOpencodeBin(opts.execPath, opts.env, opts.exists)
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
async function refreshCache(opts) {
|
|
1957
|
+
const { cacheRoot, bin, latest } = opts;
|
|
1958
|
+
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
1959
|
+
const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
|
|
1960
|
+
const fresh = join3(cacheRoot, "node_modules", "@lawaty", "lacode");
|
|
1961
|
+
const aside = `${cacheRoot}.stale-${Date.now()}`;
|
|
1962
|
+
try {
|
|
1963
|
+
rmSync(aside, { recursive: true, force: true });
|
|
1964
|
+
renameSync2(cacheRoot, aside);
|
|
1965
|
+
} catch (error) {
|
|
1966
|
+
return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
|
|
1967
|
+
}
|
|
1968
|
+
const exited = await new Promise((settle) => {
|
|
1969
|
+
let done = false;
|
|
1970
|
+
const finish = (value) => {
|
|
1971
|
+
if (done) return;
|
|
1972
|
+
done = true;
|
|
1973
|
+
clearTimeout(timer);
|
|
1974
|
+
settle(value);
|
|
1975
|
+
};
|
|
1976
|
+
const child = spawnImpl(bin, ["plugin", PACKAGE_NAME, "-g"], {
|
|
1977
|
+
env: { ...process.env, [CHILD_ENV]: "1" },
|
|
1978
|
+
stdio: "ignore"
|
|
1979
|
+
});
|
|
1980
|
+
const timer = setTimeout(() => {
|
|
1981
|
+
try {
|
|
1982
|
+
child.kill("SIGKILL");
|
|
1983
|
+
} catch {
|
|
1984
|
+
}
|
|
1985
|
+
finish(false);
|
|
1986
|
+
}, timeoutMs);
|
|
1987
|
+
child.on("error", () => finish(false));
|
|
1988
|
+
child.on("exit", (code) => finish(code === 0));
|
|
1989
|
+
});
|
|
1990
|
+
const installed = readInstalledVersion(fresh);
|
|
1991
|
+
const ok = exited && installed === latest;
|
|
1992
|
+
if (ok) {
|
|
1993
|
+
try {
|
|
1994
|
+
rmSync(aside, { recursive: true, force: true });
|
|
1995
|
+
} catch {
|
|
1996
|
+
}
|
|
1997
|
+
} else {
|
|
1998
|
+
try {
|
|
1999
|
+
renameSync2(aside, cacheRoot);
|
|
2000
|
+
} catch {
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
return {
|
|
2004
|
+
ok,
|
|
2005
|
+
detail: ok ? `installed ${installed}` : `installer exited ${exited ? "0" : "non-zero"}, cache now ${installed ?? "missing"}`
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
async function maybeSelfUpdate(opts) {
|
|
2009
|
+
const env = opts.env ?? process.env;
|
|
2010
|
+
if (opts.enabled === false || env[CHILD_ENV] === "1" || env[OPT_OUT_ENV] === "1" || inTestRunner(env)) {
|
|
2011
|
+
return { action: "disabled" };
|
|
2012
|
+
}
|
|
2013
|
+
const now = (opts.now ?? Date.now)();
|
|
2014
|
+
const statePath = opts.statePath ?? STATE_PATH;
|
|
2015
|
+
const state2 = readState(statePath);
|
|
2016
|
+
if (!isDue(state2, now, opts.intervalMs ?? CHECK_INTERVAL_MS)) return { action: "skipped", detail: "checked recently" };
|
|
2017
|
+
const release2 = acquireLock(opts.lockPath, now);
|
|
2018
|
+
if (!release2) return { action: "skipped", detail: "another process is updating" };
|
|
2019
|
+
try {
|
|
2020
|
+
const check = await checkForUpdate(opts);
|
|
2021
|
+
if (!check.installed) return { action: "skipped", detail: "no installed version" };
|
|
2022
|
+
if (!check.latest) {
|
|
2023
|
+
writeState({ ...state2, checkedAt: now }, statePath);
|
|
2024
|
+
return { action: "skipped", detail: "registry unreachable" };
|
|
2025
|
+
}
|
|
2026
|
+
if (!check.newer) {
|
|
2027
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
2028
|
+
return { action: "current", detail: `${check.installed} is current` };
|
|
2029
|
+
}
|
|
2030
|
+
if (!check.cacheRoot) {
|
|
2031
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
2032
|
+
return { action: "skipped", detail: "not an opencode cache install" };
|
|
2033
|
+
}
|
|
2034
|
+
if (!check.bin) {
|
|
2035
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
2036
|
+
opts.log?.("warn", `LaCode ${check.latest} is available but no opencode binary was found to install it`);
|
|
2037
|
+
return { action: "failed", detail: "no opencode binary" };
|
|
2038
|
+
}
|
|
2039
|
+
const result = await refreshCache({ cacheRoot: check.cacheRoot, bin: check.bin, latest: check.latest, spawnImpl: opts.spawnImpl });
|
|
2040
|
+
if (result.ok) {
|
|
2041
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest, updatedTo: check.latest }, statePath);
|
|
2042
|
+
opts.log?.("info", `LaCode updated ${check.installed} -> ${check.latest}; restart opencode to apply`);
|
|
2043
|
+
return { action: "updated", detail: `${check.installed} -> ${check.latest}` };
|
|
2044
|
+
}
|
|
2045
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
2046
|
+
opts.log?.("warn", `LaCode update to ${check.latest} failed: ${result.detail}`);
|
|
2047
|
+
return { action: "failed", detail: result.detail };
|
|
2048
|
+
} finally {
|
|
2049
|
+
release2();
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// src/plugins/agent-pool.ts
|
|
2054
|
+
import { unwatchFile, watchFile } from "node:fs";
|
|
2055
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2056
|
+
|
|
2057
|
+
// src/lib/probe.ts
|
|
2058
|
+
var PROBE_SYSTEM = "You are a connection health check. Reply with the single word: ok";
|
|
2059
|
+
var PROBE_PROMPT = "ok";
|
|
2060
|
+
var PROBE_TIMEOUT_MS = 45e3;
|
|
2061
|
+
var failureText = (error) => {
|
|
2062
|
+
if (error === void 0 || error === null) return "";
|
|
2063
|
+
if (typeof error === "string") return error;
|
|
2064
|
+
try {
|
|
2065
|
+
return JSON.stringify(error) ?? String(error);
|
|
2066
|
+
} catch {
|
|
2067
|
+
return String(error);
|
|
2068
|
+
}
|
|
2069
|
+
};
|
|
2070
|
+
var shortReason = (text) => {
|
|
2071
|
+
if (!text) return "failed";
|
|
2072
|
+
if (isModelMissingText(text)) return MODEL_MISSING_REASON;
|
|
2073
|
+
return limitReason(text) ?? (isLimitText(text) ? "limit" : "failed");
|
|
2074
|
+
};
|
|
2075
|
+
var probeOne = async (client, opts, model, leftSeconds) => {
|
|
2076
|
+
const { state: s, directory } = opts;
|
|
2077
|
+
const { provider, id: modelID } = splitModel(model);
|
|
2078
|
+
const settle = (ok, reason, left) => ({
|
|
2079
|
+
model,
|
|
2080
|
+
ok,
|
|
2081
|
+
reason,
|
|
2082
|
+
hadSeconds: leftSeconds,
|
|
2083
|
+
leftSeconds: left
|
|
2084
|
+
});
|
|
2085
|
+
const created = await client.session.create({ body: { title: `pool probe (${model})` }, query: { directory } }).then((r) => r?.data?.id).catch(() => void 0);
|
|
2086
|
+
if (!created) return settle(false, "no probe session", leftSeconds);
|
|
2087
|
+
markProbe(s, created);
|
|
2088
|
+
let timer;
|
|
2089
|
+
try {
|
|
2090
|
+
const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
|
|
2091
|
+
const clock = opts.now ?? Date.now;
|
|
2092
|
+
let timedOut = false;
|
|
2093
|
+
const deadline = new Promise((resolve3) => {
|
|
2094
|
+
timer = setTimeout(() => {
|
|
2095
|
+
timedOut = true;
|
|
2096
|
+
void client.session.abort({ path: { id: created }, query: { directory } }).catch(() => {
|
|
2097
|
+
});
|
|
2098
|
+
resolve3(void 0);
|
|
2099
|
+
}, timeoutMs);
|
|
2100
|
+
});
|
|
2101
|
+
const answer = client.session.prompt({
|
|
2102
|
+
path: { id: created },
|
|
2103
|
+
query: { directory },
|
|
2104
|
+
body: {
|
|
2105
|
+
model: { providerID: provider, modelID },
|
|
2106
|
+
system: PROBE_SYSTEM,
|
|
2107
|
+
parts: [{ type: "text", text: PROBE_PROMPT }]
|
|
2108
|
+
}
|
|
2109
|
+
}).then(
|
|
2110
|
+
(result2) => result2,
|
|
2111
|
+
(error) => ({ error })
|
|
2112
|
+
);
|
|
2113
|
+
const result = await Promise.race([answer, deadline]);
|
|
2114
|
+
const failure = result?.error ?? result?.data?.info?.error;
|
|
2115
|
+
if (!timedOut && failure === void 0 && result?.data?.info !== void 0) {
|
|
2116
|
+
clearCooldowns(s, [model]);
|
|
2117
|
+
return settle(true, "answered", 0);
|
|
2118
|
+
}
|
|
2119
|
+
const text = failureText(failure);
|
|
2120
|
+
if (isModelMissingText(text)) {
|
|
2121
|
+
recordStrike(s, model, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
|
|
2122
|
+
} else {
|
|
2123
|
+
const stated = statedCooldown(text);
|
|
2124
|
+
if (stated !== void 0) recordStrike(s, model, shortReason(text), stated);
|
|
2125
|
+
}
|
|
2126
|
+
const still = readLimits(s).get(model);
|
|
2127
|
+
return settle(
|
|
2128
|
+
false,
|
|
2129
|
+
timedOut ? `no answer in ${Math.round(timeoutMs / 1e3)}s` : shortReason(text),
|
|
2130
|
+
still && still.until > clock() ? Math.ceil((still.until - clock()) / 1e3) : 0
|
|
2131
|
+
);
|
|
2132
|
+
} finally {
|
|
2133
|
+
if (timer) clearTimeout(timer);
|
|
2134
|
+
const deleted = await client.session.delete({ path: { id: created }, query: { directory } }).then(() => true, () => false);
|
|
2135
|
+
if (deleted) unmarkProbe(s, created);
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
async function probeModels(client, opts) {
|
|
2139
|
+
const s = opts.state;
|
|
2140
|
+
const now = opts.now ?? Date.now;
|
|
2141
|
+
const empty = { outcomes: [], cleared: [], kept: [], missing: [], changed: false };
|
|
2142
|
+
if (s.probing) return empty;
|
|
2143
|
+
const models = [...new Set(opts.models)];
|
|
2144
|
+
if (models.length === 0) return empty;
|
|
2145
|
+
const cooling = readLimits(s);
|
|
2146
|
+
const at = now();
|
|
2147
|
+
const wasCooling = new Set([...cooling.values()].filter((strike) => strike.until > at).map((strike) => strike.model));
|
|
2148
|
+
s.probing = true;
|
|
2149
|
+
const outcomes = [];
|
|
2150
|
+
try {
|
|
2151
|
+
for (const model of models) {
|
|
2152
|
+
const strike = cooling.get(model);
|
|
2153
|
+
outcomes.push(await probeOne(client, opts, model, strike && strike.until > at ? Math.ceil((strike.until - at) / 1e3) : 0));
|
|
2154
|
+
}
|
|
2155
|
+
} finally {
|
|
2156
|
+
s.probing = false;
|
|
2157
|
+
s.lastProbe = Date.now();
|
|
2158
|
+
}
|
|
2159
|
+
const reAdmitted = outcomes.filter((o) => o.ok && wasCooling.has(o.model)).map((o) => o.model);
|
|
2160
|
+
const missing = outcomes.filter((o) => o.reason === MODEL_MISSING_REASON).map((o) => o.model);
|
|
2161
|
+
const stillLimited = outcomes.filter((o) => !o.ok && o.reason !== MODEL_MISSING_REASON);
|
|
2162
|
+
return {
|
|
2163
|
+
outcomes,
|
|
2164
|
+
cleared: reAdmitted,
|
|
2165
|
+
kept: stillLimited.map((o) => o.model),
|
|
2166
|
+
missing,
|
|
2167
|
+
changed: reAdmitted.length > 0 || missing.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
async function probeCooling(client, opts) {
|
|
2171
|
+
const s = opts.state;
|
|
2172
|
+
const now = opts.now ?? Date.now;
|
|
2173
|
+
const empty = { outcomes: [], cleared: [], kept: [], missing: [], changed: false };
|
|
2174
|
+
if (s.probing) return empty;
|
|
2175
|
+
const cooling = readLimits(s);
|
|
2176
|
+
const at = now();
|
|
2177
|
+
const wanted = opts.models && opts.models.length > 0 ? new Set(opts.models) : void 0;
|
|
2178
|
+
const candidates = [...cooling.values()].filter(
|
|
2179
|
+
(strike) => strike.until > at && (wanted === void 0 || wanted.has(strike.model))
|
|
2180
|
+
);
|
|
2181
|
+
if (candidates.length === 0) return empty;
|
|
2182
|
+
s.probing = true;
|
|
2183
|
+
const outcomes = [];
|
|
2184
|
+
try {
|
|
2185
|
+
for (const strike of candidates.sort((a, b) => a.until - b.until)) {
|
|
2186
|
+
outcomes.push(await probeOne(client, opts, strike.model, Math.ceil((strike.until - at) / 1e3)));
|
|
2187
|
+
}
|
|
2188
|
+
} finally {
|
|
2189
|
+
s.probing = false;
|
|
2190
|
+
s.lastProbe = Date.now();
|
|
2191
|
+
}
|
|
2192
|
+
const reAdmitted = outcomes.filter((o) => o.ok).map((o) => o.model);
|
|
2193
|
+
const missing = outcomes.filter((o) => o.reason === MODEL_MISSING_REASON).map((o) => o.model);
|
|
2194
|
+
const stillLimited = outcomes.filter((o) => !o.ok && o.reason !== MODEL_MISSING_REASON);
|
|
2195
|
+
return {
|
|
2196
|
+
outcomes,
|
|
2197
|
+
cleared: reAdmitted,
|
|
2198
|
+
kept: stillLimited.map((o) => o.model),
|
|
2199
|
+
missing,
|
|
2200
|
+
// A window the provider restated is a change too: the pool now waits a
|
|
2201
|
+
// different length than it did, and the user is the one who set that.
|
|
2202
|
+
changed: reAdmitted.length > 0 || missing.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
// src/plugins/agent-pool.ts
|
|
2207
|
+
var SERVICE = "agent-pool";
|
|
2208
|
+
var REAP_INTERVAL_MS = 3e4;
|
|
2209
|
+
var HANG_FALLBACK_MS = 10 * 60 * 1e3;
|
|
2210
|
+
var PROBE_INTERVAL_MS = 15 * 60 * 1e3;
|
|
2211
|
+
var SLOT_WATCH_MS = 1e3;
|
|
2212
|
+
var SLOT_WATCH_DEBOUNCE_MS = 250;
|
|
1880
2213
|
var ABORT_TIMEOUT_MS = 1e4;
|
|
1881
2214
|
var formatWindow = (seconds) => seconds < 60 ? `${seconds}s` : `${Math.ceil(seconds / 60)}m`;
|
|
1882
2215
|
var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
@@ -1899,15 +2232,18 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
1899
2232
|
};
|
|
1900
2233
|
sweepDirIfDue();
|
|
1901
2234
|
const abortSession = async (sessionID) => {
|
|
1902
|
-
if (!serverUrl) return { ok: false, why: "no serverUrl: this process has no HTTP server to abort through" };
|
|
1903
2235
|
try {
|
|
1904
|
-
const
|
|
1905
|
-
|
|
2236
|
+
const result = await client.session.abort({
|
|
2237
|
+
path: { id: sessionID },
|
|
2238
|
+
query: { directory },
|
|
1906
2239
|
signal: AbortSignal.timeout(ABORT_TIMEOUT_MS)
|
|
1907
2240
|
});
|
|
1908
|
-
if (!
|
|
1909
|
-
|
|
1910
|
-
|
|
2241
|
+
if (!result.response?.ok) {
|
|
2242
|
+
return { ok: false, why: `server answered HTTP ${result.response?.status ?? "?"}` };
|
|
2243
|
+
}
|
|
2244
|
+
if (result.data === false) {
|
|
2245
|
+
return { ok: false, why: "server reported the session was not aborted" };
|
|
2246
|
+
}
|
|
1911
2247
|
return { ok: true, why: "" };
|
|
1912
2248
|
} catch (error) {
|
|
1913
2249
|
return { ok: false, why: String(error) };
|
|
@@ -1921,18 +2257,95 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
1921
2257
|
const strike = recordStrike(s, claim.m, "hang", HANG_FALLBACK_MS);
|
|
1922
2258
|
if (strike) await log2("warn", `cooling ${claim.m} for ${Math.round((strike.until - now) / 1e3)}s (hang)`);
|
|
1923
2259
|
};
|
|
1924
|
-
const
|
|
1925
|
-
if (!sessionID) return;
|
|
1926
|
-
const
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
2260
|
+
const claimFor = (sessionID) => {
|
|
2261
|
+
if (!sessionID) return void 0;
|
|
2262
|
+
for (const [key, claim] of s.own) if (claim.g === sessionID) return { key, claim };
|
|
2263
|
+
return void 0;
|
|
2264
|
+
};
|
|
2265
|
+
const identifyPooledSession = async (sessionID) => {
|
|
2266
|
+
try {
|
|
2267
|
+
const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
|
|
2268
|
+
if (!info?.parentID) return void 0;
|
|
2269
|
+
const msgs = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then(
|
|
2270
|
+
(r) => r.data
|
|
2271
|
+
).catch(() => void 0);
|
|
2272
|
+
if (!Array.isArray(msgs)) return void 0;
|
|
2273
|
+
const userMsg = msgs.find((m) => m?.info?.role === "user");
|
|
2274
|
+
if (!userMsg?.info) return void 0;
|
|
2275
|
+
const agent = userMsg.info.agent;
|
|
2276
|
+
if (typeof agent !== "string") return void 0;
|
|
2277
|
+
const modelInfo = userMsg.info.model;
|
|
2278
|
+
const userModel = modelInfo?.providerID && modelInfo?.modelID ? `${modelInfo.providerID}/${modelInfo.modelID}` : void 0;
|
|
2279
|
+
if (s.known.size > 0 && !s.known.has(agent)) return void 0;
|
|
2280
|
+
if (s.known.size === 0 && manualVariant(agent) === void 0 && !(BASES.includes(agent) && userModel)) {
|
|
2281
|
+
return void 0;
|
|
2282
|
+
}
|
|
2283
|
+
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
2284
|
+
if (!slots || slots.length === 0) return void 0;
|
|
2285
|
+
let slot;
|
|
2286
|
+
const variantBase = manualVariant(agent);
|
|
2287
|
+
if (variantBase) {
|
|
2288
|
+
const idx = Number(agent.slice(agent.lastIndexOf("-") + 1));
|
|
2289
|
+
if (!Number.isFinite(idx)) return void 0;
|
|
2290
|
+
slot = slots.find((sl) => sl.index === idx);
|
|
2291
|
+
} else if (BASES.includes(agent) && userModel) {
|
|
2292
|
+
slot = slots.find((sl) => sl.model === userModel);
|
|
2293
|
+
}
|
|
2294
|
+
if (!slot) return void 0;
|
|
2295
|
+
return { agent, slot: slot.index, model: slot.model };
|
|
2296
|
+
} catch {
|
|
2297
|
+
return void 0;
|
|
2298
|
+
}
|
|
2299
|
+
};
|
|
2300
|
+
const failOver = async (sessionID, model, reason, opts2) => {
|
|
2301
|
+
const entry = claimFor(sessionID);
|
|
2302
|
+
if (entry) {
|
|
2303
|
+
const { key, claim } = entry;
|
|
2304
|
+
const kill = await abortSession(sessionID);
|
|
2305
|
+
if (kill.ok) {
|
|
2306
|
+
release(s, key);
|
|
2307
|
+
await log(
|
|
2308
|
+
"warn",
|
|
2309
|
+
`limit (${reason}) on ${model}; aborted in-flight ${claim.v} session ${sessionID} to fail over now`,
|
|
2310
|
+
{ base: claim.v, model: claim.m, slot: claim.k, session: sessionID, aborted: true, reason }
|
|
2311
|
+
);
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
await log(
|
|
2315
|
+
"warn",
|
|
2316
|
+
`limit (${reason}) on ${model}; abort failed (${kill.why}); claim on slot ${claim.k} held for retry`,
|
|
2317
|
+
{ base: claim.v, model: claim.m, slot: claim.k, session: sessionID, aborted: false, reason }
|
|
2318
|
+
);
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
if (!entry) {
|
|
2322
|
+
if (opts2?.cool !== false && sessionID) {
|
|
2323
|
+
const pooled = await identifyPooledSession(sessionID);
|
|
2324
|
+
if (pooled) {
|
|
2325
|
+
recordStrike(s, pooled.model, reason, opts2?.stated);
|
|
2326
|
+
const kill = await abortSession(sessionID);
|
|
2327
|
+
await log(
|
|
2328
|
+
"warn",
|
|
2329
|
+
`limit (${reason}) on ${pooled.model}; aborted pooled ${pooled.agent} session ${sessionID} (slot ${pooled.slot}) to fail over now`,
|
|
2330
|
+
{ base: pooled.agent, model: pooled.model, slot: pooled.slot, session: sessionID, aborted: kill.ok, reason }
|
|
2331
|
+
);
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
} else if (opts2?.cool === false && sessionID) {
|
|
2335
|
+
const pooled = await identifyPooledSession(sessionID);
|
|
2336
|
+
if (pooled) {
|
|
2337
|
+
const kill = await abortSession(sessionID);
|
|
2338
|
+
await log(
|
|
2339
|
+
"warn",
|
|
2340
|
+
`limit (${reason}) on ${pooled.model}; aborted pooled ${pooled.agent} session ${sessionID} (slot ${pooled.slot}) to fail over now`,
|
|
2341
|
+
{ base: pooled.agent, model: pooled.model, slot: pooled.slot, session: sessionID, aborted: kill.ok, reason }
|
|
2342
|
+
);
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
await log("warn", `limit (${reason}) on ${model} for session ${sessionID ?? "?"}, which has no pool claim; nothing aborted`);
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
1936
2349
|
};
|
|
1937
2350
|
const reapStuck = async () => {
|
|
1938
2351
|
if (s.reaping) return;
|
|
@@ -2005,20 +2418,25 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2005
2418
|
"info",
|
|
2006
2419
|
`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`
|
|
2007
2420
|
);
|
|
2008
|
-
const EMPTY_REPORT = { outcomes: [], cleared: [], kept: [], changed: false };
|
|
2009
|
-
const reportTitle = (report) =>
|
|
2421
|
+
const EMPTY_REPORT = { outcomes: [], cleared: [], kept: [], missing: [], changed: false };
|
|
2422
|
+
const reportTitle = (report) => {
|
|
2423
|
+
if (report.outcomes.length === 0) return "nothing cooling to probe";
|
|
2424
|
+
const parts = [`probed ${report.outcomes.length} model(s)`, `re-admitted ${report.cleared.length}`];
|
|
2425
|
+
if (report.missing.length > 0) parts.push(`benched ${report.missing.length} missing`);
|
|
2426
|
+
return parts.join(", ");
|
|
2427
|
+
};
|
|
2010
2428
|
const reportText = (report) => {
|
|
2011
2429
|
if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
|
|
2012
2430
|
return [
|
|
2013
2431
|
...report.outcomes.map(
|
|
2014
|
-
(outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the
|
|
2432
|
+
(outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the pool` : outcome.reason === MODEL_MISSING_REASON ? `${outcome.model}: not served by the provider (${outcome.reason}) \u2014 benched${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}` : `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`
|
|
2015
2433
|
),
|
|
2016
|
-
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."
|
|
2434
|
+
report.cleared.length > 0 ? `re-admitted: ${report.cleared.join(", ")}. They route again on the next pooled spawn \u2014 nothing needs restarting or reloading.` : report.missing.length > 0 ? `benched: ${report.missing.join(", ")}. The provider does not serve them; replace them in the pool.` : report.outcomes.every((outcome) => outcome.ok) ? "every model that was asked answered; nothing needed re-admitting." : "nothing was re-admitted; the pool is short exactly as much as it was."
|
|
2017
2435
|
].join("\n");
|
|
2018
2436
|
};
|
|
2019
2437
|
const announce = async (report) => {
|
|
2020
2438
|
if (!report.changed) return;
|
|
2021
|
-
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.`;
|
|
2439
|
+
const message = report.cleared.length > 0 ? `Re-admitted ${report.cleared.join(", ")} \u2014 they answer again.` : report.missing.length > 0 ? `Benched ${report.missing.join(", ")} \u2014 the provider does not serve them.` : `${report.kept.length} cooling model(s) re-checked, still limited.`;
|
|
2022
2440
|
await log("info", `pool probe: ${message}`, { cleared: report.cleared, kept: report.kept });
|
|
2023
2441
|
try {
|
|
2024
2442
|
await client.tui?.showToast?.({
|
|
@@ -2045,47 +2463,90 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2045
2463
|
"info",
|
|
2046
2464
|
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"
|
|
2047
2465
|
);
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
continue;
|
|
2062
|
-
}
|
|
2063
|
-
if (def.model && def.model !== slot.model) {
|
|
2064
|
-
await warnOnce(`${name} still hard-codes ${def.model}; the resolved pool preset wins (${slot.model}). Delete the line to keep one source of truth.`);
|
|
2065
|
-
}
|
|
2066
|
-
def.model = slot.model;
|
|
2067
|
-
known.add(name);
|
|
2068
|
-
s.models.set(name, slot.model);
|
|
2069
|
-
}
|
|
2070
|
-
const slash = slot.model.indexOf("/");
|
|
2071
|
-
const provider = slot.model.slice(0, slash);
|
|
2072
|
-
const id = slot.model.slice(slash + 1);
|
|
2073
|
-
const entry = providers?.[provider];
|
|
2074
|
-
if (!entry) {
|
|
2075
|
-
await warnOnce(`provider ${provider} is not configured in opencode.jsonc; slot ${slot.index} (${slot.model}) cannot resolve`);
|
|
2466
|
+
let liveConfig;
|
|
2467
|
+
const bind = async (cfg) => {
|
|
2468
|
+
const slots = ensureSlots(s, (m) => void warnOnce(m));
|
|
2469
|
+
const agents = cfg.agent;
|
|
2470
|
+
const providers = cfg.provider;
|
|
2471
|
+
const known = /* @__PURE__ */ new Set();
|
|
2472
|
+
for (const [name, def] of Object.entries(cfg.agent ?? {})) if (def?.model) s.models.set(name, def.model);
|
|
2473
|
+
for (const slot of slots) {
|
|
2474
|
+
for (const base of BASES) {
|
|
2475
|
+
const name = agentFor(base, slot.index);
|
|
2476
|
+
const def = agents?.[name];
|
|
2477
|
+
if (!def) {
|
|
2478
|
+
await warnOnce(`variant ${name} is missing from the config; spawns of ${base} that route to slot ${slot.index} would fail`);
|
|
2076
2479
|
continue;
|
|
2077
2480
|
}
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
list.push(id);
|
|
2081
|
-
await log("info", `whitelisted ${id} on ${provider} (${s.origin} slot ${slot.index})`);
|
|
2481
|
+
if (def.model && def.model !== slot.model) {
|
|
2482
|
+
await warnOnce(`${name} still hard-codes ${def.model}; the resolved pool preset wins (${slot.model}). Delete the line to keep one source of truth.`);
|
|
2082
2483
|
}
|
|
2484
|
+
def.model = slot.model;
|
|
2485
|
+
known.add(name);
|
|
2486
|
+
s.models.set(name, slot.model);
|
|
2083
2487
|
}
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
)
|
|
2488
|
+
const slash = slot.model.indexOf("/");
|
|
2489
|
+
const provider = slot.model.slice(0, slash);
|
|
2490
|
+
const id = slot.model.slice(slash + 1);
|
|
2491
|
+
const entry = providers?.[provider];
|
|
2492
|
+
if (!entry) {
|
|
2493
|
+
await warnOnce(`provider ${provider} is not configured in opencode.jsonc; slot ${slot.index} (${slot.model}) cannot resolve`);
|
|
2494
|
+
continue;
|
|
2495
|
+
}
|
|
2496
|
+
const list = entry.whitelist;
|
|
2497
|
+
if (list && !list.includes(id)) {
|
|
2498
|
+
list.push(id);
|
|
2499
|
+
await log("info", `whitelisted ${id} on ${provider} (${s.origin} slot ${slot.index})`);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
s.known = known;
|
|
2503
|
+
await log(
|
|
2504
|
+
"info",
|
|
2505
|
+
`${s.origin}: bound ${known.size} variant agent(s) to ${slots.length} slot(s): ${slots.map((sl) => `${sl.index}=${sl.model} w${sl.weight}`).join(", ")}`
|
|
2506
|
+
);
|
|
2507
|
+
};
|
|
2508
|
+
const runValidation = async (models) => {
|
|
2509
|
+
let report = EMPTY_REPORT;
|
|
2510
|
+
try {
|
|
2511
|
+
report = await probeModels(client, { state: s, directory, models });
|
|
2512
|
+
} catch (error) {
|
|
2513
|
+
await log("error", `pool validation failed: ${String(error)}`);
|
|
2514
|
+
return EMPTY_REPORT;
|
|
2515
|
+
}
|
|
2516
|
+
await announce(report);
|
|
2517
|
+
return report;
|
|
2518
|
+
};
|
|
2519
|
+
const watchMs = opts.watchMs ?? SLOT_WATCH_MS;
|
|
2520
|
+
const watchDebounceMs = opts.watchDebounceMs ?? SLOT_WATCH_DEBOUNCE_MS;
|
|
2521
|
+
const sources = watchMs > 0 ? slotSources(opts) : [];
|
|
2522
|
+
let reloadTimer;
|
|
2523
|
+
const reloadFromDisk = async () => {
|
|
2524
|
+
const reload = reloadSlots(s, (m) => void warnOnce(m));
|
|
2525
|
+
if (!reload.changed) return;
|
|
2526
|
+
await log(
|
|
2527
|
+
"info",
|
|
2528
|
+
`pool changed (${reload.origin}): ${reload.slots.map((sl) => `${sl.index}=${sl.model} w${sl.weight}`).join(", ")}`
|
|
2529
|
+
);
|
|
2530
|
+
if (liveConfig) await bind(liveConfig);
|
|
2531
|
+
await runValidation(reload.slots.map((slot) => slot.model));
|
|
2532
|
+
};
|
|
2533
|
+
const scheduleReload = () => {
|
|
2534
|
+
if (reloadTimer) return;
|
|
2535
|
+
reloadTimer = setTimeout(() => {
|
|
2536
|
+
reloadTimer = void 0;
|
|
2537
|
+
void reloadFromDisk();
|
|
2538
|
+
}, watchDebounceMs);
|
|
2539
|
+
reloadTimer.unref?.();
|
|
2540
|
+
};
|
|
2541
|
+
for (const source of sources) watchFile(source, { interval: watchMs, persistent: false }, scheduleReload);
|
|
2542
|
+
void log(
|
|
2543
|
+
"info",
|
|
2544
|
+
sources.length > 0 ? `pool config watch armed: ${sources.join(", ")} every ${watchMs}ms; a change reloads the slots and validates every slot` : "pool config watch disabled (watchMs: 0)"
|
|
2545
|
+
);
|
|
2546
|
+
return {
|
|
2547
|
+
config: async (cfg) => {
|
|
2548
|
+
liveConfig = cfg;
|
|
2549
|
+
await bind(cfg);
|
|
2089
2550
|
},
|
|
2090
2551
|
event: async ({ event }) => {
|
|
2091
2552
|
try {
|
|
@@ -2093,24 +2554,88 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2093
2554
|
if (event.type === "session.status") {
|
|
2094
2555
|
const sessionID = typeof props.sessionID === "string" ? props.sessionID : void 0;
|
|
2095
2556
|
const status2 = props.status;
|
|
2096
|
-
const
|
|
2097
|
-
|
|
2098
|
-
|
|
2557
|
+
const action = status2?.action;
|
|
2558
|
+
const provider = typeof action?.provider === "string" ? action.provider : void 0;
|
|
2559
|
+
const reason2 = action?.reason ?? "";
|
|
2560
|
+
const message = typeof status2?.message === "string" ? status2.message : "";
|
|
2561
|
+
const limited2 = reason2 === "free_tier_limit" || isLimitText(reason2) || isLimitText(message);
|
|
2562
|
+
if (status2?.type === "retry" && limited2) {
|
|
2099
2563
|
if (sessionID && isProbe(s, sessionID)) {
|
|
2100
|
-
await log("debug", `probe on provider ${provider} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
|
|
2564
|
+
await log("debug", `probe on provider ${provider ?? "?"} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
|
|
2101
2565
|
return;
|
|
2102
2566
|
}
|
|
2103
|
-
const
|
|
2104
|
-
const
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2567
|
+
const own = claimFor(sessionID);
|
|
2568
|
+
const outage = isUnavailableText(message) && !isQuotaReason(reason2);
|
|
2569
|
+
const named = outage ? UNAVAILABLE_REASON : reason2 || limitReason(message) || "rate-limited";
|
|
2570
|
+
if (provider) {
|
|
2571
|
+
const until = outage ? Date.now() + UNAVAILABLE_COOLDOWN_MS : typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
|
|
2572
|
+
const cooled = coolProvider(s, provider, until, named);
|
|
2573
|
+
if (cooled.length > 0) {
|
|
2574
|
+
await log("warn", `limit on provider ${provider} (${named}); cooling ${cooled.length} slot(s) until ${new Date(until).toISOString().slice(11, 19)}Z`, {
|
|
2575
|
+
provider,
|
|
2576
|
+
until,
|
|
2577
|
+
models: cooled.map((c) => c.model)
|
|
2578
|
+
});
|
|
2579
|
+
}
|
|
2580
|
+
} else if (own) {
|
|
2581
|
+
if (outage) {
|
|
2582
|
+
const transient = coolUnavailable(s, own.claim.m);
|
|
2583
|
+
if (transient) await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
|
|
2584
|
+
} else {
|
|
2585
|
+
const stated2 = statedCooldown(message);
|
|
2586
|
+
const strike2 = recordStrike(s, own.claim.m, named, stated2);
|
|
2587
|
+
if (strike2) {
|
|
2588
|
+
await log("warn", `limit on ${own.claim.m} (${named}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s`, {
|
|
2589
|
+
model: own.claim.m,
|
|
2590
|
+
slot: own.claim.k,
|
|
2591
|
+
session: sessionID
|
|
2592
|
+
});
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
if (provider) {
|
|
2597
|
+
await failOver(sessionID, provider ?? own?.claim.m ?? "?", named, { stated: void 0, cool: false });
|
|
2598
|
+
} else {
|
|
2599
|
+
const stated2 = outage ? void 0 : statedCooldown(message);
|
|
2600
|
+
await failOver(sessionID, own?.claim.m ?? "?", named, { stated: stated2, cool: !own });
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
if (event.type === "session.error") {
|
|
2606
|
+
const sessionID = typeof props.sessionID === "string" ? props.sessionID : void 0;
|
|
2607
|
+
const error = props.error;
|
|
2608
|
+
const haystack2 = JSON.stringify(error) ?? "";
|
|
2609
|
+
if (sessionID && isProbe(s, sessionID)) {
|
|
2610
|
+
await log("debug", `probe session ${sessionID} ended with an error; not cooling anything`);
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
const status2 = typeof error?.data?.statusCode === "number" ? error.data.statusCode : void 0;
|
|
2614
|
+
const limited2 = isLimitText(haystack2) || status2 === 429;
|
|
2615
|
+
const outage = isUnavailableText(haystack2);
|
|
2616
|
+
if (!limited2 && !outage) return;
|
|
2617
|
+
const own = claimFor(sessionID);
|
|
2618
|
+
const named = outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
|
|
2619
|
+
let stated2;
|
|
2620
|
+
if (own) {
|
|
2621
|
+
if (outage) {
|
|
2622
|
+
const transient = coolUnavailable(s, own.claim.m);
|
|
2623
|
+
if (transient) {
|
|
2624
|
+
await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason}) after a terminal error`);
|
|
2625
|
+
}
|
|
2626
|
+
} else {
|
|
2627
|
+
stated2 = statedCooldown(haystack2) ?? retryAfterMs(error?.data?.responseHeaders);
|
|
2628
|
+
const strike2 = recordStrike(s, own.claim.m, named, stated2);
|
|
2629
|
+
if (strike2) {
|
|
2630
|
+
await log("warn", `limit on ${own.claim.m} (${named}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s from a terminal session.error`, {
|
|
2631
|
+
model: own.claim.m,
|
|
2632
|
+
slot: own.claim.k,
|
|
2633
|
+
session: sessionID
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
2111
2636
|
}
|
|
2112
|
-
await failOver(sessionID, provider, reason2 || "rate-limited");
|
|
2113
2637
|
}
|
|
2638
|
+
await failOver(sessionID, own?.claim.m ?? "?", named, { stated: stated2, cool: !own });
|
|
2114
2639
|
return;
|
|
2115
2640
|
}
|
|
2116
2641
|
if (event.type === "session.created") {
|
|
@@ -2138,32 +2663,38 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2138
2663
|
await log("debug", `probe of ${model} hit a limit (${isLimitText(haystack) ? "limit" : "transient"}); leaving the cooldown to the probe`);
|
|
2139
2664
|
return;
|
|
2140
2665
|
}
|
|
2666
|
+
const ownMsg = claimFor(info.sessionID);
|
|
2141
2667
|
if (isUnavailableText(haystack) || !limited && transientGateway) {
|
|
2142
2668
|
const transient = coolUnavailable(s, model);
|
|
2143
2669
|
if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
|
|
2670
|
+
await failOver(info.sessionID, model, transient?.reason ?? UNAVAILABLE_REASON, { cool: false });
|
|
2144
2671
|
return;
|
|
2145
2672
|
}
|
|
2146
2673
|
const stated = statedCooldown(haystack) ?? retryAfterMs(info.error.data?.responseHeaders);
|
|
2147
|
-
const reason = haystack
|
|
2674
|
+
const reason = limitReason(haystack);
|
|
2148
2675
|
const strike = recordStrike(s, model, reason ?? "rate-limited", stated);
|
|
2149
2676
|
if (strike) await log("warn", `cooling ${model} for ${Math.round((strike.until - Date.now()) / 1e3)}s (${reason ?? "rate-limited"}, strike ${strike.strikes})`);
|
|
2150
|
-
await failOver(info.sessionID, model, reason ?? "rate-limited");
|
|
2677
|
+
await failOver(info.sessionID, model, reason ?? "rate-limited", { stated, cool: !ownMsg });
|
|
2151
2678
|
} catch (error) {
|
|
2152
2679
|
await log("error", `limit detection failed: ${String(error)}`);
|
|
2153
2680
|
}
|
|
2154
2681
|
},
|
|
2155
2682
|
"tool.execute.before": async (input, output) => {
|
|
2156
2683
|
if (input.tool !== "task") return;
|
|
2157
|
-
|
|
2158
|
-
if (typeof requested !== "string")
|
|
2684
|
+
let requested = output.args?.subagent_type;
|
|
2685
|
+
if (typeof requested !== "string") {
|
|
2686
|
+
await warnOnce(
|
|
2687
|
+
`task spawn has no subagent_type, so the pool cannot route it -- it will run on the caller's own model. Pass subagent_type "explore" or "implement" to use a slot.`
|
|
2688
|
+
);
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2159
2691
|
const manual = manualVariant(requested);
|
|
2160
2692
|
if (manual) {
|
|
2161
|
-
await log("warn", `
|
|
2693
|
+
await log("warn", `rewrote a hand-named pool slot to its base; the pool owns slot choice`, {
|
|
2162
2694
|
base: manual
|
|
2163
2695
|
});
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
);
|
|
2696
|
+
requested = manual;
|
|
2697
|
+
output.args.subagent_type = manual;
|
|
2167
2698
|
}
|
|
2168
2699
|
try {
|
|
2169
2700
|
await reapStuck();
|
|
@@ -2176,8 +2707,6 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2176
2707
|
await warnOnce(`variant ${variant} missing from config; spawn left unchanged`);
|
|
2177
2708
|
return;
|
|
2178
2709
|
}
|
|
2179
|
-
acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
|
|
2180
|
-
output.args.subagent_type = variant;
|
|
2181
2710
|
await log("info", `routed ${requested} to slot ${slot.index} on ${slot.model}`, {
|
|
2182
2711
|
slot: slot.index,
|
|
2183
2712
|
weight: slot.weight,
|
|
@@ -2185,6 +2714,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2185
2714
|
ceiling: slotCeiling(slot),
|
|
2186
2715
|
load: (snap.counts.get(slot.model) ?? 0) + 1
|
|
2187
2716
|
});
|
|
2717
|
+
output.args.subagent_type = variant;
|
|
2718
|
+
acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
|
|
2188
2719
|
return;
|
|
2189
2720
|
}
|
|
2190
2721
|
const pinned = s.models.get(requested);
|
|
@@ -2204,22 +2735,24 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2204
2735
|
},
|
|
2205
2736
|
tool: {
|
|
2206
2737
|
pool_recheck: tool({
|
|
2207
|
-
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
|
|
2738
|
+
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 pool; if it is still limited, the existing cooldown stands; if the provider does not serve it at all, it is benched so routing stops choosing it. Pass `models` to validate those even when they are not cooling. Call it after editing the pool, 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.",
|
|
2208
2739
|
args: {
|
|
2209
|
-
models: tool.schema.array(tool.schema.string()).optional().describe("Model ids to
|
|
2740
|
+
models: tool.schema.array(tool.schema.string()).optional().describe("Model ids to validate, as provider/model-id. Omit to probe only the models that are cooling.")
|
|
2210
2741
|
},
|
|
2211
2742
|
async execute(args) {
|
|
2212
|
-
const
|
|
2743
|
+
const models = args?.models;
|
|
2744
|
+
const report = models && models.length > 0 ? await runValidation(models) : await runProbe();
|
|
2213
2745
|
return { title: `agent-pool: ${reportTitle(report)}`, output: reportText(report) };
|
|
2214
2746
|
}
|
|
2215
2747
|
}),
|
|
2216
2748
|
pool_status: tool({
|
|
2217
|
-
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.",
|
|
2749
|
+
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; COOLING/UNAVAILABLE = not routable. Check it before launching many parallel subagents.",
|
|
2218
2750
|
args: {},
|
|
2219
2751
|
async execute() {
|
|
2220
2752
|
refreshCatalog(s, serverUrl);
|
|
2221
2753
|
const slots = ensureSlots(s, (m) => void warnOnce(m));
|
|
2222
2754
|
const snap = snapshot(s);
|
|
2755
|
+
const pass = poolPass(s, snap);
|
|
2223
2756
|
const now = Date.now();
|
|
2224
2757
|
const byModel = /* @__PURE__ */ new Map();
|
|
2225
2758
|
const add = (model, base) => {
|
|
@@ -2230,14 +2763,14 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2230
2763
|
};
|
|
2231
2764
|
for (const claim of snap.own.values()) add(claim.m, claim.v);
|
|
2232
2765
|
for (const sibling of snap.siblings) for (const claim of sibling.claims) add(claim.m, claim.v);
|
|
2233
|
-
const next = pickSlot(s, snap
|
|
2766
|
+
const next = pickSlot(s, snap);
|
|
2234
2767
|
const line = (slot) => {
|
|
2235
2768
|
const entry = byModel.get(slot.model);
|
|
2236
2769
|
const users = entry && entry.bases.size > 0 ? [...entry.bases].map(([b, n]) => `${b}x${n}`).join(" ") : "idle";
|
|
2237
2770
|
const strike = snap.cooling.get(slot.model);
|
|
2238
2771
|
const status = s.catalog.get(slot.model);
|
|
2239
2772
|
const notes = [
|
|
2240
|
-
strike ? `COOLING ${Math.ceil((strike.until - now) / 1e3)}s
|
|
2773
|
+
strike ? `UNAVAILABLE (COOLING ${Math.ceil((strike.until - now) / 1e3)}s, ${strike.reason}, strike ${strike.strikes})` : "",
|
|
2241
2774
|
status && status !== "active" && status !== "unknown" ? `catalog:${status}` : ""
|
|
2242
2775
|
].filter(Boolean);
|
|
2243
2776
|
const marker = slot.index === next.index ? " <- next" : "";
|
|
@@ -2253,8 +2786,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2253
2786
|
return {
|
|
2254
2787
|
title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
|
|
2255
2788
|
output: [
|
|
2256
|
-
`shared pool over ${slots.length} model(s) from ${s.origin}; bases: ${BASES.join(", ")}
|
|
2257
|
-
|
|
2789
|
+
`shared pool over ${slots.length} model(s) from ${s.origin}; pass ${pass}; bases: ${BASES.join(", ")}
|
|
2790
|
+
two ordered passes in slot order: pass 1 fills each slot to its weight, pass 2 to its ceiling, then overflow goes to the least-loaded slot (FULL = at ceiling, used only when no other slot is available and under its ceiling)`,
|
|
2258
2791
|
...slots.map(line),
|
|
2259
2792
|
"",
|
|
2260
2793
|
"by process:",
|
|
@@ -2268,8 +2801,9 @@ round robin in slot order, each slot taking as many turns as its weight says, so
|
|
|
2268
2801
|
// When the cooled models were last USED rather than inferred, because
|
|
2269
2802
|
// "still cooling" and "nobody has actually asked it lately" are
|
|
2270
2803
|
// different situations and this is the line that tells them apart.
|
|
2271
|
-
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.",
|
|
2272
|
-
|
|
2804
|
+
probeMs > 0 ? `cooldown probe: last ${s.lastProbe ? `${Math.max(0, Math.round((now - s.lastProbe) / 1e3))}s ago` : coolingNow.length > 0 ? "never in this process (cooldowns came from disk)" : "never (nothing has been cooling)"}, every ${Math.round(probeMs / 6e4)}m; call the pool_recheck tool (or run /pool-recheck) to ask now.` : "cooldown probe: off (probeMs: 0); call the pool_recheck tool (or run /pool-recheck) to ask now.",
|
|
2805
|
+
watchMs > 0 ? `pool config watch: ${sources.join(", ")} every ${watchMs}ms; a change reloads the slot table and validates every slot.` : "pool config watch: off (watchMs: 0).",
|
|
2806
|
+
coolingNow.length === 0 ? "no models cooling down." : `unavailable/cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
|
|
2273
2807
|
].join("\n")
|
|
2274
2808
|
};
|
|
2275
2809
|
}
|
|
@@ -2278,6 +2812,8 @@ round robin in slot order, each slot taking as many turns as its weight says, so
|
|
|
2278
2812
|
dispose: async () => {
|
|
2279
2813
|
clearInterval(timer);
|
|
2280
2814
|
if (probeTimer) clearInterval(probeTimer);
|
|
2815
|
+
if (reloadTimer) clearTimeout(reloadTimer);
|
|
2816
|
+
for (const source of sources) unwatchFile(source, scheduleReload);
|
|
2281
2817
|
s.own.clear();
|
|
2282
2818
|
s.activity.clear();
|
|
2283
2819
|
s.children.clear();
|
|
@@ -2287,8 +2823,8 @@ round robin in slot order, each slot taking as many turns as its weight says, so
|
|
|
2287
2823
|
});
|
|
2288
2824
|
|
|
2289
2825
|
// src/plugins/context-autoupdate.ts
|
|
2290
|
-
import { existsSync as existsSync2, mkdirSync as
|
|
2291
|
-
import { homedir as
|
|
2826
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync5, statSync as statSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2827
|
+
import { homedir as homedir3 } from "node:os";
|
|
2292
2828
|
import * as path2 from "node:path";
|
|
2293
2829
|
|
|
2294
2830
|
// src/lib/active-backlog.ts
|
|
@@ -2439,7 +2975,7 @@ var parseJsonc = (raw) => {
|
|
|
2439
2975
|
};
|
|
2440
2976
|
|
|
2441
2977
|
// src/lib/writer-rule.ts
|
|
2442
|
-
import { readFileSync as
|
|
2978
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
2443
2979
|
import * as path from "node:path";
|
|
2444
2980
|
var CONTEXT_DIR = path.join(".opencode", "context");
|
|
2445
2981
|
var WRITER = "context";
|
|
@@ -2479,7 +3015,7 @@ var agentsForm = (mapDir, projectRoot) => {
|
|
|
2479
3015
|
var verifyWriterRule = (configPath, mapDir, projectRoot) => {
|
|
2480
3016
|
let raw;
|
|
2481
3017
|
try {
|
|
2482
|
-
raw =
|
|
3018
|
+
raw = readFileSync4(configPath, "utf8");
|
|
2483
3019
|
} catch {
|
|
2484
3020
|
return void 0;
|
|
2485
3021
|
}
|
|
@@ -2580,7 +3116,7 @@ var snapshotMap = (dir) => {
|
|
|
2580
3116
|
for (const name of readdirSync2(dir)) {
|
|
2581
3117
|
const file = path2.join(dir, name);
|
|
2582
3118
|
try {
|
|
2583
|
-
const st =
|
|
3119
|
+
const st = statSync3(file);
|
|
2584
3120
|
if (st.isFile()) out.set(name, `${st.size}:${st.mtimeMs}`);
|
|
2585
3121
|
} catch {
|
|
2586
3122
|
}
|
|
@@ -2597,15 +3133,15 @@ var changedSince = (before, dir) => {
|
|
|
2597
3133
|
return changed;
|
|
2598
3134
|
};
|
|
2599
3135
|
var RETAIN_TRANSCRIPTS = 20;
|
|
2600
|
-
var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(
|
|
3136
|
+
var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(homedir3(), ".local", "share", "opencode", "lacode", "cartographer");
|
|
2601
3137
|
var retireSession = async (client, directory, sessionID, mode, log) => {
|
|
2602
3138
|
try {
|
|
2603
3139
|
const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
|
|
2604
3140
|
const dir = transcriptDir();
|
|
2605
|
-
|
|
3141
|
+
mkdirSync3(dir, { recursive: true });
|
|
2606
3142
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2607
3143
|
const file = path2.join(dir, `${stamp}-${mode}.json`);
|
|
2608
|
-
|
|
3144
|
+
writeFileSync3(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
|
|
2609
3145
|
pruneTranscripts(dir);
|
|
2610
3146
|
const gone = async () => await client.session.get({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0) === void 0;
|
|
2611
3147
|
await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
|
|
@@ -2626,7 +3162,7 @@ var pruneTranscripts = (dir) => {
|
|
|
2626
3162
|
const entries = readdirSync2(dir).filter((name) => name.endsWith(".json")).sort();
|
|
2627
3163
|
for (const name of entries.slice(0, Math.max(0, entries.length - RETAIN_TRANSCRIPTS))) {
|
|
2628
3164
|
try {
|
|
2629
|
-
|
|
3165
|
+
unlinkSync3(path2.join(dir, name));
|
|
2630
3166
|
} catch {
|
|
2631
3167
|
}
|
|
2632
3168
|
}
|
|
@@ -2655,9 +3191,9 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2655
3191
|
const normalizeActiveBacklog = async () => {
|
|
2656
3192
|
try {
|
|
2657
3193
|
if (!existsSync2(activeBacklogPath)) return { removed: 0 };
|
|
2658
|
-
const before =
|
|
3194
|
+
const before = readFileSync5(activeBacklogPath, "utf8");
|
|
2659
3195
|
const { text, removed } = normalizeBacklog(before);
|
|
2660
|
-
if (text !== before)
|
|
3196
|
+
if (text !== before) writeFileSync3(activeBacklogPath, text);
|
|
2661
3197
|
if (removed.length > 0) {
|
|
2662
3198
|
await log("info", `active backlog: dropped ${removed.length} task(s) finished more than a week ago`, {
|
|
2663
3199
|
removed: removed.length
|
|
@@ -2695,7 +3231,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2695
3231
|
"",
|
|
2696
3232
|
NO_BULK_READS,
|
|
2697
3233
|
"",
|
|
2698
|
-
'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
|
|
3234
|
+
'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 an invariant not yet recorded. Do not edit documentation merely because source files changed. If nothing material changed, make no edits and report "no update needed". Also keep `.opencode/context/active.md` current: record work that is in flight, and mark a task finished \u2014 with the date it finished \u2014 when it is.'
|
|
2699
3235
|
].join("\n");
|
|
2700
3236
|
return {
|
|
2701
3237
|
event: async ({ event }) => {
|
|
@@ -2809,12 +3345,330 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2809
3345
|
};
|
|
2810
3346
|
});
|
|
2811
3347
|
|
|
3348
|
+
// src/plugins/image-paste.ts
|
|
3349
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3350
|
+
import { globSync, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync as renameSync3, statSync as statSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
3351
|
+
import { homedir as homedir4 } from "node:os";
|
|
3352
|
+
import { basename as basename2, join as join6 } from "node:path";
|
|
3353
|
+
|
|
3354
|
+
// src/lib/clipboard.ts
|
|
3355
|
+
import { spawnSync } from "node:child_process";
|
|
3356
|
+
import { readFile, rm } from "node:fs/promises";
|
|
3357
|
+
var RUN_TIMEOUT_MS = 3e3;
|
|
3358
|
+
var defaultRun = async (cmd, args) => {
|
|
3359
|
+
try {
|
|
3360
|
+
const result = spawnSync(cmd, args, {
|
|
3361
|
+
timeout: RUN_TIMEOUT_MS,
|
|
3362
|
+
killSignal: "SIGKILL",
|
|
3363
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
3364
|
+
});
|
|
3365
|
+
const stderr = result.stderr ? result.stderr.toString().trim() : "";
|
|
3366
|
+
const detail = result.error ? String(result.error.code ?? result.error.message) : "";
|
|
3367
|
+
return {
|
|
3368
|
+
ok: result.status === 0,
|
|
3369
|
+
stdout: result.stdout ?? Buffer.alloc(0),
|
|
3370
|
+
stderr: [stderr, detail].filter(Boolean).join("\n")
|
|
3371
|
+
};
|
|
3372
|
+
} catch (error) {
|
|
3373
|
+
return { ok: false, stdout: Buffer.alloc(0), stderr: String(error) };
|
|
3374
|
+
}
|
|
3375
|
+
};
|
|
3376
|
+
var defaultHave = (cmd) => {
|
|
3377
|
+
try {
|
|
3378
|
+
return spawnSync("sh", ["-c", `command -v ${cmd}`], { encoding: "utf8" }).status === 0;
|
|
3379
|
+
} catch {
|
|
3380
|
+
return false;
|
|
3381
|
+
}
|
|
3382
|
+
};
|
|
3383
|
+
var hasImageSignature = (bytes) => {
|
|
3384
|
+
const head = bytes.toString("latin1", 0, 12);
|
|
3385
|
+
if (head.startsWith("\x89PNG")) return true;
|
|
3386
|
+
if (head.startsWith("\xFF\xD8\xFF")) return true;
|
|
3387
|
+
if (head.startsWith("GIF8")) return true;
|
|
3388
|
+
if (head.startsWith("RIFF") && head.slice(8, 12) === "WEBP") return true;
|
|
3389
|
+
return false;
|
|
3390
|
+
};
|
|
3391
|
+
var validImage = (result) => {
|
|
3392
|
+
if (!result.ok || result.stdout.length === 0) return null;
|
|
3393
|
+
return hasImageSignature(result.stdout) ? result.stdout : null;
|
|
3394
|
+
};
|
|
3395
|
+
var linuxCandidates = (env) => {
|
|
3396
|
+
const xclip = { cmd: "xclip", args: ["-selection", "clipboard", "-t", "image/png", "-o"] };
|
|
3397
|
+
const wlPaste = { cmd: "wl-paste", args: ["--type", "image/png"] };
|
|
3398
|
+
const wayland = Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland";
|
|
3399
|
+
return wayland ? [wlPaste, xclip] : [xclip, wlPaste];
|
|
3400
|
+
};
|
|
3401
|
+
var WINDOWS_SCRIPT = [
|
|
3402
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
3403
|
+
"Add-Type -AssemblyName System.Drawing",
|
|
3404
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
|
|
3405
|
+
"if ($null -eq $img) { exit 1 }",
|
|
3406
|
+
"$path = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName()) + '.png'",
|
|
3407
|
+
"$img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)",
|
|
3408
|
+
"Write-Output $path"
|
|
3409
|
+
].join("\n");
|
|
3410
|
+
var readWindows = async (run) => {
|
|
3411
|
+
const result = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT]);
|
|
3412
|
+
if (!result.ok) return null;
|
|
3413
|
+
const path4 = result.stdout.toString("utf8").replace(/^\uFEFF/, "").trim();
|
|
3414
|
+
if (!path4) return null;
|
|
3415
|
+
try {
|
|
3416
|
+
return validImage({ ok: true, stdout: await readFile(path4) });
|
|
3417
|
+
} finally {
|
|
3418
|
+
await rm(path4, { force: true }).catch(() => {
|
|
3419
|
+
});
|
|
3420
|
+
}
|
|
3421
|
+
};
|
|
3422
|
+
async function readClipboardImage(deps = {}) {
|
|
3423
|
+
try {
|
|
3424
|
+
const platform = deps.platform ?? process.platform;
|
|
3425
|
+
const env = deps.env ?? process.env;
|
|
3426
|
+
const run = deps.run ?? defaultRun;
|
|
3427
|
+
const have = deps.have ?? defaultHave;
|
|
3428
|
+
if (platform === "darwin") {
|
|
3429
|
+
if (!have("pngpaste")) return null;
|
|
3430
|
+
return validImage(await run("pngpaste", ["-"]));
|
|
3431
|
+
}
|
|
3432
|
+
if (platform === "win32") {
|
|
3433
|
+
return await readWindows(run);
|
|
3434
|
+
}
|
|
3435
|
+
if (platform === "linux") {
|
|
3436
|
+
for (const candidate of linuxCandidates(env)) {
|
|
3437
|
+
if (!have(candidate.cmd)) continue;
|
|
3438
|
+
const bytes = validImage(await run(candidate.cmd, candidate.args));
|
|
3439
|
+
if (bytes) return bytes;
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
return null;
|
|
3443
|
+
} catch {
|
|
3444
|
+
return null;
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
// src/plugins/image-paste.ts
|
|
3449
|
+
var DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
|
|
3450
|
+
function resolveMaxBytes(opts) {
|
|
3451
|
+
if (typeof opts.maxBytes === "number" && Number.isFinite(opts.maxBytes)) return opts.maxBytes;
|
|
3452
|
+
const parsed = Number.parseInt(process.env.LACODE_IMAGE_MAX_BYTES ?? "", 10);
|
|
3453
|
+
return Number.isNaN(parsed) ? DEFAULT_MAX_BYTES : parsed;
|
|
3454
|
+
}
|
|
3455
|
+
var DEFAULT_MAX_IMAGES = 10;
|
|
3456
|
+
var MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
3457
|
+
function resolveMaxImages(opts) {
|
|
3458
|
+
if (typeof opts.maxImages === "number" && Number.isFinite(opts.maxImages)) return opts.maxImages;
|
|
3459
|
+
const parsed = Number.parseInt(process.env.LACODE_IMAGE_MAX_IMAGES ?? "", 10);
|
|
3460
|
+
return Number.isNaN(parsed) ? DEFAULT_MAX_IMAGES : parsed;
|
|
3461
|
+
}
|
|
3462
|
+
function detectImageKind(bytes) {
|
|
3463
|
+
if (bytes.length >= 4 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) {
|
|
3464
|
+
return { mime: "image/png", ext: "png" };
|
|
3465
|
+
}
|
|
3466
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
|
|
3467
|
+
return { mime: "image/jpeg", ext: "jpg" };
|
|
3468
|
+
}
|
|
3469
|
+
if (bytes.length >= 4 && bytes.toString("latin1", 0, 4) === "GIF8") {
|
|
3470
|
+
return { mime: "image/gif", ext: "gif" };
|
|
3471
|
+
}
|
|
3472
|
+
if (bytes.length >= 12 && bytes.toString("latin1", 0, 4) === "RIFF" && bytes.toString("latin1", 8, 12) === "WEBP") {
|
|
3473
|
+
return { mime: "image/webp", ext: "webp" };
|
|
3474
|
+
}
|
|
3475
|
+
return null;
|
|
3476
|
+
}
|
|
3477
|
+
function splitArguments(input) {
|
|
3478
|
+
const out = [];
|
|
3479
|
+
let current = "";
|
|
3480
|
+
let quote = null;
|
|
3481
|
+
for (const ch of input) {
|
|
3482
|
+
if (quote !== null) {
|
|
3483
|
+
if (ch === quote) quote = null;
|
|
3484
|
+
else current += ch;
|
|
3485
|
+
continue;
|
|
3486
|
+
}
|
|
3487
|
+
if (ch === "'" || ch === '"') {
|
|
3488
|
+
quote = ch;
|
|
3489
|
+
continue;
|
|
3490
|
+
}
|
|
3491
|
+
if (/\s/.test(ch)) {
|
|
3492
|
+
if (current !== "") out.push(current);
|
|
3493
|
+
current = "";
|
|
3494
|
+
continue;
|
|
3495
|
+
}
|
|
3496
|
+
current += ch;
|
|
3497
|
+
}
|
|
3498
|
+
if (current !== "") out.push(current);
|
|
3499
|
+
return out;
|
|
3500
|
+
}
|
|
3501
|
+
function expandTilde(p) {
|
|
3502
|
+
if (p === "~") return homedir4();
|
|
3503
|
+
if (p.startsWith("~/")) return join6(homedir4(), p.slice(2));
|
|
3504
|
+
return p;
|
|
3505
|
+
}
|
|
3506
|
+
function expandGlob(p) {
|
|
3507
|
+
try {
|
|
3508
|
+
const matches = globSync(p);
|
|
3509
|
+
if (matches.length > 0) return [...matches].sort();
|
|
3510
|
+
} catch {
|
|
3511
|
+
}
|
|
3512
|
+
return [p];
|
|
3513
|
+
}
|
|
3514
|
+
function storeImage(bytes, dir, sessionID, label) {
|
|
3515
|
+
const kind = detectImageKind(bytes);
|
|
3516
|
+
if (kind === null) return null;
|
|
3517
|
+
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
3518
|
+
mkdirSync4(dir, { recursive: true });
|
|
3519
|
+
const target = join6(dir, `${hash}.${kind.ext}`);
|
|
3520
|
+
const tmp = `${target}.tmp`;
|
|
3521
|
+
writeFileSync4(tmp, bytes, { mode: 384 });
|
|
3522
|
+
renameSync3(tmp, target);
|
|
3523
|
+
const part = {
|
|
3524
|
+
id: randomUUID(),
|
|
3525
|
+
sessionID,
|
|
3526
|
+
messageID: randomUUID(),
|
|
3527
|
+
type: "file",
|
|
3528
|
+
mime: kind.mime,
|
|
3529
|
+
filename: `${label}-${hash.slice(0, 8)}.${kind.ext}`,
|
|
3530
|
+
url: `data:${kind.mime};base64,${bytes.toString("base64")}`
|
|
3531
|
+
};
|
|
3532
|
+
return { part, hash };
|
|
3533
|
+
}
|
|
3534
|
+
function warn(input, message) {
|
|
3535
|
+
input.client.app.log({ body: { service: "image-paste", level: "warn", message } }).catch(() => {
|
|
3536
|
+
});
|
|
3537
|
+
}
|
|
3538
|
+
async function toast(input, message) {
|
|
3539
|
+
try {
|
|
3540
|
+
await input.client.tui.showToast({ body: { title: "lacode", message, variant: "warning" } });
|
|
3541
|
+
} catch {
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
async function imagePasteHooks(input, options, deps) {
|
|
3545
|
+
try {
|
|
3546
|
+
const opts = options ?? {};
|
|
3547
|
+
if (opts.enabled === false) return {};
|
|
3548
|
+
const maxBytes = resolveMaxBytes(opts);
|
|
3549
|
+
const maxImages = resolveMaxImages(opts);
|
|
3550
|
+
const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ?? join6(homedir4(), ".local/share/opencode/lacode/images");
|
|
3551
|
+
const readClipboard = (deps ?? {}).readClipboard ?? readClipboardImage;
|
|
3552
|
+
return {
|
|
3553
|
+
"command.execute.before": async (cmd, output) => {
|
|
3554
|
+
try {
|
|
3555
|
+
if (cmd.command === "image-in-cb") {
|
|
3556
|
+
const bytes = await readClipboard();
|
|
3557
|
+
if (bytes === null) {
|
|
3558
|
+
warn(input, "No image on the clipboard");
|
|
3559
|
+
await toast(input, "No image on the clipboard");
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
if (bytes.length > maxBytes) {
|
|
3563
|
+
const message = `image too large: ${bytes.length} bytes, max ${maxBytes}`;
|
|
3564
|
+
warn(input, message);
|
|
3565
|
+
await toast(input, message);
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
const stored = storeImage(bytes, dir, cmd.sessionID, "clipboard");
|
|
3569
|
+
if (stored === null) {
|
|
3570
|
+
warn(input, "unsupported clipboard image format (expected PNG, JPEG, GIF or WebP)");
|
|
3571
|
+
await toast(input, "unsupported clipboard image format (expected PNG, JPEG, GIF or WebP)");
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
const firstText = output.parts.findIndex((p) => p.type === "text");
|
|
3575
|
+
if (firstText === -1) output.parts.push(stored.part);
|
|
3576
|
+
else output.parts.splice(firstText, 0, stored.part);
|
|
3577
|
+
return;
|
|
3578
|
+
}
|
|
3579
|
+
if (cmd.command === "image") {
|
|
3580
|
+
const rawArgs = typeof cmd.arguments === "string" ? cmd.arguments : "";
|
|
3581
|
+
const resolved = [];
|
|
3582
|
+
for (const arg of splitArguments(rawArgs)) resolved.push(...expandGlob(expandTilde(arg)));
|
|
3583
|
+
const parts = [];
|
|
3584
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3585
|
+
let totalBytes = 0;
|
|
3586
|
+
const skipped = [];
|
|
3587
|
+
for (const filePath of resolved) {
|
|
3588
|
+
const name = basename2(filePath);
|
|
3589
|
+
let size;
|
|
3590
|
+
try {
|
|
3591
|
+
const stat = statSync4(filePath);
|
|
3592
|
+
if (!stat.isFile()) {
|
|
3593
|
+
skipped.push(name);
|
|
3594
|
+
continue;
|
|
3595
|
+
}
|
|
3596
|
+
size = stat.size;
|
|
3597
|
+
} catch {
|
|
3598
|
+
skipped.push(name);
|
|
3599
|
+
continue;
|
|
3600
|
+
}
|
|
3601
|
+
if (size > maxBytes) {
|
|
3602
|
+
skipped.push(name);
|
|
3603
|
+
continue;
|
|
3604
|
+
}
|
|
3605
|
+
let bytes;
|
|
3606
|
+
try {
|
|
3607
|
+
bytes = readFileSync6(filePath);
|
|
3608
|
+
} catch {
|
|
3609
|
+
skipped.push(name);
|
|
3610
|
+
continue;
|
|
3611
|
+
}
|
|
3612
|
+
if (detectImageKind(bytes) === null) {
|
|
3613
|
+
skipped.push(name);
|
|
3614
|
+
continue;
|
|
3615
|
+
}
|
|
3616
|
+
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
3617
|
+
if (seen.has(hash)) continue;
|
|
3618
|
+
if (parts.length >= maxImages || totalBytes + bytes.length > MAX_TOTAL_BYTES) {
|
|
3619
|
+
skipped.push(name);
|
|
3620
|
+
continue;
|
|
3621
|
+
}
|
|
3622
|
+
const stored = storeImage(bytes, dir, cmd.sessionID, name.replace(/\.[^.]+$/, "") || "image");
|
|
3623
|
+
if (stored === null) {
|
|
3624
|
+
skipped.push(name);
|
|
3625
|
+
continue;
|
|
3626
|
+
}
|
|
3627
|
+
seen.add(hash);
|
|
3628
|
+
parts.push(stored.part);
|
|
3629
|
+
totalBytes += bytes.length;
|
|
3630
|
+
}
|
|
3631
|
+
if (skipped.length > 0) {
|
|
3632
|
+
const message = `skipped ${skipped.length} image file${skipped.length === 1 ? "" : "s"}: ${skipped.slice(0, 3).join(", ")}${skipped.length > 3 ? ", \u2026" : ""}`;
|
|
3633
|
+
warn(input, message);
|
|
3634
|
+
await toast(input, message);
|
|
3635
|
+
}
|
|
3636
|
+
if (parts.length > 0) {
|
|
3637
|
+
const firstText = output.parts.findIndex((p) => p.type === "text");
|
|
3638
|
+
if (firstText === -1) output.parts.push(...parts);
|
|
3639
|
+
else output.parts.splice(firstText, 0, ...parts);
|
|
3640
|
+
}
|
|
3641
|
+
return;
|
|
3642
|
+
}
|
|
3643
|
+
} catch (error) {
|
|
3644
|
+
try {
|
|
3645
|
+
input.client.app.log({ body: { service: "image-paste", level: "error", message: `image-paste failed: ${String(error)}` } }).catch(() => {
|
|
3646
|
+
});
|
|
3647
|
+
} catch {
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
};
|
|
3652
|
+
} catch {
|
|
3653
|
+
return {};
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
// src/lib/prompt-guard.ts
|
|
3658
|
+
var SLOT = new RegExp(`\\b(${BASES.join("|")})(?:-[1-9][0-9]*)+\\b`, "g");
|
|
3659
|
+
function scrubSlotNames(text) {
|
|
3660
|
+
return text.replace(SLOT, "$1");
|
|
3661
|
+
}
|
|
3662
|
+
|
|
2812
3663
|
// src/plugins/notify.ts
|
|
2813
|
-
import { spawn } from "node:child_process";
|
|
3664
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2814
3665
|
import { existsSync as existsSync3 } from "node:fs";
|
|
2815
3666
|
import * as path3 from "node:path";
|
|
2816
3667
|
|
|
2817
3668
|
// src/lib/notify.ts
|
|
3669
|
+
import { closeSync as closeSync2, mkdirSync as mkdirSync5, openSync as openSync2, readFileSync as readFileSync7, renameSync as renameSync4, statSync as statSync5, unlinkSync as unlinkSync4, writeFileSync as writeFileSync5, writeSync as writeSync2 } from "node:fs";
|
|
3670
|
+
import { homedir as homedir5 } from "node:os";
|
|
3671
|
+
import { join as join7 } from "node:path";
|
|
2818
3672
|
var INTERNAL_TITLE_PREFIXES = [
|
|
2819
3673
|
// context-autoupdate spawns the cartographer through the session API, with no
|
|
2820
3674
|
// parentID, so it looks exactly like a root session going idle. Notifying for
|
|
@@ -2834,6 +3688,8 @@ var helperArgv = (helper, title, message, urgency, icon, sound) => {
|
|
|
2834
3688
|
};
|
|
2835
3689
|
var PERMISSION_REPLIES = ["once", "always", "reject"];
|
|
2836
3690
|
var REQUEST_ID_ENV = "LACODE_REQUEST_ID";
|
|
3691
|
+
var NOTIFY_SESSION_ENV = "LACODE_NOTIFY_SESSION";
|
|
3692
|
+
var NOTIFY_CLOSE_ENV = "LACODE_NOTIFY_CLOSE";
|
|
2837
3693
|
var safeStringify = (value) => {
|
|
2838
3694
|
try {
|
|
2839
3695
|
return JSON.stringify(value) ?? void 0;
|
|
@@ -2905,7 +3761,11 @@ var shouldNotify = (input) => {
|
|
|
2905
3761
|
}
|
|
2906
3762
|
const notification = {
|
|
2907
3763
|
notify: true,
|
|
2908
|
-
|
|
3764
|
+
// The session's name on its own, with nothing in front of it. The popup's
|
|
3765
|
+
// header is the notification's APP name, and that is "LaCode" -- so a
|
|
3766
|
+
// prefix here would only repeat the app, and it would repeat it wrongly:
|
|
3767
|
+
// the app is not "opencode".
|
|
3768
|
+
title,
|
|
2909
3769
|
message: notifyMessage(outcome, detail),
|
|
2910
3770
|
urgency: outcome === "error" ? "critical" : "normal"
|
|
2911
3771
|
};
|
|
@@ -2987,6 +3847,178 @@ var Notifier = class {
|
|
|
2987
3847
|
return this.lastSent.size;
|
|
2988
3848
|
}
|
|
2989
3849
|
};
|
|
3850
|
+
var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join7(homedir5(), ".local", "share", "opencode", "lacode", "notify");
|
|
3851
|
+
var safeCorrelationKey = (correlationKey) => correlationKey.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
|
|
3852
|
+
var CLAIM_WINDOW_MS = 5e3;
|
|
3853
|
+
var isPidAlive2 = (pid) => {
|
|
3854
|
+
try {
|
|
3855
|
+
process.kill(pid, 0);
|
|
3856
|
+
return true;
|
|
3857
|
+
} catch (error) {
|
|
3858
|
+
return error.code === "EPERM";
|
|
3859
|
+
}
|
|
3860
|
+
};
|
|
3861
|
+
var claimFile = (sessionID, kind) => join7(notificationStateDir(), `claim.${safeCorrelationKey(`${sessionID}.${kind}`)}`);
|
|
3862
|
+
var claimNotification = (sessionID, kind) => {
|
|
3863
|
+
const path4 = claimFile(sessionID, kind);
|
|
3864
|
+
try {
|
|
3865
|
+
mkdirSync5(notificationStateDir(), { recursive: true });
|
|
3866
|
+
} catch {
|
|
3867
|
+
return true;
|
|
3868
|
+
}
|
|
3869
|
+
const take = () => {
|
|
3870
|
+
try {
|
|
3871
|
+
const fd = openSync2(path4, "wx");
|
|
3872
|
+
try {
|
|
3873
|
+
writeSync2(fd, String(process.pid));
|
|
3874
|
+
} finally {
|
|
3875
|
+
closeSync2(fd);
|
|
3876
|
+
}
|
|
3877
|
+
return "taken";
|
|
3878
|
+
} catch (error) {
|
|
3879
|
+
return error.code === "EEXIST" ? "held" : "impossible";
|
|
3880
|
+
}
|
|
3881
|
+
};
|
|
3882
|
+
const first = take();
|
|
3883
|
+
if (first !== "held") {
|
|
3884
|
+
return true;
|
|
3885
|
+
}
|
|
3886
|
+
const judge = () => {
|
|
3887
|
+
let raw;
|
|
3888
|
+
try {
|
|
3889
|
+
raw = readFileSync7(path4, "utf8").trim();
|
|
3890
|
+
} catch (error) {
|
|
3891
|
+
return error.code === "ENOENT" ? "gone" : "unknown";
|
|
3892
|
+
}
|
|
3893
|
+
let holder;
|
|
3894
|
+
const empty = raw === "";
|
|
3895
|
+
if (!empty) {
|
|
3896
|
+
const parsed = Number.parseInt(raw, 10);
|
|
3897
|
+
holder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
3898
|
+
}
|
|
3899
|
+
if (holder === process.pid) return "ours";
|
|
3900
|
+
let age = Number.POSITIVE_INFINITY;
|
|
3901
|
+
try {
|
|
3902
|
+
age = Date.now() - statSync5(path4).mtimeMs;
|
|
3903
|
+
} catch {
|
|
3904
|
+
return "gone";
|
|
3905
|
+
}
|
|
3906
|
+
if (empty && age < CLAIM_WINDOW_MS) {
|
|
3907
|
+
return "held";
|
|
3908
|
+
}
|
|
3909
|
+
if (holder !== void 0 && isPidAlive2(holder) && age < CLAIM_WINDOW_MS) {
|
|
3910
|
+
return "held";
|
|
3911
|
+
}
|
|
3912
|
+
return "stale";
|
|
3913
|
+
};
|
|
3914
|
+
const judged = judge();
|
|
3915
|
+
if (judged === "ours") return true;
|
|
3916
|
+
if (judged === "held") return false;
|
|
3917
|
+
if (judged === "unknown") return true;
|
|
3918
|
+
if (judged === "gone") return take() !== "held";
|
|
3919
|
+
const lockPath = `${path4}.takeover`;
|
|
3920
|
+
const takeOver = () => {
|
|
3921
|
+
const create = () => {
|
|
3922
|
+
try {
|
|
3923
|
+
const fd = openSync2(lockPath, "wx");
|
|
3924
|
+
try {
|
|
3925
|
+
writeSync2(fd, String(process.pid));
|
|
3926
|
+
} finally {
|
|
3927
|
+
closeSync2(fd);
|
|
3928
|
+
}
|
|
3929
|
+
return () => {
|
|
3930
|
+
try {
|
|
3931
|
+
unlinkSync4(lockPath);
|
|
3932
|
+
} catch {
|
|
3933
|
+
}
|
|
3934
|
+
};
|
|
3935
|
+
} catch (error) {
|
|
3936
|
+
return error.code === "EEXIST" ? void 0 : () => {
|
|
3937
|
+
};
|
|
3938
|
+
}
|
|
3939
|
+
};
|
|
3940
|
+
const release3 = create();
|
|
3941
|
+
if (release3 !== void 0) return release3;
|
|
3942
|
+
let lockHolder;
|
|
3943
|
+
let lockAge = Number.POSITIVE_INFINITY;
|
|
3944
|
+
try {
|
|
3945
|
+
const parsed = Number.parseInt(readFileSync7(lockPath, "utf8").trim(), 10);
|
|
3946
|
+
lockHolder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
3947
|
+
lockAge = Date.now() - statSync5(lockPath).mtimeMs;
|
|
3948
|
+
} catch {
|
|
3949
|
+
return create();
|
|
3950
|
+
}
|
|
3951
|
+
if (lockAge < CLAIM_WINDOW_MS && (lockHolder === void 0 || isPidAlive2(lockHolder))) {
|
|
3952
|
+
return void 0;
|
|
3953
|
+
}
|
|
3954
|
+
try {
|
|
3955
|
+
unlinkSync4(lockPath);
|
|
3956
|
+
} catch {
|
|
3957
|
+
return void 0;
|
|
3958
|
+
}
|
|
3959
|
+
return create();
|
|
3960
|
+
};
|
|
3961
|
+
const release2 = takeOver();
|
|
3962
|
+
if (release2 === void 0) return false;
|
|
3963
|
+
try {
|
|
3964
|
+
const current = judge();
|
|
3965
|
+
if (current === "ours") return true;
|
|
3966
|
+
if (current === "held") return false;
|
|
3967
|
+
if (current === "unknown") return true;
|
|
3968
|
+
try {
|
|
3969
|
+
unlinkSync4(path4);
|
|
3970
|
+
} catch {
|
|
3971
|
+
}
|
|
3972
|
+
return take() !== "held";
|
|
3973
|
+
} finally {
|
|
3974
|
+
release2();
|
|
3975
|
+
}
|
|
3976
|
+
};
|
|
3977
|
+
var releaseNotificationClaim = (sessionID, kind) => {
|
|
3978
|
+
const path4 = claimFile(sessionID, kind);
|
|
3979
|
+
try {
|
|
3980
|
+
const parsed = Number.parseInt(readFileSync7(path4, "utf8").trim(), 10);
|
|
3981
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return;
|
|
3982
|
+
if (parsed !== process.pid) return;
|
|
3983
|
+
unlinkSync4(path4);
|
|
3984
|
+
} catch {
|
|
3985
|
+
}
|
|
3986
|
+
};
|
|
3987
|
+
var completionFile = (sessionID) => join7(notificationStateDir(), `completion.${safeCorrelationKey(sessionID)}`);
|
|
3988
|
+
var claimCompletion = (sessionID, kind) => {
|
|
3989
|
+
const path4 = completionFile(sessionID);
|
|
3990
|
+
try {
|
|
3991
|
+
mkdirSync5(notificationStateDir(), { recursive: true });
|
|
3992
|
+
const fd = openSync2(path4, "wx");
|
|
3993
|
+
try {
|
|
3994
|
+
writeFileSync5(fd, `${process.pid} ${kind}
|
|
3995
|
+
`);
|
|
3996
|
+
} finally {
|
|
3997
|
+
closeSync2(fd);
|
|
3998
|
+
}
|
|
3999
|
+
return true;
|
|
4000
|
+
} catch (error) {
|
|
4001
|
+
if (error.code === "EEXIST") return false;
|
|
4002
|
+
return true;
|
|
4003
|
+
}
|
|
4004
|
+
};
|
|
4005
|
+
var releaseCompletion = (sessionID) => {
|
|
4006
|
+
try {
|
|
4007
|
+
unlinkSync4(completionFile(sessionID));
|
|
4008
|
+
} catch {
|
|
4009
|
+
}
|
|
4010
|
+
};
|
|
4011
|
+
var markCompletion = (sessionID, kind) => {
|
|
4012
|
+
try {
|
|
4013
|
+
mkdirSync5(notificationStateDir(), { recursive: true });
|
|
4014
|
+
const path4 = completionFile(sessionID);
|
|
4015
|
+
const tmp = `${path4}.${process.pid}.tmp`;
|
|
4016
|
+
writeFileSync5(tmp, `${process.pid} ${kind}
|
|
4017
|
+
`);
|
|
4018
|
+
renameSync4(tmp, path4);
|
|
4019
|
+
} catch {
|
|
4020
|
+
}
|
|
4021
|
+
};
|
|
2990
4022
|
|
|
2991
4023
|
// src/plugins/notify.ts
|
|
2992
4024
|
var SERVICE3 = "notify";
|
|
@@ -2995,6 +4027,9 @@ var MAX_INFLIGHT_HELPERS = 4;
|
|
|
2995
4027
|
var HELPER_TIMEOUT_MS = 3e4;
|
|
2996
4028
|
var ACTION_TIMEOUT_MS = 10 * 6e4 + 3e4;
|
|
2997
4029
|
var MAX_TRACKED_SESSIONS = 500;
|
|
4030
|
+
var MIN_VISIBLE_MS = 5e3;
|
|
4031
|
+
var PERMISSION_SETTLE_MS = 250;
|
|
4032
|
+
var UNANSWERABLE_REQUEST = "";
|
|
2998
4033
|
var SOUND_ENV = "LACODE_SOUND";
|
|
2999
4034
|
var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
|
|
3000
4035
|
var findHelper = () => {
|
|
@@ -3020,7 +4055,9 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3020
4055
|
const active = /* @__PURE__ */ new Set();
|
|
3021
4056
|
const titles = /* @__PURE__ */ new Map();
|
|
3022
4057
|
const pending = /* @__PURE__ */ new Set();
|
|
3023
|
-
const permissions = /* @__PURE__ */ new
|
|
4058
|
+
const permissions = /* @__PURE__ */ new Map();
|
|
4059
|
+
const pendingPermissionPopups = /* @__PURE__ */ new Map();
|
|
4060
|
+
const live = /* @__PURE__ */ new Map();
|
|
3024
4061
|
let warnedHelper = false;
|
|
3025
4062
|
let inflight = 0;
|
|
3026
4063
|
const rememberActive = (sessionID) => {
|
|
@@ -3031,17 +4068,43 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3031
4068
|
titles.set(id, title);
|
|
3032
4069
|
while (titles.size > MAX_TRACKED_SESSIONS) titles.delete(titles.keys().next().value);
|
|
3033
4070
|
};
|
|
4071
|
+
const rememberLive = (sessionID, popup) => {
|
|
4072
|
+
const previous = live.get(sessionID);
|
|
4073
|
+
if (previous?.timer) clearTimeout(previous.timer);
|
|
4074
|
+
live.set(sessionID, popup);
|
|
4075
|
+
while (live.size > MAX_TRACKED_SESSIONS) {
|
|
4076
|
+
const oldest = live.keys().next().value;
|
|
4077
|
+
const forgotten = live.get(oldest);
|
|
4078
|
+
if (forgotten?.timer) clearTimeout(forgotten.timer);
|
|
4079
|
+
live.delete(oldest);
|
|
4080
|
+
}
|
|
4081
|
+
};
|
|
4082
|
+
const forgetLive = (sessionID) => {
|
|
4083
|
+
const popup = live.get(sessionID);
|
|
4084
|
+
if (popup?.timer) clearTimeout(popup.timer);
|
|
4085
|
+
live.delete(sessionID);
|
|
4086
|
+
};
|
|
4087
|
+
const rememberPermissions = (sessionID, requests) => {
|
|
4088
|
+
permissions.set(sessionID, requests);
|
|
4089
|
+
while (permissions.size > MAX_TRACKED_SESSIONS) {
|
|
4090
|
+
const oldest = permissions.keys().next().value;
|
|
4091
|
+
permissions.delete(oldest);
|
|
4092
|
+
}
|
|
4093
|
+
};
|
|
4094
|
+
const forgetPermission = (sessionID, requestID) => {
|
|
4095
|
+
const outstanding = permissions.get(sessionID);
|
|
4096
|
+
if (!outstanding) return;
|
|
4097
|
+
outstanding.delete(requestID);
|
|
4098
|
+
if (outstanding.size === 0) permissions.delete(sessionID);
|
|
4099
|
+
};
|
|
4100
|
+
const forgetPermissionPopup = (sessionID) => {
|
|
4101
|
+
const timer = pendingPermissionPopups.get(sessionID);
|
|
4102
|
+
if (timer) clearTimeout(timer);
|
|
4103
|
+
pendingPermissionPopups.delete(sessionID);
|
|
4104
|
+
};
|
|
3034
4105
|
const log = (level, message, extra) => client.app.log({ body: { service: SERVICE3, level, message, extra } }).catch(() => {
|
|
3035
4106
|
});
|
|
3036
|
-
const
|
|
3037
|
-
const response = PERMISSION_REPLIES.find((candidate) => candidate === action);
|
|
3038
|
-
if (!response) {
|
|
3039
|
-
void log("warn", `notification action ignored: ${JSON.stringify(action)} is not a permission reply`, {
|
|
3040
|
-
sessionID,
|
|
3041
|
-
requestID
|
|
3042
|
-
});
|
|
3043
|
-
return;
|
|
3044
|
-
}
|
|
4107
|
+
const replyPermission = async (sessionID, requestID, response) => {
|
|
3045
4108
|
const errorStatus = (error) => {
|
|
3046
4109
|
const candidate = error;
|
|
3047
4110
|
const status2 = candidate?.response?.status ?? candidate?.cause?.status ?? candidate?.status;
|
|
@@ -3054,6 +4117,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3054
4117
|
const status = "result" in reply ? reply.result.response?.status : errorStatus(reply.error);
|
|
3055
4118
|
if (status === 404) {
|
|
3056
4119
|
void log("debug", "permission already answered", { sessionID, requestID });
|
|
4120
|
+
forgetPermission(sessionID, requestID);
|
|
3057
4121
|
return;
|
|
3058
4122
|
}
|
|
3059
4123
|
if ("error" in reply) {
|
|
@@ -3070,13 +4134,27 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3070
4134
|
});
|
|
3071
4135
|
return;
|
|
3072
4136
|
}
|
|
4137
|
+
forgetPermission(sessionID, requestID);
|
|
3073
4138
|
void log("info", "permission answered from the notification", { sessionID, requestID, response });
|
|
3074
4139
|
};
|
|
4140
|
+
const answerPermission = async (sessionID, requestIDs, action) => {
|
|
4141
|
+
const response = PERMISSION_REPLIES.find((candidate) => candidate === action);
|
|
4142
|
+
if (!response) {
|
|
4143
|
+
void log("warn", `notification action ignored: ${JSON.stringify(action)} is not a permission reply`, {
|
|
4144
|
+
sessionID,
|
|
4145
|
+
requestIDs
|
|
4146
|
+
});
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
for (const requestID of requestIDs) {
|
|
4150
|
+
await replyPermission(sessionID, requestID, response);
|
|
4151
|
+
}
|
|
4152
|
+
};
|
|
3075
4153
|
const fetchInfo = async (sessionID) => {
|
|
3076
4154
|
const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
|
|
3077
4155
|
return info;
|
|
3078
4156
|
};
|
|
3079
|
-
const deliverViaHelper = (title, message, urgency, sound, requestID) => new Promise((resolve3) => {
|
|
4157
|
+
const deliverViaHelper = (title, message, urgency, sound, requestID, sessionID) => new Promise((resolve3) => {
|
|
3080
4158
|
if (inflight >= maxInflight) {
|
|
3081
4159
|
void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
|
|
3082
4160
|
resolve3({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
|
|
@@ -3091,15 +4169,17 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3091
4169
|
resolve3({ ok: false, detail: "no oc-notify helper found" });
|
|
3092
4170
|
return;
|
|
3093
4171
|
}
|
|
4172
|
+
const childEnv = { ...process.env, ...sessionID ? { [NOTIFY_SESSION_ENV]: sessionID } : {} };
|
|
3094
4173
|
const actionable = Boolean(requestID);
|
|
3095
4174
|
const timeoutMs = actionable ? ACTION_TIMEOUT_MS : helperTimeoutMs;
|
|
3096
|
-
const child = actionable ?
|
|
4175
|
+
const child = actionable ? spawn2(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
|
|
3097
4176
|
stdio: ["ignore", "pipe", "pipe"],
|
|
3098
4177
|
detached: false,
|
|
3099
|
-
env: { ...
|
|
3100
|
-
}) :
|
|
4178
|
+
env: { ...childEnv, [REQUEST_ID_ENV]: requestID }
|
|
4179
|
+
}) : spawn2(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
|
|
3101
4180
|
stdio: ["ignore", "ignore", "pipe"],
|
|
3102
|
-
detached: true
|
|
4181
|
+
detached: true,
|
|
4182
|
+
env: childEnv
|
|
3103
4183
|
});
|
|
3104
4184
|
inflight++;
|
|
3105
4185
|
let settled = false;
|
|
@@ -3147,6 +4227,88 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3147
4227
|
if (!actionable) child.unref();
|
|
3148
4228
|
});
|
|
3149
4229
|
const deliver = deps.deliver ?? deliverViaHelper;
|
|
4230
|
+
const closeViaHelper = (sessionID) => {
|
|
4231
|
+
try {
|
|
4232
|
+
const helper = findHelper();
|
|
4233
|
+
if (!helper) {
|
|
4234
|
+
if (!warnedHelper) {
|
|
4235
|
+
warnedHelper = true;
|
|
4236
|
+
void log("warn", "no oc-notify helper found; run 'bin/oc-sync' to install one");
|
|
4237
|
+
}
|
|
4238
|
+
return;
|
|
4239
|
+
}
|
|
4240
|
+
const child = spawn2(helper, helperArgv(helper, "opencode", "", "normal", "utilities-terminal", "none"), {
|
|
4241
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
4242
|
+
detached: true,
|
|
4243
|
+
env: { ...process.env, [NOTIFY_CLOSE_ENV]: sessionID }
|
|
4244
|
+
});
|
|
4245
|
+
child.on("error", (error) => {
|
|
4246
|
+
void log("warn", `notification close failed: ${String(error)}`, { sessionID });
|
|
4247
|
+
});
|
|
4248
|
+
child.on("exit", (code) => {
|
|
4249
|
+
if (code !== 0) void log("warn", `notification not closed: helper exit ${code}`, { sessionID });
|
|
4250
|
+
});
|
|
4251
|
+
child.unref();
|
|
4252
|
+
} catch (error) {
|
|
4253
|
+
void log("warn", `notification close failed: ${String(error)}`, { sessionID });
|
|
4254
|
+
}
|
|
4255
|
+
};
|
|
4256
|
+
const close = deps.close ?? closeViaHelper;
|
|
4257
|
+
const dismiss = (sessionID) => {
|
|
4258
|
+
const popup = live.get(sessionID);
|
|
4259
|
+
if (!popup) return;
|
|
4260
|
+
if (popup.timer) {
|
|
4261
|
+
clearTimeout(popup.timer);
|
|
4262
|
+
popup.timer = void 0;
|
|
4263
|
+
}
|
|
4264
|
+
popup.raisedAt = Date.now();
|
|
4265
|
+
const failed = (error) => void log("warn", `notification dismiss failed: ${String(error)}`, { sessionID });
|
|
4266
|
+
try {
|
|
4267
|
+
const outcome = close(sessionID);
|
|
4268
|
+
if (outcome && typeof outcome.catch === "function") void outcome.catch(failed);
|
|
4269
|
+
} catch (error) {
|
|
4270
|
+
failed(error);
|
|
4271
|
+
}
|
|
4272
|
+
};
|
|
4273
|
+
const scheduleDismiss = (sessionID) => {
|
|
4274
|
+
const popup = live.get(sessionID);
|
|
4275
|
+
if (!popup) return;
|
|
4276
|
+
if (popup.timer) return;
|
|
4277
|
+
const delay = Math.max(0, popup.raisedAt + MIN_VISIBLE_MS - Date.now());
|
|
4278
|
+
const timer = setTimeout(() => dismiss(sessionID), delay);
|
|
4279
|
+
timer.unref?.();
|
|
4280
|
+
popup.timer = timer;
|
|
4281
|
+
};
|
|
4282
|
+
const noteInteraction = (sessionID) => {
|
|
4283
|
+
const popup = live.get(sessionID);
|
|
4284
|
+
if (!popup) return;
|
|
4285
|
+
popup.interacted = true;
|
|
4286
|
+
scheduleDismiss(sessionID);
|
|
4287
|
+
};
|
|
4288
|
+
const claimReleases = /* @__PURE__ */ new Map();
|
|
4289
|
+
const scheduleClaimRelease = (sessionID, kind) => {
|
|
4290
|
+
const key = `${kind}:${sessionID}`;
|
|
4291
|
+
if (claimReleases.has(key)) return;
|
|
4292
|
+
const timer = setTimeout(() => {
|
|
4293
|
+
claimReleases.delete(key);
|
|
4294
|
+
releaseNotificationClaim(sessionID, kind);
|
|
4295
|
+
}, COOLDOWN_MS2);
|
|
4296
|
+
timer.unref?.();
|
|
4297
|
+
claimReleases.set(key, timer);
|
|
4298
|
+
};
|
|
4299
|
+
const raisePermissionPopup = (sessionID, requestID, headline) => {
|
|
4300
|
+
forgetPermissionPopup(sessionID);
|
|
4301
|
+
const timer = setTimeout(() => {
|
|
4302
|
+
if (pendingPermissionPopups.get(sessionID) !== timer) return;
|
|
4303
|
+
pendingPermissionPopups.delete(sessionID);
|
|
4304
|
+
const outstanding = permissions.get(sessionID);
|
|
4305
|
+
if (!outstanding || outstanding.size === 0) return;
|
|
4306
|
+
const detail = outstanding.size > 1 ? `${outstanding.size} requests` : headline;
|
|
4307
|
+
void announce(sessionID, "permission", detail || void 0, requestID);
|
|
4308
|
+
}, PERMISSION_SETTLE_MS);
|
|
4309
|
+
timer.unref?.();
|
|
4310
|
+
pendingPermissionPopups.set(sessionID, timer);
|
|
4311
|
+
};
|
|
3150
4312
|
const announce = async (sessionID, outcome, detail, requestID) => {
|
|
3151
4313
|
try {
|
|
3152
4314
|
const info = await fetchInfo(sessionID);
|
|
@@ -3180,10 +4342,38 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3180
4342
|
void log("debug", "suppressed: cooldown", { sessionID });
|
|
3181
4343
|
return;
|
|
3182
4344
|
}
|
|
4345
|
+
if (outcome === "done") {
|
|
4346
|
+
if (!claimCompletion(sessionID, "done")) {
|
|
4347
|
+
void log("debug", "suppressed: completion already announced", { sessionID });
|
|
4348
|
+
return;
|
|
4349
|
+
}
|
|
4350
|
+
} else if (!claimNotification(sessionID, kind)) {
|
|
4351
|
+
void log("debug", "suppressed: another process is raising this", { sessionID, kind });
|
|
4352
|
+
return;
|
|
4353
|
+
} else if (outcome === "error") {
|
|
4354
|
+
markCompletion(sessionID, "error");
|
|
4355
|
+
}
|
|
3183
4356
|
const message = isRemote ? `${decision.message} (remote)` : decision.message;
|
|
3184
4357
|
const sound = soundOn && NOTIFY_OUTCOMES.includes(outcome) ? outcome : "none";
|
|
3185
|
-
const
|
|
3186
|
-
|
|
4358
|
+
const previous = live.get(sessionID);
|
|
4359
|
+
const popup = { raisedAt: Date.now(), interacted: previous?.interacted ?? false };
|
|
4360
|
+
rememberLive(sessionID, popup);
|
|
4361
|
+
if (popup.interacted) scheduleDismiss(sessionID);
|
|
4362
|
+
let sent = { ok: false };
|
|
4363
|
+
try {
|
|
4364
|
+
sent = await deliver(decision.title, message, decision.urgency, sound, requestID, sessionID);
|
|
4365
|
+
} catch (error) {
|
|
4366
|
+
if (outcome === "done" || outcome === "error") releaseCompletion(sessionID);
|
|
4367
|
+
throw error;
|
|
4368
|
+
} finally {
|
|
4369
|
+
if (outcome !== "done") scheduleClaimRelease(sessionID, kind);
|
|
4370
|
+
}
|
|
4371
|
+
if ((outcome === "done" || outcome === "error") && !sent.ok) releaseCompletion(sessionID);
|
|
4372
|
+
if (sent.action && requestID) {
|
|
4373
|
+
const outstanding = permissions.get(sessionID);
|
|
4374
|
+
const ids = outstanding ? [...outstanding].filter((id) => id !== UNANSWERABLE_REQUEST) : [];
|
|
4375
|
+
await answerPermission(sessionID, ids.length > 0 ? ids : [requestID], sent.action);
|
|
4376
|
+
}
|
|
3187
4377
|
const answered = Boolean(sent.action);
|
|
3188
4378
|
void log(answered || sent.ok ? "info" : "error", answered ? "permission answered from the notification" : sent.ok ? "notified" : "notification failed", {
|
|
3189
4379
|
sessionID,
|
|
@@ -3200,7 +4390,15 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3200
4390
|
try {
|
|
3201
4391
|
if (event.type === "session.status") {
|
|
3202
4392
|
const { sessionID: sessionID2, status } = event.properties;
|
|
3203
|
-
if (status.type === "busy" && sessionID2)
|
|
4393
|
+
if (status.type === "busy" && sessionID2) {
|
|
4394
|
+
rememberActive(sessionID2);
|
|
4395
|
+
releaseCompletion(sessionID2);
|
|
4396
|
+
}
|
|
4397
|
+
return;
|
|
4398
|
+
}
|
|
4399
|
+
if (event.type === "message.updated") {
|
|
4400
|
+
const info = event.properties.info;
|
|
4401
|
+
if (info?.role === "user" && info.sessionID) noteInteraction(info.sessionID);
|
|
3204
4402
|
return;
|
|
3205
4403
|
}
|
|
3206
4404
|
if (event.type === "session.updated") {
|
|
@@ -3220,22 +4418,34 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3220
4418
|
}
|
|
3221
4419
|
if (event.type === "question.replied" || event.type === "question.rejected") {
|
|
3222
4420
|
const sessionID2 = event.properties?.sessionID;
|
|
3223
|
-
if (sessionID2)
|
|
4421
|
+
if (sessionID2) {
|
|
4422
|
+
pending.delete(sessionID2);
|
|
4423
|
+
noteInteraction(sessionID2);
|
|
4424
|
+
}
|
|
3224
4425
|
return;
|
|
3225
4426
|
}
|
|
3226
4427
|
if (event.type === "permission.asked") {
|
|
3227
4428
|
const properties = event.properties;
|
|
3228
4429
|
const sessionID2 = properties?.sessionID;
|
|
3229
4430
|
if (!sessionID2) return;
|
|
3230
|
-
permissions.add(sessionID2);
|
|
3231
|
-
rememberActive(sessionID2);
|
|
3232
4431
|
const requestID = typeof properties?.id === "string" && properties.id ? properties.id : void 0;
|
|
3233
|
-
|
|
4432
|
+
const outstanding = permissions.get(sessionID2) ?? /* @__PURE__ */ new Set();
|
|
4433
|
+
const first = outstanding.size === 0;
|
|
4434
|
+
outstanding.add(requestID ?? UNANSWERABLE_REQUEST);
|
|
4435
|
+
rememberPermissions(sessionID2, outstanding);
|
|
4436
|
+
rememberActive(sessionID2);
|
|
4437
|
+
if (!first) return;
|
|
4438
|
+
raisePermissionPopup(sessionID2, requestID, permissionHeadline(properties));
|
|
3234
4439
|
return;
|
|
3235
4440
|
}
|
|
3236
4441
|
if (event.type === "permission.replied") {
|
|
3237
4442
|
const sessionID2 = event.properties?.sessionID;
|
|
3238
|
-
if (sessionID2)
|
|
4443
|
+
if (sessionID2) {
|
|
4444
|
+
const requestID = event.properties?.requestID;
|
|
4445
|
+
if (requestID) forgetPermission(sessionID2, requestID);
|
|
4446
|
+
else permissions.delete(sessionID2);
|
|
4447
|
+
noteInteraction(sessionID2);
|
|
4448
|
+
}
|
|
3239
4449
|
return;
|
|
3240
4450
|
}
|
|
3241
4451
|
if (event.type === "session.error") {
|
|
@@ -3259,6 +4469,9 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3259
4469
|
titles.delete(sessionID2);
|
|
3260
4470
|
pending.delete(sessionID2);
|
|
3261
4471
|
permissions.delete(sessionID2);
|
|
4472
|
+
forgetLive(sessionID2);
|
|
4473
|
+
forgetPermissionPopup(sessionID2);
|
|
4474
|
+
releaseCompletion(sessionID2);
|
|
3262
4475
|
}
|
|
3263
4476
|
return;
|
|
3264
4477
|
}
|
|
@@ -3709,10 +4922,10 @@ var omniRouteHooks = async (input, options, deps = {}) => {
|
|
|
3709
4922
|
};
|
|
3710
4923
|
let started = null;
|
|
3711
4924
|
const initialize = () => started ??= probe();
|
|
3712
|
-
const injectProvider = async (cfg,
|
|
4925
|
+
const injectProvider = async (cfg, warn2) => {
|
|
3713
4926
|
const providers = cfg.provider ??= {};
|
|
3714
4927
|
if (providers.omniroute !== void 0) {
|
|
3715
|
-
await
|
|
4928
|
+
await warn2("omniroute provider is already defined in your config; lacode left it untouched");
|
|
3716
4929
|
return;
|
|
3717
4930
|
}
|
|
3718
4931
|
const exposed = selector.candidates();
|
|
@@ -3754,267 +4967,6 @@ var omniRouteHooks = async (input, options, deps = {}) => {
|
|
|
3754
4967
|
};
|
|
3755
4968
|
};
|
|
3756
4969
|
|
|
3757
|
-
// src/lib/selfupdate.ts
|
|
3758
|
-
import { spawn as spawn2 } from "node:child_process";
|
|
3759
|
-
import { closeSync, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync5, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3, writeSync } from "node:fs";
|
|
3760
|
-
import { homedir as homedir3 } from "node:os";
|
|
3761
|
-
import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
|
|
3762
|
-
var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
3763
|
-
var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
|
|
3764
|
-
var FETCH_TIMEOUT_MS = 4e3;
|
|
3765
|
-
var INSTALL_TIMEOUT_MS = 12e4;
|
|
3766
|
-
var LOCK_STALE_MS = 10 * 60 * 1e3;
|
|
3767
|
-
var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
|
|
3768
|
-
var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
|
|
3769
|
-
var inTestRunner = (env = process.env) => typeof env.NODE_TEST_CONTEXT === "string" && env.NODE_TEST_CONTEXT.length > 0;
|
|
3770
|
-
var STATE_DIR = join6(homedir3(), ".local", "share", "opencode", "lacode");
|
|
3771
|
-
var STATE_PATH = join6(STATE_DIR, "selfupdate.json");
|
|
3772
|
-
var LOCK_PATH = join6(STATE_DIR, "selfupdate.lock");
|
|
3773
|
-
function compareSemver(a, b) {
|
|
3774
|
-
const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
3775
|
-
const left = parse(a);
|
|
3776
|
-
const right = parse(b);
|
|
3777
|
-
for (let i = 0; i < 3; i++) {
|
|
3778
|
-
const l = left[i] ?? 0;
|
|
3779
|
-
const r = right[i] ?? 0;
|
|
3780
|
-
if (l !== r) return l > r ? 1 : -1;
|
|
3781
|
-
}
|
|
3782
|
-
return 0;
|
|
3783
|
-
}
|
|
3784
|
-
function readInstalledVersion(packageRoot, read = (path4) => {
|
|
3785
|
-
try {
|
|
3786
|
-
return readFileSync5(path4, "utf8");
|
|
3787
|
-
} catch {
|
|
3788
|
-
return void 0;
|
|
3789
|
-
}
|
|
3790
|
-
}) {
|
|
3791
|
-
try {
|
|
3792
|
-
const raw = read(join6(packageRoot, "package.json"));
|
|
3793
|
-
if (raw === void 0) return void 0;
|
|
3794
|
-
const manifest = JSON.parse(raw);
|
|
3795
|
-
if (manifest.name !== PACKAGE_NAME) return void 0;
|
|
3796
|
-
return typeof manifest.version === "string" ? manifest.version : void 0;
|
|
3797
|
-
} catch {
|
|
3798
|
-
return void 0;
|
|
3799
|
-
}
|
|
3800
|
-
}
|
|
3801
|
-
function resolveCacheRoot(packageRoot, exists = (p) => {
|
|
3802
|
-
try {
|
|
3803
|
-
return statSync3(p).isFile();
|
|
3804
|
-
} catch {
|
|
3805
|
-
return false;
|
|
3806
|
-
}
|
|
3807
|
-
}) {
|
|
3808
|
-
const tail = join6("node_modules", "@lawaty", "lacode");
|
|
3809
|
-
if (!packageRoot.endsWith(tail)) return void 0;
|
|
3810
|
-
const cacheRoot = resolve2(packageRoot, "..", "..", "..");
|
|
3811
|
-
if (!exists(join6(cacheRoot, "package.json"))) return void 0;
|
|
3812
|
-
return cacheRoot;
|
|
3813
|
-
}
|
|
3814
|
-
function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
|
|
3815
|
-
try {
|
|
3816
|
-
return statSync3(p).isFile();
|
|
3817
|
-
} catch {
|
|
3818
|
-
return false;
|
|
3819
|
-
}
|
|
3820
|
-
}) {
|
|
3821
|
-
if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
|
|
3822
|
-
for (const dir of (env.PATH ?? "").split(":")) {
|
|
3823
|
-
if (!dir) continue;
|
|
3824
|
-
const candidate = join6(dir, "opencode");
|
|
3825
|
-
if (exists(candidate)) return candidate;
|
|
3826
|
-
}
|
|
3827
|
-
const fallback = join6(homedir3(), ".opencode", "bin", "opencode");
|
|
3828
|
-
return exists(fallback) ? fallback : void 0;
|
|
3829
|
-
}
|
|
3830
|
-
async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
3831
|
-
const controller = new AbortController();
|
|
3832
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
3833
|
-
try {
|
|
3834
|
-
const response = await fetchImpl(REGISTRY_LATEST, { signal: controller.signal });
|
|
3835
|
-
if (!response.ok) return void 0;
|
|
3836
|
-
const body = await response.json();
|
|
3837
|
-
return typeof body.version === "string" ? body.version : void 0;
|
|
3838
|
-
} catch {
|
|
3839
|
-
return void 0;
|
|
3840
|
-
} finally {
|
|
3841
|
-
clearTimeout(timer);
|
|
3842
|
-
}
|
|
3843
|
-
}
|
|
3844
|
-
function readState(path4 = STATE_PATH) {
|
|
3845
|
-
try {
|
|
3846
|
-
return JSON.parse(readFileSync5(path4, "utf8"));
|
|
3847
|
-
} catch {
|
|
3848
|
-
return {};
|
|
3849
|
-
}
|
|
3850
|
-
}
|
|
3851
|
-
function writeState(state2, path4 = STATE_PATH) {
|
|
3852
|
-
try {
|
|
3853
|
-
mkdirSync3(dirname3(path4), { recursive: true });
|
|
3854
|
-
writeFileSync3(path4, JSON.stringify(state2));
|
|
3855
|
-
} catch {
|
|
3856
|
-
}
|
|
3857
|
-
}
|
|
3858
|
-
var isDue = (state2, now, interval = CHECK_INTERVAL_MS) => typeof state2.checkedAt !== "number" || now - state2.checkedAt >= interval;
|
|
3859
|
-
var isProcessAlive = (pid) => {
|
|
3860
|
-
try {
|
|
3861
|
-
process.kill(pid, 0);
|
|
3862
|
-
return true;
|
|
3863
|
-
} catch (error) {
|
|
3864
|
-
return error.code === "EPERM";
|
|
3865
|
-
}
|
|
3866
|
-
};
|
|
3867
|
-
function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
|
|
3868
|
-
const claim = () => {
|
|
3869
|
-
const fd = openSync(path4, "wx");
|
|
3870
|
-
writeSync(fd, String(process.pid));
|
|
3871
|
-
closeSync(fd);
|
|
3872
|
-
return () => {
|
|
3873
|
-
try {
|
|
3874
|
-
unlinkSync3(path4);
|
|
3875
|
-
} catch {
|
|
3876
|
-
}
|
|
3877
|
-
};
|
|
3878
|
-
};
|
|
3879
|
-
try {
|
|
3880
|
-
mkdirSync3(dirname3(path4), { recursive: true });
|
|
3881
|
-
return claim();
|
|
3882
|
-
} catch {
|
|
3883
|
-
let orphaned = false;
|
|
3884
|
-
try {
|
|
3885
|
-
const pid = Number.parseInt(readFileSync5(path4, "utf8").trim(), 10);
|
|
3886
|
-
orphaned = !Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid);
|
|
3887
|
-
} catch {
|
|
3888
|
-
orphaned = true;
|
|
3889
|
-
}
|
|
3890
|
-
if (!orphaned) {
|
|
3891
|
-
try {
|
|
3892
|
-
orphaned = now - statSync3(path4).mtimeMs > LOCK_STALE_MS;
|
|
3893
|
-
} catch {
|
|
3894
|
-
orphaned = true;
|
|
3895
|
-
}
|
|
3896
|
-
}
|
|
3897
|
-
if (!orphaned) return void 0;
|
|
3898
|
-
try {
|
|
3899
|
-
unlinkSync3(path4);
|
|
3900
|
-
} catch {
|
|
3901
|
-
return void 0;
|
|
3902
|
-
}
|
|
3903
|
-
try {
|
|
3904
|
-
return claim();
|
|
3905
|
-
} catch {
|
|
3906
|
-
return void 0;
|
|
3907
|
-
}
|
|
3908
|
-
}
|
|
3909
|
-
}
|
|
3910
|
-
async function checkForUpdate(opts) {
|
|
3911
|
-
const installed = readInstalledVersion(opts.packageRoot);
|
|
3912
|
-
const latest = await fetchLatest(opts.fetchImpl);
|
|
3913
|
-
return {
|
|
3914
|
-
installed,
|
|
3915
|
-
latest,
|
|
3916
|
-
newer: Boolean(installed && latest && compareSemver(latest, installed) > 0),
|
|
3917
|
-
cacheRoot: resolveCacheRoot(opts.packageRoot, opts.exists),
|
|
3918
|
-
bin: resolveOpencodeBin(opts.execPath, opts.env, opts.exists)
|
|
3919
|
-
};
|
|
3920
|
-
}
|
|
3921
|
-
async function refreshCache(opts) {
|
|
3922
|
-
const { cacheRoot, bin, latest } = opts;
|
|
3923
|
-
const spawnImpl = opts.spawnImpl ?? spawn2;
|
|
3924
|
-
const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
|
|
3925
|
-
const fresh = join6(cacheRoot, "node_modules", "@lawaty", "lacode");
|
|
3926
|
-
const aside = `${cacheRoot}.stale-${Date.now()}`;
|
|
3927
|
-
try {
|
|
3928
|
-
rmSync(aside, { recursive: true, force: true });
|
|
3929
|
-
renameSync2(cacheRoot, aside);
|
|
3930
|
-
} catch (error) {
|
|
3931
|
-
return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
|
|
3932
|
-
}
|
|
3933
|
-
const exited = await new Promise((settle) => {
|
|
3934
|
-
let done = false;
|
|
3935
|
-
const finish = (value) => {
|
|
3936
|
-
if (done) return;
|
|
3937
|
-
done = true;
|
|
3938
|
-
clearTimeout(timer);
|
|
3939
|
-
settle(value);
|
|
3940
|
-
};
|
|
3941
|
-
const child = spawnImpl(bin, ["plugin", PACKAGE_NAME, "-g"], {
|
|
3942
|
-
env: { ...process.env, [CHILD_ENV]: "1" },
|
|
3943
|
-
stdio: "ignore"
|
|
3944
|
-
});
|
|
3945
|
-
const timer = setTimeout(() => {
|
|
3946
|
-
try {
|
|
3947
|
-
child.kill("SIGKILL");
|
|
3948
|
-
} catch {
|
|
3949
|
-
}
|
|
3950
|
-
finish(false);
|
|
3951
|
-
}, timeoutMs);
|
|
3952
|
-
child.on("error", () => finish(false));
|
|
3953
|
-
child.on("exit", (code) => finish(code === 0));
|
|
3954
|
-
});
|
|
3955
|
-
const installed = readInstalledVersion(fresh);
|
|
3956
|
-
const ok = exited && installed === latest;
|
|
3957
|
-
if (ok) {
|
|
3958
|
-
try {
|
|
3959
|
-
rmSync(aside, { recursive: true, force: true });
|
|
3960
|
-
} catch {
|
|
3961
|
-
}
|
|
3962
|
-
} else {
|
|
3963
|
-
try {
|
|
3964
|
-
renameSync2(aside, cacheRoot);
|
|
3965
|
-
} catch {
|
|
3966
|
-
}
|
|
3967
|
-
}
|
|
3968
|
-
return {
|
|
3969
|
-
ok,
|
|
3970
|
-
detail: ok ? `installed ${installed}` : `installer exited ${exited ? "0" : "non-zero"}, cache now ${installed ?? "missing"}`
|
|
3971
|
-
};
|
|
3972
|
-
}
|
|
3973
|
-
async function maybeSelfUpdate(opts) {
|
|
3974
|
-
const env = opts.env ?? process.env;
|
|
3975
|
-
if (opts.enabled === false || env[CHILD_ENV] === "1" || env[OPT_OUT_ENV] === "1" || inTestRunner(env)) {
|
|
3976
|
-
return { action: "disabled" };
|
|
3977
|
-
}
|
|
3978
|
-
const now = (opts.now ?? Date.now)();
|
|
3979
|
-
const statePath = opts.statePath ?? STATE_PATH;
|
|
3980
|
-
const state2 = readState(statePath);
|
|
3981
|
-
if (!isDue(state2, now, opts.intervalMs ?? CHECK_INTERVAL_MS)) return { action: "skipped", detail: "checked recently" };
|
|
3982
|
-
const release2 = acquireLock(opts.lockPath, now);
|
|
3983
|
-
if (!release2) return { action: "skipped", detail: "another process is updating" };
|
|
3984
|
-
try {
|
|
3985
|
-
const check = await checkForUpdate(opts);
|
|
3986
|
-
if (!check.installed) return { action: "skipped", detail: "no installed version" };
|
|
3987
|
-
if (!check.latest) {
|
|
3988
|
-
writeState({ ...state2, checkedAt: now }, statePath);
|
|
3989
|
-
return { action: "skipped", detail: "registry unreachable" };
|
|
3990
|
-
}
|
|
3991
|
-
if (!check.newer) {
|
|
3992
|
-
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
3993
|
-
return { action: "current", detail: `${check.installed} is current` };
|
|
3994
|
-
}
|
|
3995
|
-
if (!check.cacheRoot) {
|
|
3996
|
-
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
3997
|
-
return { action: "skipped", detail: "not an opencode cache install" };
|
|
3998
|
-
}
|
|
3999
|
-
if (!check.bin) {
|
|
4000
|
-
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
4001
|
-
opts.log?.("warn", `LaCode ${check.latest} is available but no opencode binary was found to install it`);
|
|
4002
|
-
return { action: "failed", detail: "no opencode binary" };
|
|
4003
|
-
}
|
|
4004
|
-
const result = await refreshCache({ cacheRoot: check.cacheRoot, bin: check.bin, latest: check.latest, spawnImpl: opts.spawnImpl });
|
|
4005
|
-
if (result.ok) {
|
|
4006
|
-
writeState({ ...state2, checkedAt: now, latest: check.latest, updatedTo: check.latest }, statePath);
|
|
4007
|
-
opts.log?.("info", `LaCode updated ${check.installed} -> ${check.latest}; restart opencode to apply`);
|
|
4008
|
-
return { action: "updated", detail: `${check.installed} -> ${check.latest}` };
|
|
4009
|
-
}
|
|
4010
|
-
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
4011
|
-
opts.log?.("warn", `LaCode update to ${check.latest} failed: ${result.detail}`);
|
|
4012
|
-
return { action: "failed", detail: result.detail };
|
|
4013
|
-
} finally {
|
|
4014
|
-
release2();
|
|
4015
|
-
}
|
|
4016
|
-
}
|
|
4017
|
-
|
|
4018
4970
|
// src/plugins/selfupdate.ts
|
|
4019
4971
|
async function selfUpdateHooks(input, options) {
|
|
4020
4972
|
const opts = options ?? {};
|
|
@@ -4036,11 +4988,24 @@ var inertHooks = {
|
|
|
4036
4988
|
dispose: async () => {
|
|
4037
4989
|
}
|
|
4038
4990
|
};
|
|
4991
|
+
var promptDriftWarned = false;
|
|
4039
4992
|
var LaCode = (async (input, options) => {
|
|
4040
4993
|
const opts = options ?? {};
|
|
4041
4994
|
if (opts.enabled === false) return inertHooks;
|
|
4042
4995
|
const log = (level, message, extra) => input.client.app.log({ body: { service: "lacode", level, message, extra } }).catch(() => {
|
|
4043
4996
|
});
|
|
4997
|
+
const warnPromptDrift = async () => {
|
|
4998
|
+
if (promptDriftWarned) return;
|
|
4999
|
+
promptDriftWarned = true;
|
|
5000
|
+
await log("warn", "a system prompt named a pool slot variant; stripped it before the model saw it. Remove the literal from the prompt source.");
|
|
5001
|
+
};
|
|
5002
|
+
let version;
|
|
5003
|
+
try {
|
|
5004
|
+
version = readInstalledVersion(PACKAGE_ROOT);
|
|
5005
|
+
} catch {
|
|
5006
|
+
version = void 0;
|
|
5007
|
+
}
|
|
5008
|
+
await log("info", version ? `lacode ${version} started` : "lacode started");
|
|
4044
5009
|
const settled = async (label, make) => {
|
|
4045
5010
|
try {
|
|
4046
5011
|
return await make();
|
|
@@ -4055,6 +5020,7 @@ var LaCode = (async (input, options) => {
|
|
|
4055
5020
|
const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
|
|
4056
5021
|
const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
|
|
4057
5022
|
const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
|
|
5023
|
+
const imagepaste = await settled("image-paste", () => imagePasteHooks(input, opts.imagePaste)) ?? inertHooks;
|
|
4058
5024
|
const config = async (cfg) => {
|
|
4059
5025
|
try {
|
|
4060
5026
|
const injected = applyAgents(cfg, agentSet);
|
|
@@ -4089,12 +5055,30 @@ var LaCode = (async (input, options) => {
|
|
|
4089
5055
|
"tool.execute.before": pool["tool.execute.before"],
|
|
4090
5056
|
"tool.execute.after": pool["tool.execute.after"],
|
|
4091
5057
|
tool: pool.tool,
|
|
5058
|
+
"command.execute.before": imagepaste["command.execute.before"],
|
|
4092
5059
|
dispose: async () => {
|
|
4093
5060
|
await pool.dispose?.();
|
|
4094
5061
|
await autoupdate.dispose?.();
|
|
4095
5062
|
await notifier.dispose?.();
|
|
4096
5063
|
await selfupdate.dispose?.();
|
|
4097
5064
|
await omniroute.dispose?.();
|
|
5065
|
+
await imagepaste.dispose?.();
|
|
5066
|
+
},
|
|
5067
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
5068
|
+
if (!Array.isArray(output?.system)) return;
|
|
5069
|
+
let changed = false;
|
|
5070
|
+
for (let i = 0; i < output.system.length; i++) {
|
|
5071
|
+
const text = output.system[i];
|
|
5072
|
+
if (typeof text !== "string") continue;
|
|
5073
|
+
const scrubbed = scrubSlotNames(text);
|
|
5074
|
+
if (scrubbed !== text) {
|
|
5075
|
+
output.system[i] = scrubbed;
|
|
5076
|
+
changed = true;
|
|
5077
|
+
}
|
|
5078
|
+
}
|
|
5079
|
+
if (changed) {
|
|
5080
|
+
await warnPromptDrift();
|
|
5081
|
+
}
|
|
4098
5082
|
}
|
|
4099
5083
|
};
|
|
4100
5084
|
});
|