@lawaty/lacode 0.10.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +235 -2
- package/README.md +78 -37
- package/dist/agents.js +24 -16
- package/dist/commands.js +25 -6
- package/dist/index.js +1550 -566
- package/dist/lacode.js +211 -48
- package/dist/pool.js +55 -44
- package/dist/tunnel.js +185 -13
- package/docs/CONFIGURATION.md +23 -9
- package/docs/OMNIROUTE.md +1 -1
- package/docs/USER-STORIES.md +99 -21
- package/package.json +3 -3
- package/presets/README.md +26 -27
- package/presets/free-tier.json +26 -42
package/dist/pool.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(path, "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(path);
|
|
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 path = join2(s.dir, limitFile(model));
|
|
517
545
|
try {
|
|
518
546
|
writeFileSync(`${path}.tmp`, JSON.stringify(strike));
|
|
@@ -598,48 +626,24 @@ function snapshot(s) {
|
|
|
598
626
|
}
|
|
599
627
|
var loadOf = (counts, model) => counts.get(model) ?? 0;
|
|
600
628
|
var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
|
|
601
|
-
function
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
function readCursor(s) {
|
|
608
|
-
try {
|
|
609
|
-
const parsed = JSON.parse(readFileSync2(join2(s.dir, CURSOR_FILE), "utf8"));
|
|
610
|
-
if (typeof parsed.seq === "number" && Number.isInteger(parsed.seq) && parsed.seq >= 0) return parsed.seq;
|
|
611
|
-
} catch {
|
|
629
|
+
function pickSlot(s, snap) {
|
|
630
|
+
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
631
|
+
const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
632
|
+
const pool = usable.length > 0 ? usable : slots;
|
|
633
|
+
for (const slot of usable) {
|
|
634
|
+
if (loadOf(snap.counts, slot.model) < slot.weight) return slot;
|
|
612
635
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
function writeCursor(s, seq) {
|
|
616
|
-
if (!s.dirReady) return;
|
|
617
|
-
const path = join2(s.dir, CURSOR_FILE);
|
|
618
|
-
try {
|
|
619
|
-
writeFileSync(`${path}.tmp`, JSON.stringify({ seq }));
|
|
620
|
-
renameSync(`${path}.tmp`, path);
|
|
621
|
-
} catch {
|
|
636
|
+
for (const slot of usable) {
|
|
637
|
+
if (loadOf(snap.counts, slot.model) < slotCeiling(slot)) return slot;
|
|
622
638
|
}
|
|
639
|
+
return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
|
|
623
640
|
}
|
|
624
|
-
function
|
|
625
|
-
const advance = options?.advance !== false;
|
|
641
|
+
function poolPass(s, snap) {
|
|
626
642
|
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
627
|
-
const seq = roundRobinSequence(slots);
|
|
628
|
-
if (seq.length > 0) {
|
|
629
|
-
const byIndex = new Map(slots.map((slot) => [slot.index, slot]));
|
|
630
|
-
const start = readCursor(s) % seq.length;
|
|
631
|
-
for (let step = 0; step < seq.length; step++) {
|
|
632
|
-
const at = (start + step) % seq.length;
|
|
633
|
-
const slot = byIndex.get(seq[at]);
|
|
634
|
-
if (slot && isAvailable(s, slot.model, snap.cooling) && underCeiling(slot, snap.counts)) {
|
|
635
|
-
if (advance) writeCursor(s, at + 1);
|
|
636
|
-
return slot;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
643
|
const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
641
|
-
|
|
642
|
-
|
|
644
|
+
if (usable.some((slot) => loadOf(snap.counts, slot.model) < slot.weight)) return 1;
|
|
645
|
+
if (usable.some((slot) => loadOf(snap.counts, slot.model) < slotCeiling(slot))) return 2;
|
|
646
|
+
return "overflow";
|
|
643
647
|
}
|
|
644
648
|
function agentFor(base, slot) {
|
|
645
649
|
return `${base}-${slot}`;
|
|
@@ -755,6 +759,8 @@ export {
|
|
|
755
759
|
MAX_CLAIM_AGE_MS,
|
|
756
760
|
MAX_SLOTS,
|
|
757
761
|
MAX_WEIGHT,
|
|
762
|
+
MODEL_MISSING_COOLDOWN_MS,
|
|
763
|
+
MODEL_MISSING_REASON,
|
|
758
764
|
ORPHAN_TMP_MS,
|
|
759
765
|
PACKAGE_ROOT,
|
|
760
766
|
PRESETS_DIR,
|
|
@@ -766,6 +772,7 @@ export {
|
|
|
766
772
|
STUCK_IDLE_MS,
|
|
767
773
|
STUCK_MIN_AGE_MS,
|
|
768
774
|
UNAVAILABLE_COOLDOWN_MS,
|
|
775
|
+
UNAVAILABLE_REASON,
|
|
769
776
|
USER_CONFIG_DIR,
|
|
770
777
|
USER_MODELS_PATH,
|
|
771
778
|
USER_MODELS_TXT,
|
|
@@ -785,10 +792,12 @@ export {
|
|
|
785
792
|
init,
|
|
786
793
|
isAvailable,
|
|
787
794
|
isLimitText,
|
|
795
|
+
isModelMissingText,
|
|
788
796
|
isProbe,
|
|
789
797
|
isQuotaReason,
|
|
790
798
|
isTransientGatewayText,
|
|
791
799
|
isUnavailableText,
|
|
800
|
+
limitReason,
|
|
792
801
|
manualVariant,
|
|
793
802
|
markProbe,
|
|
794
803
|
noteActivity,
|
|
@@ -796,6 +805,7 @@ export {
|
|
|
796
805
|
parseSlots,
|
|
797
806
|
parseTextSlots,
|
|
798
807
|
pickSlot,
|
|
808
|
+
poolPass,
|
|
799
809
|
providerOf,
|
|
800
810
|
pruneOwn,
|
|
801
811
|
pruneSessions,
|
|
@@ -807,12 +817,13 @@ export {
|
|
|
807
817
|
refreshCatalog,
|
|
808
818
|
release,
|
|
809
819
|
releaseStuck,
|
|
820
|
+
reloadSlots,
|
|
810
821
|
resolveModels,
|
|
811
822
|
retryAfterMs,
|
|
812
|
-
roundRobinSequence,
|
|
813
823
|
selfFile,
|
|
814
824
|
slotCeiling,
|
|
815
825
|
slotFor,
|
|
826
|
+
slotSources,
|
|
816
827
|
snapshot,
|
|
817
828
|
splitModel,
|
|
818
829
|
state,
|
package/dist/tunnel.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
// src/tunnel/index.ts
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
3
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, unlinkSync as unlinkSync3 } from "node:fs";
|
|
4
|
+
import { homedir as homedir3 } from "node:os";
|
|
5
|
+
import { join as join4 } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/lib/notify.ts
|
|
8
|
+
import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync3, renameSync as renameSync2, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2, writeSync } from "node:fs";
|
|
9
|
+
import { homedir as homedir2 } from "node:os";
|
|
10
|
+
import { join as join3 } from "node:path";
|
|
11
|
+
|
|
12
|
+
// src/lib/pool.ts
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
14
|
import { homedir } from "node:os";
|
|
5
15
|
import { join as join2 } from "node:path";
|
|
6
16
|
|
|
@@ -24,6 +34,36 @@ var findPackageRoot = (start) => {
|
|
|
24
34
|
};
|
|
25
35
|
var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
|
|
26
36
|
|
|
37
|
+
// src/lib/pool.ts
|
|
38
|
+
var CLAIM_TTL_MS = 10 * 60 * 1e3;
|
|
39
|
+
var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
|
|
40
|
+
var ORPHAN_TMP_MS = 60 * 1e3;
|
|
41
|
+
var CATALOG_TTL_MS = 10 * 60 * 1e3;
|
|
42
|
+
var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
|
|
43
|
+
var STUCK_IDLE_MS = 3 * 60 * 1e3;
|
|
44
|
+
var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
|
|
45
|
+
var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
|
|
46
|
+
var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
47
|
+
var COOLDOWN_BASE_MS = 60 * 1e3;
|
|
48
|
+
var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
49
|
+
var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
|
|
50
|
+
var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
|
|
51
|
+
var STRIKE_DECAY_MS = 30 * 60 * 1e3;
|
|
52
|
+
var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
|
|
53
|
+
var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
|
|
54
|
+
var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
|
|
55
|
+
var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
|
|
56
|
+
var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
|
|
57
|
+
var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
|
|
58
|
+
var CHILD_MEMORY_MS = 30 * 6e4;
|
|
59
|
+
|
|
60
|
+
// src/lib/notify.ts
|
|
61
|
+
var NOTIFY_SESSION_ENV = "LACODE_NOTIFY_SESSION";
|
|
62
|
+
var NOTIFY_CLOSE_ENV = "LACODE_NOTIFY_CLOSE";
|
|
63
|
+
var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join3(homedir2(), ".local", "share", "opencode", "lacode", "notify");
|
|
64
|
+
var safeCorrelationKey = (correlationKey) => correlationKey.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
|
|
65
|
+
var notificationIdFile = (correlationKey) => join3(notificationStateDir(), `${safeCorrelationKey(correlationKey)}.ids`);
|
|
66
|
+
|
|
27
67
|
// src/tunnel/index.ts
|
|
28
68
|
var ICONS = [
|
|
29
69
|
"utilities-terminal",
|
|
@@ -46,7 +86,7 @@ function resolveTunnel(env = process.env) {
|
|
|
46
86
|
user: env.OC_LOCAL_USER ?? "",
|
|
47
87
|
host: env.OC_LOCAL_HOST ?? "",
|
|
48
88
|
port: Number.isFinite(port) && port > 0 ? port : 10022,
|
|
49
|
-
key: env.OC_NOTIFY_KEY ??
|
|
89
|
+
key: env.OC_NOTIFY_KEY ?? join4(homedir3(), ".ssh", "lacode_notify"),
|
|
50
90
|
connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
|
|
51
91
|
};
|
|
52
92
|
}
|
|
@@ -74,16 +114,32 @@ var safeRequestId = (raw) => {
|
|
|
74
114
|
if (!clean) return void 0;
|
|
75
115
|
return clean.length > 128 ? clean.slice(0, 128) : clean;
|
|
76
116
|
};
|
|
117
|
+
var CLOSE_REQUEST_PREFIX = "close:";
|
|
118
|
+
var wireCorrelationKey = (raw) => safeText(raw ?? "", "", 200);
|
|
119
|
+
var presentCorrelationKey = (raw) => {
|
|
120
|
+
const clean = wireCorrelationKey(raw);
|
|
121
|
+
return clean ? clean : void 0;
|
|
122
|
+
};
|
|
123
|
+
var isCloseRequest = (correlationKey) => typeof correlationKey === "string" && correlationKey.startsWith(CLOSE_REQUEST_PREFIX);
|
|
124
|
+
var closeRequestTarget = (correlationKey) => {
|
|
125
|
+
if (!isCloseRequest(correlationKey)) return void 0;
|
|
126
|
+
const target = correlationKey?.slice(CLOSE_REQUEST_PREFIX.length).trim() ?? "";
|
|
127
|
+
return target ? target : void 0;
|
|
128
|
+
};
|
|
77
129
|
var FIELD_SEPARATOR = "\n";
|
|
78
130
|
function parsePayload(raw) {
|
|
79
|
-
const [title, message, urgency, icon, sound, requestID] = raw.split(FIELD_SEPARATOR);
|
|
131
|
+
const [title, message, urgency, icon, sound, requestID, correlationKey] = raw.split(FIELD_SEPARATOR);
|
|
132
|
+
const key = wireCorrelationKey(correlationKey);
|
|
80
133
|
return {
|
|
81
134
|
title: safeTitle(title),
|
|
82
135
|
message: safeMessage(message),
|
|
83
136
|
urgency: normalizeUrgency(urgency),
|
|
84
137
|
icon: normalizeIcon(icon),
|
|
85
138
|
sound: normalizeSound(sound),
|
|
86
|
-
requestID: safeRequestId(requestID)
|
|
139
|
+
requestID: safeRequestId(requestID),
|
|
140
|
+
// The seventh field, empty when there is none. A close request keeps its
|
|
141
|
+
// marker here, so the receiver can still tell it apart from a session.
|
|
142
|
+
...key ? { correlationKey: key } : {}
|
|
87
143
|
};
|
|
88
144
|
}
|
|
89
145
|
var encodePayload = (request) => [
|
|
@@ -94,7 +150,11 @@ var encodePayload = (request) => [
|
|
|
94
150
|
normalizeSound(request.sound),
|
|
95
151
|
// The sixth field, empty when there is no request id: an absent id and an
|
|
96
152
|
// empty one mean the same thing, and the field count never changes.
|
|
97
|
-
safeRequestId(request.requestID) ?? ""
|
|
153
|
+
safeRequestId(request.requestID) ?? "",
|
|
154
|
+
// The seventh field, the correlation key, empty when there is none. The
|
|
155
|
+
// `close:` marker of a close request is part of the key and travels with
|
|
156
|
+
// it, so the far end closes instead of raising.
|
|
157
|
+
wireCorrelationKey(request.correlationKey)
|
|
98
158
|
].join(FIELD_SEPARATOR);
|
|
99
159
|
var SSH_OPTS = [
|
|
100
160
|
"-o",
|
|
@@ -114,11 +174,16 @@ var CONTROL_OPTS = [
|
|
|
114
174
|
"-o",
|
|
115
175
|
"ControlPersist=300",
|
|
116
176
|
"-o",
|
|
117
|
-
`ControlPath=${
|
|
177
|
+
`ControlPath=${join4(homedir3(), ".ssh", "lacode-cm-%C")}`
|
|
118
178
|
];
|
|
119
|
-
var run = (command, args, timeout) => {
|
|
179
|
+
var run = (command, args, timeout, extraEnv) => {
|
|
120
180
|
try {
|
|
121
|
-
const result = spawnSync(command, args, {
|
|
181
|
+
const result = spawnSync(command, args, {
|
|
182
|
+
timeout,
|
|
183
|
+
encoding: "utf8",
|
|
184
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
185
|
+
...extraEnv ? { env: { ...process.env, ...extraEnv } } : {}
|
|
186
|
+
});
|
|
122
187
|
return {
|
|
123
188
|
ok: result.status === 0,
|
|
124
189
|
stdout: result.stdout ?? "",
|
|
@@ -178,20 +243,104 @@ var NO_CHANNEL = {
|
|
|
178
243
|
darwin: "neither osascript nor terminal-notifier is installed"
|
|
179
244
|
};
|
|
180
245
|
var NEVER_EXPIRE_FLAG = "--expire-time=0";
|
|
246
|
+
var APP_NAME_FLAG = "--app-name=LaCode";
|
|
181
247
|
var NOTIFICATION_ACTIONS = [
|
|
182
248
|
["once", "Allow once"],
|
|
183
249
|
["always", "Always allow"],
|
|
184
250
|
["reject", "Deny"]
|
|
185
251
|
];
|
|
186
252
|
var ACTION_WAIT_MS = 10 * 6e4;
|
|
253
|
+
var isNotificationId = (id) => /^\d+$/.test(id);
|
|
254
|
+
var recordNotificationId = (correlationKey, id) => {
|
|
255
|
+
if (!isNotificationId(id)) return false;
|
|
256
|
+
try {
|
|
257
|
+
mkdirSync3(notificationStateDir(), { recursive: true });
|
|
258
|
+
appendFileSync(notificationIdFile(correlationKey), `${id}
|
|
259
|
+
`, "utf8");
|
|
260
|
+
return true;
|
|
261
|
+
} catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
var sessionBusEnv = () => {
|
|
266
|
+
if (process.env.DBUS_SESSION_BUS_ADDRESS) return {};
|
|
267
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : void 0;
|
|
268
|
+
return uid === void 0 ? {} : { DBUS_SESSION_BUS_ADDRESS: `unix:path=/run/user/${uid}/bus` };
|
|
269
|
+
};
|
|
270
|
+
var CLOSE_TIMEOUT_MS = 5e3;
|
|
271
|
+
function closeNotification(id) {
|
|
272
|
+
if (!isNotificationId(id)) return false;
|
|
273
|
+
const env = sessionBusEnv();
|
|
274
|
+
const viaGdbus = run(
|
|
275
|
+
"gdbus",
|
|
276
|
+
[
|
|
277
|
+
"call",
|
|
278
|
+
"--session",
|
|
279
|
+
"--dest",
|
|
280
|
+
"org.freedesktop.Notifications",
|
|
281
|
+
"--object-path",
|
|
282
|
+
"/org/freedesktop/Notifications",
|
|
283
|
+
"--method",
|
|
284
|
+
"org.freedesktop.Notifications.CloseNotification",
|
|
285
|
+
id
|
|
286
|
+
],
|
|
287
|
+
CLOSE_TIMEOUT_MS,
|
|
288
|
+
env
|
|
289
|
+
);
|
|
290
|
+
if (viaGdbus.ok) return true;
|
|
291
|
+
const viaDbusSend = run(
|
|
292
|
+
"dbus-send",
|
|
293
|
+
[
|
|
294
|
+
"--session",
|
|
295
|
+
"--dest=org.freedesktop.Notifications",
|
|
296
|
+
"--type=method_call",
|
|
297
|
+
"/org/freedesktop/Notifications",
|
|
298
|
+
"org.freedesktop.Notifications.CloseNotification",
|
|
299
|
+
`uint32:${id}`
|
|
300
|
+
],
|
|
301
|
+
CLOSE_TIMEOUT_MS,
|
|
302
|
+
env
|
|
303
|
+
);
|
|
304
|
+
return viaDbusSend.ok;
|
|
305
|
+
}
|
|
306
|
+
function closeCorrelationKey(correlationKey) {
|
|
307
|
+
const file = notificationIdFile(correlationKey);
|
|
308
|
+
let content = "";
|
|
309
|
+
try {
|
|
310
|
+
content = readFileSync4(file, "utf8");
|
|
311
|
+
} catch {
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
let closed = true;
|
|
315
|
+
for (const line of content.split("\n")) {
|
|
316
|
+
const id = line.trim();
|
|
317
|
+
if (!isNotificationId(id)) continue;
|
|
318
|
+
if (!closeNotification(id)) closed = false;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
unlinkSync3(file);
|
|
322
|
+
} catch {
|
|
323
|
+
}
|
|
324
|
+
return closed;
|
|
325
|
+
}
|
|
326
|
+
var splitNotifyOutput = (stdout) => {
|
|
327
|
+
const newline = stdout.indexOf("\n");
|
|
328
|
+
if (newline === -1) return { id: stdout.trim(), action: "" };
|
|
329
|
+
return { id: stdout.slice(0, newline).trim(), action: stdout.slice(newline + 1).trim() };
|
|
330
|
+
};
|
|
187
331
|
function sendLocal(request, platform = process.platform) {
|
|
332
|
+
const closing = closeRequestTarget(request.correlationKey);
|
|
333
|
+
if (closing !== void 0) {
|
|
334
|
+
const closed = closeCorrelationKey(closing);
|
|
335
|
+
return { ok: closed, detail: closed ? void 0 : "a popup could not be closed", transport: "local" };
|
|
336
|
+
}
|
|
188
337
|
const sound = normalizeSound(request.sound);
|
|
189
338
|
const playSound = () => {
|
|
190
339
|
if (sound === "none") return;
|
|
191
340
|
const channel2 = soundChannel(platform);
|
|
192
341
|
if (channel2 === "none") return;
|
|
193
|
-
const path =
|
|
194
|
-
if (!
|
|
342
|
+
const path = join4(PACKAGE_ROOT, "sounds", `${sound}.wav`);
|
|
343
|
+
if (!existsSync2(path)) return;
|
|
195
344
|
const child = spawn(channel2, [path], { detached: true, stdio: "ignore" });
|
|
196
345
|
child.on("error", () => {
|
|
197
346
|
});
|
|
@@ -207,13 +356,17 @@ function sendLocal(request, platform = process.platform) {
|
|
|
207
356
|
const requestID = safeRequestId(request.requestID);
|
|
208
357
|
const actionable = channel === "notify-send" && requestID !== void 0;
|
|
209
358
|
const actionArgs = actionable ? NOTIFICATION_ACTIONS.map(([name, label]) => `--action=${name}=${label}`) : [];
|
|
359
|
+
const correlationKey = presentCorrelationKey(request.correlationKey);
|
|
360
|
+
const trackable = channel === "notify-send" && correlationKey !== void 0;
|
|
210
361
|
const args = channel === "osascript" ? ["-e", DARWIN_SCRIPT, DARWIN_ARGV_MARKER, title, body] : channel === "terminal-notifier" ? ["-title", title, "-message", body, "-appName", "LaCode", "-priority", normalizeUrgency(request.urgency)] : (
|
|
211
362
|
// Never auto-dismissed: the notification waits for the user to close it.
|
|
212
|
-
[title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`, NEVER_EXPIRE_FLAG, ...actionArgs]
|
|
363
|
+
[title, body, APP_NAME_FLAG, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`, NEVER_EXPIRE_FLAG, ...trackable ? ["--print-id"] : [], ...actionArgs]
|
|
213
364
|
);
|
|
214
365
|
const result = run(CHANNEL_BINARY[channel], args, actionable ? ACTION_WAIT_MS : 5e3);
|
|
215
366
|
playSound();
|
|
216
|
-
const
|
|
367
|
+
const printed = trackable ? splitNotifyOutput(result.stdout) : { id: "", action: result.stdout.trim() };
|
|
368
|
+
const action = actionable ? printed.action : "";
|
|
369
|
+
if (correlationKey !== void 0 && printed.id) recordNotificationId(correlationKey, printed.id);
|
|
217
370
|
return {
|
|
218
371
|
ok: result.ok,
|
|
219
372
|
detail: result.ok ? void 0 : result.stderr || result.detail,
|
|
@@ -262,7 +415,17 @@ function sendRemote(request, tunnel) {
|
|
|
262
415
|
}
|
|
263
416
|
function notify(request, env = process.env, platform = process.platform) {
|
|
264
417
|
try {
|
|
265
|
-
const
|
|
418
|
+
const closeKey = (env[NOTIFY_CLOSE_ENV] ?? "").trim();
|
|
419
|
+
if (closeKey) {
|
|
420
|
+
if (!isRemote(env)) {
|
|
421
|
+
const closed = closeCorrelationKey(closeKey);
|
|
422
|
+
return { ok: closed, detail: closed ? void 0 : "a popup could not be closed", transport: "local" };
|
|
423
|
+
}
|
|
424
|
+
return sendRemote({ ...request, correlationKey: `${CLOSE_REQUEST_PREFIX}${closeKey}` }, resolveTunnel(env));
|
|
425
|
+
}
|
|
426
|
+
const sessionKey = presentCorrelationKey(env[NOTIFY_SESSION_ENV]);
|
|
427
|
+
const tracked = sessionKey ? { ...request, correlationKey: request.correlationKey ?? sessionKey } : request;
|
|
428
|
+
const result = isRemote(env) ? sendRemote(tracked, resolveTunnel(env)) : sendLocal(tracked, platform);
|
|
266
429
|
return result.action ? { ...result, action: result.action } : result;
|
|
267
430
|
} catch (error) {
|
|
268
431
|
return { ok: false, detail: String(error), transport: "none" };
|
|
@@ -294,21 +457,30 @@ function describe(env = process.env, platform = process.platform) {
|
|
|
294
457
|
};
|
|
295
458
|
}
|
|
296
459
|
export {
|
|
460
|
+
CLOSE_REQUEST_PREFIX,
|
|
297
461
|
FIELD_SEPARATOR,
|
|
298
462
|
ICONS,
|
|
299
463
|
NOTIFICATION_ACTIONS,
|
|
300
464
|
SOUNDS,
|
|
465
|
+
closeCorrelationKey,
|
|
466
|
+
closeNotification,
|
|
467
|
+
closeRequestTarget,
|
|
301
468
|
describe,
|
|
302
469
|
encodePayload,
|
|
303
470
|
forward,
|
|
471
|
+
isCloseRequest,
|
|
472
|
+
isNotificationId,
|
|
304
473
|
isRemote,
|
|
305
474
|
localChannel,
|
|
306
475
|
normalizeIcon,
|
|
307
476
|
normalizeSound,
|
|
308
477
|
normalizeUrgency,
|
|
478
|
+
notificationIdFile,
|
|
479
|
+
notificationStateDir,
|
|
309
480
|
notify,
|
|
310
481
|
parsePayload,
|
|
311
482
|
resolveTunnel,
|
|
483
|
+
safeCorrelationKey,
|
|
312
484
|
safeMessage,
|
|
313
485
|
safeRequestId,
|
|
314
486
|
safeTitle,
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -7,13 +7,16 @@ The plugin options, where the model pool gets its slots, and exactly what LaCode
|
|
|
7
7
|
The slot list is the first of these that yields usable slots:
|
|
8
8
|
|
|
9
9
|
1. plugin options — `models` in the [tuple](../README.md#configure)
|
|
10
|
-
2. `~/.config/lacode/pool.
|
|
11
|
-
3.
|
|
12
|
-
4.
|
|
10
|
+
2. `~/.config/lacode/pool.txt` — flat `model [weight]` per line, preferred
|
|
11
|
+
3. `~/.config/lacode/pool.json` (its parent directory is created for you)
|
|
12
|
+
4. the bundled `presets/free-tier.json`
|
|
13
|
+
5. a built-in fallback, so routing always works
|
|
13
14
|
|
|
14
15
|
A missing, unreadable or structurally broken source falls through to the next and
|
|
15
|
-
logs one warning; nothing here throws into opencode.
|
|
16
|
-
change
|
|
16
|
+
logs one warning; nothing here throws into opencode. Sources 2–4 are watched: a
|
|
17
|
+
change reloads the slot table, re-binds the variant agents and validates every
|
|
18
|
+
slot (one real request per model) without a restart. Only a change to the
|
|
19
|
+
plugin's own code needs one.
|
|
17
20
|
|
|
18
21
|
**Any model list is accepted, including paid ones** (US-7). There is no free-tier
|
|
19
22
|
allowlist anywhere in the package, and no cost check of any kind: only the
|
|
@@ -65,12 +68,23 @@ fields are not affected; `permission`, `prompt`, `model`, `description`,
|
|
|
65
68
|
The hidden pool variants (`explore-1`, `implement-2`, …) inherit
|
|
66
69
|
nothing here — add a line per variant, or drop the global
|
|
67
70
|
deny and scope it to the agents that should not have browser access instead.
|
|
68
|
-
Note that you cannot *spawn* a variant by name: the pool
|
|
69
|
-
`explore-1` and friends
|
|
71
|
+
Note that you cannot *spawn* a variant by name: the pool rewrites
|
|
72
|
+
`explore-1` and friends to their base and routes them, so per-variant
|
|
70
73
|
permissions only ever apply to a spawn the plugin routed itself.
|
|
71
74
|
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
The pool files are the exception to the restart rule: editing `pool.txt`,
|
|
76
|
+
`pool.json` or the bundled preset is picked up live — the table reloads, the
|
|
77
|
+
variant agents re-bind and every slot is validated. A change to `opencode.jsonc`
|
|
78
|
+
itself, or to the plugin's code, still needs a restart.
|
|
79
|
+
|
|
80
|
+
**The delegation path matters.** The pool routes `task` tool calls whose
|
|
81
|
+
`subagent_type` is `explore`, `implement` or `context`. A spawn with no
|
|
82
|
+
`subagent_type`, an `@explore`/`@implement` mention, or a direct session prompt
|
|
83
|
+
naming a base agent does not pass through that hook: it runs opencode's
|
|
84
|
+
default/subagent path and inherits the caller's model. Spawn the bare base
|
|
85
|
+
through the task tool (`subagent_type: "explore"`), not by mention or variant
|
|
86
|
+
name. The plugin logs a warning for the no-`subagent_type` case; the
|
|
87
|
+
mention/direct-session case cannot be detected before it runs.
|
|
74
88
|
|
|
75
89
|
|
|
76
90
|
## What gets injected
|
package/docs/OMNIROUTE.md
CHANGED
|
@@ -104,7 +104,7 @@ work (exploration, implementation, cartography) through the gateway, add an
|
|
|
104
104
|
# ~/.config/lacode/pool.txt
|
|
105
105
|
omniroute/free-mix
|
|
106
106
|
opencode/big-pickle
|
|
107
|
-
opencode-go/
|
|
107
|
+
opencode-go/beam-free
|
|
108
108
|
```
|
|
109
109
|
|
|
110
110
|
OmniRoute is a provider like any other: the pool decides its place and weight,
|