@lawaty/lacode 2.3.0-beta.3 → 2.3.0-beta.5

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 CHANGED
@@ -9,6 +9,29 @@ commit as the changelog entry it describes.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [2.3.0-beta.5] — bench in place, cheap recovery, visible slots
13
+
14
+ ### Added
15
+
16
+ - Pool: selection retains current pool members unless the run proved them hard-dead (gone, credential, agreement, credit). Head and tail are never vacated by a failed scan; a healthy-but-capped model is still decided by the caps, and a catalogue removal on a clean scan vacates the slot.
17
+ - Pool: the refresh benches kept-but-transiently-failed members in place with a flat `scan-kept` cooling record (never a strike) and clears stale benches for members that answered. The runtime re-admits a benched model on the first answering probe.
18
+ - Pool: degraded retries run a canary over the current members first (`--canary`) and scan fully only when one stops answering. The scorer learned `--probe-models`, `--deprioritize-providers`, `--current-pool`, and `--out` to support it.
19
+ - Pool: the status page is now hint-only. Sick providers probe last; nothing is ever skipped or benched because of the page.
20
+ - Pool: `pool_status` shows each cooling slot's absolute expiry, the structural verdict (head/tail/count vs the 10-slot contract), the last refresh age, and the live status hint.
21
+
22
+ ### Changed
23
+
24
+ - Pool: per-request probe deadline is 12 seconds (was 45); `--timeout MS` still overrides it.
25
+
26
+ ## [2.3.0-beta.4] — status never benches, structure is the contract
27
+
28
+ ### Fixed
29
+
30
+ - Pool: only a hard outage (a gating `go`/`free` service reporting `down`) with a structurally intact pool may skip a refresh scan. A mere `degraded` never skips, because models routinely answer while the status page says degraded. Paid-tier (`payg`) and roll-up (`inference`) components can no longer freeze the free pool.
31
+ - Pool: the fail-closed guard now enforces structure, not just count. A selection that drops the opencode-go head or the opencode tail is refused even at full count, and the keep threshold is anchored to the contract pool size (10) instead of the current file, so repeated writes can no longer ratchet the pool down. `--force-write` overrides the guard for a genuinely changed catalogue.
32
+ - Pool: per-request probe deadline is 12 seconds (was 45), so a flapping provider stretches a scan far less; `--timeout MS` still overrides it.
33
+ - Pool: a failed scorer run or a refused write now schedules a 5-minute recheck like every other failed run, instead of waiting for the next 6-hour slot.
34
+
12
35
  ## [2.3.0-beta.3] — persist autonomy modes in global config
13
36
 
14
37
  ### Changed
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
39
39
  var UNAVAILABLE_REASON = "endpoint-unavailable";
40
40
  var PROVIDER_DEGRADED_REASON = "provider-degraded";
41
41
  var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
42
+ var SCAN_KEPT_REASON = "scan-kept";
42
43
  var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
43
44
  var PROVIDER_DEGRADED_MIN_MODELS = 2;
44
45
  var TRANSIENT_DEGRADATION_REASONS = /^(?:no answer|no-answer|hang|timeout|timed out|endpoint-unavailable|provider-degraded|empty response|network|server)$/i;
