@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/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));
|
|
@@ -755,6 +783,8 @@ export {
|
|
|
755
783
|
MAX_CLAIM_AGE_MS,
|
|
756
784
|
MAX_SLOTS,
|
|
757
785
|
MAX_WEIGHT,
|
|
786
|
+
MODEL_MISSING_COOLDOWN_MS,
|
|
787
|
+
MODEL_MISSING_REASON,
|
|
758
788
|
ORPHAN_TMP_MS,
|
|
759
789
|
PACKAGE_ROOT,
|
|
760
790
|
PRESETS_DIR,
|
|
@@ -766,6 +796,7 @@ export {
|
|
|
766
796
|
STUCK_IDLE_MS,
|
|
767
797
|
STUCK_MIN_AGE_MS,
|
|
768
798
|
UNAVAILABLE_COOLDOWN_MS,
|
|
799
|
+
UNAVAILABLE_REASON,
|
|
769
800
|
USER_CONFIG_DIR,
|
|
770
801
|
USER_MODELS_PATH,
|
|
771
802
|
USER_MODELS_TXT,
|
|
@@ -785,10 +816,12 @@ export {
|
|
|
785
816
|
init,
|
|
786
817
|
isAvailable,
|
|
787
818
|
isLimitText,
|
|
819
|
+
isModelMissingText,
|
|
788
820
|
isProbe,
|
|
789
821
|
isQuotaReason,
|
|
790
822
|
isTransientGatewayText,
|
|
791
823
|
isUnavailableText,
|
|
824
|
+
limitReason,
|
|
792
825
|
manualVariant,
|
|
793
826
|
markProbe,
|
|
794
827
|
noteActivity,
|
|
@@ -807,12 +840,14 @@ export {
|
|
|
807
840
|
refreshCatalog,
|
|
808
841
|
release,
|
|
809
842
|
releaseStuck,
|
|
843
|
+
reloadSlots,
|
|
810
844
|
resolveModels,
|
|
811
845
|
retryAfterMs,
|
|
812
846
|
roundRobinSequence,
|
|
813
847
|
selfFile,
|
|
814
848
|
slotCeiling,
|
|
815
849
|
slotFor,
|
|
850
|
+
slotSources,
|
|
816
851
|
snapshot,
|
|
817
852
|
splitModel,
|
|
818
853
|
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, statSync as statSync2, unlinkSync as unlinkSync2, 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
|
}
|
|
@@ -69,15 +109,37 @@ var safeText = (raw, fallback, max) => {
|
|
|
69
109
|
};
|
|
70
110
|
var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
|
|
71
111
|
var safeMessage = (raw) => safeText(raw ?? "", "", 400);
|
|
112
|
+
var safeRequestId = (raw) => {
|
|
113
|
+
const clean = (raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
114
|
+
if (!clean) return void 0;
|
|
115
|
+
return clean.length > 128 ? clean.slice(0, 128) : clean;
|
|
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
|
+
};
|
|
72
129
|
var FIELD_SEPARATOR = "\n";
|
|
73
130
|
function parsePayload(raw) {
|
|
74
|
-
const [title, message, urgency, icon, sound] = raw.split(FIELD_SEPARATOR);
|
|
131
|
+
const [title, message, urgency, icon, sound, requestID, correlationKey] = raw.split(FIELD_SEPARATOR);
|
|
132
|
+
const key = wireCorrelationKey(correlationKey);
|
|
75
133
|
return {
|
|
76
134
|
title: safeTitle(title),
|
|
77
135
|
message: safeMessage(message),
|
|
78
136
|
urgency: normalizeUrgency(urgency),
|
|
79
137
|
icon: normalizeIcon(icon),
|
|
80
|
-
sound: normalizeSound(sound)
|
|
138
|
+
sound: normalizeSound(sound),
|
|
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 } : {}
|
|
81
143
|
};
|
|
82
144
|
}
|
|
83
145
|
var encodePayload = (request) => [
|
|
@@ -85,7 +147,14 @@ var encodePayload = (request) => [
|
|
|
85
147
|
safeMessage(request.message),
|
|
86
148
|
normalizeUrgency(request.urgency),
|
|
87
149
|
normalizeIcon(request.icon),
|
|
88
|
-
normalizeSound(request.sound)
|
|
150
|
+
normalizeSound(request.sound),
|
|
151
|
+
// The sixth field, empty when there is no request id: an absent id and an
|
|
152
|
+
// empty one mean the same thing, and the field count never changes.
|
|
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)
|
|
89
158
|
].join(FIELD_SEPARATOR);
|
|
90
159
|
var SSH_OPTS = [
|
|
91
160
|
"-o",
|
|
@@ -105,11 +174,16 @@ var CONTROL_OPTS = [
|
|
|
105
174
|
"-o",
|
|
106
175
|
"ControlPersist=300",
|
|
107
176
|
"-o",
|
|
108
|
-
`ControlPath=${
|
|
177
|
+
`ControlPath=${join4(homedir3(), ".ssh", "lacode-cm-%C")}`
|
|
109
178
|
];
|
|
110
|
-
var run = (command, args, timeout) => {
|
|
179
|
+
var run = (command, args, timeout, extraEnv) => {
|
|
111
180
|
try {
|
|
112
|
-
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
|
+
});
|
|
113
187
|
return {
|
|
114
188
|
ok: result.status === 0,
|
|
115
189
|
stdout: result.stdout ?? "",
|
|
@@ -168,14 +242,105 @@ var NO_CHANNEL = {
|
|
|
168
242
|
linux: "notify-send is not installed",
|
|
169
243
|
darwin: "neither osascript nor terminal-notifier is installed"
|
|
170
244
|
};
|
|
245
|
+
var NEVER_EXPIRE_FLAG = "--expire-time=0";
|
|
246
|
+
var APP_NAME_FLAG = "--app-name=LaCode";
|
|
247
|
+
var NOTIFICATION_ACTIONS = [
|
|
248
|
+
["once", "Allow once"],
|
|
249
|
+
["always", "Always allow"],
|
|
250
|
+
["reject", "Deny"]
|
|
251
|
+
];
|
|
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
|
+
};
|
|
171
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
|
+
}
|
|
172
337
|
const sound = normalizeSound(request.sound);
|
|
173
338
|
const playSound = () => {
|
|
174
339
|
if (sound === "none") return;
|
|
175
340
|
const channel2 = soundChannel(platform);
|
|
176
341
|
if (channel2 === "none") return;
|
|
177
|
-
const path =
|
|
178
|
-
if (!
|
|
342
|
+
const path = join4(PACKAGE_ROOT, "sounds", `${sound}.wav`);
|
|
343
|
+
if (!existsSync2(path)) return;
|
|
179
344
|
const child = spawn(channel2, [path], { detached: true, stdio: "ignore" });
|
|
180
345
|
child.on("error", () => {
|
|
181
346
|
});
|
|
@@ -188,35 +353,80 @@ function sendLocal(request, platform = process.platform) {
|
|
|
188
353
|
}
|
|
189
354
|
const title = safeTitle(request.title);
|
|
190
355
|
const body = safeMessage(request.message);
|
|
356
|
+
const requestID = safeRequestId(request.requestID);
|
|
357
|
+
const actionable = channel === "notify-send" && requestID !== void 0;
|
|
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;
|
|
191
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)] : (
|
|
192
|
-
//
|
|
193
|
-
[title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}
|
|
362
|
+
// Never auto-dismissed: the notification waits for the user to close it.
|
|
363
|
+
[title, body, APP_NAME_FLAG, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`, NEVER_EXPIRE_FLAG, ...trackable ? ["--print-id"] : [], ...actionArgs]
|
|
194
364
|
);
|
|
195
|
-
const result = run(CHANNEL_BINARY[channel], args, 5e3);
|
|
365
|
+
const result = run(CHANNEL_BINARY[channel], args, actionable ? ACTION_WAIT_MS : 5e3);
|
|
196
366
|
playSound();
|
|
197
|
-
|
|
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);
|
|
370
|
+
return {
|
|
371
|
+
ok: result.ok,
|
|
372
|
+
detail: result.ok ? void 0 : result.stderr || result.detail,
|
|
373
|
+
transport: "local",
|
|
374
|
+
...action ? { action } : {}
|
|
375
|
+
};
|
|
198
376
|
}
|
|
199
377
|
function sendRemote(request, tunnel) {
|
|
200
378
|
if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
|
|
201
379
|
const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
|
|
380
|
+
const actionable = safeRequestId(request.requestID) !== void 0;
|
|
381
|
+
const timeoutMs = actionable ? Math.max(tunnel.connectTimeoutMs + 2e3, ACTION_WAIT_MS + 2e3) : tunnel.connectTimeoutMs + 2e3;
|
|
382
|
+
const deliver = (args) => {
|
|
383
|
+
const result = run("ssh", args, timeoutMs);
|
|
384
|
+
const action = actionable ? result.stdout.trim() : "";
|
|
385
|
+
return {
|
|
386
|
+
ok: result.ok,
|
|
387
|
+
detail: result.ok ? void 0 : result.stderr || result.detail,
|
|
388
|
+
transport: "remote",
|
|
389
|
+
...action ? { action } : {}
|
|
390
|
+
};
|
|
391
|
+
};
|
|
202
392
|
if (tunnel.host) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
393
|
+
return deliver([
|
|
394
|
+
...SSH_OPTS,
|
|
395
|
+
...CONTROL_OPTS,
|
|
396
|
+
...connect,
|
|
397
|
+
"-i",
|
|
398
|
+
tunnel.key,
|
|
399
|
+
`${tunnel.user}@${tunnel.host}`,
|
|
400
|
+
"--",
|
|
401
|
+
encodePayload(request)
|
|
402
|
+
]);
|
|
209
403
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
404
|
+
return deliver([
|
|
405
|
+
...SSH_OPTS,
|
|
406
|
+
...connect,
|
|
407
|
+
"-i",
|
|
408
|
+
tunnel.key,
|
|
409
|
+
"-p",
|
|
410
|
+
String(tunnel.port),
|
|
411
|
+
`${tunnel.user}@localhost`,
|
|
412
|
+
"--",
|
|
413
|
+
encodePayload(request)
|
|
414
|
+
]);
|
|
216
415
|
}
|
|
217
416
|
function notify(request, env = process.env, platform = process.platform) {
|
|
218
417
|
try {
|
|
219
|
-
|
|
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);
|
|
429
|
+
return result.action ? { ...result, action: result.action } : result;
|
|
220
430
|
} catch (error) {
|
|
221
431
|
return { ok: false, detail: String(error), transport: "none" };
|
|
222
432
|
}
|
|
@@ -247,21 +457,32 @@ function describe(env = process.env, platform = process.platform) {
|
|
|
247
457
|
};
|
|
248
458
|
}
|
|
249
459
|
export {
|
|
460
|
+
CLOSE_REQUEST_PREFIX,
|
|
250
461
|
FIELD_SEPARATOR,
|
|
251
462
|
ICONS,
|
|
463
|
+
NOTIFICATION_ACTIONS,
|
|
252
464
|
SOUNDS,
|
|
465
|
+
closeCorrelationKey,
|
|
466
|
+
closeNotification,
|
|
467
|
+
closeRequestTarget,
|
|
253
468
|
describe,
|
|
254
469
|
encodePayload,
|
|
255
470
|
forward,
|
|
471
|
+
isCloseRequest,
|
|
472
|
+
isNotificationId,
|
|
256
473
|
isRemote,
|
|
257
474
|
localChannel,
|
|
258
475
|
normalizeIcon,
|
|
259
476
|
normalizeSound,
|
|
260
477
|
normalizeUrgency,
|
|
478
|
+
notificationIdFile,
|
|
479
|
+
notificationStateDir,
|
|
261
480
|
notify,
|
|
262
481
|
parsePayload,
|
|
263
482
|
resolveTunnel,
|
|
483
|
+
safeCorrelationKey,
|
|
264
484
|
safeMessage,
|
|
485
|
+
safeRequestId,
|
|
265
486
|
safeTitle,
|
|
266
487
|
sendLocal,
|
|
267
488
|
sendRemote,
|
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
|
|
@@ -69,8 +72,19 @@ Note that you cannot *spawn* a variant by name: the pool refuses
|
|
|
69
72
|
`explore-1` and friends and points you at the bare base, 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,
|