@lawaty/lacode 0.10.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/lacode.js CHANGED
@@ -2,12 +2,22 @@
2
2
 
3
3
  // bin/lacode
4
4
  import { fileURLToPath as fileURLToPath3 } from "node:url";
5
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
5
+ import { existsSync as existsSync4, readFileSync as readFileSync7 } from "node:fs";
6
6
  import { spawnSync as spawnSync3 } from "node:child_process";
7
7
 
8
8
  // src/tunnel/index.ts
9
9
  import { spawn, spawnSync } from "node:child_process";
10
- import { existsSync } from "node:fs";
10
+ import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, unlinkSync as unlinkSync3 } from "node:fs";
11
+ import { homedir as homedir3 } from "node:os";
12
+ import { join as join4 } from "node:path";
13
+
14
+ // src/lib/notify.ts
15
+ import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync3, statSync as statSync2, unlinkSync as unlinkSync2, writeSync } from "node:fs";
16
+ import { homedir as homedir2 } from "node:os";
17
+ import { join as join3 } from "node:path";
18
+
19
+ // src/lib/pool.ts
20
+ import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
11
21
  import { homedir } from "node:os";
12
22
  import { join as join2 } from "node:path";
13
23
 
@@ -31,6 +41,36 @@ var findPackageRoot = (start) => {
31
41
  };
32
42
  var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
33
43
 
44
+ // src/lib/pool.ts
45
+ var CLAIM_TTL_MS = 10 * 60 * 1e3;
46
+ var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
47
+ var ORPHAN_TMP_MS = 60 * 1e3;
48
+ var CATALOG_TTL_MS = 10 * 60 * 1e3;
49
+ var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
50
+ var STUCK_IDLE_MS = 3 * 60 * 1e3;
51
+ var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
52
+ var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
53
+ var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
54
+ var COOLDOWN_BASE_MS = 60 * 1e3;
55
+ var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
56
+ var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
57
+ var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
58
+ var STRIKE_DECAY_MS = 30 * 60 * 1e3;
59
+ var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
60
+ var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
61
+ var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
62
+ var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
63
+ var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
64
+ var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
65
+ var CHILD_MEMORY_MS = 30 * 6e4;
66
+
67
+ // src/lib/notify.ts
68
+ var NOTIFY_SESSION_ENV = "LACODE_NOTIFY_SESSION";
69
+ var NOTIFY_CLOSE_ENV = "LACODE_NOTIFY_CLOSE";
70
+ var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join3(homedir2(), ".local", "share", "opencode", "lacode", "notify");
71
+ var safeCorrelationKey = (correlationKey) => correlationKey.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
72
+ var notificationIdFile = (correlationKey) => join3(notificationStateDir(), `${safeCorrelationKey(correlationKey)}.ids`);
73
+
34
74
  // src/tunnel/index.ts
35
75
  var ICONS = [
36
76
  "utilities-terminal",
@@ -53,7 +93,7 @@ function resolveTunnel(env = process.env) {
53
93
  user: env.OC_LOCAL_USER ?? "",
54
94
  host: env.OC_LOCAL_HOST ?? "",
55
95
  port: Number.isFinite(port) && port > 0 ? port : 10022,
56
- key: env.OC_NOTIFY_KEY ?? join2(homedir(), ".ssh", "lacode_notify"),
96
+ key: env.OC_NOTIFY_KEY ?? join4(homedir3(), ".ssh", "lacode_notify"),
57
97
  connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
58
98
  };
59
99
  }
@@ -81,16 +121,32 @@ var safeRequestId = (raw) => {
81
121
  if (!clean) return void 0;
82
122
  return clean.length > 128 ? clean.slice(0, 128) : clean;
83
123
  };
124
+ var CLOSE_REQUEST_PREFIX = "close:";
125
+ var wireCorrelationKey = (raw) => safeText(raw ?? "", "", 200);
126
+ var presentCorrelationKey = (raw) => {
127
+ const clean = wireCorrelationKey(raw);
128
+ return clean ? clean : void 0;
129
+ };
130
+ var isCloseRequest = (correlationKey) => typeof correlationKey === "string" && correlationKey.startsWith(CLOSE_REQUEST_PREFIX);
131
+ var closeRequestTarget = (correlationKey) => {
132
+ if (!isCloseRequest(correlationKey)) return void 0;
133
+ const target = correlationKey?.slice(CLOSE_REQUEST_PREFIX.length).trim() ?? "";
134
+ return target ? target : void 0;
135
+ };
84
136
  var FIELD_SEPARATOR = "\n";
