@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/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));
@@ -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 ?? 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
  }
@@ -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=${join2(homedir(), ".ssh", "lacode-cm-%C")}`
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, { 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
+ });
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 = join2(PACKAGE_ROOT, "sounds", `${sound}.wav`);
178
- if (!existsSync(path)) return;
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
- // Byte-identical to the notify-send call this package has always made.
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
- return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
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
- const result2 = run(
204
- "ssh",
205
- [...SSH_OPTS, ...CONTROL_OPTS, ...connect, "-i", tunnel.key, `${tunnel.user}@${tunnel.host}`, "--", encodePayload(request)],
206
- tunnel.connectTimeoutMs + 2e3
207
- );
208
- return { ok: result2.ok, detail: result2.ok ? void 0 : result2.stderr || result2.detail, transport: "remote" };
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
- const result = run(
211
- "ssh",
212
- [...SSH_OPTS, ...connect, "-i", tunnel.key, "-p", String(tunnel.port), `${tunnel.user}@localhost`, "--", encodePayload(request)],
213
- tunnel.connectTimeoutMs + 2e3
214
- );
215
- return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
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
- return 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);
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,
@@ -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
@@ -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
- 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,