@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/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/minimax-m3.1-flash-free", weight: 2 },
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 isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text));
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: "endpoint-unavailable", last: now };
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 roundRobinSequence(slots) {
602
- const seq = [];
603
- for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
604
- return seq;
605
- }
606
- var CURSOR_FILE = "cursor.json";
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
- return 0;
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 pickSlot(s, snap, options) {
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
- const pool = usable.length > 0 ? usable : slots;
642
- return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
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 ?? join2(homedir(), ".ssh", "lacode_notify"),
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=${join2(homedir(), ".ssh", "lacode-cm-%C")}`
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, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
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 = join2(PACKAGE_ROOT, "sounds", `${sound}.wav`);
194
- if (!existsSync(path)) return;
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 action = actionable ? result.stdout.trim() : "";
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 result = isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
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,
@@ -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.json` (its parent directory is created for you)
11
- 3. the bundled `presets/free-tier.json`
12
- 4. a built-in fallback, so routing always works
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. Nothing hot-reloads —
16
- change a preset, then restart every opencode process.
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 refuses
69
- `explore-1` and friends and points you at the bare base, so per-variant
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
- Nothing hot-reloads: after editing your config, restart every running opencode
73
- process.
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/minimax-m3.1-flash-free
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,