85
137
  function parsePayload(raw) {
86
- const [title, message, urgency, icon, sound, requestID] = raw.split(FIELD_SEPARATOR);
138
+ const [title, message, urgency, icon, sound, requestID, correlationKey] = raw.split(FIELD_SEPARATOR);
139
+ const key = wireCorrelationKey(correlationKey);
87
140
  return {
88
141
  title: safeTitle(title),
89
142
  message: safeMessage(message),
90
143
  urgency: normalizeUrgency(urgency),
91
144
  icon: normalizeIcon(icon),
92
145
  sound: normalizeSound(sound),
93
- requestID: safeRequestId(requestID)
146
+ requestID: safeRequestId(requestID),
147
+ // The seventh field, empty when there is none. A close request keeps its
148
+ // marker here, so the receiver can still tell it apart from a session.
149
+ ...key ? { correlationKey: key } : {}
94
150
  };
95
151
  }
96
152
  var encodePayload = (request) => [
@@ -101,7 +157,11 @@ var encodePayload = (request) => [
101
157
  normalizeSound(request.sound),
102
158
  // The sixth field, empty when there is no request id: an absent id and an
103
159
  // empty one mean the same thing, and the field count never changes.
104
- safeRequestId(request.requestID) ?? ""
160
+ safeRequestId(request.requestID) ?? "",
161
+ // The seventh field, the correlation key, empty when there is none. The
162
+ // `close:` marker of a close request is part of the key and travels with
163
+ // it, so the far end closes instead of raising.
164
+ wireCorrelationKey(request.correlationKey)
105
165
  ].join(FIELD_SEPARATOR);
106
166
  var SSH_OPTS = [
107
167
  "-o",
@@ -121,11 +181,16 @@ var CONTROL_OPTS = [
121
181
  "-o",
122
182
  "ControlPersist=300",
123
183
  "-o",
124
- `ControlPath=${join2(homedir(), ".ssh", "lacode-cm-%C")}`
184
+ `ControlPath=${join4(homedir3(), ".ssh", "lacode-cm-%C")}`
125
185
  ];
126
- var run = (command2, args, timeout) => {
186
+ var run = (command2, args, timeout, extraEnv) => {
127
187
  try {
128
- const result = spawnSync(command2, args, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
188
+ const result = spawnSync(command2, args, {
189
+ timeout,
190
+ encoding: "utf8",
191
+ stdio: ["ignore", "pipe", "pipe"],
192
+ ...extraEnv ? { env: { ...process.env, ...extraEnv } } : {}
193
+ });
129
194
  return {
130
195
  ok: result.status === 0,
131
196
  stdout: result.stdout ?? "",
@@ -185,20 +250,104 @@ var NO_CHANNEL = {
185
250
  darwin: "neither osascript nor terminal-notifier is installed"
186
251
  };
187
252
  var NEVER_EXPIRE_FLAG = "--expire-time=0";
253
+ var APP_NAME_FLAG = "--app-name=LaCode";
188
254
  var NOTIFICATION_ACTIONS = [
189
255
  ["once", "Allow once"],
190
256
  ["always", "Always allow"],
191
257
  ["reject", "Deny"]
192
258
  ];
193
259
  var ACTION_WAIT_MS = 10 * 6e4;
260
+ var isNotificationId = (id) => /^\d+$/.test(id);
261
+ var recordNotificationId = (correlationKey, id) => {
262
+ if (!isNotificationId(id)) return false;
263
+ try {
264
+ mkdirSync3(notificationStateDir(), { recursive: true });
265
+ appendFileSync(notificationIdFile(correlationKey), `${id}
266
+ `, "utf8");
267
+ return true;
268
+ } catch {
269
+ return false;
270
+ }
271
+ };
272
+ var sessionBusEnv = () => {
273
+ if (process.env.DBUS_SESSION_BUS_ADDRESS) return {};
274
+ const uid = typeof process.getuid === "function" ? process.getuid() : void 0;
275
+ return uid === void 0 ? {} : { DBUS_SESSION_BUS_ADDRESS: `unix:path=/run/user/${uid}/bus` };
276
+ };
277
+ var CLOSE_TIMEOUT_MS = 5e3;
278
+ function closeNotification(id) {
279
+ if (!isNotificationId(id)) return false;
280
+ const env = sessionBusEnv();
281
+ const viaGdbus = run(
282
+ "gdbus",
283
+ [
284
+ "call",
285
+ "--session",
286
+ "--dest",
287
+ "org.freedesktop.Notifications",
288
+ "--object-path",
289
+ "/org/freedesktop/Notifications",
290
+ "--method",
291
+ "org.freedesktop.Notifications.CloseNotification",
292
+ id
293
+ ],
294
+ CLOSE_TIMEOUT_MS,
295
+ env
296
+ );
297
+ if (viaGdbus.ok) return true;
298
+ const viaDbusSend = run(
299
+ "dbus-send",
300
+ [
301
+ "--session",
302
+ "--dest=org.freedesktop.Notifications",
303
+ "--type=method_call",
304
+ "/org/freedesktop/Notifications",
305
+ "org.freedesktop.Notifications.CloseNotification",
306
+ `uint32:${id}`
307
+ ],
308
+ CLOSE_TIMEOUT_MS,
309
+ env
310
+ );
311
+ return viaDbusSend.ok;
312
+ }
313
+ function closeCorrelationKey(correlationKey) {
314
+ const file = notificationIdFile(correlationKey);
315
+ let content = "";
316
+ try {
317
+ content = readFileSync4(file, "utf8");
318
+ } catch {
319
+ return true;
320
+ }
321
+ let closed = true;
322
+ for (const line of content.split("\n")) {
323
+ const id = line.trim();
324
+ if (!isNotificationId(id)) continue;
325
+ if (!closeNotification(id)) closed = false;
326
+ }
327
+ try {
328
+ unlinkSync3(file);
329
+ } catch {
330
+ }
331
+ return closed;
332
+ }
333
+ var splitNotifyOutput = (stdout) => {
334
+ const newline = stdout.indexOf("\n");
335
+ if (newline === -1) return { id: stdout.trim(), action: "" };
336
+ return { id: stdout.slice(0, newline).trim(), action: stdout.slice(newline + 1).trim() };
337
+ };
194
338
  function sendLocal(request, platform = process.platform) {
339
+ const closing = closeRequestTarget(request.correlationKey);
340
+ if (closing !== void 0) {
341
+ const closed = closeCorrelationKey(closing);
342
+ return { ok: closed, detail: closed ? void 0 : "a popup could not be closed", transport: "local" };
343
+ }
195
344
  const sound = normalizeSound(request.sound);
196
345
  const playSound = () => {
197
346
  if (sound === "none") return;
198
347
  const channel2 = soundChannel(platform);
199
348
  if (channel2 === "none") return;
200
- const path = join2(PACKAGE_ROOT, "sounds", `${sound}.wav`);
201
- if (!existsSync(path)) return;
349
+ const path = join4(PACKAGE_ROOT, "sounds", `${sound}.wav`);
350
+ if (!existsSync2(path)) return;
202
351
  const child = spawn(channel2, [path], { detached: true, stdio: "ignore" });
203
352
  child.on("error", () => {
204
353
  });
@@ -214,13 +363,17 @@ function sendLocal(request, platform = process.platform) {
214
363
  const requestID = safeRequestId(request.requestID);
215
364
  const actionable = channel === "notify-send" && requestID !== void 0;
216
365
  const actionArgs = actionable ? NOTIFICATION_ACTIONS.map(([name, label]) => `--action=${name}=${label}`) : [];
366
+ const correlationKey = presentCorrelationKey(request.correlationKey);
367
+ const trackable = channel === "notify-send" && correlationKey !== void 0;
217
368
  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)] : (
218
369
  // Never auto-dismissed: the notification waits for the user to close it.
219
- [title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`, NEVER_EXPIRE_FLAG, ...actionArgs]
370
+ [title, body, APP_NAME_FLAG, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`, NEVER_EXPIRE_FLAG, ...trackable ? ["--print-id"] : [], ...actionArgs]
220
371
  );
221
372
  const result = run(CHANNEL_BINARY[channel], args, actionable ? ACTION_WAIT_MS : 5e3);
222
373
  playSound();
223
- const action = actionable ? result.stdout.trim() : "";
374
+ const printed = trackable ? splitNotifyOutput(result.stdout) : { id: "", action: result.stdout.trim() };
375
+ const action = actionable ? printed.action : "";
376
+ if (correlationKey !== void 0 && printed.id) recordNotificationId(correlationKey, printed.id);
224
377
  return {
225
378
  ok: result.ok,
226
379
  detail: result.ok ? void 0 : result.stderr || result.detail,
@@ -269,7 +422,17 @@ function sendRemote(request, tunnel) {
269
422
  }
270
423
  function notify(request, env = process.env, platform = process.platform) {
271
424
  try {
272
- const result = isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
425
+ const closeKey = (env[NOTIFY_CLOSE_ENV] ?? "").trim();
426
+ if (closeKey) {
427
+ if (!isRemote(env)) {
428
+ const closed = closeCorrelationKey(closeKey);
429
+ return { ok: closed, detail: closed ? void 0 : "a popup could not be closed", transport: "local" };
430
+ }
431
+ return sendRemote({ ...request, correlationKey: `${CLOSE_REQUEST_PREFIX}${closeKey}` }, resolveTunnel(env));
432
+ }
433
+ const sessionKey = presentCorrelationKey(env[NOTIFY_SESSION_ENV]);
434
+ const tracked = sessionKey ? { ...request, correlationKey: request.correlationKey ?? sessionKey } : request;
435
+ const result = isRemote(env) ? sendRemote(tracked, resolveTunnel(env)) : sendLocal(tracked, platform);
273
436
  return result.action ? { ...result, action: result.action } : result;
274
437
  } catch (error) {
275
438
  return { ok: false, detail: String(error), transport: "none" };
@@ -303,18 +466,18 @@ function describe(env = process.env, platform = process.platform) {
303
466
 
304
467
  // src/lib/selfupdate.ts
305
468
  import { spawn as spawn2 } from "node:child_process";
306
- import { closeSync, mkdirSync, openSync, readFileSync as readFileSync2, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
307
- import { homedir as homedir2 } from "node:os";
308
- import { dirname as dirname2, join as join3, resolve } from "node:path";
469
+ import { closeSync as closeSync2, mkdirSync as mkdirSync4, openSync as openSync2, readFileSync as readFileSync5, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync4, writeFileSync as writeFileSync2, writeSync as writeSync2 } from "node:fs";
470
+ import { homedir as homedir4 } from "node:os";
471
+ import { dirname as dirname2, join as join5, resolve } from "node:path";
309
472
  var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
310
473
  var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
311
474
  var FETCH_TIMEOUT_MS = 4e3;
312
475
  var INSTALL_TIMEOUT_MS = 12e4;
313
476
  var LOCK_STALE_MS = 10 * 60 * 1e3;
314
477
  var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
315
- var STATE_DIR = join3(homedir2(), ".local", "share", "opencode", "lacode");
316
- var STATE_PATH = join3(STATE_DIR, "selfupdate.json");
317
- var LOCK_PATH = join3(STATE_DIR, "selfupdate.lock");
478
+ var STATE_DIR = join5(homedir4(), ".local", "share", "opencode", "lacode");
479
+ var STATE_PATH = join5(STATE_DIR, "selfupdate.json");
480
+ var LOCK_PATH = join5(STATE_DIR, "selfupdate.lock");
318
481
  function compareSemver(a, b) {
319
482
  const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
320
483
  const left = parse(a);
@@ -328,13 +491,13 @@ function compareSemver(a, b) {
328
491
  }
329
492
  function readInstalledVersion(packageRoot, read = (path) => {
330
493
  try {
331
- return readFileSync2(path, "utf8");
494
+ return readFileSync5(path, "utf8");
332
495
  } catch {
333
496
  return void 0;
334
497
  }
335
498
  }) {
336
499
  try {
337
- const raw = read(join3(packageRoot, "package.json"));
500
+ const raw = read(join5(packageRoot, "package.json"));
338
501
  if (raw === void 0) return void 0;
339
502
  const manifest = JSON.parse(raw);
340
503
  if (manifest.name !== PACKAGE_NAME) return void 0;
@@ -345,20 +508,20 @@ function readInstalledVersion(packageRoot, read = (path) => {
345
508
  }
346
509
  function resolveCacheRoot(packageRoot, exists = (p) => {
347
510
  try {
348
- return statSync(p).isFile();
511
+ return statSync3(p).isFile();
349
512
  } catch {
350
513
  return false;
351
514
  }
352
515
  }) {
353
- const tail = join3("node_modules", "@lawaty", "lacode");
516
+ const tail = join5("node_modules", "@lawaty", "lacode");
354
517
  if (!packageRoot.endsWith(tail)) return void 0;
355
518
  const cacheRoot = resolve(packageRoot, "..", "..", "..");
356
- if (!exists(join3(cacheRoot, "package.json"))) return void 0;
519
+ if (!exists(join5(cacheRoot, "package.json"))) return void 0;
357
520
  return cacheRoot;
358
521
  }
359
522
  function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
360
523
  try {
361
- return statSync(p).isFile();
524
+ return statSync3(p).isFile();
362
525
  } catch {
363
526
  return false;
364
527
  }
@@ -366,10 +529,10 @@ function resolveOpencodeBin(execPath = process.execPath, env = process.env, exis
366
529
  if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
367
530
  for (const dir of (env.PATH ?? "").split(":")) {
368
531
  if (!dir) continue;
369
- const candidate = join3(dir, "opencode");
532
+ const candidate = join5(dir, "opencode");
370
533
  if (exists(candidate)) return candidate;
371
534
  }
372
- const fallback = join3(homedir2(), ".opencode", "bin", "opencode");
535
+ const fallback = join5(homedir4(), ".opencode", "bin", "opencode");
373
536
  return exists(fallback) ? fallback : void 0;
374
537
  }
375
538
  async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
@@ -401,11 +564,11 @@ async function refreshCache(opts) {
401
564
  const { cacheRoot, bin, latest } = opts;
402
565
  const spawnImpl = opts.spawnImpl ?? spawn2;
403
566
  const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
404
- const fresh = join3(cacheRoot, "node_modules", "@lawaty", "lacode");
567
+ const fresh = join5(cacheRoot, "node_modules", "@lawaty", "lacode");
405
568
  const aside = `${cacheRoot}.stale-${Date.now()}`;
406
569
  try {
407
570
  rmSync(aside, { recursive: true, force: true });
408
- renameSync(cacheRoot, aside);
571
+ renameSync2(cacheRoot, aside);
409
572
  } catch (error) {
410
573
  return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
411
574
  }
@@ -440,7 +603,7 @@ async function refreshCache(opts) {
440
603
  }
441
604
  } else {
442
605
  try {
443
- renameSync(aside, cacheRoot);
606
+ renameSync2(aside, cacheRoot);
444
607
  } catch {
445
608
  }
446
609
  }
@@ -452,9 +615,9 @@ async function refreshCache(opts) {
452
615
 
453
616
  // src/lib/version.ts
454
617
  import { spawnSync as spawnSync2 } from "node:child_process";
455
- import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync, statSync as statSync2 } from "node:fs";
456
- import { homedir as homedir3 } from "node:os";
457
- import { isAbsolute, join as join4, relative, sep } from "node:path";
618
+ import { existsSync as existsSync3, readFileSync as readFileSync6, readdirSync as readdirSync2, statSync as statSync4 } from "node:fs";
619
+ import { homedir as homedir5 } from "node:os";
620
+ import { isAbsolute, join as join6, relative, sep } from "node:path";
458
621
  import { fileURLToPath as fileURLToPath2 } from "node:url";
459
622
 
460
623
  // src/lib/jsonc.ts
@@ -507,15 +670,15 @@ var parseJsonc = (raw) => {
507
670
  // src/lib/version.ts
508
671
  var nodeStat = (path) => {
509
672
  try {
510
- return statSync2(path).mtimeMs;
673
+ return statSync4(path).mtimeMs;
511
674
  } catch {
512
675
  return void 0;
513
676
  }
514
677
  };
515
- var nodeExists = (path) => existsSync2(path);
678
+ var nodeExists = (path) => existsSync3(path);
516
679
  var nodeRead = (path) => {
517
680
  try {
518
- return readFileSync3(path, "utf8");
681
+ return readFileSync6(path, "utf8");
519
682
  } catch {
520
683
  return void 0;
521
684
  }
@@ -531,17 +694,17 @@ var realGit = (root) => (args) => {
531
694
  };
532
695
  var walkSources = (root) => {
533
696
  const found = [];
534
- const stack = [join4(root, "src")];
697
+ const stack = [join6(root, "src")];
535
698
  while (stack.length > 0) {
536
699
  const dir = stack.pop();
537
700
  let names;
538
701
  try {
539
- names = readdirSync(dir);
702
+ names = readdirSync2(dir);
540
703
  } catch {
541
704
  continue;
542
705
  }
543
706
  for (const name of names) {
544
- const full = join4(dir, name);
707
+ const full = join6(dir, name);
545
708
  if (name.endsWith(".ts")) found.push(full);
546
709
  else if (name === "plugins" || name === "lib" || name === "tunnel") stack.push(full);
547
710
  }
@@ -549,8 +712,8 @@ var walkSources = (root) => {
549
712
  return found;
550
713
  };
551
714
  var checkDist = (root, deps) => {
552
- if (!deps.exists(join4(root, "dist"))) return { dist: "unknown" };
553
- const built = deps.stat(join4(root, "dist", "index.js"));
715
+ if (!deps.exists(join6(root, "dist"))) return { dist: "unknown" };
716
+ const built = deps.stat(join6(root, "dist", "index.js"));
554
717
  if (built === void 0) return { dist: "unknown" };
555
718
  const sources = deps.listSources(root).map((file) => ({ file, mtime: deps.stat(file) }));
556
719
  const newer = sources.filter((s) => s.mtime !== void 0 && s.mtime > built).map((s) => relative(root, s.file));
@@ -591,13 +754,13 @@ var resolvesHere = (specifier, packageRoot) => {
591
754
  };
592
755
  function readVersionReport(deps = {}) {
593
756
  const packageRoot = deps.packageRoot ?? PACKAGE_ROOT;
594
- const home = deps.home ?? homedir3();
757
+ const home = deps.home ?? homedir5();
595
758
  const git = deps.git ?? realGit(packageRoot);
596
759
  const readFile = deps.readFile ?? nodeRead;
597
760
  const stat = deps.stat ?? nodeStat;
598
761
  const exists = deps.exists ?? nodeExists;
599
762
  const listSources = deps.listSources ?? walkSources;
600
- const isCheckout = exists(join4(packageRoot, ".git"));
763
+ const isCheckout = exists(join6(packageRoot, ".git"));
601
764
  const report = {
602
765
  version: readInstalledVersion(packageRoot, readFile),
603
766
  root: packageRoot,
@@ -610,11 +773,11 @@ function readVersionReport(deps = {}) {
610
773
  const dist = checkDist(packageRoot, { stat, exists, listSources });
611
774
  report.dist = dist.dist;
612
775
  if (dist.stale) report.staleSources = dist.stale;
613
- const configPath = [join4(home, ".config", "opencode", "opencode.jsonc"), join4(home, ".config", "opencode", "opencode.json")].find(
776
+ const configPath = [join6(home, ".config", "opencode", "opencode.jsonc"), join6(home, ".config", "opencode", "opencode.json")].find(
614
777
  (candidate) => exists(candidate)
615
778
  );
616
779
  if (!configPath) {
617
- report.note = `No opencode config found under ${join4(home, ".config", "opencode")}; nothing to compare this copy against.`;
780
+ report.note = `No opencode config found under ${join6(home, ".config", "opencode")}; nothing to compare this copy against.`;
618
781
  return report;
619
782
  }
620
783
  report.configPath = configPath;
@@ -836,7 +999,7 @@ ${report.note}`);
836
999
  }
837
1000
  const { user, host, key } = resolveTunnel();
838
1001
  const pub = `${key}.pub`;
839
- if (!existsSync3(key)) {
1002
+ if (!existsSync4(key)) {
840
1003
  const gen = spawnSync3("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", "lacode-notify", "-f", key], { stdio: ["ignore", "ignore", "inherit"] });
841
1004
  if (gen.status !== 0) {
842
1005
  console.error("lacode tunnel setup: ssh-keygen failed; create the key by hand and re-run.");
@@ -844,7 +1007,7 @@ ${report.note}`);
844
1007
  }
845
1008
  console.log(`Generated ${key}`);
846
1009
  }
847
- const pubkey = existsSync3(pub) ? readFileSync4(pub, "utf8").trim() : "(public key missing)";
1010
+ const pubkey = existsSync4(pub) ? readFileSync7(pub, "utf8").trim() : "(public key missing)";
848
1011
  const self = process.argv[1] ?? "lacode";
849
1012
  console.log("\nOn the REMOTE host (where opencode runs), export:");
850
1013
  console.log(` OC_LOCAL_USER=${user || "<desktop-user>"}`);
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,