@@ -695,7 +696,7 @@ function readLimits(s) {
695
696
  try {
696
697
  const strike = JSON.parse(readFileSync2(path4, "utf8"));
697
698
  if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
698
- const countsAsStrike = strike.reason !== UNAVAILABLE_REASON;
699
+ const countsAsStrike = strike.reason !== UNAVAILABLE_REASON && strike.reason !== SCAN_KEPT_REASON;
699
700
  if (strike.until > now) {
700
701
  cooling.set(strike.model, strike);
701
702
  if (countsAsStrike) memory.set(strike.model, strike);
@@ -2527,7 +2528,9 @@ async function maybeSelfUpdate(opts) {
2527
2528
  }
2528
2529
 
2529
2530
  // src/plugins/agent-pool.ts
2530
- import { unwatchFile, watchFile } from "node:fs";
2531
+ import { readFileSync as readFileSync5, unwatchFile, watchFile } from "node:fs";
2532
+ import { homedir as homedir4 } from "node:os";
2533
+ import { join as join6 } from "node:path";
2531
2534
  import { tool } from "@opencode-ai/plugin";
2532
2535
 
2533
2536
  // src/lib/probe.ts
@@ -3179,6 +3182,43 @@ function estimateContextTokens(prompt) {
3179
3182
  if (typeof prompt !== "string" || prompt.length === 0) return void 0;
3180
3183
  return Math.ceil(prompt.length / 4) + CONTEXT_HEADROOM_TOKENS;
3181
3184
  }
3185
+ var poolHealthLines = (slots, now) => {
3186
+ const lines = [];
3187
+ const heads = slots.filter((slot) => slot.model.startsWith("opencode-go/"));
3188
+ const headOk = heads.length > 0 && slots.slice(0, heads.length).every((slot) => slot.model.startsWith("opencode-go/"));
3189
+ const tail = slots[slots.length - 1];
3190
+ const tailOk = !!tail && tail.model.startsWith("opencode/") && tail.bucket === "opencode";
3191
+ const verdict = headOk && tailOk ? "intact" : "BROKEN";
3192
+ lines.push(
3193
+ `pool structure: ${verdict} (${slots.length}/10 slots, head=${headOk ? `${heads.length} opencode-go` : "missing"}, tail=${tailOk ? tail.model : "missing"})`
3194
+ );
3195
+ try {
3196
+ const text = readFileSync5(join6(homedir4(), ".config", "lacode", "pool.txt"), "utf8");
3197
+ const stamp = /^#\s*Refreshed\s+(\S+)/m.exec(text)?.[1];
3198
+ if (stamp) {
3199
+ const ageMs = now - Date.parse(stamp);
3200
+ if (Number.isFinite(ageMs) && ageMs >= 0) {
3201
+ const mins = Math.floor(ageMs / 6e4);
3202
+ lines.push(`last refresh: ${mins < 60 ? `${mins}m ago` : `${Math.floor(mins / 60)}h${mins % 60}m ago`} (${stamp})`);
3203
+ }
3204
+ }
3205
+ } catch {
3206
+ }
3207
+ try {
3208
+ const raw = readFileSync5(join6(homedir4(), ".config", "lacode", "provider-status.json"), "utf8");
3209
+ const status = JSON.parse(raw);
3210
+ if (status && typeof status === "object") {
3211
+ const ageS = typeof status.checkedAt === "number" ? Math.max(0, Math.round((now - status.checkedAt) / 1e3)) : -1;
3212
+ const bad = (status.services ?? []).filter((entry) => String(entry?.status ?? "") !== "operational");
3213
+ const detail = bad.length > 0 ? `: ${bad.map((entry) => `${entry.service}=${entry.status}`).join(", ")}` : "";
3214
+ lines.push(
3215
+ `status page: ${status.ok === false ? "unreachable" : status.overall ?? "unknown"}${detail}${ageS >= 0 ? ` (checked ${ageS}s ago)` : ""}`
3216
+ );
3217
+ }
3218
+ } catch {
3219
+ }
3220
+ return lines;
3221
+ };
3182
3222
  var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3183
3223
  const opts = options ?? {};
3184
3224
  const notify = opts.notify ?? ((title, message) => void notifyDesktop(title, message));
@@ -4278,8 +4318,9 @@ ${detail}` : detail;
4278
4318
  const users = entry && entry.bases.size > 0 ? [...entry.bases].map(([b, n]) => `${b}x${n}`).join(" ") : "idle";
4279
4319
  const strike = snap.cooling.get(slot.model);
4280
4320
  const status = s.catalog.get(slot.model);
4321
+ const untilClock = strike ? new Date(strike.until).toISOString().slice(11, 19) + "Z" : "";
4281
4322
  const notes = [
4282
- strike ? `UNAVAILABLE (COOLING ${Math.ceil((strike.until - now) / 1e3)}s, ${strike.reason}, strike ${strike.strikes})` : "",
4323
+ strike ? `UNAVAILABLE (COOLING ${Math.ceil((strike.until - now) / 1e3)}s left, until ${untilClock}, ${strike.reason}, strike ${strike.strikes})` : "",
4283
4324
  !providerReady(s, slot.model) ? `UNAVAILABLE (provider-unconfigured: ${providerOf(slot.model)}; run \`opencode auth login ${providerOf(slot.model)}\`)` : "",
4284
4325
  status && status !== "active" && status !== "unknown" ? `catalog:${status}` : ""
4285
4326
  ].filter(Boolean);
@@ -4313,6 +4354,7 @@ one ordered pass in slot order: each slot fills to its weight, then overflow goe
4313
4354
  ...sharedBuckets.length ? [`buckets: ${sharedBuckets.map(([name, entry]) => `${name} ${entry.seats} seat(s)${entry.cooling ? " COOLING" : ""}`).join("; ")}`] : [],
4314
4355
  ...degradedProviders.length ? [`provider degraded: ${degradedProviders.join(", ")} (flat window; a probe re-admits a model when it answers)`] : [],
4315
4356
  ...slots.map(line),
4357
+ ...poolHealthLines(slots, now),
4316
4358
  "",
4317
4359
  "by process:",
4318
4360
  ` ${s.id} (this process): ${snap.own.size} claim(s)`,
@@ -4380,11 +4422,11 @@ one ordered pass in slot order: each slot fills to its weight, then overflow goe
4380
4422
 
4381
4423
  // src/plugins/context-autoupdate.ts
4382
4424
  import { existsSync as existsSync4, mkdirSync as mkdirSync6, readdirSync as readdirSync3, statSync as statSync6, unlinkSync as unlinkSync5, writeFileSync as writeFileSync7 } from "node:fs";
4383
- import { homedir as homedir5 } from "node:os";
4425
+ import { homedir as homedir6 } from "node:os";
4384
4426
  import * as path3 from "node:path";
4385
4427
 
4386
4428
  // src/lib/map-schema.ts
4387
- import { existsSync as existsSync3, readFileSync as readFileSync5, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
4429
+ import { existsSync as existsSync3, readFileSync as readFileSync6, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
4388
4430
  import * as path from "node:path";
4389
4431
  var PROVISIONAL_DAYS = 30;
4390
4432
  var MAP_SPECS = [
@@ -4537,7 +4579,7 @@ var resolvePointer = (root, pointer, section) => {
4537
4579
  if (!stat.isFile()) return { exists: false };
4538
4580
  let text;
4539
4581
  try {
4540
- if (stat.size <= 512 * 1024) text = readFileSync5(absolute, "utf8");
4582
+ if (stat.size <= 512 * 1024) text = readFileSync6(absolute, "utf8");
4541
4583
  } catch {
4542
4584
  text = void 0;
4543
4585
  }
@@ -4786,7 +4828,7 @@ var maintainMap = (root, mapDir, now = Date.now()) => {
4786
4828
  if (!existsSync3(absolute)) continue;
4787
4829
  let before;
4788
4830
  try {
4789
- before = readFileSync5(absolute, "utf8");
4831
+ before = readFileSync6(absolute, "utf8");
4790
4832
  } catch {
4791
4833
  continue;
4792
4834
  }
@@ -4814,7 +4856,7 @@ var auditMap = (root, mapDir, now = Date.now()) => {
4814
4856
  }
4815
4857
  let text;
4816
4858
  try {
4817
- text = readFileSync5(absolute, "utf8");
4859
+ text = readFileSync6(absolute, "utf8");
4818
4860
  } catch {
4819
4861
  violations.push({ file: spec.file, kind: "missing-file", detail: "unreadable" });
4820
4862
  continue;
@@ -4893,7 +4935,7 @@ var parseJsonc = (raw) => {
4893
4935
  };
4894
4936
 
4895
4937
  // src/lib/writer-rule.ts
4896
- import { readFileSync as readFileSync6 } from "node:fs";
4938
+ import { readFileSync as readFileSync7 } from "node:fs";
4897
4939
  import * as path2 from "node:path";
4898
4940
  var CONTEXT_DIR = path2.join(".opencode", "context");
4899
4941
  var WRITER = "context";
@@ -4933,7 +4975,7 @@ var agentsForm = (mapDir, projectRoot) => {
4933
4975
  var verifyWriterRule = (configPath, mapDir, projectRoot) => {
4934
4976
  let raw;
4935
4977
  try {
4936
- raw = readFileSync6(configPath, "utf8");
4978
+ raw = readFileSync7(configPath, "utf8");
4937
4979
  } catch {
4938
4980
  return void 0;
4939
4981
  }
@@ -4978,25 +5020,25 @@ var verifyWriterRule = (configPath, mapDir, projectRoot) => {
4978
5020
  };
4979
5021
 
4980
5022
  // src/lib/timeline.ts
4981
- import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync5, readdirSync as readdirSync2, readFileSync as readFileSync7, renameSync as renameSync4, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "node:fs";
4982
- import { homedir as homedir4 } from "node:os";
4983
- import { dirname as dirname3, join as join8, resolve as resolve3 } from "node:path";
5023
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync5, readdirSync as readdirSync2, readFileSync as readFileSync8, renameSync as renameSync4, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "node:fs";
5024
+ import { homedir as homedir5 } from "node:os";
5025
+ import { dirname as dirname3, join as join9, resolve as resolve3 } from "node:path";
4984
5026
  var TIMELINE_STATE_VERSION = 1;
4985
5027
  function timelineDir() {
4986
5028
  const env = process.env.LACODE_TIMELINE_DIR;
4987
5029
  if (env) return resolve3(env);
4988
- return join8(homedir4(), ".local/share/opencode/lacode/timeline");
5030
+ return join9(homedir5(), ".local/share/opencode/lacode/timeline");
4989
5031
  }
4990
5032
  function assertValidSessionID(sessionID) {
4991
5033
  if (!/^[A-Za-z0-9_.-]+$/.test(sessionID)) throw new Error("invalid sessionID");
4992
5034
  }
4993
5035
  function timelineFile(sessionID) {
4994
5036
  assertValidSessionID(sessionID);
4995
- return join8(timelineDir(), sessionID + ".jsonl");
5037
+ return join9(timelineDir(), sessionID + ".jsonl");
4996
5038
  }
4997
5039
  function timelineStateFile(sessionID) {
4998
5040
  assertValidSessionID(sessionID);
4999
- return join8(timelineDir(), sessionID + ".state.json");
5041
+ return join9(timelineDir(), sessionID + ".state.json");
5000
5042
  }
5001
5043
  function normalizeThrough(through) {
5002
5044
  if (!through || typeof through !== "object") return void 0;
@@ -5043,7 +5085,7 @@ function appendEntriesAt(file, texts, now, through) {
5043
5085
  function readEntriesAt(file, limit) {
5044
5086
  let raw = "";
5045
5087
  try {
5046
- raw = readFileSync7(file, "utf8");
5088
+ raw = readFileSync8(file, "utf8");
5047
5089
  } catch {
5048
5090
  return [];
5049
5091
  }
@@ -5096,7 +5138,7 @@ function normalizePersistedState(raw) {
5096
5138
  }
5097
5139
  function readTimelineState(sessionID) {
5098
5140
  try {
5099
- const raw = readFileSync7(timelineStateFile(sessionID), "utf8");
5141
+ const raw = readFileSync8(timelineStateFile(sessionID), "utf8");
5100
5142
  return normalizePersistedState(JSON.parse(raw));
5101
5143
  } catch {
5102
5144
  return void 0;
@@ -5522,7 +5564,7 @@ var changedSince = (before, dir) => {
5522
5564
  return changed;
5523
5565
  };
5524
5566
  var RETAIN_TRANSCRIPTS = 20;
5525
- var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path3.join(homedir5(), ".local", "share", "opencode", "lacode", "cartographer");
5567
+ var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path3.join(homedir6(), ".local", "share", "opencode", "lacode", "cartographer");
5526
5568
  var retireSession = async (client, directory, sessionID, mode, log) => {
5527
5569
  try {
5528
5570
  const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
@@ -5778,15 +5820,15 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
5778
5820
  });
5779
5821
 
5780
5822
  // src/lib/feedback.ts
5781
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync8, renameSync as renameSync5, unlinkSync as unlinkSync6, writeFileSync as writeFileSync8 } from "node:fs";
5782
- import { homedir as homedir6 } from "node:os";
5783
- import { dirname as dirname5, join as join10 } from "node:path";
5823
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync9, renameSync as renameSync5, unlinkSync as unlinkSync6, writeFileSync as writeFileSync8 } from "node:fs";
5824
+ import { homedir as homedir7 } from "node:os";
5825
+ import { dirname as dirname5, join as join11 } from "node:path";
5784
5826
  var FEEDBACK_HINT_ENV = "LACODE_NO_FEEDBACK_HINT";
5785
5827
  var FEEDBACK_HINT = "Enjoying lacode? Bugs, ideas, or a quick note on how you use it: github.com/lawaty/lacode/issues (a \u2B50 on the repo helps others find it)";
5786
- var feedbackStatePath = () => join10(homedir6(), ".local", "share", "opencode", "lacode", "feedback.json");
5828
+ var feedbackStatePath = () => join11(homedir7(), ".local", "share", "opencode", "lacode", "feedback.json");
5787
5829
  function readFeedbackState(path4 = feedbackStatePath()) {
5788
5830
  try {
5789
- const parsed = JSON.parse(readFileSync8(path4, "utf8"));
5831
+ const parsed = JSON.parse(readFileSync9(path4, "utf8"));
5790
5832
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
5791
5833
  return parsed;
5792
5834
  } catch (error) {
@@ -5860,9 +5902,9 @@ async function feedbackHooks(input, options, deps = {}) {
5860
5902
 
5861
5903
  // src/plugins/image-paste.ts
5862
5904
  import { createHash, randomUUID } from "node:crypto";
5863
- import { globSync, mkdirSync as mkdirSync8, readFileSync as readFileSync9, renameSync as renameSync6, statSync as statSync7, writeFileSync as writeFileSync9 } from "node:fs";
5864
- import { homedir as homedir7 } from "node:os";
5865
- import { basename as basename3, join as join11 } from "node:path";
5905
+ import { globSync, mkdirSync as mkdirSync8, readFileSync as readFileSync10, renameSync as renameSync6, statSync as statSync7, writeFileSync as writeFileSync9 } from "node:fs";
5906
+ import { homedir as homedir8 } from "node:os";
5907
+ import { basename as basename3, join as join12 } from "node:path";
5866
5908
 
5867
5909
  // src/lib/clipboard.ts
5868
5910
  import { spawnSync } from "node:child_process";
@@ -6012,8 +6054,8 @@ function splitArguments(input) {
6012
6054
  return out;
6013
6055
  }
6014
6056
  function expandTilde(p) {
6015
- if (p === "~") return homedir7();
6016
- if (p.startsWith("~/")) return join11(homedir7(), p.slice(2));
6057
+ if (p === "~") return homedir8();
6058
+ if (p.startsWith("~/")) return join12(homedir8(), p.slice(2));
6017
6059
  return p;
6018
6060
  }
6019
6061
  function expandGlob(p) {
@@ -6029,7 +6071,7 @@ function storeImage(bytes, dir, sessionID, label) {
6029
6071
  if (kind === null) return null;
6030
6072
  const hash = createHash("sha256").update(bytes).digest("hex");
6031
6073
  mkdirSync8(dir, { recursive: true });
6032
- const target = join11(dir, `${hash}.${kind.ext}`);
6074
+ const target = join12(dir, `${hash}.${kind.ext}`);
6033
6075
  const tmp = `${target}.tmp`;
6034
6076
  writeFileSync9(tmp, bytes, { mode: 384 });
6035
6077
  renameSync6(tmp, target);
@@ -6060,7 +6102,7 @@ async function imagePasteHooks(input, options, deps) {
6060
6102
  if (opts.enabled === false) return {};
6061
6103
  const maxBytes = resolveMaxBytes(opts);
6062
6104
  const maxImages = resolveMaxImages(opts);
6063
- const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ?? join11(homedir7(), ".local/share/opencode/lacode/images");
6105
+ const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ?? join12(homedir8(), ".local/share/opencode/lacode/images");
6064
6106
  const readClipboard = (deps ?? {}).readClipboard ?? readClipboardImage;
6065
6107
  return {
6066
6108
  "command.execute.before": async (cmd, output) => {
@@ -6117,7 +6159,7 @@ async function imagePasteHooks(input, options, deps) {
6117
6159
  }
6118
6160
  let bytes;
6119
6161
  try {
6120
- bytes = readFileSync9(filePath);
6162
+ bytes = readFileSync10(filePath);
6121
6163
  } catch {
6122
6164
  skipped.push(name);
6123
6165
  continue;
@@ -6168,14 +6210,14 @@ async function imagePasteHooks(input, options, deps) {
6168
6210
  }
6169
6211
 
6170
6212
  // src/lib/opencode-config.ts
6171
- import { existsSync as existsSync5, mkdirSync as mkdirSync9, readFileSync as readFileSync10, renameSync as renameSync7, writeFileSync as writeFileSync10 } from "node:fs";
6172
- import { homedir as homedir8 } from "node:os";
6173
- import { dirname as dirname6, join as join12 } from "node:path";
6213
+ import { existsSync as existsSync5, mkdirSync as mkdirSync9, readFileSync as readFileSync11, renameSync as renameSync7, writeFileSync as writeFileSync10 } from "node:fs";
6214
+ import { homedir as homedir9 } from "node:os";
6215
+ import { dirname as dirname6, join as join13 } from "node:path";
6174
6216
  import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
6175
6217
  function globalOpencodeConfigPath() {
6176
- const base = process.env.XDG_CONFIG_HOME ?? join12(homedir8(), ".config");
6177
- const jsonc = join12(base, "opencode", "opencode.jsonc");
6178
- return existsSync5(jsonc) ? jsonc : join12(base, "opencode", "opencode.json");
6218
+ const base = process.env.XDG_CONFIG_HOME ?? join13(homedir9(), ".config");
6219
+ const jsonc = join13(base, "opencode", "opencode.jsonc");
6220
+ return existsSync5(jsonc) ? jsonc : join13(base, "opencode", "opencode.json");
6179
6221
  }
6180
6222
  var OpenCodeConfigError = class extends Error {
6181
6223
  constructor(message) {
@@ -6188,7 +6230,7 @@ function isPlainObject(value) {
6188
6230
  }
6189
6231
  function readOpencodeConfig(path4 = globalOpencodeConfigPath()) {
6190
6232
  if (!existsSync5(path4)) return {};
6191
- const text = readFileSync10(path4, "utf8");
6233
+ const text = readFileSync11(path4, "utf8");
6192
6234
  const errors = [];
6193
6235
  const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
6194
6236
  if (errors.length > 0) {
@@ -7424,7 +7466,7 @@ async function selfUpdateHooks(input, options) {
7424
7466
 
7425
7467
  // src/plugins/timeline.ts
7426
7468
  import { existsSync as existsSync6, writeFileSync as writeFileSync11, renameSync as renameSync8, rmSync as rmSync3 } from "node:fs";
7427
- import { join as join13 } from "node:path";
7469
+ import { join as join14 } from "node:path";
7428
7470
  import { tool as tool2 } from "@opencode-ai/plugin";
7429
7471
  var DEFAULT_INTERVAL_MS = 3e5;
7430
7472
  var MIN_INTERVAL_MS = 1e4;
@@ -7500,7 +7542,7 @@ function errorText(value) {
7500
7542
  }
7501
7543
  var MAX_TASK_CHILDREN_EXPANDED = 40;
7502
7544
  async function expandTaskSessions(client, messages, directory, owned, onError, cursor) {
7503
- const projectOwnsTooling = existsSync6(join13(directory, "src/plugins/timeline.ts"));
7545
+ const projectOwnsTooling = existsSync6(join14(directory, "src/plugins/timeline.ts"));
7504
7546
  let attempts = 0;
7505
7547
  const expandedMessages = [];
7506
7548
  let startIndex = 0;
@@ -8066,9 +8108,9 @@ function timelineHooks(input, options) {
8066
8108
 
8067
8109
  // src/lib/timer-migration.ts
8068
8110
  import { execFile } from "node:child_process";
8069
- import { existsSync as existsSync7, mkdirSync as mkdirSync10, readFileSync as readFileSync11, renameSync as renameSync9, writeFileSync as writeFileSync12 } from "node:fs";
8070
- import { homedir as homedir9 } from "node:os";
8071
- import { basename as basename4, dirname as dirname7, join as join14 } from "node:path";
8111
+ import { existsSync as existsSync7, mkdirSync as mkdirSync10, readFileSync as readFileSync12, renameSync as renameSync9, writeFileSync as writeFileSync12 } from "node:fs";
8112
+ import { homedir as homedir10 } from "node:os";
8113
+ import { basename as basename4, dirname as dirname7, join as join15 } from "node:path";
8072
8114
  var TIMER_MIGRATION_ENV = "LACODE_NO_TIMER_MIGRATION";
8073
8115
  var TIMER_MIGRATION_STATE_VERSION = 1;
8074
8116
  var defaultRun2 = (file, args, options) => new Promise((resolve5, reject) => {
@@ -8082,15 +8124,15 @@ function resolveNodeBinary(execPath = process.execPath) {
8082
8124
  if (existsSync7("/usr/bin/node")) return "/usr/bin/node";
8083
8125
  return "node";
8084
8126
  }
8085
- function timerMigrationStatePath(home = homedir9()) {
8086
- return join14(home, ".local", "share", "opencode", "lacode", "migrations.json");
8127
+ function timerMigrationStatePath(home = homedir10()) {
8128
+ return join15(home, ".local", "share", "opencode", "lacode", "migrations.json");
8087
8129
  }
8088
- function timerMigrationUnitPath(home = homedir9()) {
8089
- return join14(home, ".config", "systemd", "user", "lacode-refresh-pool.timer");
8130
+ function timerMigrationUnitPath(home = homedir10()) {
8131
+ return join15(home, ".config", "systemd", "user", "lacode-refresh-pool.timer");
8090
8132
  }
8091
8133
  function readState2(path4) {
8092
8134
  try {
8093
- return JSON.parse(readFileSync11(path4, "utf8"));
8135
+ return JSON.parse(readFileSync12(path4, "utf8"));
8094
8136
  } catch {
8095
8137
  return {};
8096
8138
  }
@@ -8109,7 +8151,7 @@ async function runTimerMigration(options = {}) {
8109
8151
  const packageRoot = options.packageRoot ?? PACKAGE_ROOT;
8110
8152
  const version = options.version ?? readInstalledVersion(packageRoot);
8111
8153
  if (!version) return { action: "skipped", reason: "no-version" };
8112
- const home = options.home ?? homedir9();
8154
+ const home = options.home ?? homedir10();
8113
8155
  const statePath = options.statePath ?? timerMigrationStatePath(home);
8114
8156
  const state2 = readState2(statePath);
8115
8157
  if (state2.timersVersion === version && existsSync7(timerMigrationUnitPath(home))) {
@@ -8121,7 +8163,7 @@ async function runTimerMigration(options = {}) {
8121
8163
  } catch {
8122
8164
  return { action: "skipped", reason: "no-systemd" };
8123
8165
  }
8124
- const script = join14(packageRoot, "scripts", "refresh-pool.mjs");
8166
+ const script = join15(packageRoot, "scripts", "refresh-pool.mjs");
8125
8167
  if (!existsSync7(script)) return { action: "failed", version, error: `timer script not found: ${script}` };
8126
8168
  try {
8127
8169
  await run(resolveNodeBinary(), [script, "--install-timer", "--stable"], { stdio: "ignore" });
package/dist/pool.js CHANGED
@@ -39,6 +39,7 @@ var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
39
39
  var UNAVAILABLE_REASON = "endpoint-unavailable";
40
40
  var PROVIDER_DEGRADED_REASON = "provider-degraded";
41
41
  var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
42
+ var SCAN_KEPT_REASON = "scan-kept";
42
43
  var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
43
44
  var PROVIDER_DEGRADED_MIN_MODELS = 2;
44
45
  var TRANSIENT_DEGRADATION_REASONS = /^(?:no answer|no-answer|hang|timeout|timed out|endpoint-unavailable|provider-degraded|empty response|network|server)$/i;
@@ -695,7 +696,7 @@ function readLimits(s) {
695
696
  try {
696
697
  const strike = JSON.parse(readFileSync2(path, "utf8"));
697
698
  if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
698
- const countsAsStrike = strike.reason !== UNAVAILABLE_REASON;
699
+ const countsAsStrike = strike.reason !== UNAVAILABLE_REASON && strike.reason !== SCAN_KEPT_REASON;
699
700
  if (strike.until > now) {
700
701
  cooling.set(strike.model, strike);
701
702
  if (countsAsStrike) memory.set(strike.model, strike);
@@ -1098,6 +1099,7 @@ export {
1098
1099
  PROVIDER_LIST_TTL_MS,
1099
1100
  QUOTA_COOLDOWN_BASE_MS,
1100
1101
  QUOTA_COOLDOWN_MAX_MS,
1102
+ SCAN_KEPT_REASON,
1101
1103
  SLOTS_WARN_AT,
1102
1104
  STATED_COOLDOWN_MAX_MS,
1103
1105
  STATE_SWEEP_INTERVAL_MS,
@@ -194,12 +194,30 @@ A refresh once replaced a healthy ten-slot pool with a two-slot scan because not
194
194
  **Story ID(s) for tests:** US-40
195
195
  **Acceptance criteria:**
196
196
  - [ ] Given a healthy pool and a scan that returns fewer slots than half of it (minimum four), When the refresh finishes, Then the previous pool file is left untouched, the refusal is logged, and a recheck is scheduled in 5 minutes.
197
- - [ ] Given opencode's serving path reports degraded on the live status and the current pool is intact, When the refresh runs, Then no scan is started, the pool is kept, and a recheck is scheduled in 5 minutes.
197
+ - [ ] Given a scan that drops the opencode-go head or the opencode tail the current pool has, When the refresh finishes, Then the previous pool is kept even at full count, because the structure is the contract.
198
+ - [ ] Given opencode's serving path reports a hard outage on the live status and the current pool is structurally intact, When the refresh runs, Then no scan is started, the pool is kept, and a recheck is scheduled in 5 minutes.
199
+ - [ ] Given the status reports degraded (latency, elevated errors) rather than down, When the refresh runs, Then the scan proceeds as normal, because models routinely answer while the page says degraded.
198
200
  - [ ] Given any failed run with a stated reset hours out, When the retry is scheduled, Then the recheck still happens within 5 minutes; a sooner provider reset is honoured.
199
201
  - [ ] Given the degradation lifts, When the next 5-minute recheck runs, Then a full scan restores the pool instead of waiting for the next 6-hour slot.
200
202
  - [ ] Given a thin or missing pool, When a fuller scan completes, Then it is always written — the guard only ever blocks shrinking, never recovery.
201
203
  - [ ] Given the status endpoint is unreachable, When the refresh runs, Then the scan proceeds as before, because an unknown signal must never block a refresh and the write guard still protects the pool.
202
204
 
205
+ ### US-41: The pool keeps its structure through any outage and shows its state
206
+ **As a** user relying on the pool, **I want** members to be benched in place instead of deleted, recovery to be cheap, and the slot states visible, **so that** no outage can shrink the pool, no recovery takes longer than minutes, and I can see what each slot is doing.
207
+
208
+ Selection retains current members unless the run proved them hard-dead (gone, credential, agreement, credit); a healthy-but-capped model is still decided by the caps. The refresh writes a flat `scan-kept` cooling record for every kept-but-transiently-failed member — the runtime already honours it and re-admits the model on the first answering probe — and clears stale benches for members that answered. The status page is a hint only: sick providers probe last, nothing is ever skipped or benched because of it. Degraded retries run a canary over the current members first and scan fully only when one stops answering. `pool_status` shows each cooling slot's absolute expiry, the structural verdict, the last refresh age, and the status hint.
209
+
210
+ **Story ID(s) for tests:** US-41
211
+ **Acceptance criteria:**
212
+ - [ ] Given a current member whose probe failed transiently, When selection runs, Then the member is retained in place (marked retained) with its pool weight, and head/tail positions are never vacated.
213
+ - [ ] Given a current member that is healthy but capped out, When selection runs, Then it is not retained — the caps decide.
214
+ - [ ] Given a model absent from a cleanly scanned catalogue, When selection runs, Then it is dropped as hard-dead, not retained.
215
+ - [ ] Given a retained member, When the refresh finishes, Then a flat `scan-kept` cooling record benches it without entering the strike memory, and a member that answered has any stale bench cleared.
216
+ - [ ] Given a scan-kept bench, When any probe watches the model answer, Then it is re-admitted without waiting out the window.
217
+ - [ ] Given a degraded page, When the refresh runs, Then sick providers only probe last; nothing is skipped or benched because of the page.
218
+ - [ ] Given a degraded retry, When it runs, Then it probes only the current members first and scans fully only when one stops answering.
219
+ - [ ] Given cooling slots, When `pool_status` renders, Then each shows its absolute expiry, plus the structural verdict, last refresh age, and status hint.
220
+
203
221
  ---
204
222
 
205
223
  ## Epic C — Hang detection and auto-recovery
@@ -621,6 +639,7 @@ table was filled in.
621
639
  | US-31 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/stories/c-hang-recovery.test.mjs` |
622
640
  | US-39 | B | `tests/stories/l-pool-refresh.test.mjs` |
623
641
  | US-40 | B | `tests/stories/l-pool-refresh.test.mjs`, `tests/refresh-pool-test.mjs`, `tests/provider-status-test.mjs` |
642
+ | US-41 | B | `tests/stories/l-pool-refresh.test.mjs`, `tests/pool-selection-test.mjs`, `tests/refresh-pool-test.mjs`, `tests/pool-shared-test.mjs`, `tests/provider-status-test.mjs` |
624
643
  | US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
625
644
  | US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
626
645
  | US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
@@ -654,6 +673,6 @@ The root-level suites under `tests/` are unit-style; the `tests/stories/` suites
654
673
  the behavioural layer, written one epic per file with every test name carrying
655
674
  its story id. Run everything with `npm test`.
656
675
 
657
- 40/40 stories covered by 376 test case(s); thinnest story has 1.
676
+ 41/41 stories covered by 380 test case(s); thinnest story has 1.
658
677
  No story is untested. The scan covers the `tests/stories/` suites and the root-level
659
678
  `tests/*-test.mjs` suites.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "2.3.0-beta.3",
3
+ "version": "2.3.0-beta.5",
4
4
  "description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,6 +49,13 @@ const FAILURE_KINDS = [
49
49
  // and not-served failures are permanent for this model until a human acts.
50
50
  const TRANSIENT_KINDS = new Set(["rate-limited", "credit", "timeout", "network", "server"])
51
51
 
52
+ /**
53
+ * Failure kinds that vacate a pool slot for good: the model is gone, the
54
+ * credential is wrong, the terms are unsigned, or the balance is empty. Every
55
+ * other failure mode keeps the slot — benched, not deleted.
56
+ */
57
+ export const HARD_DEAD_KINDS = new Set(["not-served", "credential", "agreement", "credit"])
58
+
52
59
  export const isTransientKind = (kind) => TRANSIENT_KINDS.has(String(kind ?? "").toLowerCase())
53
60
 
54
61
  export function classifyFailure({ status, reason, kind } = {}) {
@@ -127,9 +134,84 @@ const rowsOf = (providers) =>
127
134
 
128
135
  const providerOf = (model) => String(model ?? "").split("/")[0]
129
136
 
137
+ /**
138
+ * Parse pool.txt slot lines into [{ model, weight, bucket?, context? }].
139
+ * Comments and blanks are ignored; weight defaults to 1.
140
+ */
141
+ export function parsePoolSlots(text) {
142
+ const out = []
143
+ for (const line of String(text ?? "").split(/\r?\n/)) {
144
+ const code = line.split("#")[0].trim()
145
+ if (!code || !code.includes("/")) continue
146
+ const [model, weight] = code.split(/\s+/)
147
+ if (!model || !model.includes("/")) continue
148
+ const bucket = /b:([^\s]+)/.exec(code)?.[1]
149
+ const context = /c:(\d+)/.exec(code)?.[1]
150
+ out.push({
151
+ model,
152
+ weight: Number(weight) > 0 ? Number(weight) : 1,
153
+ ...(bucket ? { bucket } : {}),
154
+ context: context ? Number(context) : null,
155
+ })
156
+ }
157
+ return out
158
+ }
159
+
160
+ /**
161
+ * Pure structural verdict for a pool slot list (formatted lines or selection
162
+ * entries). The contract: an opencode-go head (every go slot first, weight 4),
163
+ * exactly one opencode tail (last, weight 2, b:opencode), POOL_SIZE slots.
164
+ * Returns { valid, problems[] } — valid covers head+tail; count is reported
165
+ * separately so a short-but-structured pool reads as short, not broken.
166
+ */
167
+ export function poolStructureValid(slots) {
168
+ const problems = []
169
+ const items = typeof slots === "string" ? String(slots).split(/\r?\n/) : Array.isArray(slots) ? slots : []
170
+ const lines = items.map((entry) => {
171
+ if (typeof entry === "string") {
172
+ const parts = entry.split("#")[0].trim().split(/\s+/)
173
+ const bucket = /b:([^\s]+)/.exec(entry)?.[1]
174
+ return { model: parts[0] ?? "", weight: Number(parts[1]) || 0, bucket }
175
+ }
176
+ return { model: entry?.model ?? "", weight: Number(entry?.weight) || 0, bucket: entry?.bucket }
177
+ }).filter((entry) => entry.model.includes("/"))
178
+ const go = lines.filter((entry) => providerOf(entry.model) === "opencode-go")
179
+ const tails = lines.filter((entry) => providerOf(entry.model) === "opencode")
180
+ if (go.length === 0) problems.push("missing opencode-go head")
181
+ for (let i = 0; i < go.length; i++) {
182
+ if (lines[i] !== go[i]) {
183
+ problems.push("opencode-go slots must all come first")
184
+ break
185
+ }
186
+ if (go[i].weight !== 4) {
187
+ problems.push("opencode-go head slots must have weight 4")
188
+ break
189
+ }
190
+ }
191
+ if (tails.length !== 1) problems.push(`expected exactly one opencode tail, found ${tails.length}`)
192
+ else {
193
+ const tail = tails[0]
194
+ if (lines[lines.length - 1] !== tail) problems.push("opencode tail slot must be last")
195
+ if (tail.weight !== 2 || tail.bucket !== "opencode") problems.push("opencode tail must be weight 2 with b:opencode")
196
+ }
197
+ if (lines.length !== POOL_SIZE) problems.push(`expected ${POOL_SIZE} slots, found ${lines.length}`)
198
+ const structural = problems.filter((p) => !p.startsWith(`expected ${POOL_SIZE}`))
199
+ return { valid: structural.length === 0, problems, count: lines.length }
200
+ }
201
+
202
+ /**
203
+ * Hard-dead check for retention: only these failure kinds vacate a slot.
204
+ */
205
+ export const isHardDeadRow = (row) => {
206
+ if (!row || row.ok !== false) return false
207
+ const kind = String(row.kind ?? classifyFailure({ status: row.status, reason: row.reason }) ?? "").toLowerCase()
208
+ return HARD_DEAD_KINDS.has(kind)
209
+ }
210
+
130
211
  export function selectPool({
131
212
  providers,
132
213
  previousProviders,
214
+ currentSlots = [],
133
215
  minContext = MIN_CONTEXT,
134
216
  minScore = MIN_SCORE,
135
217
  poolSize = POOL_SIZE,
@@ -237,14 +319,93 @@ export function selectPool({
237
319
 
238
320
  const pickedZen = zen.slice(0, zenReserve)
239
321
 
240
- const selection = [
241
- ...go.map((row) => ({ model: row.model, weight: 4, context: row.context ?? null })),
242
- ...pickedSmall.map((row) => ({ model: row.model, weight: 2, context: row.context ?? null })),
243
- ...pickedLarge.map((row) => ({ model: row.model, weight: 1, context: row.context ?? null })),
244
- ...pickedZen.map((row) => ({ model: row.model, weight: 2, bucket: "opencode", context: row.context ?? null })),
322
+ const warnings = []
323
+
324
+ // Pool-level retention: a current member stays unless this run proved it
325
+ // hard-dead. A healthy-but-unselected model (caps, score floors) is NOT
326
+ // retained — the caps decided that. A transiently failed, unscanned, or
327
+ // budget-cut model is retained and benched in place by the writer; only a
328
+ // catalogue removal on a cleanly scanned provider vacates the slot. Head
329
+ // and tail are never vacated by a failed or partial scan.
330
+ const rowByModel = new Map(rows.map((row) => [row.model, row]))
331
+ const cleanlyScanned = (model) => {
332
+ const provider = providerOf(model)
333
+ return scannedProviders.has(provider) && !truncatedProviders.has(provider) && !erroredProviders.has(provider)
334
+ }
335
+ const placedModels = new Set([...go, ...cappedSmall, ...cappedLarge, ...pickedZen].map((row) => row.model))
336
+ const shouldRetain = (model) => {
337
+ if (!model.includes("/") || placedModels.has(model)) return false
338
+ const row = rowByModel.get(model)
339
+ if (row) return row.ok === false && !isHardDeadRow(row)
340
+ return !cleanlyScanned(model)
341
+ }
342
+ const retainedHead = []
343
+ const retainedMiddle = []
344
+ let retainedTail = null
345
+ for (const slot of Array.isArray(currentSlots) ? currentSlots : []) {
346
+ const model = String(slot?.model ?? "")
347
+ if (!shouldRetain(model)) continue
348
+ const weight = Number(slot?.weight)
349
+ if (providerOf(model) === "opencode-go") {
350
+ retainedHead.push({ model, weight: 4, context: slot?.context ?? null, retained: true })
351
+ } else if (providerOf(model) === "opencode") {
352
+ if (!retainedTail) retainedTail = { model, weight: 2, bucket: "opencode", context: slot?.context ?? null, retained: true }
353
+ } else {
354
+ retainedMiddle.push({
355
+ model,
356
+ weight: Number.isFinite(weight) && weight > 0 ? weight : 2,
357
+ ...(slot?.bucket ? { bucket: slot.bucket } : {}),
358
+ context: slot?.context ?? null,
359
+ retained: true,
360
+ })
361
+ }
362
+ }
363
+
364
+ const toSmall = (row) => ({ model: row.model, weight: 2, context: row.context ?? null })
365
+ const toLarge = (row) => ({ model: row.model, weight: 1, context: row.context ?? null })
366
+ const middle = [...pickedSmall.map(toSmall), ...pickedLarge.map(toLarge)]
367
+ for (const entry of retainedMiddle) {
368
+ if (!middle.some((existing) => existing.model === entry.model)) middle.push(entry)
369
+ }
370
+ if (retainedTail && !pickedZen.some((row) => providerOf(row.model) === "opencode")) {
371
+ middle.push({ ...retainedTail })
372
+ retainedTail = { ...retainedTail, placed: true }
373
+ }
374
+ const head = [
375
+ ...retainedHead,
376
+ ...go.filter((row) => !retainedHead.some((entry) => entry.model === row.model)).map((row) => ({ model: row.model, weight: 4, context: row.context ?? null })),
245
377
  ]
378
+ const tail = pickedZen.map((row) => ({ model: row.model, weight: 2, bucket: "opencode", context: row.context ?? null }))
379
+ let selection = [...head, ...middle, ...tail]
380
+ // Fit the contract size without ever vacating head or tail: trim the middle
381
+ // from the back (lowest score first), skipping retained slots. If even that
382
+ // cannot fit, the head wins and a warning says so.
383
+ while (selection.length > poolSize) {
384
+ let index = -1
385
+ for (let i = selection.length - 1; i >= 0; i--) {
386
+ const entry = selection[i]
387
+ if (!entry.retained && providerOf(entry.model) !== "opencode-go" && providerOf(entry.model) !== "opencode") {
388
+ index = i
389
+ break
390
+ }
391
+ }
392
+ if (index === -1) {
393
+ warnings.push(`pool head exceeds the contract size ${poolSize}; keeping every head slot`)
394
+ break
395
+ }
396
+ selection.splice(index, 1)
397
+ }
398
+
399
+ const retainedCount = selection.filter((entry) => entry.retained).length
400
+ if (retainedCount > 0) {
401
+ warnings.push(
402
+ `retained ${retainedCount} current slot(s) benched in place: ${selection
403
+ .filter((entry) => entry.retained)
404
+ .map((entry) => entry.model)
405
+ .join(", ")}`,
406
+ )
407
+ }
246
408
 
247
- const warnings = []
248
409
  for (const entry of Array.isArray(providers) ? providers : []) {
249
410
  if (entry?.error) {
250
411
  warnings.push(`${entry.provider}: ${entry.error}`)
@@ -20,10 +20,11 @@
20
20
 
21
21
  import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
22
22
  import { execFileSync } from "node:child_process"
23
- import { homedir, userInfo } from "node:os"
23
+ import { homedir, tmpdir, userInfo } from "node:os"
24
24
  import { dirname, join, resolve } from "node:path"
25
25
  import { fileURLToPath } from "node:url"
26
- import { fetchProviderStatus } from "./provider-status.mjs"
26
+ import { fetchProviderStatus, statusDeprioritizedProviders } from "./provider-status.mjs"
27
+ import { POOL_SIZE, isTransientFailure, parsePoolSlots, poolStructureValid } from "./pool-selection.mjs"
27
28
 
28
29
  const HOME = homedir()
29
30
  export const DEFAULT_SCORES_PATH = join(HOME, ".config", "lacode", "model-scores.json")
@@ -194,14 +195,47 @@ function writeFileAtomic(path, text) {
194
195
  renameSync(tmp, path)
195
196
  }
196
197
 
197
- function runScorer(max) {
198
+ function runScorer(max, extraArgs = []) {
198
199
  const here = dirname(fileURLToPath(import.meta.url))
199
200
  const scorer = resolve(here, "score-models.mjs")
200
201
  if (!existsSync(scorer)) throw new Error(`scorer not found: ${scorer}`)
201
- const args = [scorer, "--max", String(max)]
202
+ const args = [scorer, "--max", String(max), ...extraArgs]
202
203
  execFileSync(process.execPath, args, { stdio: "inherit" })
203
204
  }
204
205
 
206
+ /**
207
+ * Cheap health check: probe exactly the current pool members (no catalogue
208
+ * sweep). Every member answers → the pool stands as-is: no full scan, no
209
+ * retry, back to the 6-hour cadence. Anything else → false, and the caller
210
+ * runs a full scan to verify. Returns true when the pool stands.
211
+ */
212
+ function runCanary({ currentPoolText, scorerArgs, max }) {
213
+ const members = parsePoolSlots(currentPoolText).map((slot) => slot.model)
214
+ if (members.length === 0) return false
215
+ const tmp = join(tmpdir(), `lacode-canary-${process.pid}.json`)
216
+ try {
217
+ runScorer(Math.max(max, members.length), [...scorerArgs, "--probe-models", members.join(","), "--out", tmp])
218
+ const payload = JSON.parse(readFileSync(tmp, "utf8"))
219
+ const answered = new Set()
220
+ for (const entry of payload?.providers ?? []) {
221
+ for (const row of entry?.rows ?? []) {
222
+ if (row?.model && row?.ok === true) answered.add(row.model)
223
+ }
224
+ }
225
+ const missing = members.filter((model) => !answered.has(model))
226
+ if (missing.length === 0) {
227
+ console.log(`canary healthy: all ${members.length} pool member(s) answered; pool stands, no full scan`)
228
+ return true
229
+ }
230
+ console.log(`canary: ${missing.length} member(s) not answering (${missing.join(", ")}); running a full scan`)
231
+ return false
232
+ } finally {
233
+ try {
234
+ rmSync(tmp, { force: true })
235
+ } catch {}
236
+ }
237
+ }
238
+
205
239
  /**
206
240
  * Count the usable model slots in a pool.txt text (comments and blanks ignored).
207
241
  */
@@ -210,22 +244,103 @@ export function poolSlotCount(text) {
210
244
  }
211
245
 
212
246
  /**
213
- * Pure fail-closed guard. A refresh must never replace a working pool with a
214
- * degenerate selection: when the new slot count falls below half the previous
215
- * pool (floored at MIN_POOL_SLOTS), the previous pool is kept. There is no
216
- * previous pool on a first-ever run, so there is nothing to protect and the
217
- * guard passes.
247
+ * Bench-in-place file layout. Must match src/lib/pool.ts: the runtime treats
248
+ * these files as flat cooling records (never strikes) and re-admits a model
249
+ * the moment a probe watches it answer.
250
+ */
251
+ export const SCAN_BENCH_REASON = "scan-kept"
252
+ export const SCAN_BENCH_WINDOW_MS = 45 * 60 * 1000
253
+ export const scanBenchDir = (home = HOME) => join(home, ".local", "share", "opencode", "agent-pool")
254
+ export const scanBenchFile = (model) => `limit.${String(model).replace(/[^a-zA-Z0-9._-]/g, "__")}.json`
255
+
256
+ /**
257
+ * Pure bench plan for the current pool members. A member whose current row
258
+ * failed transiently is benched until its stated reset (else 45 minutes); a
259
+ * member that answered has any stale bench cleared; an unscanned member is
260
+ * left alone for lack of evidence. Only pool members are ever touched.
261
+ */
262
+ export function computeScanBench({ selectionModels, providers, now = Date.now() }) {
263
+ const currentByModel = new Map()
264
+ for (const entry of Array.isArray(providers) ? providers : []) {
265
+ for (const row of Array.isArray(entry?.rows) ? entry.rows : []) {
266
+ if (row?.model && !currentByModel.has(row.model)) currentByModel.set(row.model, row)
267
+ }
268
+ }
269
+ const bench = []
270
+ const clear = []
271
+ for (const model of selectionModels ?? []) {
272
+ const row = currentByModel.get(model)
273
+ if (!row) continue
274
+ if (row.ok !== false) {
275
+ clear.push(model)
276
+ continue
277
+ }
278
+ if (!isTransientFailure(row)) continue
279
+ const at = Number(row.retryAfter)
280
+ bench.push({
281
+ model,
282
+ until: Number.isFinite(at) && at > now ? at : now + SCAN_BENCH_WINDOW_MS,
283
+ kind: String(row.kind ?? "unknown"),
284
+ })
285
+ }
286
+ return { bench, clear }
287
+ }
288
+
289
+ /** Apply a bench plan with atomic writes; every failure is best-effort. */
290
+ export function applyScanBench({ dir, bench, clear }) {
291
+ const applied = { benched: [], cleared: [] }
292
+ try {
293
+ mkdirSync(dir, { recursive: true })
294
+ } catch {}
295
+ for (const { model, until } of bench ?? []) {
296
+ try {
297
+ const record = { model, until, strikes: 1, reason: SCAN_BENCH_REASON, last: Date.now() }
298
+ const tmp = join(dir, `${scanBenchFile(model)}.tmp-${process.pid}`)
299
+ writeFileSync(tmp, JSON.stringify(record))
300
+ renameSync(tmp, join(dir, scanBenchFile(model)))
301
+ applied.benched.push(model)
302
+ } catch {}
303
+ }
304
+ for (const model of clear ?? []) {
305
+ try {
306
+ rmSync(join(dir, scanBenchFile(model)), { force: true })
307
+ applied.cleared.push(model)
308
+ } catch {}
309
+ }
310
+ return applied
311
+ }
312
+
313
+ /**
314
+ * Pure fail-closed guard. A refresh must never break the pool's structure or
315
+ * replace a working pool with a degenerate selection: the new pool must keep
316
+ * the opencode-go head and the opencode tail the current pool has, and hold at
317
+ * least half of POOL_SIZE. The baseline is the contract size, never the
318
+ * current file — a previously shrunk pool can always grow back, but no write
319
+ * may shrink or behead it. There is no previous pool on a first-ever run, so
320
+ * there is nothing to protect and the guard passes. `--force-write` is the
321
+ * human override for a genuinely changed catalogue.
218
322
  */
219
- export function shouldKeepPool({ currentPoolText, newSlotCount }) {
323
+ export function shouldKeepPool({ currentPoolText, newSlots }) {
220
324
  const prev = poolSlotCount(currentPoolText ?? "")
221
325
  if (prev <= 0) return { keep: false, prev, threshold: 0, reason: "no previous pool to protect" }
222
- const threshold = Math.max(MIN_POOL_SLOTS, Math.floor(prev / 2))
223
- if (newSlotCount >= threshold) return { keep: false, prev, threshold, reason: "" }
326
+ const slots = Array.isArray(newSlots) ? newSlots : []
327
+ const threshold = Math.max(MIN_POOL_SLOTS, Math.floor(POOL_SIZE / 2))
328
+ const currentCheck = poolStructureValid(currentPoolText)
329
+ const newCheck = poolStructureValid(slots)
330
+ if (currentCheck.valid && !newCheck.valid) {
331
+ return {
332
+ keep: true,
333
+ prev,
334
+ threshold,
335
+ reason: `new selection breaks the pool structure (${newCheck.problems.join("; ")}); keeping the previous pool`,
336
+ }
337
+ }
338
+ if (slots.filter(isSlotLine).length >= threshold) return { keep: false, prev, threshold, reason: "" }
224
339
  return {
225
340
  keep: true,
226
341
  prev,
227
342
  threshold,
228
- reason: `new selection has ${newSlotCount} usable slot(s), below the keep threshold of ${threshold} (previous pool had ${prev}); refusing to shrink the pool`,
343
+ reason: `new selection has ${slots.filter(isSlotLine).length} usable slot(s), below the keep threshold of ${threshold} (contract pool size ${POOL_SIZE}); refusing to shrink the pool`,
229
344
  }
230
345
  }
231
346
 
@@ -251,6 +366,7 @@ export function scheduleRetryIn(seconds, options = {}) {
251
366
  const scriptPath = options.scriptPath ?? fileURLToPath(import.meta.url)
252
367
  const nodePath = options.nodePath ?? process.execPath
253
368
  const home = options.home ?? HOME
369
+ const extraArgs = Array.isArray(options.args) ? options.args : []
254
370
  const nowMs = (options.now ?? Date.now)()
255
371
  const path = `${home}/.opencode/bin:/usr/local/bin:/usr/bin:/bin`
256
372
  const unit = `lacode-refresh-pool-retry-${Math.floor(nowMs / 1000).toString(36)}`
@@ -266,6 +382,7 @@ export function scheduleRetryIn(seconds, options = {}) {
266
382
  `--setenv=PATH=${path}`,
267
383
  nodePath,
268
384
  scriptPath,
385
+ ...extraArgs,
269
386
  ],
270
387
  { stdio: "ignore" },
271
388
  )
@@ -287,9 +404,11 @@ export function scheduleRetry(retry, options = {}) {
287
404
  return scheduled
288
405
  }
289
406
 
290
- /** Schedule a recheck on the degraded cadence (default 5 minutes). */
407
+ /** Schedule a recheck on the degraded cadence (default 5 minutes). Retries run
408
+ * a cheap canary first and only fall back to a full scan when a pool member
409
+ * stops answering. */
291
410
  export function scheduleRetrySoon(seconds = DEGRADED_RETRY_S, options = {}) {
292
- return scheduleRetryIn(seconds, options)
411
+ return scheduleRetryIn(seconds, { ...options, args: [...(options.args ?? []), "--canary"] })
293
412
  }
294
413
 
295
414
  export function renderTimerUnits(input = {}) {
@@ -380,17 +499,24 @@ function usage() {
380
499
  systemctl --user start lacode-refresh-pool.service
381
500
  --print print the proposed pool.txt and make no changes (alias: --dry-run)
382
501
  --dry-run same as --print
502
+ --canary probe only the current pool members; run a full scan only
503
+ when one stops answering (used by degraded rechecks)
383
504
  --max N models per provider passed to the scorer (default: 50)
384
505
  --scores PATH scores file (default: ${DEFAULT_SCORES_PATH})
385
506
  --pool PATH pool file to read/compare/write (default: ${DEFAULT_POOL_PATH})
507
+ --force-write write the new selection even when the fail-closed guard would
508
+ keep the previous pool (human override for a changed catalogue)
386
509
  -h, --help this message
387
510
 
388
- Degradation behaviour: before scanning, the refresh checks
389
- https://status.opencode.ai/api/status. When opencode's serving path reports
390
- degraded and the current pool is intact, the scan is skipped, the pool is kept,
391
- and a recheck is scheduled in 5 minutes. After a scan, a selection below half
392
- the previous pool (minimum ${MIN_POOL_SLOTS} slots) is never written; the previous pool
393
- is kept and a 5-minute recheck is scheduled. A retry is always rechecked
511
+ Degradation behaviour: before scanning, the refresh reads
512
+ https://status.opencode.ai/api/status. The page is a hint, never a verdict:
513
+ sick providers are only probed last, and nothing is ever skipped or benched
514
+ because of it. A canary (--canary, used by every degraded recheck) probes only
515
+ the current pool members and runs a full scan solely when one stops answering.
516
+ After a scan, a selection that breaks the pool structure or falls below half
517
+ the contract pool size (minimum ${MIN_POOL_SLOTS} slots) is never written; the
518
+ previous pool is kept, transiently failed members are still benched in place,
519
+ and a 5-minute canary recheck is scheduled. A retry is always rechecked
394
520
  within 5 minutes while anything failed, even when the stated reset is hours out.`)
395
521
  }
396
522
 
@@ -404,6 +530,7 @@ async function main() {
404
530
  const fromScores = argv.includes("--from-scores")
405
531
  const printOnly = argv.includes("--print") || argv.includes("--dry-run")
406
532
  const noRetry = argv.includes("--no-retry")
533
+ const forceWrite = argv.includes("--force-write")
407
534
  const value = (flag, fallback) => {
408
535
  const hit = argv.find((a) => a === flag || a.startsWith(`${flag}=`))
409
536
  if (!hit) return fallback
@@ -422,41 +549,46 @@ async function main() {
422
549
  }
423
550
 
424
551
  const currentPoolText = readTextFile(poolPath)
425
-
426
- // Live degradation signal. When opencode's own serving path reports degraded
427
- // and the current pool is intact, probing now can only shrink it: keep the
428
- // pool untouched and recheck in 5 minutes. A thin pool is still scanned, so
429
- // a first-ever run (or a previously clobbered one) can recover, and the
430
- // write guard below protects it if the scan comes back degenerate.
431
- let providerStatus = { ok: false, providers: [], services: [] }
432
- if (!printOnly) {
552
+ const canary = argv.includes("--canary")
553
+
554
+ // Live status is a hint, never a verdict. Gating services that report down
555
+ // or degraded only reorder probes (sick providers last) so they cannot eat
556
+ // the scan budget. Nothing is ever skipped or benched because of the page —
557
+ // only real probe evidence benches a model, and the write guard below
558
+ // protects the pool if the scan comes back degenerate.
559
+ let deprioritized = []
560
+ if (!printOnly && !fromScores) {
433
561
  try {
434
- providerStatus = await fetchProviderStatus()
562
+ deprioritized = statusDeprioritizedProviders(await fetchProviderStatus())
435
563
  } catch (error) {
436
- providerStatus = { ok: false, providers: [], services: [], reason: String(error?.message ?? error) }
564
+ console.log(`live status unreadable (${String(error?.message ?? error)}); scanning in default order`)
437
565
  }
438
- if (providerStatus.ok && providerStatus.providers.length > 0 && poolSlotCount(currentPoolText) >= MIN_POOL_SLOTS) {
439
- const detail = providerStatus.services.map((entry) => `${entry.service}=${entry.status}`).join(", ")
440
- console.log(
441
- `opencode status degraded (${detail}); keeping the current pool of ${poolSlotCount(currentPoolText)} slots and rechecking in ${DEGRADED_RETRY_S / 60} minutes`,
442
- )
443
- if (!noRetry) {
444
- const scheduled = scheduleRetrySoon()
445
- console.log(
446
- scheduled.scheduled
447
- ? `scheduled degraded recheck in ${scheduled.seconds}s`
448
- : `degraded recheck not scheduled: ${scheduled.reason}`,
449
- )
450
- }
451
- return
566
+ if (deprioritized.length > 0) {
567
+ console.log(`status hint: probing ${deprioritized.join(", ")} last`)
452
568
  }
453
569
  }
570
+ const scorerArgs = [`--current-pool`, poolPath]
571
+ if (deprioritized.length > 0) scorerArgs.push(`--deprioritize-providers`, deprioritized.join(","))
572
+
573
+ const runFullScan = () => runScorer(max, scorerArgs)
454
574
 
455
575
  if (!fromScores) {
456
576
  try {
457
- runScorer(max)
577
+ if (canary) {
578
+ runCanary({ poolPath, currentPoolText, scorerArgs, max })
579
+ } else {
580
+ runFullScan()
581
+ }
458
582
  } catch (error) {
459
583
  console.error(`scorer failed; pool.txt not written: ${String(error?.message ?? error)}`)
584
+ if (!printOnly && !noRetry) {
585
+ const scheduled = scheduleRetrySoon()
586
+ console.log(
587
+ scheduled.scheduled
588
+ ? `scheduled degraded recheck in ${scheduled.seconds}s`
589
+ : `degraded recheck not scheduled: ${scheduled.reason}`,
590
+ )
591
+ }
460
592
  process.exitCode = 1
461
593
  return
462
594
  }
@@ -476,12 +608,35 @@ async function main() {
476
608
  built = buildPool({ scores, currentPoolText, now: new Date() })
477
609
  } catch (error) {
478
610
  console.error(`refresh refused (fail closed): ${String(error?.message ?? error)}`)
611
+ if (!printOnly && !noRetry) {
612
+ const scheduled = scheduleRetrySoon()
613
+ console.log(
614
+ scheduled.scheduled
615
+ ? `scheduled degraded recheck in ${scheduled.seconds}s`
616
+ : `degraded recheck not scheduled: ${scheduled.reason}`,
617
+ )
618
+ }
479
619
  process.exitCode = 1
480
620
  return
481
621
  }
482
622
 
483
623
  const oldSlotCount = poolSlotCount(currentPoolText)
484
624
  const newSlotCount = built.slots.filter(isSlotLine).length
625
+
626
+ // Bench-in-place: a pool member this run failed transiently keeps its slot
627
+ // and gets a flat cooling record the runtime already honours; a member that
628
+ // answered has any stale bench cleared. Unscanned members are left alone.
629
+ // Runs on the write path and the refusal path alike — a kept pool still
630
+ // learns what the scan found.
631
+ if (!printOnly) {
632
+ const plan = computeScanBench({ selectionModels: built.chosen.selected, providers: scores.providers })
633
+ const applied = applyScanBench({ dir: scanBenchDir(), bench: plan.bench, clear: plan.clear })
634
+ if (applied.benched.length > 0) {
635
+ const detail = plan.bench.map((entry) => `${entry.model} (${entry.kind})`).join(", ")
636
+ console.log(`scan benched in place: ${detail}`)
637
+ }
638
+ if (applied.cleared.length > 0) console.log(`scan cleared stale benches: ${applied.cleared.join(", ")}`)
639
+ }
485
640
  const dropped = scores.providers.flatMap((entry) => (Array.isArray(entry?.excluded) ? entry.excluded : []))
486
641
  const summary = [
487
642
  `selected [${built.chosen.selected.join(", ")}]`,
@@ -491,8 +646,9 @@ async function main() {
491
646
 
492
647
  // Fail closed on a degenerate selection: keep the previous pool, say so
493
648
  // loudly, and recheck in 5 minutes instead of waiting out a long reset.
494
- if (!printOnly) {
495
- const kept = shouldKeepPool({ currentPoolText, newSlotCount })
649
+ // --force-write is the human override for a genuinely changed catalogue.
650
+ if (!printOnly && !forceWrite) {
651
+ const kept = shouldKeepPool({ currentPoolText, newSlots: built.slots })
496
652
  if (kept.keep) {
497
653
  console.error(`refresh refused (fail closed): ${kept.reason}`)
498
654
  if (!noRetry) {
@@ -46,13 +46,14 @@ import {
46
46
  classifyFailure,
47
47
  isTransientFailure,
48
48
  isTransientKind,
49
+ parsePoolSlots,
49
50
  providerRank,
50
51
  retryAfterMs,
51
52
  selectPool,
52
53
  } from "./pool-selection.mjs"
53
54
 
54
55
  const AUTH = join(homedir(), ".local", "share", "opencode", "auth.json")
55
- const OUT = join(homedir(), ".config", "lacode", "model-scores.json")
56
+ const DEFAULT_OUT = join(homedir(), ".config", "lacode", "model-scores.json")
56
57
  // No default provider list: `opencode models --verbose` with no argument returns
57
58
  // every provider opencode knows about, and that is what we scan by default.
58
59
  const SESSION_PROVIDERS = new Set(["opencode", "opencode-go"])
@@ -100,11 +101,18 @@ const value = (flag, fallback) => {
100
101
  console.log(`Usage: npm run score -- [options]
101
102
 
102
103
  --providers a,b restrict the scan to these providers (default: all configured)
104
+ --probe-models a,b probe exactly these provider/model ids (canary: skips the
105
+ catalogue scan for everything else; unscanned providers keep
106
+ their previous rows)
107
+ --deprioritize-providers a,b probe these providers last (a live status hint;
108
+ never excludes anything, ordering only)
109
+ --current-pool PATH feed pool.txt as the current pool so selection retains
110
+ members this run could not reach (default: no retention)
103
111
  --max N probe at most N candidate models per provider (default: 50)
104
112
  --min-context N exclude models whose context limit is below N (default: ${MIN_CONTEXT})
105
113
  --min-score N do not rank models scoring below N (default: ${MIN_SCORE})
106
114
  --free-only only probe catalogue cost 0/0 models on every provider
107
- --timeout MS per-request deadline in ms (default: 45000)
115
+ --timeout MS per-request deadline in ms (default: 12000)
108
116
  --delay MS pause between requests in ms (default: 300)
109
117
  --budget-min N stop starting new probes after N minutes and write what was
110
118
  measured; providers not scanned keep their previous rows
@@ -126,13 +134,24 @@ Providers with no direct HTTP endpoint (or an unexpanded \${...} URL) are probed
126
134
 
127
135
  const providerArg = value("--providers", null)
128
136
  const providers = providerArg ? String(providerArg).split(",").map((s) => s.trim()).filter(Boolean) : null
137
+ const probeModelsArg = value("--probe-models", null)
138
+ const probeModels = probeModelsArg ? new Set(String(probeModelsArg).split(",").map((s) => s.trim()).filter(Boolean)) : null
139
+ const deprioritizeArg = value("--deprioritize-providers", null)
140
+ const deprioritizedProviders = new Set(
141
+ deprioritizeArg ? String(deprioritizeArg).split(",").map((s) => s.trim()).filter(Boolean) : [],
142
+ )
143
+ // probe --probe-models ids absent from every catalogue. Filled during the
144
+ // catalogue filter above; emitted as synthetic not-served rows after probing.
145
+ const missingProbeModels = []
146
+ const currentPoolPath = value("--current-pool", null)
129
147
  const maxPerProvider = Math.max(1, Number(value("--max", "50")) || 50)
130
148
  const minContext = Math.max(0, Number(value("--min-context", String(MIN_CONTEXT))) || MIN_CONTEXT)
131
149
  const minScore = Math.max(0, Number(value("--min-score", String(MIN_SCORE))) || MIN_SCORE)
132
- const timeoutMs = Math.max(1_000, Number(value("--timeout", "45000")) || 45_000)
150
+ const timeoutMs = Math.max(1_000, Number(value("--timeout", "12000")) || 12_000)
133
151
  const delayMs = Math.max(0, Number(value("--delay", "300")) || 300)
134
152
  const budgetMin = Math.max(1, Number(value("--budget-min", "30")) || 30)
135
153
  const budgetMs = budgetMin * 60_000
154
+ const OUT = value("--out", DEFAULT_OUT)
136
155
  const writeResults = !has("--no-write")
137
156
  const freeOnlyFlag = has("--free-only")
138
157
 
@@ -570,12 +589,35 @@ const pad = (s, n) => String(s).padEnd(n)
570
589
  const padL = (s, n) => String(s).padStart(n)
571
590
 
572
591
  async function main() {
573
- const scanned = providers ? providers.map((provider) => freeModels(provider)) : freeModelsAll()
592
+ let scanned = providers ? providers.map((provider) => freeModels(provider)) : freeModelsAll()
574
593
  // Core providers first, then the free-only middle tier, then the slow paid
575
- // catalogues. Ordering only; every provider is still probed if time allows.
594
+ // catalogues — and explicitly deprioritized providers last. Ordering only;
595
+ // every provider is still probed if time allows.
576
596
  scanned.sort(
577
- (a, b) => providerRank(a.provider) - providerRank(b.provider) || String(a.provider).localeCompare(String(b.provider)),
597
+ (a, b) =>
598
+ Number(deprioritizedProviders.has(a.provider)) - Number(deprioritizedProviders.has(b.provider)) ||
599
+ providerRank(a.provider) - providerRank(b.provider) ||
600
+ String(a.provider).localeCompare(String(b.provider)),
578
601
  )
602
+ const currentSlots = (() => {
603
+ if (!currentPoolPath) return []
604
+ try {
605
+ return parsePoolSlots(readFileSync(currentPoolPath, "utf8"))
606
+ } catch {
607
+ return []
608
+ }
609
+ })()
610
+ if (probeModels && probeModels.size > 0) {
611
+ const seen = new Set()
612
+ scanned = scanned.flatMap((entry) => {
613
+ if (entry.error) return []
614
+ const models = (entry.models ?? []).filter((candidate) => probeModels.has(candidate.model))
615
+ models.forEach((candidate) => seen.add(candidate.model))
616
+ if (models.length === 0) return []
617
+ return [{ ...entry, models }]
618
+ })
619
+ missingProbeModels.push(...[...probeModels].filter((model) => !seen.has(model)))
620
+ }
579
621
  const results = []
580
622
  const deadline = Date.now() + budgetMs
581
623
  const skippedProviders = []
@@ -655,6 +697,32 @@ async function main() {
655
697
  }
656
698
  stopSessionServer()
657
699
 
700
+ // A canary model absent from every catalogue is not "unscanned" — it is
701
+ // gone. A synthetic not-served row makes the selector drop it (hard-dead)
702
+ // instead of retaining it blindly.
703
+ for (const model of missingProbeModels) {
704
+ const slash = model.indexOf("/")
705
+ const provider = slash === -1 ? "unknown" : model.slice(0, slash)
706
+ const row = {
707
+ provider,
708
+ model,
709
+ url: "",
710
+ apiId: slash === -1 ? model : model.slice(slash + 1),
711
+ context: null,
712
+ output: null,
713
+ ok: false,
714
+ ms: null,
715
+ reason: "not in provider catalogue (model-missing)",
716
+ status: 404,
717
+ kind: "not-served",
718
+ score: null,
719
+ retryAfter: null,
720
+ }
721
+ const entry = results.find((candidate) => candidate.provider === provider)
722
+ if (entry) entry.rows.push(row)
723
+ else results.push({ provider, error: null, rows: [row], excluded: [], truncated: false, degraded: false })
724
+ }
725
+
658
726
  // Per-provider ranked tables.
659
727
  for (const entry of results) {
660
728
  if (entry.error || entry.rows.length === 0) continue
@@ -705,6 +773,7 @@ async function main() {
705
773
  excluded: entry.excluded,
706
774
  })),
707
775
  previousProviders: previousScores?.providers,
776
+ currentSlots,
708
777
  minContext,
709
778
  minScore,
710
779
  })