@lawaty/lacode 0.9.0 → 0.11.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 +242 -2
- package/README.md +25 -16
- package/dist/commands.js +14 -3
- package/dist/index.js +963 -379
- package/dist/lacode.js +288 -65
- package/dist/pool.js +41 -6
- package/dist/tunnel.js +248 -27
- package/docs/CONFIGURATION.md +21 -7
- package/docs/OMNIROUTE.md +1 -1
- package/docs/TUNNEL.md +86 -16
- package/docs/USER-STORIES.md +94 -9
- package/package.json +1 -1
- package/presets/README.md +4 -3
- package/presets/free-tier.json +3 -3
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));
|
|
@@ -1713,7 +1741,7 @@ Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
|
|
|
1713
1741
|
Change nothing. Print a final one-line summary of failures.`
|
|
1714
1742
|
},
|
|
1715
1743
|
"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.
|
|
1744
|
+
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
1745
|
template: `Apply this change to the LaCode pool on this machine:
|
|
1718
1746
|
|
|
1719
1747
|
$ARGUMENTS
|
|
@@ -1731,16 +1759,27 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
|
|
|
1731
1759
|
|
|
1732
1760
|
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 the rotation. Keep the existing comment header. If the write fails on a missing directory, create \`~/.config/lacode\` first and retry.
|
|
1733
1761
|
|
|
1734
|
-
4. Report the before \u2192 after slot table
|
|
1762
|
+
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
1763
|
|
|
1736
1764
|
Two traps that fail silently, so check and state them explicitly:
|
|
1737
1765
|
- 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
1766
|
- 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
1767
|
- A provider \`whitelist\` is an allowlist, and the plugin appends your model to it at config time. Nothing for you to do there.
|
|
1740
1768
|
|
|
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
|
|
1769
|
+
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
1770
|
|
|
1743
1771
|
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.`
|
|
1772
|
+
},
|
|
1773
|
+
"pool-recheck": {
|
|
1774
|
+
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.",
|
|
1775
|
+
template: `Probe the LaCode pool now and report the result. Run inline; do not spawn a subagent and do not edit the pool.
|
|
1776
|
+
|
|
1777
|
+
$ARGUMENTS
|
|
1778
|
+
|
|
1779
|
+
- If the text above names one or more models, call the \`pool_recheck\` tool with those ids in \`models\` (provider/model-id).
|
|
1780
|
+
- Otherwise call \`pool_recheck\` with no arguments; it probes every model that is currently cooling.
|
|
1781
|
+
|
|
1782
|
+
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.`
|
|
1744
1783
|
}
|
|
1745
1784
|
};
|
|
1746
1785
|
function applyCommands(cfg, commands = COMMANDS) {
|
|
@@ -1758,7 +1797,269 @@ function applyCommands(cfg, commands = COMMANDS) {
|
|
|
1758
1797
|
return { added, kept };
|
|
1759
1798
|
}
|
|
1760
1799
|
|
|
1800
|
+
// src/lib/selfupdate.ts
|
|
1801
|
+
import { spawn } from "node:child_process";
|
|
1802
|
+
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";
|
|
1803
|
+
import { homedir as homedir2 } from "node:os";
|
|
1804
|
+
import { dirname as dirname2, join as join3, resolve } from "node:path";
|
|
1805
|
+
var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
1806
|
+
var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
|
|
1807
|
+
var FETCH_TIMEOUT_MS = 4e3;
|
|
1808
|
+
var INSTALL_TIMEOUT_MS = 12e4;
|
|
1809
|
+
var LOCK_STALE_MS = 10 * 60 * 1e3;
|
|
1810
|
+
var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
|
|
1811
|
+
var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
|
|
1812
|
+
var inTestRunner = (env = process.env) => typeof env.NODE_TEST_CONTEXT === "string" && env.NODE_TEST_CONTEXT.length > 0;
|
|
1813
|
+
var STATE_DIR = join3(homedir2(), ".local", "share", "opencode", "lacode");
|
|
1814
|
+
var STATE_PATH = join3(STATE_DIR, "selfupdate.json");
|
|
1815
|
+
var LOCK_PATH = join3(STATE_DIR, "selfupdate.lock");
|
|
1816
|
+
function compareSemver(a, b) {
|
|
1817
|
+
const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
1818
|
+
const left = parse(a);
|
|
1819
|
+
const right = parse(b);
|
|
1820
|
+
for (let i = 0; i < 3; i++) {
|
|
1821
|
+
const l = left[i] ?? 0;
|
|
1822
|
+
const r = right[i] ?? 0;
|
|
1823
|
+
if (l !== r) return l > r ? 1 : -1;
|
|
1824
|
+
}
|
|
1825
|
+
return 0;
|
|
1826
|
+
}
|
|
1827
|
+
function readInstalledVersion(packageRoot, read = (path4) => {
|
|
1828
|
+
try {
|
|
1829
|
+
return readFileSync3(path4, "utf8");
|
|
1830
|
+
} catch {
|
|
1831
|
+
return void 0;
|
|
1832
|
+
}
|
|
1833
|
+
}) {
|
|
1834
|
+
try {
|
|
1835
|
+
const raw = read(join3(packageRoot, "package.json"));
|
|
1836
|
+
if (raw === void 0) return void 0;
|
|
1837
|
+
const manifest = JSON.parse(raw);
|
|
1838
|
+
if (manifest.name !== PACKAGE_NAME) return void 0;
|
|
1839
|
+
return typeof manifest.version === "string" ? manifest.version : void 0;
|
|
1840
|
+
} catch {
|
|
1841
|
+
return void 0;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
function resolveCacheRoot(packageRoot, exists = (p) => {
|
|
1845
|
+
try {
|
|
1846
|
+
return statSync2(p).isFile();
|
|
1847
|
+
} catch {
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1850
|
+
}) {
|
|
1851
|
+
const tail = join3("node_modules", "@lawaty", "lacode");
|
|
1852
|
+
if (!packageRoot.endsWith(tail)) return void 0;
|
|
1853
|
+
const cacheRoot = resolve(packageRoot, "..", "..", "..");
|
|
1854
|
+
if (!exists(join3(cacheRoot, "package.json"))) return void 0;
|
|
1855
|
+
return cacheRoot;
|
|
1856
|
+
}
|
|
1857
|
+
function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
|
|
1858
|
+
try {
|
|
1859
|
+
return statSync2(p).isFile();
|
|
1860
|
+
} catch {
|
|
1861
|
+
return false;
|
|
1862
|
+
}
|
|
1863
|
+
}) {
|
|
1864
|
+
if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
|
|
1865
|
+
for (const dir of (env.PATH ?? "").split(":")) {
|
|
1866
|
+
if (!dir) continue;
|
|
1867
|
+
const candidate = join3(dir, "opencode");
|
|
1868
|
+
if (exists(candidate)) return candidate;
|
|
1869
|
+
}
|
|
1870
|
+
const fallback = join3(homedir2(), ".opencode", "bin", "opencode");
|
|
1871
|
+
return exists(fallback) ? fallback : void 0;
|
|
1872
|
+
}
|
|
1873
|
+
async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
1874
|
+
const controller = new AbortController();
|
|
1875
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1876
|
+
try {
|
|
1877
|
+
const response = await fetchImpl(REGISTRY_LATEST, { signal: controller.signal });
|
|
1878
|
+
if (!response.ok) return void 0;
|
|
1879
|
+
const body = await response.json();
|
|
1880
|
+
return typeof body.version === "string" ? body.version : void 0;
|
|
1881
|
+
} catch {
|
|
1882
|
+
return void 0;
|
|
1883
|
+
} finally {
|
|
1884
|
+
clearTimeout(timer);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
function readState(path4 = STATE_PATH) {
|
|
1888
|
+
try {
|
|
1889
|
+
return JSON.parse(readFileSync3(path4, "utf8"));
|
|
1890
|
+
} catch {
|
|
1891
|
+
return {};
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
function writeState(state2, path4 = STATE_PATH) {
|
|
1895
|
+
try {
|
|
1896
|
+
mkdirSync2(dirname2(path4), { recursive: true });
|
|
1897
|
+
writeFileSync2(path4, JSON.stringify(state2));
|
|
1898
|
+
} catch {
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
var isDue = (state2, now, interval = CHECK_INTERVAL_MS) => typeof state2.checkedAt !== "number" || now - state2.checkedAt >= interval;
|
|
1902
|
+
var isProcessAlive = (pid) => {
|
|
1903
|
+
try {
|
|
1904
|
+
process.kill(pid, 0);
|
|
1905
|
+
return true;
|
|
1906
|
+
} catch (error) {
|
|
1907
|
+
return error.code === "EPERM";
|
|
1908
|
+
}
|
|
1909
|
+
};
|
|
1910
|
+
function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
|
|
1911
|
+
const claim = () => {
|
|
1912
|
+
const fd = openSync(path4, "wx");
|
|
1913
|
+
writeSync(fd, String(process.pid));
|
|
1914
|
+
closeSync(fd);
|
|
1915
|
+
return () => {
|
|
1916
|
+
try {
|
|
1917
|
+
unlinkSync2(path4);
|
|
1918
|
+
} catch {
|
|
1919
|
+
}
|
|
1920
|
+
};
|
|
1921
|
+
};
|
|
1922
|
+
try {
|
|
1923
|
+
mkdirSync2(dirname2(path4), { recursive: true });
|
|
1924
|
+
return claim();
|
|
1925
|
+
} catch {
|
|
1926
|
+
let orphaned = false;
|
|
1927
|
+
try {
|
|
1928
|
+
const pid = Number.parseInt(readFileSync3(path4, "utf8").trim(), 10);
|
|
1929
|
+
orphaned = !Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid);
|
|
1930
|
+
} catch {
|
|
1931
|
+
orphaned = true;
|
|
1932
|
+
}
|
|
1933
|
+
if (!orphaned) {
|
|
1934
|
+
try {
|
|
1935
|
+
orphaned = now - statSync2(path4).mtimeMs > LOCK_STALE_MS;
|
|
1936
|
+
} catch {
|
|
1937
|
+
orphaned = true;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
if (!orphaned) return void 0;
|
|
1941
|
+
try {
|
|
1942
|
+
unlinkSync2(path4);
|
|
1943
|
+
} catch {
|
|
1944
|
+
return void 0;
|
|
1945
|
+
}
|
|
1946
|
+
try {
|
|
1947
|
+
return claim();
|
|
1948
|
+
} catch {
|
|
1949
|
+
return void 0;
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
async function checkForUpdate(opts) {
|
|
1954
|
+
const installed = readInstalledVersion(opts.packageRoot);
|
|
1955
|
+
const latest = await fetchLatest(opts.fetchImpl);
|
|
1956
|
+
return {
|
|
1957
|
+
installed,
|
|
1958
|
+
latest,
|
|
1959
|
+
newer: Boolean(installed && latest && compareSemver(latest, installed) > 0),
|
|
1960
|
+
cacheRoot: resolveCacheRoot(opts.packageRoot, opts.exists),
|
|
1961
|
+
bin: resolveOpencodeBin(opts.execPath, opts.env, opts.exists)
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
async function refreshCache(opts) {
|
|
1965
|
+
const { cacheRoot, bin, latest } = opts;
|
|
1966
|
+
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
1967
|
+
const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
|
|
1968
|
+
const fresh = join3(cacheRoot, "node_modules", "@lawaty", "lacode");
|
|
1969
|
+
const aside = `${cacheRoot}.stale-${Date.now()}`;
|
|
1970
|
+
try {
|
|
1971
|
+
rmSync(aside, { recursive: true, force: true });
|
|
1972
|
+
renameSync2(cacheRoot, aside);
|
|
1973
|
+
} catch (error) {
|
|
1974
|
+
return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
|
|
1975
|
+
}
|
|
1976
|
+
const exited = await new Promise((settle) => {
|
|
1977
|
+
let done = false;
|
|
1978
|
+
const finish = (value) => {
|
|
1979
|
+
if (done) return;
|
|
1980
|
+
done = true;
|
|
1981
|
+
clearTimeout(timer);
|
|
1982
|
+
settle(value);
|
|
1983
|
+
};
|
|
1984
|
+
const child = spawnImpl(bin, ["plugin", PACKAGE_NAME, "-g"], {
|
|
1985
|
+
env: { ...process.env, [CHILD_ENV]: "1" },
|
|
1986
|
+
stdio: "ignore"
|
|
1987
|
+
});
|
|
1988
|
+
const timer = setTimeout(() => {
|
|
1989
|
+
try {
|
|
1990
|
+
child.kill("SIGKILL");
|
|
1991
|
+
} catch {
|
|
1992
|
+
}
|
|
1993
|
+
finish(false);
|
|
1994
|
+
}, timeoutMs);
|
|
1995
|
+
child.on("error", () => finish(false));
|
|
1996
|
+
child.on("exit", (code) => finish(code === 0));
|
|
1997
|
+
});
|
|
1998
|
+
const installed = readInstalledVersion(fresh);
|
|
1999
|
+
const ok = exited && installed === latest;
|
|
2000
|
+
if (ok) {
|
|
2001
|
+
try {
|
|
2002
|
+
rmSync(aside, { recursive: true, force: true });
|
|
2003
|
+
} catch {
|
|
2004
|
+
}
|
|
2005
|
+
} else {
|
|
2006
|
+
try {
|
|
2007
|
+
renameSync2(aside, cacheRoot);
|
|
2008
|
+
} catch {
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
return {
|
|
2012
|
+
ok,
|
|
2013
|
+
detail: ok ? `installed ${installed}` : `installer exited ${exited ? "0" : "non-zero"}, cache now ${installed ?? "missing"}`
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
async function maybeSelfUpdate(opts) {
|
|
2017
|
+
const env = opts.env ?? process.env;
|
|
2018
|
+
if (opts.enabled === false || env[CHILD_ENV] === "1" || env[OPT_OUT_ENV] === "1" || inTestRunner(env)) {
|
|
2019
|
+
return { action: "disabled" };
|
|
2020
|
+
}
|
|
2021
|
+
const now = (opts.now ?? Date.now)();
|
|
2022
|
+
const statePath = opts.statePath ?? STATE_PATH;
|
|
2023
|
+
const state2 = readState(statePath);
|
|
2024
|
+
if (!isDue(state2, now, opts.intervalMs ?? CHECK_INTERVAL_MS)) return { action: "skipped", detail: "checked recently" };
|
|
2025
|
+
const release2 = acquireLock(opts.lockPath, now);
|
|
2026
|
+
if (!release2) return { action: "skipped", detail: "another process is updating" };
|
|
2027
|
+
try {
|
|
2028
|
+
const check = await checkForUpdate(opts);
|
|
2029
|
+
if (!check.installed) return { action: "skipped", detail: "no installed version" };
|
|
2030
|
+
if (!check.latest) {
|
|
2031
|
+
writeState({ ...state2, checkedAt: now }, statePath);
|
|
2032
|
+
return { action: "skipped", detail: "registry unreachable" };
|
|
2033
|
+
}
|
|
2034
|
+
if (!check.newer) {
|
|
2035
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
2036
|
+
return { action: "current", detail: `${check.installed} is current` };
|
|
2037
|
+
}
|
|
2038
|
+
if (!check.cacheRoot) {
|
|
2039
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
2040
|
+
return { action: "skipped", detail: "not an opencode cache install" };
|
|
2041
|
+
}
|
|
2042
|
+
if (!check.bin) {
|
|
2043
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
2044
|
+
opts.log?.("warn", `LaCode ${check.latest} is available but no opencode binary was found to install it`);
|
|
2045
|
+
return { action: "failed", detail: "no opencode binary" };
|
|
2046
|
+
}
|
|
2047
|
+
const result = await refreshCache({ cacheRoot: check.cacheRoot, bin: check.bin, latest: check.latest, spawnImpl: opts.spawnImpl });
|
|
2048
|
+
if (result.ok) {
|
|
2049
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest, updatedTo: check.latest }, statePath);
|
|
2050
|
+
opts.log?.("info", `LaCode updated ${check.installed} -> ${check.latest}; restart opencode to apply`);
|
|
2051
|
+
return { action: "updated", detail: `${check.installed} -> ${check.latest}` };
|
|
2052
|
+
}
|
|
2053
|
+
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
2054
|
+
opts.log?.("warn", `LaCode update to ${check.latest} failed: ${result.detail}`);
|
|
2055
|
+
return { action: "failed", detail: result.detail };
|
|
2056
|
+
} finally {
|
|
2057
|
+
release2();
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1761
2061
|
// src/plugins/agent-pool.ts
|
|
2062
|
+
import { unwatchFile, watchFile } from "node:fs";
|
|
1762
2063
|
import { tool } from "@opencode-ai/plugin";
|
|
1763
2064
|
|
|
1764
2065
|
// src/lib/probe.ts
|
|
@@ -1776,8 +2077,8 @@ var failureText = (error) => {
|
|
|
1776
2077
|
};
|
|
1777
2078
|
var shortReason = (text) => {
|
|
1778
2079
|
if (!text) return "failed";
|
|
1779
|
-
|
|
1780
|
-
return
|
|
2080
|
+
if (isModelMissingText(text)) return MODEL_MISSING_REASON;
|
|
2081
|
+
return limitReason(text) ?? (isLimitText(text) ? "limit" : "failed");
|
|
1781
2082
|
};
|
|
1782
2083
|
var probeOne = async (client, opts, model, leftSeconds) => {
|
|
1783
2084
|
const { state: s, directory } = opts;
|
|
@@ -1824,8 +2125,12 @@ var probeOne = async (client, opts, model, leftSeconds) => {
|
|
|
1824
2125
|
return settle(true, "answered", 0);
|
|
1825
2126
|
}
|
|
1826
2127
|
const text = failureText(failure);
|
|
1827
|
-
|
|
1828
|
-
|
|
2128
|
+
if (isModelMissingText(text)) {
|
|
2129
|
+
recordStrike(s, model, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
|
|
2130
|
+
} else {
|
|
2131
|
+
const stated = statedCooldown(text);
|
|
2132
|
+
if (stated !== void 0) recordStrike(s, model, shortReason(text), stated);
|
|
2133
|
+
}
|
|
1829
2134
|
const still = readLimits(s).get(model);
|
|
1830
2135
|
return settle(
|
|
1831
2136
|
false,
|
|
@@ -1838,16 +2143,48 @@ var probeOne = async (client, opts, model, leftSeconds) => {
|
|
|
1838
2143
|
if (deleted) unmarkProbe(s, created);
|
|
1839
2144
|
}
|
|
1840
2145
|
};
|
|
1841
|
-
async function
|
|
2146
|
+
async function probeModels(client, opts) {
|
|
1842
2147
|
const s = opts.state;
|
|
1843
2148
|
const now = opts.now ?? Date.now;
|
|
1844
|
-
const empty = { outcomes: [], cleared: [], kept: [], changed: false };
|
|
2149
|
+
const empty = { outcomes: [], cleared: [], kept: [], missing: [], changed: false };
|
|
1845
2150
|
if (s.probing) return empty;
|
|
2151
|
+
const models = [...new Set(opts.models)];
|
|
2152
|
+
if (models.length === 0) return empty;
|
|
1846
2153
|
const cooling = readLimits(s);
|
|
1847
2154
|
const at = now();
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
2155
|
+
const wasCooling = new Set([...cooling.values()].filter((strike) => strike.until > at).map((strike) => strike.model));
|
|
2156
|
+
s.probing = true;
|
|
2157
|
+
const outcomes = [];
|
|
2158
|
+
try {
|
|
2159
|
+
for (const model of models) {
|
|
2160
|
+
const strike = cooling.get(model);
|
|
2161
|
+
outcomes.push(await probeOne(client, opts, model, strike && strike.until > at ? Math.ceil((strike.until - at) / 1e3) : 0));
|
|
2162
|
+
}
|
|
2163
|
+
} finally {
|
|
2164
|
+
s.probing = false;
|
|
2165
|
+
s.lastProbe = Date.now();
|
|
2166
|
+
}
|
|
2167
|
+
const reAdmitted = outcomes.filter((o) => o.ok && wasCooling.has(o.model)).map((o) => o.model);
|
|
2168
|
+
const missing = outcomes.filter((o) => o.reason === MODEL_MISSING_REASON).map((o) => o.model);
|
|
2169
|
+
const stillLimited = outcomes.filter((o) => !o.ok && o.reason !== MODEL_MISSING_REASON);
|
|
2170
|
+
return {
|
|
2171
|
+
outcomes,
|
|
2172
|
+
cleared: reAdmitted,
|
|
2173
|
+
kept: stillLimited.map((o) => o.model),
|
|
2174
|
+
missing,
|
|
2175
|
+
changed: reAdmitted.length > 0 || missing.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
async function probeCooling(client, opts) {
|
|
2179
|
+
const s = opts.state;
|
|
2180
|
+
const now = opts.now ?? Date.now;
|
|
2181
|
+
const empty = { outcomes: [], cleared: [], kept: [], missing: [], changed: false };
|
|
2182
|
+
if (s.probing) return empty;
|
|
2183
|
+
const cooling = readLimits(s);
|
|
2184
|
+
const at = now();
|
|
2185
|
+
const wanted = opts.models && opts.models.length > 0 ? new Set(opts.models) : void 0;
|
|
2186
|
+
const candidates = [...cooling.values()].filter(
|
|
2187
|
+
(strike) => strike.until > at && (wanted === void 0 || wanted.has(strike.model))
|
|
1851
2188
|
);
|
|
1852
2189
|
if (candidates.length === 0) return empty;
|
|
1853
2190
|
s.probing = true;
|
|
@@ -1861,14 +2198,16 @@ async function probeCooling(client, opts) {
|
|
|
1861
2198
|
s.lastProbe = Date.now();
|
|
1862
2199
|
}
|
|
1863
2200
|
const reAdmitted = outcomes.filter((o) => o.ok).map((o) => o.model);
|
|
1864
|
-
const
|
|
2201
|
+
const missing = outcomes.filter((o) => o.reason === MODEL_MISSING_REASON).map((o) => o.model);
|
|
2202
|
+
const stillLimited = outcomes.filter((o) => !o.ok && o.reason !== MODEL_MISSING_REASON);
|
|
1865
2203
|
return {
|
|
1866
2204
|
outcomes,
|
|
1867
2205
|
cleared: reAdmitted,
|
|
1868
2206
|
kept: stillLimited.map((o) => o.model),
|
|
2207
|
+
missing,
|
|
1869
2208
|
// A window the provider restated is a change too: the pool now waits a
|
|
1870
2209
|
// 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)
|
|
2210
|
+
changed: reAdmitted.length > 0 || missing.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
|
|
1872
2211
|
};
|
|
1873
2212
|
}
|
|
1874
2213
|
|
|
@@ -1877,6 +2216,8 @@ var SERVICE = "agent-pool";
|
|
|
1877
2216
|
var REAP_INTERVAL_MS = 3e4;
|
|
1878
2217
|
var HANG_FALLBACK_MS = 10 * 60 * 1e3;
|
|
1879
2218
|
var PROBE_INTERVAL_MS = 15 * 60 * 1e3;
|
|
2219
|
+
var SLOT_WATCH_MS = 1e3;
|
|
2220
|
+
var SLOT_WATCH_DEBOUNCE_MS = 250;
|
|
1880
2221
|
var ABORT_TIMEOUT_MS = 1e4;
|
|
1881
2222
|
var formatWindow = (seconds) => seconds < 60 ? `${seconds}s` : `${Math.ceil(seconds / 60)}m`;
|
|
1882
2223
|
var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
@@ -1921,11 +2262,18 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
1921
2262
|
const strike = recordStrike(s, claim.m, "hang", HANG_FALLBACK_MS);
|
|
1922
2263
|
if (strike) await log2("warn", `cooling ${claim.m} for ${Math.round((strike.until - now) / 1e3)}s (hang)`);
|
|
1923
2264
|
};
|
|
2265
|
+
const claimFor = (sessionID) => {
|
|
2266
|
+
if (!sessionID) return void 0;
|
|
2267
|
+
for (const [key, claim] of s.own) if (claim.g === sessionID) return { key, claim };
|
|
2268
|
+
return void 0;
|
|
2269
|
+
};
|
|
1924
2270
|
const failOver = async (sessionID, model, reason) => {
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2271
|
+
const entry = claimFor(sessionID);
|
|
2272
|
+
if (!entry) {
|
|
2273
|
+
await log("warn", `limit (${reason}) on ${model} for session ${sessionID ?? "?"}, which has no pool claim; nothing aborted`);
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
const { key, claim } = entry;
|
|
1929
2277
|
const kill = await abortSession(sessionID);
|
|
1930
2278
|
release(s, key);
|
|
1931
2279
|
await log(
|
|
@@ -2005,20 +2353,25 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2005
2353
|
"info",
|
|
2006
2354
|
`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
2355
|
);
|
|
2008
|
-
const EMPTY_REPORT = { outcomes: [], cleared: [], kept: [], changed: false };
|
|
2009
|
-
const reportTitle = (report) =>
|
|
2356
|
+
const EMPTY_REPORT = { outcomes: [], cleared: [], kept: [], missing: [], changed: false };
|
|
2357
|
+
const reportTitle = (report) => {
|
|
2358
|
+
if (report.outcomes.length === 0) return "nothing cooling to probe";
|
|
2359
|
+
const parts = [`probed ${report.outcomes.length} model(s)`, `re-admitted ${report.cleared.length}`];
|
|
2360
|
+
if (report.missing.length > 0) parts.push(`benched ${report.missing.length} missing`);
|
|
2361
|
+
return parts.join(", ");
|
|
2362
|
+
};
|
|
2010
2363
|
const reportText = (report) => {
|
|
2011
2364
|
if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
|
|
2012
2365
|
return [
|
|
2013
2366
|
...report.outcomes.map(
|
|
2014
|
-
(outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the rotation` : `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`
|
|
2367
|
+
(outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the rotation` : 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
2368
|
),
|
|
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."
|
|
2369
|
+
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
2370
|
].join("\n");
|
|
2018
2371
|
};
|
|
2019
2372
|
const announce = async (report) => {
|
|
2020
2373
|
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.`;
|
|
2374
|
+
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
2375
|
await log("info", `pool probe: ${message}`, { cleared: report.cleared, kept: report.kept });
|
|
2023
2376
|
try {
|
|
2024
2377
|
await client.tui?.showToast?.({
|
|
@@ -2045,47 +2398,90 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2045
2398
|
"info",
|
|
2046
2399
|
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
2400
|
);
|
|
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`);
|
|
2401
|
+
let liveConfig;
|
|
2402
|
+
const bind = async (cfg) => {
|
|
2403
|
+
const slots = ensureSlots(s, (m) => void warnOnce(m));
|
|
2404
|
+
const agents = cfg.agent;
|
|
2405
|
+
const providers = cfg.provider;
|
|
2406
|
+
const known = /* @__PURE__ */ new Set();
|
|
2407
|
+
for (const [name, def] of Object.entries(cfg.agent ?? {})) if (def?.model) s.models.set(name, def.model);
|
|
2408
|
+
for (const slot of slots) {
|
|
2409
|
+
for (const base of BASES) {
|
|
2410
|
+
const name = agentFor(base, slot.index);
|
|
2411
|
+
const def = agents?.[name];
|
|
2412
|
+
if (!def) {
|
|
2413
|
+
await warnOnce(`variant ${name} is missing from the config; spawns of ${base} that route to slot ${slot.index} would fail`);
|
|
2076
2414
|
continue;
|
|
2077
2415
|
}
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
list.push(id);
|
|
2081
|
-
await log("info", `whitelisted ${id} on ${provider} (${s.origin} slot ${slot.index})`);
|
|
2416
|
+
if (def.model && def.model !== slot.model) {
|
|
2417
|
+
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
2418
|
}
|
|
2419
|
+
def.model = slot.model;
|
|
2420
|
+
known.add(name);
|
|
2421
|
+
s.models.set(name, slot.model);
|
|
2083
2422
|
}
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
)
|
|
2423
|
+
const slash = slot.model.indexOf("/");
|
|
2424
|
+
const provider = slot.model.slice(0, slash);
|
|
2425
|
+
const id = slot.model.slice(slash + 1);
|
|
2426
|
+
const entry = providers?.[provider];
|
|
2427
|
+
if (!entry) {
|
|
2428
|
+
await warnOnce(`provider ${provider} is not configured in opencode.jsonc; slot ${slot.index} (${slot.model}) cannot resolve`);
|
|
2429
|
+
continue;
|
|
2430
|
+
}
|
|
2431
|
+
const list = entry.whitelist;
|
|
2432
|
+
if (list && !list.includes(id)) {
|
|
2433
|
+
list.push(id);
|
|
2434
|
+
await log("info", `whitelisted ${id} on ${provider} (${s.origin} slot ${slot.index})`);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
s.known = known;
|
|
2438
|
+
await log(
|
|
2439
|
+
"info",
|
|
2440
|
+
`${s.origin}: bound ${known.size} variant agent(s) to ${slots.length} slot(s): ${slots.map((sl) => `${sl.index}=${sl.model} w${sl.weight}`).join(", ")}`
|
|
2441
|
+
);
|
|
2442
|
+
};
|
|
2443
|
+
const runValidation = async (models) => {
|
|
2444
|
+
let report = EMPTY_REPORT;
|
|
2445
|
+
try {
|
|
2446
|
+
report = await probeModels(client, { state: s, directory, models });
|
|
2447
|
+
} catch (error) {
|
|
2448
|
+
await log("error", `pool validation failed: ${String(error)}`);
|
|
2449
|
+
return EMPTY_REPORT;
|
|
2450
|
+
}
|
|
2451
|
+
await announce(report);
|
|
2452
|
+
return report;
|
|
2453
|
+
};
|
|
2454
|
+
const watchMs = opts.watchMs ?? SLOT_WATCH_MS;
|
|
2455
|
+
const watchDebounceMs = opts.watchDebounceMs ?? SLOT_WATCH_DEBOUNCE_MS;
|
|
2456
|
+
const sources = watchMs > 0 ? slotSources(opts) : [];
|
|
2457
|
+
let reloadTimer;
|
|
2458
|
+
const reloadFromDisk = async () => {
|
|
2459
|
+
const reload = reloadSlots(s, (m) => void warnOnce(m));
|
|
2460
|
+
if (!reload.changed) return;
|
|
2461
|
+
await log(
|
|
2462
|
+
"info",
|
|
2463
|
+
`pool changed (${reload.origin}): ${reload.slots.map((sl) => `${sl.index}=${sl.model} w${sl.weight}`).join(", ")}`
|
|
2464
|
+
);
|
|
2465
|
+
if (liveConfig) await bind(liveConfig);
|
|
2466
|
+
await runValidation(reload.slots.map((slot) => slot.model));
|
|
2467
|
+
};
|
|
2468
|
+
const scheduleReload = () => {
|
|
2469
|
+
if (reloadTimer) return;
|
|
2470
|
+
reloadTimer = setTimeout(() => {
|
|
2471
|
+
reloadTimer = void 0;
|
|
2472
|
+
void reloadFromDisk();
|
|
2473
|
+
}, watchDebounceMs);
|
|
2474
|
+
reloadTimer.unref?.();
|
|
2475
|
+
};
|
|
2476
|
+
for (const source of sources) watchFile(source, { interval: watchMs, persistent: false }, scheduleReload);
|
|
2477
|
+
void log(
|
|
2478
|
+
"info",
|
|
2479
|
+
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)"
|
|
2480
|
+
);
|
|
2481
|
+
return {
|
|
2482
|
+
config: async (cfg) => {
|
|
2483
|
+
liveConfig = cfg;
|
|
2484
|
+
await bind(cfg);
|
|
2089
2485
|
},
|
|
2090
2486
|
event: async ({ event }) => {
|
|
2091
2487
|
try {
|
|
@@ -2093,24 +2489,82 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2093
2489
|
if (event.type === "session.status") {
|
|
2094
2490
|
const sessionID = typeof props.sessionID === "string" ? props.sessionID : void 0;
|
|
2095
2491
|
const status2 = props.status;
|
|
2096
|
-
const
|
|
2097
|
-
|
|
2098
|
-
|
|
2492
|
+
const action = status2?.action;
|
|
2493
|
+
const provider = typeof action?.provider === "string" ? action.provider : void 0;
|
|
2494
|
+
const reason2 = action?.reason ?? "";
|
|
2495
|
+
const message = typeof status2?.message === "string" ? status2.message : "";
|
|
2496
|
+
const limited2 = reason2 === "free_tier_limit" || isLimitText(reason2) || isLimitText(message);
|
|
2497
|
+
if (status2?.type === "retry" && limited2) {
|
|
2099
2498
|
if (sessionID && isProbe(s, sessionID)) {
|
|
2100
|
-
await log("debug", `probe on provider ${provider} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
|
|
2499
|
+
await log("debug", `probe on provider ${provider ?? "?"} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
|
|
2101
2500
|
return;
|
|
2102
2501
|
}
|
|
2103
|
-
const
|
|
2104
|
-
const
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2502
|
+
const own = claimFor(sessionID);
|
|
2503
|
+
const outage = isUnavailableText(message) && !isQuotaReason(reason2);
|
|
2504
|
+
const named = outage ? UNAVAILABLE_REASON : reason2 || limitReason(message) || "rate-limited";
|
|
2505
|
+
if (provider) {
|
|
2506
|
+
const until = outage ? Date.now() + UNAVAILABLE_COOLDOWN_MS : typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
|
|
2507
|
+
const cooled = coolProvider(s, provider, until, named);
|
|
2508
|
+
if (cooled.length > 0) {
|
|
2509
|
+
await log("warn", `limit on provider ${provider} (${named}); cooling ${cooled.length} slot(s) until ${new Date(until).toISOString().slice(11, 19)}Z`, {
|
|
2510
|
+
provider,
|
|
2511
|
+
until,
|
|
2512
|
+
models: cooled.map((c) => c.model)
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
} else if (own) {
|
|
2516
|
+
if (outage) {
|
|
2517
|
+
const transient = coolUnavailable(s, own.claim.m);
|
|
2518
|
+
if (transient) await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
|
|
2519
|
+
} else {
|
|
2520
|
+
const stated2 = statedCooldown(message);
|
|
2521
|
+
const strike2 = recordStrike(s, own.claim.m, named, stated2);
|
|
2522
|
+
if (strike2) {
|
|
2523
|
+
await log("warn", `limit on ${own.claim.m} (${named}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s`, {
|
|
2524
|
+
model: own.claim.m,
|
|
2525
|
+
slot: own.claim.k,
|
|
2526
|
+
session: sessionID
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
await failOver(sessionID, provider ?? own?.claim.m ?? "?", named);
|
|
2532
|
+
}
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
if (event.type === "session.error") {
|
|
2536
|
+
const sessionID = typeof props.sessionID === "string" ? props.sessionID : void 0;
|
|
2537
|
+
const error = props.error;
|
|
2538
|
+
const haystack2 = JSON.stringify(error) ?? "";
|
|
2539
|
+
if (sessionID && isProbe(s, sessionID)) {
|
|
2540
|
+
await log("debug", `probe session ${sessionID} ended with an error; not cooling anything`);
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
const status2 = typeof error?.data?.statusCode === "number" ? error.data.statusCode : void 0;
|
|
2544
|
+
const limited2 = isLimitText(haystack2) || status2 === 429;
|
|
2545
|
+
const outage = isUnavailableText(haystack2);
|
|
2546
|
+
if (!limited2 && !outage) return;
|
|
2547
|
+
const own = claimFor(sessionID);
|
|
2548
|
+
const named = outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
|
|
2549
|
+
if (own) {
|
|
2550
|
+
if (outage) {
|
|
2551
|
+
const transient = coolUnavailable(s, own.claim.m);
|
|
2552
|
+
if (transient) {
|
|
2553
|
+
await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason}) after a terminal error`);
|
|
2554
|
+
}
|
|
2555
|
+
} else {
|
|
2556
|
+
const stated2 = statedCooldown(haystack2) ?? retryAfterMs(error?.data?.responseHeaders);
|
|
2557
|
+
const strike2 = recordStrike(s, own.claim.m, named, stated2);
|
|
2558
|
+
if (strike2) {
|
|
2559
|
+
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`, {
|
|
2560
|
+
model: own.claim.m,
|
|
2561
|
+
slot: own.claim.k,
|
|
2562
|
+
session: sessionID
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2111
2565
|
}
|
|
2112
|
-
await failOver(sessionID, provider, reason2 || "rate-limited");
|
|
2113
2566
|
}
|
|
2567
|
+
await failOver(sessionID, own?.claim.m ?? "?", named);
|
|
2114
2568
|
return;
|
|
2115
2569
|
}
|
|
2116
2570
|
if (event.type === "session.created") {
|
|
@@ -2144,7 +2598,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2144
2598
|
return;
|
|
2145
2599
|
}
|
|
2146
2600
|
const stated = statedCooldown(haystack) ?? retryAfterMs(info.error.data?.responseHeaders);
|
|
2147
|
-
const reason = haystack
|
|
2601
|
+
const reason = limitReason(haystack);
|
|
2148
2602
|
const strike = recordStrike(s, model, reason ?? "rate-limited", stated);
|
|
2149
2603
|
if (strike) await log("warn", `cooling ${model} for ${Math.round((strike.until - Date.now()) / 1e3)}s (${reason ?? "rate-limited"}, strike ${strike.strikes})`);
|
|
2150
2604
|
await failOver(info.sessionID, model, reason ?? "rate-limited");
|
|
@@ -2155,7 +2609,12 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2155
2609
|
"tool.execute.before": async (input, output) => {
|
|
2156
2610
|
if (input.tool !== "task") return;
|
|
2157
2611
|
const requested = output.args?.subagent_type;
|
|
2158
|
-
if (typeof requested !== "string")
|
|
2612
|
+
if (typeof requested !== "string") {
|
|
2613
|
+
await warnOnce(
|
|
2614
|
+
`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.`
|
|
2615
|
+
);
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2159
2618
|
const manual = manualVariant(requested);
|
|
2160
2619
|
if (manual) {
|
|
2161
2620
|
await log("warn", `refused manual spawn of a pool slot variant; spawn "${manual}" instead`, {
|
|
@@ -2204,12 +2663,13 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2204
2663
|
},
|
|
2205
2664
|
tool: {
|
|
2206
2665
|
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 rotation; if it is still limited, the existing cooldown stands. Call it after resetting a quota in the provider's own console, when the pool feels short of capacity, or to check now instead of waiting for the next automatic pass.",
|
|
2666
|
+
description: "Probe the models the pool has benched and re-admit the ones that answer. Each probe is a real request on that exact model, so it settles what a cooldown can only guess: if the model replies, its cooldown is cleared and it is back in the rotation; if it is still limited, the existing cooldown stands; 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
2667
|
args: {
|
|
2209
|
-
models: tool.schema.array(tool.schema.string()).optional().describe("Model ids to
|
|
2668
|
+
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
2669
|
},
|
|
2211
2670
|
async execute(args) {
|
|
2212
|
-
const
|
|
2671
|
+
const models = args?.models;
|
|
2672
|
+
const report = models && models.length > 0 ? await runValidation(models) : await runProbe();
|
|
2213
2673
|
return { title: `agent-pool: ${reportTitle(report)}`, output: reportText(report) };
|
|
2214
2674
|
}
|
|
2215
2675
|
}),
|
|
@@ -2254,7 +2714,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2254
2714
|
title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
|
|
2255
2715
|
output: [
|
|
2256
2716
|
`shared pool over ${slots.length} model(s) from ${s.origin}; bases: ${BASES.join(", ")}
|
|
2257
|
-
round robin in slot order, each slot taking as many turns as its weight says, so no slot is starved while another drains (FULL = at ceiling, used only when
|
|
2717
|
+
round robin in slot order, each slot taking as many turns as its weight says, so no slot is starved while another drains (FULL = at ceiling, used only when no other slot is available and under its ceiling)`,
|
|
2258
2718
|
...slots.map(line),
|
|
2259
2719
|
"",
|
|
2260
2720
|
"by process:",
|
|
@@ -2268,7 +2728,8 @@ round robin in slot order, each slot taking as many turns as its weight says, so
|
|
|
2268
2728
|
// When the cooled models were last USED rather than inferred, because
|
|
2269
2729
|
// "still cooling" and "nobody has actually asked it lately" are
|
|
2270
2730
|
// 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.",
|
|
2731
|
+
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.",
|
|
2732
|
+
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).",
|
|
2272
2733
|
coolingNow.length === 0 ? "no models cooling down." : `cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
|
|
2273
2734
|
].join("\n")
|
|
2274
2735
|
};
|
|
@@ -2278,6 +2739,8 @@ round robin in slot order, each slot taking as many turns as its weight says, so
|
|
|
2278
2739
|
dispose: async () => {
|
|
2279
2740
|
clearInterval(timer);
|
|
2280
2741
|
if (probeTimer) clearInterval(probeTimer);
|
|
2742
|
+
if (reloadTimer) clearTimeout(reloadTimer);
|
|
2743
|
+
for (const source of sources) unwatchFile(source, scheduleReload);
|
|
2281
2744
|
s.own.clear();
|
|
2282
2745
|
s.activity.clear();
|
|
2283
2746
|
s.children.clear();
|
|
@@ -2287,8 +2750,8 @@ round robin in slot order, each slot taking as many turns as its weight says, so
|
|
|
2287
2750
|
});
|
|
2288
2751
|
|
|
2289
2752
|
// src/plugins/context-autoupdate.ts
|
|
2290
|
-
import { existsSync as existsSync2, mkdirSync as
|
|
2291
|
-
import { homedir as
|
|
2753
|
+
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";
|
|
2754
|
+
import { homedir as homedir3 } from "node:os";
|
|
2292
2755
|
import * as path2 from "node:path";
|
|
2293
2756
|
|
|
2294
2757
|
// src/lib/active-backlog.ts
|
|
@@ -2439,7 +2902,7 @@ var parseJsonc = (raw) => {
|
|
|
2439
2902
|
};
|
|
2440
2903
|
|
|
2441
2904
|
// src/lib/writer-rule.ts
|
|
2442
|
-
import { readFileSync as
|
|
2905
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
2443
2906
|
import * as path from "node:path";
|
|
2444
2907
|
var CONTEXT_DIR = path.join(".opencode", "context");
|
|
2445
2908
|
var WRITER = "context";
|
|
@@ -2479,7 +2942,7 @@ var agentsForm = (mapDir, projectRoot) => {
|
|
|
2479
2942
|
var verifyWriterRule = (configPath, mapDir, projectRoot) => {
|
|
2480
2943
|
let raw;
|
|
2481
2944
|
try {
|
|
2482
|
-
raw =
|
|
2945
|
+
raw = readFileSync4(configPath, "utf8");
|
|
2483
2946
|
} catch {
|
|
2484
2947
|
return void 0;
|
|
2485
2948
|
}
|
|
@@ -2580,7 +3043,7 @@ var snapshotMap = (dir) => {
|
|
|
2580
3043
|
for (const name of readdirSync2(dir)) {
|
|
2581
3044
|
const file = path2.join(dir, name);
|
|
2582
3045
|
try {
|
|
2583
|
-
const st =
|
|
3046
|
+
const st = statSync3(file);
|
|
2584
3047
|
if (st.isFile()) out.set(name, `${st.size}:${st.mtimeMs}`);
|
|
2585
3048
|
} catch {
|
|
2586
3049
|
}
|
|
@@ -2597,15 +3060,15 @@ var changedSince = (before, dir) => {
|
|
|
2597
3060
|
return changed;
|
|
2598
3061
|
};
|
|
2599
3062
|
var RETAIN_TRANSCRIPTS = 20;
|
|
2600
|
-
var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(
|
|
3063
|
+
var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(homedir3(), ".local", "share", "opencode", "lacode", "cartographer");
|
|
2601
3064
|
var retireSession = async (client, directory, sessionID, mode, log) => {
|
|
2602
3065
|
try {
|
|
2603
3066
|
const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
|
|
2604
3067
|
const dir = transcriptDir();
|
|
2605
|
-
|
|
3068
|
+
mkdirSync3(dir, { recursive: true });
|
|
2606
3069
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2607
3070
|
const file = path2.join(dir, `${stamp}-${mode}.json`);
|
|
2608
|
-
|
|
3071
|
+
writeFileSync3(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
|
|
2609
3072
|
pruneTranscripts(dir);
|
|
2610
3073
|
const gone = async () => await client.session.get({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0) === void 0;
|
|
2611
3074
|
await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
|
|
@@ -2626,7 +3089,7 @@ var pruneTranscripts = (dir) => {
|
|
|
2626
3089
|
const entries = readdirSync2(dir).filter((name) => name.endsWith(".json")).sort();
|
|
2627
3090
|
for (const name of entries.slice(0, Math.max(0, entries.length - RETAIN_TRANSCRIPTS))) {
|
|
2628
3091
|
try {
|
|
2629
|
-
|
|
3092
|
+
unlinkSync3(path2.join(dir, name));
|
|
2630
3093
|
} catch {
|
|
2631
3094
|
}
|
|
2632
3095
|
}
|
|
@@ -2655,9 +3118,9 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2655
3118
|
const normalizeActiveBacklog = async () => {
|
|
2656
3119
|
try {
|
|
2657
3120
|
if (!existsSync2(activeBacklogPath)) return { removed: 0 };
|
|
2658
|
-
const before =
|
|
3121
|
+
const before = readFileSync5(activeBacklogPath, "utf8");
|
|
2659
3122
|
const { text, removed } = normalizeBacklog(before);
|
|
2660
|
-
if (text !== before)
|
|
3123
|
+
if (text !== before) writeFileSync3(activeBacklogPath, text);
|
|
2661
3124
|
if (removed.length > 0) {
|
|
2662
3125
|
await log("info", `active backlog: dropped ${removed.length} task(s) finished more than a week ago`, {
|
|
2663
3126
|
removed: removed.length
|
|
@@ -2810,11 +3273,14 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2810
3273
|
});
|
|
2811
3274
|
|
|
2812
3275
|
// src/plugins/notify.ts
|
|
2813
|
-
import { spawn } from "node:child_process";
|
|
3276
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2814
3277
|
import { existsSync as existsSync3 } from "node:fs";
|
|
2815
3278
|
import * as path3 from "node:path";
|
|
2816
3279
|
|
|
2817
3280
|
// src/lib/notify.ts
|
|
3281
|
+
import { closeSync as closeSync2, mkdirSync as mkdirSync4, openSync as openSync2, readFileSync as readFileSync6, statSync as statSync4, unlinkSync as unlinkSync4, writeSync as writeSync2 } from "node:fs";
|
|
3282
|
+
import { homedir as homedir4 } from "node:os";
|
|
3283
|
+
import { join as join6 } from "node:path";
|
|
2818
3284
|
var INTERNAL_TITLE_PREFIXES = [
|
|
2819
3285
|
// context-autoupdate spawns the cartographer through the session API, with no
|
|
2820
3286
|
// parentID, so it looks exactly like a root session going idle. Notifying for
|
|
@@ -2832,6 +3298,10 @@ var helperArgv = (helper, title, message, urgency, icon, sound) => {
|
|
|
2832
3298
|
const name = helper.split(/[\\/]/).pop() ?? "";
|
|
2833
3299
|
return name === "lacode" || name === "lacode.js" ? ["notify", title, message, urgency, icon, sound] : [title, message, urgency, icon, sound];
|
|
2834
3300
|
};
|
|
3301
|
+
var PERMISSION_REPLIES = ["once", "always", "reject"];
|
|
3302
|
+
var REQUEST_ID_ENV = "LACODE_REQUEST_ID";
|
|
3303
|
+
var NOTIFY_SESSION_ENV = "LACODE_NOTIFY_SESSION";
|
|
3304
|
+
var NOTIFY_CLOSE_ENV = "LACODE_NOTIFY_CLOSE";
|
|
2835
3305
|
var safeStringify = (value) => {
|
|
2836
3306
|
try {
|
|
2837
3307
|
return JSON.stringify(value) ?? void 0;
|
|
@@ -2901,12 +3371,20 @@ var shouldNotify = (input) => {
|
|
|
2901
3371
|
if (outcome === "done" && input.awaitingResponse) {
|
|
2902
3372
|
return { notify: false, reason: "awaiting your response" };
|
|
2903
3373
|
}
|
|
2904
|
-
|
|
3374
|
+
const notification = {
|
|
2905
3375
|
notify: true,
|
|
2906
|
-
|
|
3376
|
+
// The session's name on its own, with nothing in front of it. The popup's
|
|
3377
|
+
// header is the notification's APP name, and that is "LaCode" -- so a
|
|
3378
|
+
// prefix here would only repeat the app, and it would repeat it wrongly:
|
|
3379
|
+
// the app is not "opencode".
|
|
3380
|
+
title,
|
|
2907
3381
|
message: notifyMessage(outcome, detail),
|
|
2908
3382
|
urgency: outcome === "error" ? "critical" : "normal"
|
|
2909
3383
|
};
|
|
3384
|
+
if (outcome === "permission" && input.requestID) {
|
|
3385
|
+
return { ...notification, actionable: { requestID: input.requestID } };
|
|
3386
|
+
}
|
|
3387
|
+
return notification;
|
|
2910
3388
|
};
|
|
2911
3389
|
var questionHeadline = (questions) => {
|
|
2912
3390
|
const first = questions?.find((entry) => typeof entry?.question === "string" && entry.question.trim());
|
|
@@ -2981,13 +3459,154 @@ var Notifier = class {
|
|
|
2981
3459
|
return this.lastSent.size;
|
|
2982
3460
|
}
|
|
2983
3461
|
};
|
|
3462
|
+
var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join6(homedir4(), ".local", "share", "opencode", "lacode", "notify");
|
|
3463
|
+
var safeCorrelationKey = (correlationKey) => correlationKey.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
|
|
3464
|
+
var CLAIM_WINDOW_MS = 5e3;
|
|
3465
|
+
var isPidAlive2 = (pid) => {
|
|
3466
|
+
try {
|
|
3467
|
+
process.kill(pid, 0);
|
|
3468
|
+
return true;
|
|
3469
|
+
} catch (error) {
|
|
3470
|
+
return error.code === "EPERM";
|
|
3471
|
+
}
|
|
3472
|
+
};
|
|
3473
|
+
var claimFile = (sessionID, kind) => join6(notificationStateDir(), `claim.${safeCorrelationKey(`${sessionID}.${kind}`)}`);
|
|
3474
|
+
var claimNotification = (sessionID, kind) => {
|
|
3475
|
+
const path4 = claimFile(sessionID, kind);
|
|
3476
|
+
try {
|
|
3477
|
+
mkdirSync4(notificationStateDir(), { recursive: true });
|
|
3478
|
+
} catch {
|
|
3479
|
+
return true;
|
|
3480
|
+
}
|
|
3481
|
+
const take = () => {
|
|
3482
|
+
try {
|
|
3483
|
+
const fd = openSync2(path4, "wx");
|
|
3484
|
+
try {
|
|
3485
|
+
writeSync2(fd, String(process.pid));
|
|
3486
|
+
} finally {
|
|
3487
|
+
closeSync2(fd);
|
|
3488
|
+
}
|
|
3489
|
+
return "taken";
|
|
3490
|
+
} catch (error) {
|
|
3491
|
+
return error.code === "EEXIST" ? "held" : "impossible";
|
|
3492
|
+
}
|
|
3493
|
+
};
|
|
3494
|
+
const first = take();
|
|
3495
|
+
if (first !== "held") {
|
|
3496
|
+
return true;
|
|
3497
|
+
}
|
|
3498
|
+
const judge = () => {
|
|
3499
|
+
let raw;
|
|
3500
|
+
try {
|
|
3501
|
+
raw = readFileSync6(path4, "utf8").trim();
|
|
3502
|
+
} catch (error) {
|
|
3503
|
+
return error.code === "ENOENT" ? "gone" : "unknown";
|
|
3504
|
+
}
|
|
3505
|
+
let holder;
|
|
3506
|
+
const empty = raw === "";
|
|
3507
|
+
if (!empty) {
|
|
3508
|
+
const parsed = Number.parseInt(raw, 10);
|
|
3509
|
+
holder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
3510
|
+
}
|
|
3511
|
+
if (holder === process.pid) return "ours";
|
|
3512
|
+
let age = Number.POSITIVE_INFINITY;
|
|
3513
|
+
try {
|
|
3514
|
+
age = Date.now() - statSync4(path4).mtimeMs;
|
|
3515
|
+
} catch {
|
|
3516
|
+
return "gone";
|
|
3517
|
+
}
|
|
3518
|
+
if (empty && age < CLAIM_WINDOW_MS) {
|
|
3519
|
+
return "held";
|
|
3520
|
+
}
|
|
3521
|
+
if (holder !== void 0 && isPidAlive2(holder) && age < CLAIM_WINDOW_MS) {
|
|
3522
|
+
return "held";
|
|
3523
|
+
}
|
|
3524
|
+
return "stale";
|
|
3525
|
+
};
|
|
3526
|
+
const judged = judge();
|
|
3527
|
+
if (judged === "ours") return true;
|
|
3528
|
+
if (judged === "held") return false;
|
|
3529
|
+
if (judged === "unknown") return true;
|
|
3530
|
+
if (judged === "gone") return take() !== "held";
|
|
3531
|
+
const lockPath = `${path4}.takeover`;
|
|
3532
|
+
const takeOver = () => {
|
|
3533
|
+
const create = () => {
|
|
3534
|
+
try {
|
|
3535
|
+
const fd = openSync2(lockPath, "wx");
|
|
3536
|
+
try {
|
|
3537
|
+
writeSync2(fd, String(process.pid));
|
|
3538
|
+
} finally {
|
|
3539
|
+
closeSync2(fd);
|
|
3540
|
+
}
|
|
3541
|
+
return () => {
|
|
3542
|
+
try {
|
|
3543
|
+
unlinkSync4(lockPath);
|
|
3544
|
+
} catch {
|
|
3545
|
+
}
|
|
3546
|
+
};
|
|
3547
|
+
} catch (error) {
|
|
3548
|
+
return error.code === "EEXIST" ? void 0 : () => {
|
|
3549
|
+
};
|
|
3550
|
+
}
|
|
3551
|
+
};
|
|
3552
|
+
const release3 = create();
|
|
3553
|
+
if (release3 !== void 0) return release3;
|
|
3554
|
+
let lockHolder;
|
|
3555
|
+
let lockAge = Number.POSITIVE_INFINITY;
|
|
3556
|
+
try {
|
|
3557
|
+
const parsed = Number.parseInt(readFileSync6(lockPath, "utf8").trim(), 10);
|
|
3558
|
+
lockHolder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
3559
|
+
lockAge = Date.now() - statSync4(lockPath).mtimeMs;
|
|
3560
|
+
} catch {
|
|
3561
|
+
return create();
|
|
3562
|
+
}
|
|
3563
|
+
if (lockAge < CLAIM_WINDOW_MS && (lockHolder === void 0 || isPidAlive2(lockHolder))) {
|
|
3564
|
+
return void 0;
|
|
3565
|
+
}
|
|
3566
|
+
try {
|
|
3567
|
+
unlinkSync4(lockPath);
|
|
3568
|
+
} catch {
|
|
3569
|
+
return void 0;
|
|
3570
|
+
}
|
|
3571
|
+
return create();
|
|
3572
|
+
};
|
|
3573
|
+
const release2 = takeOver();
|
|
3574
|
+
if (release2 === void 0) return false;
|
|
3575
|
+
try {
|
|
3576
|
+
const current = judge();
|
|
3577
|
+
if (current === "ours") return true;
|
|
3578
|
+
if (current === "held") return false;
|
|
3579
|
+
if (current === "unknown") return true;
|
|
3580
|
+
try {
|
|
3581
|
+
unlinkSync4(path4);
|
|
3582
|
+
} catch {
|
|
3583
|
+
}
|
|
3584
|
+
return take() !== "held";
|
|
3585
|
+
} finally {
|
|
3586
|
+
release2();
|
|
3587
|
+
}
|
|
3588
|
+
};
|
|
3589
|
+
var releaseNotificationClaim = (sessionID, kind) => {
|
|
3590
|
+
const path4 = claimFile(sessionID, kind);
|
|
3591
|
+
try {
|
|
3592
|
+
const parsed = Number.parseInt(readFileSync6(path4, "utf8").trim(), 10);
|
|
3593
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return;
|
|
3594
|
+
if (parsed !== process.pid) return;
|
|
3595
|
+
unlinkSync4(path4);
|
|
3596
|
+
} catch {
|
|
3597
|
+
}
|
|
3598
|
+
};
|
|
2984
3599
|
|
|
2985
3600
|
// src/plugins/notify.ts
|
|
2986
3601
|
var SERVICE3 = "notify";
|
|
2987
3602
|
var COOLDOWN_MS2 = 5e3;
|
|
2988
3603
|
var MAX_INFLIGHT_HELPERS = 4;
|
|
2989
3604
|
var HELPER_TIMEOUT_MS = 3e4;
|
|
3605
|
+
var ACTION_TIMEOUT_MS = 10 * 6e4 + 3e4;
|
|
2990
3606
|
var MAX_TRACKED_SESSIONS = 500;
|
|
3607
|
+
var MIN_VISIBLE_MS = 5e3;
|
|
3608
|
+
var PERMISSION_SETTLE_MS = 250;
|
|
3609
|
+
var UNANSWERABLE_REQUEST = "";
|
|
2991
3610
|
var SOUND_ENV = "LACODE_SOUND";
|
|
2992
3611
|
var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
|
|
2993
3612
|
var findHelper = () => {
|
|
@@ -3013,7 +3632,9 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3013
3632
|
const active = /* @__PURE__ */ new Set();
|
|
3014
3633
|
const titles = /* @__PURE__ */ new Map();
|
|
3015
3634
|
const pending = /* @__PURE__ */ new Set();
|
|
3016
|
-
const permissions = /* @__PURE__ */ new
|
|
3635
|
+
const permissions = /* @__PURE__ */ new Map();
|
|
3636
|
+
const pendingPermissionPopups = /* @__PURE__ */ new Map();
|
|
3637
|
+
const live = /* @__PURE__ */ new Map();
|
|
3017
3638
|
let warnedHelper = false;
|
|
3018
3639
|
let inflight = 0;
|
|
3019
3640
|
const rememberActive = (sessionID) => {
|
|
@@ -3024,13 +3645,93 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3024
3645
|
titles.set(id, title);
|
|
3025
3646
|
while (titles.size > MAX_TRACKED_SESSIONS) titles.delete(titles.keys().next().value);
|
|
3026
3647
|
};
|
|
3648
|
+
const rememberLive = (sessionID, popup) => {
|
|
3649
|
+
const previous = live.get(sessionID);
|
|
3650
|
+
if (previous?.timer) clearTimeout(previous.timer);
|
|
3651
|
+
live.set(sessionID, popup);
|
|
3652
|
+
while (live.size > MAX_TRACKED_SESSIONS) {
|
|
3653
|
+
const oldest = live.keys().next().value;
|
|
3654
|
+
const forgotten = live.get(oldest);
|
|
3655
|
+
if (forgotten?.timer) clearTimeout(forgotten.timer);
|
|
3656
|
+
live.delete(oldest);
|
|
3657
|
+
}
|
|
3658
|
+
};
|
|
3659
|
+
const forgetLive = (sessionID) => {
|
|
3660
|
+
const popup = live.get(sessionID);
|
|
3661
|
+
if (popup?.timer) clearTimeout(popup.timer);
|
|
3662
|
+
live.delete(sessionID);
|
|
3663
|
+
};
|
|
3664
|
+
const rememberPermissions = (sessionID, requests) => {
|
|
3665
|
+
permissions.set(sessionID, requests);
|
|
3666
|
+
while (permissions.size > MAX_TRACKED_SESSIONS) {
|
|
3667
|
+
const oldest = permissions.keys().next().value;
|
|
3668
|
+
permissions.delete(oldest);
|
|
3669
|
+
}
|
|
3670
|
+
};
|
|
3671
|
+
const forgetPermission = (sessionID, requestID) => {
|
|
3672
|
+
const outstanding = permissions.get(sessionID);
|
|
3673
|
+
if (!outstanding) return;
|
|
3674
|
+
outstanding.delete(requestID);
|
|
3675
|
+
if (outstanding.size === 0) permissions.delete(sessionID);
|
|
3676
|
+
};
|
|
3677
|
+
const forgetPermissionPopup = (sessionID) => {
|
|
3678
|
+
const timer = pendingPermissionPopups.get(sessionID);
|
|
3679
|
+
if (timer) clearTimeout(timer);
|
|
3680
|
+
pendingPermissionPopups.delete(sessionID);
|
|
3681
|
+
};
|
|
3027
3682
|
const log = (level, message, extra) => client.app.log({ body: { service: SERVICE3, level, message, extra } }).catch(() => {
|
|
3028
3683
|
});
|
|
3684
|
+
const replyPermission = async (sessionID, requestID, response) => {
|
|
3685
|
+
const errorStatus = (error) => {
|
|
3686
|
+
const candidate = error;
|
|
3687
|
+
const status2 = candidate?.response?.status ?? candidate?.cause?.status ?? candidate?.status;
|
|
3688
|
+
return typeof status2 === "number" ? status2 : void 0;
|
|
3689
|
+
};
|
|
3690
|
+
const reply = await client.postSessionIdPermissionsPermissionId({
|
|
3691
|
+
path: { id: sessionID, permissionID: requestID },
|
|
3692
|
+
body: { response }
|
|
3693
|
+
}).then((result) => ({ result })).catch((error) => ({ error }));
|
|
3694
|
+
const status = "result" in reply ? reply.result.response?.status : errorStatus(reply.error);
|
|
3695
|
+
if (status === 404) {
|
|
3696
|
+
void log("debug", "permission already answered", { sessionID, requestID });
|
|
3697
|
+
forgetPermission(sessionID, requestID);
|
|
3698
|
+
return;
|
|
3699
|
+
}
|
|
3700
|
+
if ("error" in reply) {
|
|
3701
|
+
void log("warn", `permission reply failed: ${errorDetail(reply.error) ?? String(reply.error)}`, {
|
|
3702
|
+
sessionID,
|
|
3703
|
+
requestID
|
|
3704
|
+
});
|
|
3705
|
+
return;
|
|
3706
|
+
}
|
|
3707
|
+
if (reply.result.error) {
|
|
3708
|
+
void log("warn", `permission reply failed: ${errorDetail(reply.result.error) ?? String(reply.result.error)}`, {
|
|
3709
|
+
sessionID,
|
|
3710
|
+
requestID
|
|
3711
|
+
});
|
|
3712
|
+
return;
|
|
3713
|
+
}
|
|
3714
|
+
forgetPermission(sessionID, requestID);
|
|
3715
|
+
void log("info", "permission answered from the notification", { sessionID, requestID, response });
|
|
3716
|
+
};
|
|
3717
|
+
const answerPermission = async (sessionID, requestIDs, action) => {
|
|
3718
|
+
const response = PERMISSION_REPLIES.find((candidate) => candidate === action);
|
|
3719
|
+
if (!response) {
|
|
3720
|
+
void log("warn", `notification action ignored: ${JSON.stringify(action)} is not a permission reply`, {
|
|
3721
|
+
sessionID,
|
|
3722
|
+
requestIDs
|
|
3723
|
+
});
|
|
3724
|
+
return;
|
|
3725
|
+
}
|
|
3726
|
+
for (const requestID of requestIDs) {
|
|
3727
|
+
await replyPermission(sessionID, requestID, response);
|
|
3728
|
+
}
|
|
3729
|
+
};
|
|
3029
3730
|
const fetchInfo = async (sessionID) => {
|
|
3030
3731
|
const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
|
|
3031
3732
|
return info;
|
|
3032
3733
|
};
|
|
3033
|
-
const deliverViaHelper = (title, message, urgency, sound) => new Promise((resolve3) => {
|
|
3734
|
+
const deliverViaHelper = (title, message, urgency, sound, requestID, sessionID) => new Promise((resolve3) => {
|
|
3034
3735
|
if (inflight >= maxInflight) {
|
|
3035
3736
|
void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
|
|
3036
3737
|
resolve3({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
|
|
@@ -3045,9 +3746,17 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3045
3746
|
resolve3({ ok: false, detail: "no oc-notify helper found" });
|
|
3046
3747
|
return;
|
|
3047
3748
|
}
|
|
3048
|
-
const
|
|
3749
|
+
const childEnv = { ...process.env, ...sessionID ? { [NOTIFY_SESSION_ENV]: sessionID } : {} };
|
|
3750
|
+
const actionable = Boolean(requestID);
|
|
3751
|
+
const timeoutMs = actionable ? ACTION_TIMEOUT_MS : helperTimeoutMs;
|
|
3752
|
+
const child = actionable ? spawn2(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
|
|
3753
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3754
|
+
detached: false,
|
|
3755
|
+
env: { ...childEnv, [REQUEST_ID_ENV]: requestID }
|
|
3756
|
+
}) : spawn2(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
|
|
3049
3757
|
stdio: ["ignore", "ignore", "pipe"],
|
|
3050
|
-
detached: true
|
|
3758
|
+
detached: true,
|
|
3759
|
+
env: childEnv
|
|
3051
3760
|
});
|
|
3052
3761
|
inflight++;
|
|
3053
3762
|
let settled = false;
|
|
@@ -3064,11 +3773,15 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3064
3773
|
child.kill("SIGKILL");
|
|
3065
3774
|
} catch {
|
|
3066
3775
|
}
|
|
3067
|
-
void log("warn", `notification helper timed out after ${
|
|
3068
|
-
finish({ ok: false, detail: `helper timed out after ${
|
|
3069
|
-
},
|
|
3776
|
+
void log("warn", `notification helper timed out after ${timeoutMs}ms`, { title });
|
|
3777
|
+
finish({ ok: false, detail: `helper timed out after ${timeoutMs}ms` });
|
|
3778
|
+
}, timeoutMs);
|
|
3070
3779
|
timer.unref?.();
|
|
3780
|
+
let stdout = "";
|
|
3071
3781
|
let stderr = "";
|
|
3782
|
+
child.stdout?.on("data", (chunk) => {
|
|
3783
|
+
if (stdout.length < 400) stdout += String(chunk);
|
|
3784
|
+
});
|
|
3072
3785
|
child.stderr?.on("data", (chunk) => {
|
|
3073
3786
|
if (stderr.length < 400) stderr += String(chunk);
|
|
3074
3787
|
});
|
|
@@ -3085,12 +3798,95 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3085
3798
|
message
|
|
3086
3799
|
});
|
|
3087
3800
|
}
|
|
3088
|
-
|
|
3801
|
+
const action = actionable ? stdout.trim() : "";
|
|
3802
|
+
finish({ ok, detail: noise || void 0, ...action ? { action } : {} });
|
|
3089
3803
|
});
|
|
3090
|
-
child.unref();
|
|
3804
|
+
if (!actionable) child.unref();
|
|
3091
3805
|
});
|
|
3092
3806
|
const deliver = deps.deliver ?? deliverViaHelper;
|
|
3093
|
-
const
|
|
3807
|
+
const closeViaHelper = (sessionID) => {
|
|
3808
|
+
try {
|
|
3809
|
+
const helper = findHelper();
|
|
3810
|
+
if (!helper) {
|
|
3811
|
+
if (!warnedHelper) {
|
|
3812
|
+
warnedHelper = true;
|
|
3813
|
+
void log("warn", "no oc-notify helper found; run 'bin/oc-sync' to install one");
|
|
3814
|
+
}
|
|
3815
|
+
return;
|
|
3816
|
+
}
|
|
3817
|
+
const child = spawn2(helper, helperArgv(helper, "opencode", "", "normal", "utilities-terminal", "none"), {
|
|
3818
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
3819
|
+
detached: true,
|
|
3820
|
+
env: { ...process.env, [NOTIFY_CLOSE_ENV]: sessionID }
|
|
3821
|
+
});
|
|
3822
|
+
child.on("error", (error) => {
|
|
3823
|
+
void log("warn", `notification close failed: ${String(error)}`, { sessionID });
|
|
3824
|
+
});
|
|
3825
|
+
child.on("exit", (code) => {
|
|
3826
|
+
if (code !== 0) void log("warn", `notification not closed: helper exit ${code}`, { sessionID });
|
|
3827
|
+
});
|
|
3828
|
+
child.unref();
|
|
3829
|
+
} catch (error) {
|
|
3830
|
+
void log("warn", `notification close failed: ${String(error)}`, { sessionID });
|
|
3831
|
+
}
|
|
3832
|
+
};
|
|
3833
|
+
const close = deps.close ?? closeViaHelper;
|
|
3834
|
+
const dismiss = (sessionID) => {
|
|
3835
|
+
const popup = live.get(sessionID);
|
|
3836
|
+
if (!popup) return;
|
|
3837
|
+
if (popup.timer) {
|
|
3838
|
+
clearTimeout(popup.timer);
|
|
3839
|
+
popup.timer = void 0;
|
|
3840
|
+
}
|
|
3841
|
+
popup.raisedAt = Date.now();
|
|
3842
|
+
const failed = (error) => void log("warn", `notification dismiss failed: ${String(error)}`, { sessionID });
|
|
3843
|
+
try {
|
|
3844
|
+
const outcome = close(sessionID);
|
|
3845
|
+
if (outcome && typeof outcome.catch === "function") void outcome.catch(failed);
|
|
3846
|
+
} catch (error) {
|
|
3847
|
+
failed(error);
|
|
3848
|
+
}
|
|
3849
|
+
};
|
|
3850
|
+
const scheduleDismiss = (sessionID) => {
|
|
3851
|
+
const popup = live.get(sessionID);
|
|
3852
|
+
if (!popup) return;
|
|
3853
|
+
if (popup.timer) return;
|
|
3854
|
+
const delay = Math.max(0, popup.raisedAt + MIN_VISIBLE_MS - Date.now());
|
|
3855
|
+
const timer = setTimeout(() => dismiss(sessionID), delay);
|
|
3856
|
+
timer.unref?.();
|
|
3857
|
+
popup.timer = timer;
|
|
3858
|
+
};
|
|
3859
|
+
const noteInteraction = (sessionID) => {
|
|
3860
|
+
const popup = live.get(sessionID);
|
|
3861
|
+
if (!popup) return;
|
|
3862
|
+
popup.interacted = true;
|
|
3863
|
+
scheduleDismiss(sessionID);
|
|
3864
|
+
};
|
|
3865
|
+
const claimReleases = /* @__PURE__ */ new Map();
|
|
3866
|
+
const scheduleClaimRelease = (sessionID, kind) => {
|
|
3867
|
+
const key = `${kind}:${sessionID}`;
|
|
3868
|
+
if (claimReleases.has(key)) return;
|
|
3869
|
+
const timer = setTimeout(() => {
|
|
3870
|
+
claimReleases.delete(key);
|
|
3871
|
+
releaseNotificationClaim(sessionID, kind);
|
|
3872
|
+
}, COOLDOWN_MS2);
|
|
3873
|
+
timer.unref?.();
|
|
3874
|
+
claimReleases.set(key, timer);
|
|
3875
|
+
};
|
|
3876
|
+
const raisePermissionPopup = (sessionID, requestID, headline) => {
|
|
3877
|
+
forgetPermissionPopup(sessionID);
|
|
3878
|
+
const timer = setTimeout(() => {
|
|
3879
|
+
if (pendingPermissionPopups.get(sessionID) !== timer) return;
|
|
3880
|
+
pendingPermissionPopups.delete(sessionID);
|
|
3881
|
+
const outstanding = permissions.get(sessionID);
|
|
3882
|
+
if (!outstanding || outstanding.size === 0) return;
|
|
3883
|
+
const detail = outstanding.size > 1 ? `${outstanding.size} requests` : headline;
|
|
3884
|
+
void announce(sessionID, "permission", detail || void 0, requestID);
|
|
3885
|
+
}, PERMISSION_SETTLE_MS);
|
|
3886
|
+
timer.unref?.();
|
|
3887
|
+
pendingPermissionPopups.set(sessionID, timer);
|
|
3888
|
+
};
|
|
3889
|
+
const announce = async (sessionID, outcome, detail, requestID) => {
|
|
3094
3890
|
try {
|
|
3095
3891
|
const info = await fetchInfo(sessionID);
|
|
3096
3892
|
if (!info) {
|
|
@@ -3106,7 +3902,8 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3106
3902
|
sawMessage: active.has(sessionID),
|
|
3107
3903
|
// Either kind of block suppresses the idle event's "finished": a session
|
|
3108
3904
|
// parked on a permission prompt has not finished, it is waiting.
|
|
3109
|
-
awaitingResponse: pending.has(sessionID) || permissions.has(sessionID)
|
|
3905
|
+
awaitingResponse: pending.has(sessionID) || permissions.has(sessionID),
|
|
3906
|
+
requestID
|
|
3110
3907
|
});
|
|
3111
3908
|
if (!decision.notify) {
|
|
3112
3909
|
void log("debug", `suppressed: ${decision.reason}`, { sessionID });
|
|
@@ -3122,10 +3919,29 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3122
3919
|
void log("debug", "suppressed: cooldown", { sessionID });
|
|
3123
3920
|
return;
|
|
3124
3921
|
}
|
|
3922
|
+
if (!claimNotification(sessionID, kind)) {
|
|
3923
|
+
void log("debug", "suppressed: another process is raising this", { sessionID, kind });
|
|
3924
|
+
return;
|
|
3925
|
+
}
|
|
3125
3926
|
const message = isRemote ? `${decision.message} (remote)` : decision.message;
|
|
3126
3927
|
const sound = soundOn && NOTIFY_OUTCOMES.includes(outcome) ? outcome : "none";
|
|
3127
|
-
const
|
|
3128
|
-
|
|
3928
|
+
const previous = live.get(sessionID);
|
|
3929
|
+
const popup = { raisedAt: Date.now(), interacted: previous?.interacted ?? false };
|
|
3930
|
+
rememberLive(sessionID, popup);
|
|
3931
|
+
if (popup.interacted) scheduleDismiss(sessionID);
|
|
3932
|
+
let sent = { ok: false };
|
|
3933
|
+
try {
|
|
3934
|
+
sent = await deliver(decision.title, message, decision.urgency, sound, requestID, sessionID);
|
|
3935
|
+
} finally {
|
|
3936
|
+
scheduleClaimRelease(sessionID, kind);
|
|
3937
|
+
}
|
|
3938
|
+
if (sent.action && requestID) {
|
|
3939
|
+
const outstanding = permissions.get(sessionID);
|
|
3940
|
+
const ids = outstanding ? [...outstanding].filter((id) => id !== UNANSWERABLE_REQUEST) : [];
|
|
3941
|
+
await answerPermission(sessionID, ids.length > 0 ? ids : [requestID], sent.action);
|
|
3942
|
+
}
|
|
3943
|
+
const answered = Boolean(sent.action);
|
|
3944
|
+
void log(answered || sent.ok ? "info" : "error", answered ? "permission answered from the notification" : sent.ok ? "notified" : "notification failed", {
|
|
3129
3945
|
sessionID,
|
|
3130
3946
|
outcome,
|
|
3131
3947
|
title: decision.title,
|
|
@@ -3140,7 +3956,14 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3140
3956
|
try {
|
|
3141
3957
|
if (event.type === "session.status") {
|
|
3142
3958
|
const { sessionID: sessionID2, status } = event.properties;
|
|
3143
|
-
if (status.type === "busy" && sessionID2)
|
|
3959
|
+
if (status.type === "busy" && sessionID2) {
|
|
3960
|
+
rememberActive(sessionID2);
|
|
3961
|
+
}
|
|
3962
|
+
return;
|
|
3963
|
+
}
|
|
3964
|
+
if (event.type === "message.updated") {
|
|
3965
|
+
const info = event.properties.info;
|
|
3966
|
+
if (info?.role === "user" && info.sessionID) noteInteraction(info.sessionID);
|
|
3144
3967
|
return;
|
|
3145
3968
|
}
|
|
3146
3969
|
if (event.type === "session.updated") {
|
|
@@ -3160,21 +3983,34 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3160
3983
|
}
|
|
3161
3984
|
if (event.type === "question.replied" || event.type === "question.rejected") {
|
|
3162
3985
|
const sessionID2 = event.properties?.sessionID;
|
|
3163
|
-
if (sessionID2)
|
|
3986
|
+
if (sessionID2) {
|
|
3987
|
+
pending.delete(sessionID2);
|
|
3988
|
+
noteInteraction(sessionID2);
|
|
3989
|
+
}
|
|
3164
3990
|
return;
|
|
3165
3991
|
}
|
|
3166
3992
|
if (event.type === "permission.asked") {
|
|
3167
3993
|
const properties = event.properties;
|
|
3168
3994
|
const sessionID2 = properties?.sessionID;
|
|
3169
3995
|
if (!sessionID2) return;
|
|
3170
|
-
|
|
3996
|
+
const requestID = typeof properties?.id === "string" && properties.id ? properties.id : void 0;
|
|
3997
|
+
const outstanding = permissions.get(sessionID2) ?? /* @__PURE__ */ new Set();
|
|
3998
|
+
const first = outstanding.size === 0;
|
|
3999
|
+
outstanding.add(requestID ?? UNANSWERABLE_REQUEST);
|
|
4000
|
+
rememberPermissions(sessionID2, outstanding);
|
|
3171
4001
|
rememberActive(sessionID2);
|
|
3172
|
-
|
|
4002
|
+
if (!first) return;
|
|
4003
|
+
raisePermissionPopup(sessionID2, requestID, permissionHeadline(properties));
|
|
3173
4004
|
return;
|
|
3174
4005
|
}
|
|
3175
4006
|
if (event.type === "permission.replied") {
|
|
3176
4007
|
const sessionID2 = event.properties?.sessionID;
|
|
3177
|
-
if (sessionID2)
|
|
4008
|
+
if (sessionID2) {
|
|
4009
|
+
const requestID = event.properties?.requestID;
|
|
4010
|
+
if (requestID) forgetPermission(sessionID2, requestID);
|
|
4011
|
+
else permissions.delete(sessionID2);
|
|
4012
|
+
noteInteraction(sessionID2);
|
|
4013
|
+
}
|
|
3178
4014
|
return;
|
|
3179
4015
|
}
|
|
3180
4016
|
if (event.type === "session.error") {
|
|
@@ -3198,6 +4034,8 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3198
4034
|
titles.delete(sessionID2);
|
|
3199
4035
|
pending.delete(sessionID2);
|
|
3200
4036
|
permissions.delete(sessionID2);
|
|
4037
|
+
forgetLive(sessionID2);
|
|
4038
|
+
forgetPermissionPopup(sessionID2);
|
|
3201
4039
|
}
|
|
3202
4040
|
return;
|
|
3203
4041
|
}
|
|
@@ -3693,267 +4531,6 @@ var omniRouteHooks = async (input, options, deps = {}) => {
|
|
|
3693
4531
|
};
|
|
3694
4532
|
};
|
|
3695
4533
|
|
|
3696
|
-
// src/lib/selfupdate.ts
|
|
3697
|
-
import { spawn as spawn2 } from "node:child_process";
|
|
3698
|
-
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";
|
|
3699
|
-
import { homedir as homedir3 } from "node:os";
|
|
3700
|
-
import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
|
|
3701
|
-
var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
3702
|
-
var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
|
|
3703
|
-
var FETCH_TIMEOUT_MS = 4e3;
|
|
3704
|
-
var INSTALL_TIMEOUT_MS = 12e4;
|
|
3705
|
-
var LOCK_STALE_MS = 10 * 60 * 1e3;
|
|
3706
|
-
var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
|
|
3707
|
-
var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
|
|
3708
|
-
var inTestRunner = (env = process.env) => typeof env.NODE_TEST_CONTEXT === "string" && env.NODE_TEST_CONTEXT.length > 0;
|
|
3709
|
-
var STATE_DIR = join6(homedir3(), ".local", "share", "opencode", "lacode");
|
|
3710
|
-
var STATE_PATH = join6(STATE_DIR, "selfupdate.json");
|
|
3711
|
-
var LOCK_PATH = join6(STATE_DIR, "selfupdate.lock");
|
|
3712
|
-
function compareSemver(a, b) {
|
|
3713
|
-
const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
3714
|
-
const left = parse(a);
|
|
3715
|
-
const right = parse(b);
|
|
3716
|
-
for (let i = 0; i < 3; i++) {
|
|
3717
|
-
const l = left[i] ?? 0;
|
|
3718
|
-
const r = right[i] ?? 0;
|
|
3719
|
-
if (l !== r) return l > r ? 1 : -1;
|
|
3720
|
-
}
|
|
3721
|
-
return 0;
|
|
3722
|
-
}
|
|
3723
|
-
function readInstalledVersion(packageRoot, read = (path4) => {
|
|
3724
|
-
try {
|
|
3725
|
-
return readFileSync5(path4, "utf8");
|
|
3726
|
-
} catch {
|
|
3727
|
-
return void 0;
|
|
3728
|
-
}
|
|
3729
|
-
}) {
|
|
3730
|
-
try {
|
|
3731
|
-
const raw = read(join6(packageRoot, "package.json"));
|
|
3732
|
-
if (raw === void 0) return void 0;
|
|
3733
|
-
const manifest = JSON.parse(raw);
|
|
3734
|
-
if (manifest.name !== PACKAGE_NAME) return void 0;
|
|
3735
|
-
return typeof manifest.version === "string" ? manifest.version : void 0;
|
|
3736
|
-
} catch {
|
|
3737
|
-
return void 0;
|
|
3738
|
-
}
|
|
3739
|
-
}
|
|
3740
|
-
function resolveCacheRoot(packageRoot, exists = (p) => {
|
|
3741
|
-
try {
|
|
3742
|
-
return statSync3(p).isFile();
|
|
3743
|
-
} catch {
|
|
3744
|
-
return false;
|
|
3745
|
-
}
|
|
3746
|
-
}) {
|
|
3747
|
-
const tail = join6("node_modules", "@lawaty", "lacode");
|
|
3748
|
-
if (!packageRoot.endsWith(tail)) return void 0;
|
|
3749
|
-
const cacheRoot = resolve2(packageRoot, "..", "..", "..");
|
|
3750
|
-
if (!exists(join6(cacheRoot, "package.json"))) return void 0;
|
|
3751
|
-
return cacheRoot;
|
|
3752
|
-
}
|
|
3753
|
-
function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
|
|
3754
|
-
try {
|
|
3755
|
-
return statSync3(p).isFile();
|
|
3756
|
-
} catch {
|
|
3757
|
-
return false;
|
|
3758
|
-
}
|
|
3759
|
-
}) {
|
|
3760
|
-
if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
|
|
3761
|
-
for (const dir of (env.PATH ?? "").split(":")) {
|
|
3762
|
-
if (!dir) continue;
|
|
3763
|
-
const candidate = join6(dir, "opencode");
|
|
3764
|
-
if (exists(candidate)) return candidate;
|
|
3765
|
-
}
|
|
3766
|
-
const fallback = join6(homedir3(), ".opencode", "bin", "opencode");
|
|
3767
|
-
return exists(fallback) ? fallback : void 0;
|
|
3768
|
-
}
|
|
3769
|
-
async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
3770
|
-
const controller = new AbortController();
|
|
3771
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
3772
|
-
try {
|
|
3773
|
-
const response = await fetchImpl(REGISTRY_LATEST, { signal: controller.signal });
|
|
3774
|
-
if (!response.ok) return void 0;
|
|
3775
|
-
const body = await response.json();
|
|
3776
|
-
return typeof body.version === "string" ? body.version : void 0;
|
|
3777
|
-
} catch {
|
|
3778
|
-
return void 0;
|
|
3779
|
-
} finally {
|
|
3780
|
-
clearTimeout(timer);
|
|
3781
|
-
}
|
|
3782
|
-
}
|
|
3783
|
-
function readState(path4 = STATE_PATH) {
|
|
3784
|
-
try {
|
|
3785
|
-
return JSON.parse(readFileSync5(path4, "utf8"));
|
|
3786
|
-
} catch {
|
|
3787
|
-
return {};
|
|
3788
|
-
}
|
|
3789
|
-
}
|
|
3790
|
-
function writeState(state2, path4 = STATE_PATH) {
|
|
3791
|
-
try {
|
|
3792
|
-
mkdirSync3(dirname3(path4), { recursive: true });
|
|
3793
|
-
writeFileSync3(path4, JSON.stringify(state2));
|
|
3794
|
-
} catch {
|
|
3795
|
-
}
|
|
3796
|
-
}
|
|
3797
|
-
var isDue = (state2, now, interval = CHECK_INTERVAL_MS) => typeof state2.checkedAt !== "number" || now - state2.checkedAt >= interval;
|
|
3798
|
-
var isProcessAlive = (pid) => {
|
|
3799
|
-
try {
|
|
3800
|
-
process.kill(pid, 0);
|
|
3801
|
-
return true;
|
|
3802
|
-
} catch (error) {
|
|
3803
|
-
return error.code === "EPERM";
|
|
3804
|
-
}
|
|
3805
|
-
};
|
|
3806
|
-
function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
|
|
3807
|
-
const claim = () => {
|
|
3808
|
-
const fd = openSync(path4, "wx");
|
|
3809
|
-
writeSync(fd, String(process.pid));
|
|
3810
|
-
closeSync(fd);
|
|
3811
|
-
return () => {
|
|
3812
|
-
try {
|
|
3813
|
-
unlinkSync3(path4);
|
|
3814
|
-
} catch {
|
|
3815
|
-
}
|
|
3816
|
-
};
|
|
3817
|
-
};
|
|
3818
|
-
try {
|
|
3819
|
-
mkdirSync3(dirname3(path4), { recursive: true });
|
|
3820
|
-
return claim();
|
|
3821
|
-
} catch {
|
|
3822
|
-
let orphaned = false;
|
|
3823
|
-
try {
|
|
3824
|
-
const pid = Number.parseInt(readFileSync5(path4, "utf8").trim(), 10);
|
|
3825
|
-
orphaned = !Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid);
|
|
3826
|
-
} catch {
|
|
3827
|
-
orphaned = true;
|
|
3828
|
-
}
|
|
3829
|
-
if (!orphaned) {
|
|
3830
|
-
try {
|
|
3831
|
-
orphaned = now - statSync3(path4).mtimeMs > LOCK_STALE_MS;
|
|
3832
|
-
} catch {
|
|
3833
|
-
orphaned = true;
|
|
3834
|
-
}
|
|
3835
|
-
}
|
|
3836
|
-
if (!orphaned) return void 0;
|
|
3837
|
-
try {
|
|
3838
|
-
unlinkSync3(path4);
|
|
3839
|
-
} catch {
|
|
3840
|
-
return void 0;
|
|
3841
|
-
}
|
|
3842
|
-
try {
|
|
3843
|
-
return claim();
|
|
3844
|
-
} catch {
|
|
3845
|
-
return void 0;
|
|
3846
|
-
}
|
|
3847
|
-
}
|
|
3848
|
-
}
|
|
3849
|
-
async function checkForUpdate(opts) {
|
|
3850
|
-
const installed = readInstalledVersion(opts.packageRoot);
|
|
3851
|
-
const latest = await fetchLatest(opts.fetchImpl);
|
|
3852
|
-
return {
|
|
3853
|
-
installed,
|
|
3854
|
-
latest,
|
|
3855
|
-
newer: Boolean(installed && latest && compareSemver(latest, installed) > 0),
|
|
3856
|
-
cacheRoot: resolveCacheRoot(opts.packageRoot, opts.exists),
|
|
3857
|
-
bin: resolveOpencodeBin(opts.execPath, opts.env, opts.exists)
|
|
3858
|
-
};
|
|
3859
|
-
}
|
|
3860
|
-
async function refreshCache(opts) {
|
|
3861
|
-
const { cacheRoot, bin, latest } = opts;
|
|
3862
|
-
const spawnImpl = opts.spawnImpl ?? spawn2;
|
|
3863
|
-
const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
|
|
3864
|
-
const fresh = join6(cacheRoot, "node_modules", "@lawaty", "lacode");
|
|
3865
|
-
const aside = `${cacheRoot}.stale-${Date.now()}`;
|
|
3866
|
-
try {
|
|
3867
|
-
rmSync(aside, { recursive: true, force: true });
|
|
3868
|
-
renameSync2(cacheRoot, aside);
|
|
3869
|
-
} catch (error) {
|
|
3870
|
-
return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
|
|
3871
|
-
}
|
|
3872
|
-
const exited = await new Promise((settle) => {
|
|
3873
|
-
let done = false;
|
|
3874
|
-
const finish = (value) => {
|
|
3875
|
-
if (done) return;
|
|
3876
|
-
done = true;
|
|
3877
|
-
clearTimeout(timer);
|
|
3878
|
-
settle(value);
|
|
3879
|
-
};
|
|
3880
|
-
const child = spawnImpl(bin, ["plugin", PACKAGE_NAME, "-g"], {
|
|
3881
|
-
env: { ...process.env, [CHILD_ENV]: "1" },
|
|
3882
|
-
stdio: "ignore"
|
|
3883
|
-
});
|
|
3884
|
-
const timer = setTimeout(() => {
|
|
3885
|
-
try {
|
|
3886
|
-
child.kill("SIGKILL");
|
|
3887
|
-
} catch {
|
|
3888
|
-
}
|
|
3889
|
-
finish(false);
|
|
3890
|
-
}, timeoutMs);
|
|
3891
|
-
child.on("error", () => finish(false));
|
|
3892
|
-
child.on("exit", (code) => finish(code === 0));
|
|
3893
|
-
});
|
|
3894
|
-
const installed = readInstalledVersion(fresh);
|
|
3895
|
-
const ok = exited && installed === latest;
|
|
3896
|
-
if (ok) {
|
|
3897
|
-
try {
|
|
3898
|
-
rmSync(aside, { recursive: true, force: true });
|
|
3899
|
-
} catch {
|
|
3900
|
-
}
|
|
3901
|
-
} else {
|
|
3902
|
-
try {
|
|
3903
|
-
renameSync2(aside, cacheRoot);
|
|
3904
|
-
} catch {
|
|
3905
|
-
}
|
|
3906
|
-
}
|
|
3907
|
-
return {
|
|
3908
|
-
ok,
|
|
3909
|
-
detail: ok ? `installed ${installed}` : `installer exited ${exited ? "0" : "non-zero"}, cache now ${installed ?? "missing"}`
|
|
3910
|
-
};
|
|
3911
|
-
}
|
|
3912
|
-
async function maybeSelfUpdate(opts) {
|
|
3913
|
-
const env = opts.env ?? process.env;
|
|
3914
|
-
if (opts.enabled === false || env[CHILD_ENV] === "1" || env[OPT_OUT_ENV] === "1" || inTestRunner(env)) {
|
|
3915
|
-
return { action: "disabled" };
|
|
3916
|
-
}
|
|
3917
|
-
const now = (opts.now ?? Date.now)();
|
|
3918
|
-
const statePath = opts.statePath ?? STATE_PATH;
|
|
3919
|
-
const state2 = readState(statePath);
|
|
3920
|
-
if (!isDue(state2, now, opts.intervalMs ?? CHECK_INTERVAL_MS)) return { action: "skipped", detail: "checked recently" };
|
|
3921
|
-
const release2 = acquireLock(opts.lockPath, now);
|
|
3922
|
-
if (!release2) return { action: "skipped", detail: "another process is updating" };
|
|
3923
|
-
try {
|
|
3924
|
-
const check = await checkForUpdate(opts);
|
|
3925
|
-
if (!check.installed) return { action: "skipped", detail: "no installed version" };
|
|
3926
|
-
if (!check.latest) {
|
|
3927
|
-
writeState({ ...state2, checkedAt: now }, statePath);
|
|
3928
|
-
return { action: "skipped", detail: "registry unreachable" };
|
|
3929
|
-
}
|
|
3930
|
-
if (!check.newer) {
|
|
3931
|
-
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
3932
|
-
return { action: "current", detail: `${check.installed} is current` };
|
|
3933
|
-
}
|
|
3934
|
-
if (!check.cacheRoot) {
|
|
3935
|
-
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
3936
|
-
return { action: "skipped", detail: "not an opencode cache install" };
|
|
3937
|
-
}
|
|
3938
|
-
if (!check.bin) {
|
|
3939
|
-
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
3940
|
-
opts.log?.("warn", `LaCode ${check.latest} is available but no opencode binary was found to install it`);
|
|
3941
|
-
return { action: "failed", detail: "no opencode binary" };
|
|
3942
|
-
}
|
|
3943
|
-
const result = await refreshCache({ cacheRoot: check.cacheRoot, bin: check.bin, latest: check.latest, spawnImpl: opts.spawnImpl });
|
|
3944
|
-
if (result.ok) {
|
|
3945
|
-
writeState({ ...state2, checkedAt: now, latest: check.latest, updatedTo: check.latest }, statePath);
|
|
3946
|
-
opts.log?.("info", `LaCode updated ${check.installed} -> ${check.latest}; restart opencode to apply`);
|
|
3947
|
-
return { action: "updated", detail: `${check.installed} -> ${check.latest}` };
|
|
3948
|
-
}
|
|
3949
|
-
writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
|
|
3950
|
-
opts.log?.("warn", `LaCode update to ${check.latest} failed: ${result.detail}`);
|
|
3951
|
-
return { action: "failed", detail: result.detail };
|
|
3952
|
-
} finally {
|
|
3953
|
-
release2();
|
|
3954
|
-
}
|
|
3955
|
-
}
|
|
3956
|
-
|
|
3957
4534
|
// src/plugins/selfupdate.ts
|
|
3958
4535
|
async function selfUpdateHooks(input, options) {
|
|
3959
4536
|
const opts = options ?? {};
|
|
@@ -3980,6 +4557,13 @@ var LaCode = (async (input, options) => {
|
|
|
3980
4557
|
if (opts.enabled === false) return inertHooks;
|
|
3981
4558
|
const log = (level, message, extra) => input.client.app.log({ body: { service: "lacode", level, message, extra } }).catch(() => {
|
|
3982
4559
|
});
|
|
4560
|
+
let version;
|
|
4561
|
+
try {
|
|
4562
|
+
version = readInstalledVersion(PACKAGE_ROOT);
|
|
4563
|
+
} catch {
|
|
4564
|
+
version = void 0;
|
|
4565
|
+
}
|
|
4566
|
+
await log("info", version ? `lacode ${version} started` : "lacode started");
|
|
3983
4567
|
const settled = async (label, make) => {
|
|
3984
4568
|
try {
|
|
3985
4569
|
return await make();
|