@lawaty/lacode 2.3.0-beta.4 → 2.3.0-beta.6

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/index.js CHANGED
@@ -39,14 +39,16 @@ 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;
45
46
  var FLAT_OUTAGE_REASONS = /* @__PURE__ */ new Set([UNAVAILABLE_REASON, PROVIDER_DEGRADED_REASON]);
46
47
  var MAX_WEIGHT = 100;
48
+ var MAX_CEILING = MAX_WEIGHT;
47
49
  var MIN_SLOT_CONTEXT = 1e5;
48
50
  var MIN_SLOT_TPM = 2e4;
49
- var slotCeiling = (slot) => slot.weight;
51
+ var slotCeiling = (slot) => slot.ceiling ?? slot.weight;
50
52
  var slotBucket = (slot) => slot.bucket ?? slot.model;
51
53
  var NOTIFY_DEFAULT_ON_PROVIDERS = /* @__PURE__ */ new Set(["opencode", "opencode-go"]);
52
54
  var notifyDefault = (model) => {
@@ -66,7 +68,7 @@ var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
66
68
  var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
67
69
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
68
70
  var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
69
- var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
71
+ var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|budget|429/i.test(reason);
70
72
  var MODEL_MISSING_REASON = "model-missing";
71
73
  var MODEL_MISSING_COOLDOWN_MS = QUOTA_COOLDOWN_MAX_MS;
72
74
  var AUTH_FAILURE_REASON = "auth-failed";
@@ -75,6 +77,16 @@ var MODEL_MISSING_PATTERN = /model\s+(?:not\s+found|does\s+not\s+exist|is\s+not\
75
77
  var isModelMissingText = (text) => MODEL_MISSING_PATTERN.test(text);
76
78
  var AUTH_FAILURE_PATTERN = /\b401\b|\b403\b|unauthori[sz]ed|not\s+authori[sz]ed|invalid[\s_-]+(?:api[\s_-]?(?:key|token)|token|credential|bearer)|api[\s_-]?key\s+(?:is\s+)?(?:invalid|missing|expired|not\s+valid)|authentication\s+(?:failed|error|required)|incorrect[\s_-]+api[\s_-]?key/i;
77
79
  var isAuthFailureText = (text) => AUTH_FAILURE_PATTERN.test(text);
80
+ var ABORT_PATTERN = /MessageAbortedError|"name"\s*:\s*"[^"]*Abort/i;
81
+ var isAbortText = (text) => ABORT_PATTERN.test(text);
82
+ var INVALID_REQUEST_REASON = "invalid-request";
83
+ var INVALID_REQUEST_COOLDOWN_MS = MODEL_MISSING_COOLDOWN_MS;
84
+ var UNSUPPORTED_PARAMETER_PATTERN = /not\s+a\s+supported\s+value|unsupported\s+(?:value|parameter|field)|(?:invalid|unknown)\s+(?:value|parameter)\s+for|is\s+not\s+supported/i;
85
+ var isUnsupportedParameterText = (text) => UNSUPPORTED_PARAMETER_PATTERN.test(text);
86
+ var TRANSIENT_UPSTREAM_REASON = "upstream-transient";
87
+ var TRANSIENT_UPSTREAM_COOLDOWN_MS = 60 * 1e3;
88
+ var TRANSIENT_UPSTREAM_PATTERN = /reasoning_display/i;
89
+ var isTransientUpstreamText = (text) => TRANSIENT_UPSTREAM_PATTERN.test(text);
78
90
  var BASES = ["explore", "implement", "context"];
79
91
  var LEARNED_TPM_FILE = "learned-tpm.json";
80
92
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
@@ -101,64 +113,100 @@ var coerceNotify = (raw, at, model, problems) => {
101
113
  problems.push(`${at} (${model}): notify must be a boolean or one of on/off, got ${JSON.stringify(raw)}; keeping the provider default`);
102
114
  return notifyDefault(model);
103
115
  };
116
+ var FALLBACK_MARKER = /^(?:#\s*-*\s*fallback(?:\s+slots)?\s*-*|\[fallback\])$/i;
117
+ var isFallbackMarker = (line) => FALLBACK_MARKER.test(String(line).trim());
118
+ function parseSlotEntry(entry, at, problems) {
119
+ const model = typeof entry?.model === "string" ? entry.model.trim() : "";
120
+ if (!model.includes("/")) {
121
+ problems.push(`${at}: model must be written provider/model-id, got "${model}"`);
122
+ return void 0;
123
+ }
124
+ const weight = readWeight(entry, at, model, problems);
125
+ const ceiling = readCeiling(entry, weight, at, model, problems);
126
+ const rawBucket = entry?.bucket;
127
+ const bucket = typeof rawBucket === "string" ? rawBucket.trim() : void 0;
128
+ if (rawBucket !== void 0 && !bucket) {
129
+ problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
130
+ }
131
+ const notify = coerceNotify(entry.notify, at, model, problems);
132
+ const rawContext = entry?.context;
133
+ let context;
134
+ if (rawContext !== void 0) {
135
+ const parsedContext = typeof rawContext === "number" ? rawContext : Number(rawContext);
136
+ if (Number.isFinite(parsedContext) && parsedContext > 0) context = parsedContext;
137
+ else problems.push(`${at} (${model}): context must be a positive number, got ${JSON.stringify(rawContext)}; ignoring it`);
138
+ }
139
+ if (context !== void 0 && context < MIN_SLOT_CONTEXT) {
140
+ problems.push(`${at} (${model}): context ${context} is below the ${MIN_SLOT_CONTEXT}-token minimum; dropping the slot`);
141
+ return void 0;
142
+ }
143
+ const rawTpm = entry?.tpm;
144
+ let tpm;
145
+ if (rawTpm !== void 0) {
146
+ const parsedTpm = typeof rawTpm === "number" ? rawTpm : Number(rawTpm);
147
+ if (Number.isFinite(parsedTpm) && parsedTpm > 0) tpm = parsedTpm;
148
+ else problems.push(`${at} (${model}): tpm must be a positive number, got ${JSON.stringify(rawTpm)}; ignoring it`);
149
+ }
150
+ if (tpm !== void 0 && tpm < MIN_SLOT_TPM) {
151
+ problems.push(`${at} (${model}): tpm ${tpm} is below the ${MIN_SLOT_TPM}-token/minute minimum; dropping the slot`);
152
+ return void 0;
153
+ }
154
+ return { index: 0, model, weight, ...ceiling === void 0 ? {} : { ceiling }, ...bucket ? { bucket } : {}, notify, ...context === void 0 ? {} : { context }, ...tpm === void 0 ? {} : { tpm } };
155
+ }
104
156
  function parseSlots(parsed, origin) {
105
157
  const problems = [];
106
- const entries = parsed?.slots;
107
- if (!Array.isArray(entries) || entries.length === 0) {
158
+ const primaryEntries = parsed?.slots;
159
+ const fallbackEntries = parsed?.fallbackSlots;
160
+ if ((!Array.isArray(primaryEntries) || primaryEntries.length === 0) && (!Array.isArray(fallbackEntries) || fallbackEntries.length === 0)) {
108
161
  return { slots: FALLBACK_SLOTS, problems: [`${origin} has no non-empty "slots" array; using built-in defaults`] };
109
162
  }
110
- const slots = [];
111
- entries.forEach((entry, i) => {
112
- const model = typeof entry?.model === "string" ? entry.model.trim() : "";
113
- const at = `slot ${i + 1}`;
114
- if (!model.includes("/")) return problems.push(`${at}: model must be written provider/model-id, got "${model}"`);
115
- const weight = readWeight(entry, at, model, problems);
116
- const rawBucket = entry?.bucket;
117
- const bucket = typeof rawBucket === "string" ? rawBucket.trim() : void 0;
118
- if (rawBucket !== void 0 && !bucket) {
119
- problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
120
- }
121
- const notify = coerceNotify(entry.notify, at, model, problems);
122
- const rawContext = entry?.context;
123
- let context;
124
- if (rawContext !== void 0) {
125
- const parsedContext = typeof rawContext === "number" ? rawContext : Number(rawContext);
126
- if (Number.isFinite(parsedContext) && parsedContext > 0) context = parsedContext;
127
- else problems.push(`${at} (${model}): context must be a positive number, got ${JSON.stringify(rawContext)}; ignoring it`);
128
- }
129
- if (context !== void 0 && context < MIN_SLOT_CONTEXT) {
130
- problems.push(`${at} (${model}): context ${context} is below the ${MIN_SLOT_CONTEXT}-token minimum; dropping the slot`);
131
- return;
132
- }
133
- const rawTpm = entry?.tpm;
134
- let tpm;
135
- if (rawTpm !== void 0) {
136
- const parsedTpm = typeof rawTpm === "number" ? rawTpm : Number(rawTpm);
137
- if (Number.isFinite(parsedTpm) && parsedTpm > 0) tpm = parsedTpm;
138
- else problems.push(`${at} (${model}): tpm must be a positive number, got ${JSON.stringify(rawTpm)}; ignoring it`);
139
- }
140
- if (tpm !== void 0 && tpm < MIN_SLOT_TPM) {
141
- problems.push(`${at} (${model}): tpm ${tpm} is below the ${MIN_SLOT_TPM}-token/minute minimum; dropping the slot`);
142
- return;
163
+ const parseList = (list, fallback2) => {
164
+ if (!Array.isArray(list)) return [];
165
+ const out = [];
166
+ list.forEach((entry, i) => {
167
+ const slot = parseSlotEntry(entry, `${fallback2 ? "fallback slot" : "slot"} ${i + 1}`, problems);
168
+ if (slot) out.push({ ...slot, ...fallback2 ? { fallback: true } : {} });
169
+ });
170
+ return out;
171
+ };
172
+ const primary = parseList(primaryEntries, false);
173
+ let fallback = parseList(fallbackEntries, true);
174
+ const primaryBuckets = new Set(primary.map((slot) => slotBucket(slot)));
175
+ const primaryModels = new Set(primary.map((slot) => slot.model));
176
+ fallback = fallback.filter((slot) => {
177
+ const shared = primaryModels.has(slot.model) || primaryBuckets.has(slotBucket(slot));
178
+ if (shared) {
179
+ problems.push(
180
+ `fallback slot (${slot.model}): shares a limit window with the primary pool (${slotBucket(slot)}); dropping it`
181
+ );
182
+ return false;
143
183
  }
144
- slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify, ...context === void 0 ? {} : { context }, ...tpm === void 0 ? {} : { tpm } });
184
+ return true;
145
185
  });
186
+ const slots = [...primary, ...fallback].map((slot, i) => ({ ...slot, index: i + 1 }));
146
187
  if (slots.length > SLOTS_WARN_AT) {
147
188
  problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
148
189
  }
149
- if (slots.length === 0) {
150
- problems.push(`${origin} yielded no usable slot; using built-in defaults`);
190
+ if (primary.length === 0) {
191
+ problems.push(`${origin} yielded no usable primary slot; using built-in defaults`);
151
192
  return { slots: FALLBACK_SLOTS, problems };
152
193
  }
153
194
  return { slots, problems };
154
195
  }
155
196
  function parseTextSlots(text, origin) {
156
197
  const entries = [];
198
+ const fallbackEntries = [];
199
+ let section = "primary";
157
200
  for (const raw of text.split(/\r?\n/)) {
201
+ if (isFallbackMarker(raw)) {
202
+ section = "fallback";
203
+ continue;
204
+ }
158
205
  const line = raw.split("#")[0].trim();
159
206
  if (!line) continue;
160
207
  const [model, ...rest] = line.split(/\s+/);
161
208
  let weight;
209
+ let ceiling;
162
210
  let bucket;
163
211
  let notify;
164
212
  let context;
@@ -168,6 +216,13 @@ function parseTextSlots(text, origin) {
168
216
  if (notify === void 0) notify = token.slice("notify:".length);
169
217
  continue;
170
218
  }
219
+ if (token.startsWith("ceil:")) {
220
+ if (ceiling === void 0) {
221
+ const parsedCeiling = Number(token.slice("ceil:".length));
222
+ if (Number.isFinite(parsedCeiling) && parsedCeiling > 0) ceiling = parsedCeiling;
223
+ }
224
+ continue;
225
+ }
171
226
  if (token.startsWith("c:")) {
172
227
  if (context === void 0) {
173
228
  const parsedContext = Number(token.slice(2));
@@ -188,9 +243,11 @@ function parseTextSlots(text, origin) {
188
243
  weight = token;
189
244
  }
190
245
  }
191
- entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify }, ...context === void 0 ? {} : { context }, ...tpm === void 0 ? {} : { tpm } });
246
+ const entry = { model, ...weight === void 0 ? {} : { weight }, ...ceiling === void 0 ? {} : { ceiling }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify }, ...context === void 0 ? {} : { context }, ...tpm === void 0 ? {} : { tpm } };
247
+ if (section === "fallback") fallbackEntries.push(entry);
248
+ else entries.push(entry);
192
249
  }
193
- return parseSlots({ slots: entries }, origin);
250
+ return parseSlots({ slots: entries, ...fallbackEntries.length > 0 ? { fallbackSlots: fallbackEntries } : {} }, origin);
194
251
  }
195
252
  function parseSlotText(text, origin) {
196
253
  if (text.trimStart().startsWith("{")) {
@@ -295,6 +352,20 @@ function readWeight(entry, at, model, problems) {
295
352
  }
296
353
  return weight;
297
354
  }
355
+ function readCeiling(entry, weight, at, model, problems) {
356
+ const raw = entry?.ceiling;
357
+ if (raw === void 0) return void 0;
358
+ const ceiling = typeof raw === "number" ? raw : Number(raw);
359
+ if (!Number.isFinite(ceiling) || ceiling <= 0 || ceiling > MAX_CEILING) {
360
+ problems.push(`${at} (${model}): ceiling must be a number in (0, ${MAX_CEILING}], got ${JSON.stringify(raw)}; using the weight`);
361
+ return void 0;
362
+ }
363
+ if (ceiling < weight) {
364
+ problems.push(`${at} (${model}): ceiling ${ceiling} is below weight ${weight}; using the weight as the ceiling`);
365
+ return void 0;
366
+ }
367
+ return Math.floor(ceiling);
368
+ }
298
369
  var UNAVAILABLE_PATTERN = /endpoint\s+is\s+unavailable/i;
299
370
  var LIMIT_PATTERNS = [
300
371
  /rate[\s_-]?limit/i,
@@ -302,6 +373,7 @@ var LIMIT_PATTERNS = [
302
373
  /too many requests/i,
303
374
  /\b429\b/,
304
375
  /quota/i,
376
+ /budget\s+exhausted|api\s+budget/i,
305
377
  /freelimiterror/i,
306
378
  /overloaded/i,
307
379
  UNAVAILABLE_PATTERN
@@ -345,7 +417,7 @@ function parseRateLimitHeaders(headers) {
345
417
  }
346
418
  return void 0;
347
419
  }
348
- var LIMIT_REASON_PATTERN = /rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded|limits?[\s_-]?(?:reached|exceeded)/i;
420
+ var LIMIT_REASON_PATTERN = /rate[\s_-]?limit|usage limit|too many requests|quota|budget\s+exhausted|freelimiterror|overloaded|limits?[\s_-]?(?:reached|exceeded)/i;
349
421
  var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text)) || LIMIT_REACHED_PATTERN.test(text) && !CONTEXT_LIMIT_PATTERN.test(text);
350
422
  var limitReason = (text) => {
351
423
  if (!isLimitText(text)) return void 0;
@@ -435,7 +507,7 @@ function ensureSlots(s, onWarn) {
435
507
  for (const problem of problems) onWarn(problem);
436
508
  return slots;
437
509
  }
438
- var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}:${slotNotify(slot) ? "1" : "0"}:${slot.tpm ?? ""}`).join("|")}`;
510
+ var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.ceiling ?? ""}:${slot.bucket ?? ""}:${slotNotify(slot) ? "1" : "0"}:${slot.tpm ?? ""}:${slot.fallback ? "f" : "p"}`).join("|")}`;
439
511
  function reloadSlots(s, onWarn) {
440
512
  const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
441
513
  const effective = applyLearnedTpm(s, slots, onWarn);
@@ -695,7 +767,7 @@ function readLimits(s) {
695
767
  try {
696
768
  const strike = JSON.parse(readFileSync2(path4, "utf8"));
697
769
  if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
698
- const countsAsStrike = strike.reason !== UNAVAILABLE_REASON;
770
+ const countsAsStrike = strike.reason !== UNAVAILABLE_REASON && strike.reason !== SCAN_KEPT_REASON;
699
771
  if (strike.until > now) {
700
772
  cooling.set(strike.model, strike);
701
773
  if (countsAsStrike) memory.set(strike.model, strike);
@@ -849,52 +921,54 @@ function snapshot(s) {
849
921
  }
850
922
  var loadOf = (counts, slot) => counts.get(slot) ?? 0;
851
923
  var underCeiling = (slot, counts) => loadOf(counts, slot.index) < slotCeiling(slot);
924
+ var fillPassIndex = (slot, counts) => Math.floor(loadOf(counts, slot.index) / Math.max(1, slot.weight));
925
+ var pickByPass = (group, counts, prefer) => {
926
+ const open = group.filter((slot) => underCeiling(slot, counts));
927
+ if (open.length === 0) return void 0;
928
+ const lowest = Math.min(...open.map((slot) => fillPassIndex(slot, counts)));
929
+ const atPass = open.filter((slot) => fillPassIndex(slot, counts) === lowest);
930
+ return prefer ? atPass.reduce(prefer, atPass[0]) : atPass[0];
931
+ };
852
932
  function pickSlot(s, snap, neededContext) {
853
933
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
854
- const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
934
+ const primary = slots.filter((slot) => !slot.fallback);
935
+ const fallback = slots.filter((slot) => slot.fallback);
936
+ const usablePrimary = primary.filter((slot) => isAvailable(s, slot.model, snap.cooling));
937
+ const usable = usablePrimary.length > 0 ? usablePrimary : fallback.filter((slot) => isAvailable(s, slot.model, snap.cooling));
855
938
  if (usable.length === 0) return void 0;
856
939
  const effective = (slot) => Math.min(slot.context ?? Number.POSITIVE_INFINITY, slot.tpm ?? Number.POSITIVE_INFINITY);
940
+ const leastLoaded = (best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best;
857
941
  if (neededContext === void 0) {
858
- for (const slot of usable) {
859
- if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
860
- }
861
- return usable.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, usable[0]);
942
+ return pickByPass(usable, snap.counts) ?? usable.reduce(leastLoaded, usable[0]);
862
943
  }
863
944
  const adequate = usable.filter((slot) => effective(slot) >= neededContext);
864
945
  const adequateKnown = adequate.filter((slot) => Number.isFinite(effective(slot)));
865
946
  const candidates = adequate.length > 0 ? adequate : usable;
947
+ const smallest = (best, slot) => Number.isFinite(effective(slot)) && effective(slot) < effective(best) ? slot : best;
866
948
  if (adequateKnown.length === 0) {
867
949
  const known = usable.filter((slot) => Number.isFinite(effective(slot)));
868
950
  if (known.length > 0) {
869
951
  const largest = Math.max(...known.map((slot) => effective(slot)));
870
952
  const largestSlots = known.filter((slot) => effective(slot) === largest);
871
- for (const slot of largestSlots) {
872
- if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
873
- }
874
- return largestSlots.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, largestSlots[0]);
953
+ return pickByPass(largestSlots, snap.counts) ?? largestSlots.reduce(leastLoaded, largestSlots[0]);
875
954
  }
876
- for (const slot of candidates) {
877
- if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
878
- }
879
- return candidates.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, candidates[0]);
880
- }
881
- const underWeight = candidates.filter((slot) => loadOf(snap.counts, slot.index) < slot.weight);
882
- if (underWeight.length > 0) {
883
- const known = underWeight.filter((slot) => Number.isFinite(effective(slot)));
884
- if (known.length === 0) return underWeight[0];
885
- return known.reduce((best, slot) => effective(slot) < effective(best) ? slot : best, known[0]);
955
+ return pickByPass(candidates, snap.counts) ?? candidates.reduce(leastLoaded, candidates[0]);
886
956
  }
887
- return candidates.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, candidates[0]);
957
+ return pickByPass(candidates, snap.counts, smallest) ?? candidates.reduce(leastLoaded, candidates[0]);
888
958
  }
889
959
  function poolPass(s, snap, neededContext) {
890
960
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
891
- const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
961
+ const primary = slots.filter((slot) => !slot.fallback);
962
+ const fallback = slots.filter((slot) => slot.fallback);
963
+ const usablePrimary = primary.filter((slot) => isAvailable(s, slot.model, snap.cooling));
964
+ const usable = usablePrimary.length > 0 ? usablePrimary : fallback.filter((slot) => isAvailable(s, slot.model, snap.cooling));
892
965
  if (usable.length === 0) return "none";
893
966
  const effective = (slot) => Math.min(slot.context ?? Number.POSITIVE_INFINITY, slot.tpm ?? Number.POSITIVE_INFINITY);
894
967
  const candidates = neededContext === void 0 ? usable : usable.filter((slot) => effective(slot) >= neededContext);
895
968
  const pool = candidates.length > 0 ? candidates : usable;
896
- if (pool.some((slot) => loadOf(snap.counts, slot.index) < slot.weight)) return 1;
897
- return "overflow";
969
+ const open = pool.filter((slot) => underCeiling(slot, snap.counts));
970
+ if (open.length === 0) return "overflow";
971
+ return Math.min(...open.map((slot) => fillPassIndex(slot, snap.counts))) + 1;
898
972
  }
899
973
  function agentFor(base, slot) {
900
974
  return `${base}-${slot}`;
@@ -2166,7 +2240,7 @@ Follow the rules for the file in your system prompt, then report what you change
2166
2240
  Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
2167
2241
 
2168
2242
  1. Pool status \u2014 call the \`pool_status\` tool. Report the slot list, each
2169
- slot's load against its budget (its weight), and which models
2243
+ slot's load against its ceiling, and which models
2170
2244
  are cooling.
2171
2245
 
2172
2246
  2. OmniRoute configured \u2014 inspect the \`@lawaty/lacode\` plugin options in the
@@ -2202,9 +2276,11 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
2202
2276
  1. Establish the baseline. Read \`${USER_MODELS_TXT}\`. If it is absent, the pool is resolving from the bundled preset \`presets/free-tier.json\`; read that file to learn what is in effect. Call the \`pool_status\` tool only if you need live load or cooldown state.
2203
2277
 
2204
2278
  2. Resolve the instruction above into a target slot table. These are the rules the loader enforces \u2014 never write a file that breaks one:
2205
- - one slot per line: \`<provider>/<model-id> [weight]\`
2279
+ - one slot per line: \`<provider>/<model-id> [weight] [ceil:<max-parallel>] [c:<context>] [b:<bucket>]\`
2206
2280
  - any number of slots; a model may appear on more than one line, and each line is its own slot with its own budget
2207
- - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's active-session budget \u2014 how many concurrent sessions it may hold. The pool's total capacity is the sum of the weights. NOT a priority; pick order follows line order.
2281
+ - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's per-pass budget \u2014 how many concurrent sessions one pass may add. The pool's total capacity is the sum of the ceilings (which equals the sum of the weights when no ceiling is set). NOT a priority; pick order follows line order.
2282
+ - \`ceil:<max-parallel>\` is optional and is the slot's hard concurrency cap (its max parallel sessions). It must be at least the weight; later passes deepen a slot toward it after every slot has had its weight. Absent means the ceiling equals the weight (one pass).
2283
+ - a line that is exactly \`# --- fallback ---\` starts the fallback section. Keep it and the slots below it when rewriting the file: those slots are used only when no primary slot is usable, and each must not share a bucket or model with the primary.
2208
2284
  - \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
2209
2285
  If the instruction names a model ambiguously, stop and ask rather than guessing.
2210
2286
 
@@ -2527,7 +2603,9 @@ async function maybeSelfUpdate(opts) {
2527
2603
  }
2528
2604
 
2529
2605
  // src/plugins/agent-pool.ts
2530
- import { unwatchFile, watchFile } from "node:fs";
2606
+ import { readFileSync as readFileSync5, unwatchFile, watchFile } from "node:fs";
2607
+ import { homedir as homedir4 } from "node:os";
2608
+ import { join as join6 } from "node:path";
2531
2609
  import { tool } from "@opencode-ai/plugin";
2532
2610
 
2533
2611
  // src/lib/probe.ts
@@ -3179,6 +3257,43 @@ function estimateContextTokens(prompt) {
3179
3257
  if (typeof prompt !== "string" || prompt.length === 0) return void 0;
3180
3258
  return Math.ceil(prompt.length / 4) + CONTEXT_HEADROOM_TOKENS;
3181
3259
  }
3260
+ var poolHealthLines = (slots, now) => {
3261
+ const lines = [];
3262
+ const heads = slots.filter((slot) => slot.model.startsWith("opencode-go/"));
3263
+ const headOk = heads.length > 0 && slots.slice(0, heads.length).every((slot) => slot.model.startsWith("opencode-go/"));
3264
+ const tail = slots[slots.length - 1];
3265
+ const tailOk = !!tail && tail.model.startsWith("opencode/") && tail.bucket === "opencode";
3266
+ const verdict = headOk && tailOk ? "intact" : "BROKEN";
3267
+ lines.push(
3268
+ `pool structure: ${verdict} (${slots.length}/10 slots, head=${headOk ? `${heads.length} opencode-go` : "missing"}, tail=${tailOk ? tail.model : "missing"})`
3269
+ );
3270
+ try {
3271
+ const text = readFileSync5(join6(homedir4(), ".config", "lacode", "pool.txt"), "utf8");
3272
+ const stamp = /^#\s*Refreshed\s+(\S+)/m.exec(text)?.[1];
3273
+ if (stamp) {
3274
+ const ageMs = now - Date.parse(stamp);
3275
+ if (Number.isFinite(ageMs) && ageMs >= 0) {
3276
+ const mins = Math.floor(ageMs / 6e4);
3277
+ lines.push(`last refresh: ${mins < 60 ? `${mins}m ago` : `${Math.floor(mins / 60)}h${mins % 60}m ago`} (${stamp})`);
3278
+ }
3279
+ }
3280
+ } catch {
3281
+ }
3282
+ try {
3283
+ const raw = readFileSync5(join6(homedir4(), ".config", "lacode", "provider-status.json"), "utf8");
3284
+ const status = JSON.parse(raw);
3285
+ if (status && typeof status === "object") {
3286
+ const ageS = typeof status.checkedAt === "number" ? Math.max(0, Math.round((now - status.checkedAt) / 1e3)) : -1;
3287
+ const bad = (status.services ?? []).filter((entry) => String(entry?.status ?? "") !== "operational");
3288
+ const detail = bad.length > 0 ? `: ${bad.map((entry) => `${entry.service}=${entry.status}`).join(", ")}` : "";
3289
+ lines.push(
3290
+ `status page: ${status.ok === false ? "unreachable" : status.overall ?? "unknown"}${detail}${ageS >= 0 ? ` (checked ${ageS}s ago)` : ""}`
3291
+ );
3292
+ }
3293
+ } catch {
3294
+ }
3295
+ return lines;
3296
+ };
3182
3297
  var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3183
3298
  const opts = options ?? {};
3184
3299
  const notify = opts.notify ?? ((title, message) => void notifyDesktop(title, message));
@@ -3862,6 +3977,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3862
3977
  const auth2 = isAuthFailureText(haystack2) || status2 === 401 || status2 === 403;
3863
3978
  const limited2 = isLimitText(haystack2) || status2 === 429;
3864
3979
  const outage = isUnavailableText(haystack2);
3980
+ const transientUpstream2 = status2 === 400 && isTransientUpstreamText(haystack2) && !auth2 && !limited2 && !outage;
3981
+ const unsupported2 = status2 === 400 && error?.data?.isRetryable === false && isUnsupportedParameterText(haystack2) && !transientUpstream2 && !auth2 && !limited2 && !outage;
3865
3982
  if (missing2) {
3866
3983
  const unbound = !own && sessionID ? [...s.own.entries()].find(([, claim]) => !claim.g && claim.s === sessionID && haystack2.includes(claim.m)) : void 0;
3867
3984
  const target = own ?? (unbound ? { key: unbound[0], claim: unbound[1] } : void 0);
@@ -3887,6 +4004,30 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3887
4004
  }
3888
4005
  return;
3889
4006
  }
4007
+ if (transientUpstream2) {
4008
+ if (own) {
4009
+ const transient = coolUnavailable(s, own.claim.m, TRANSIENT_UPSTREAM_COOLDOWN_MS, TRANSIENT_UPSTREAM_REASON);
4010
+ recordFailure(s, {
4011
+ ts: Date.now(),
4012
+ source: "observed",
4013
+ base: own.claim.v,
4014
+ slot: own.claim.k,
4015
+ model: own.claim.m,
4016
+ session: sessionID,
4017
+ reason: TRANSIENT_UPSTREAM_REASON,
4018
+ elapsedMs: Date.now() - own.claim.t,
4019
+ released: true,
4020
+ detail: haystack2.slice(0, 400)
4021
+ });
4022
+ release(s, own.key);
4023
+ await log(
4024
+ "warn",
4025
+ transient ? `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${TRANSIENT_UPSTREAM_REASON}) after a flaky upstream error` : `released ${own.claim.v} claim on ${own.claim.m} after a flaky upstream error`,
4026
+ { base: own.claim.v, model: own.claim.m, slot: own.claim.k, session: sessionID }
4027
+ );
4028
+ }
4029
+ return;
4030
+ }
3890
4031
  const isTpm2 = isRequestTooLargeText(haystack2);
3891
4032
  const statedLimit2 = isTpm2 ? parseStatedTokenLimit(haystack2) : void 0;
3892
4033
  const headers2 = error?.data?.responseHeaders;
@@ -3913,23 +4054,40 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3913
4054
  if (!limited2 && !outage && !auth2) {
3914
4055
  if (own) {
3915
4056
  const detail = haystack2.slice(0, 400);
3916
- recordFailure(s, {
3917
- ts: Date.now(),
3918
- source: "observed",
3919
- base: own.claim.v,
3920
- slot: own.claim.k,
3921
- model: own.claim.m,
3922
- session: sessionID,
3923
- reason: "provider error",
3924
- elapsedMs: Date.now() - own.claim.t,
3925
- released: true,
3926
- detail
3927
- });
3928
- if (!observedErrors.has(own.key)) {
3929
- observedErrors.set(own.key, { reason: "provider error", detail });
3930
- if (observedErrors.size > 32) {
3931
- const oldest = observedErrors.keys().next().value;
3932
- if (oldest !== void 0) observedErrors.delete(oldest);
4057
+ const aborted = isAbortText(haystack2);
4058
+ const reason2 = unsupported2 ? INVALID_REQUEST_REASON : "provider error";
4059
+ if (!aborted) {
4060
+ recordFailure(s, {
4061
+ ts: Date.now(),
4062
+ source: "observed",
4063
+ base: own.claim.v,
4064
+ slot: own.claim.k,
4065
+ model: own.claim.m,
4066
+ session: sessionID,
4067
+ reason: reason2,
4068
+ elapsedMs: Date.now() - own.claim.t,
4069
+ released: true,
4070
+ detail
4071
+ });
4072
+ if (!observedErrors.has(own.key)) {
4073
+ observedErrors.set(own.key, { reason: reason2, detail });
4074
+ if (observedErrors.size > 32) {
4075
+ const oldest = observedErrors.keys().next().value;
4076
+ if (oldest !== void 0) observedErrors.delete(oldest);
4077
+ }
4078
+ }
4079
+ const strike2 = recordStrike(
4080
+ s,
4081
+ own.claim.m,
4082
+ reason2,
4083
+ unsupported2 ? INVALID_REQUEST_COOLDOWN_MS : void 0
4084
+ );
4085
+ if (strike2) {
4086
+ await log(
4087
+ "warn",
4088
+ `bench ${own.claim.m} (${reason2}, strike ${strike2.strikes}, ${Math.round((strike2.until - Date.now()) / 1e3)}s) after a terminal session error`,
4089
+ { model: own.claim.m, slot: own.claim.k, session: sessionID, reason: detail }
4090
+ );
3933
4091
  }
3934
4092
  }
3935
4093
  release(s, own.key);
@@ -4054,6 +4212,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
4054
4212
  const gateway5xx = status !== void 0 && status >= 500 && status <= 599;
4055
4213
  const transientGateway = gateway5xx || isTransientGatewayText(haystack);
4056
4214
  const missing = isModelMissingText(haystack);
4215
+ const transientUpstream = status === 400 && isTransientUpstreamText(haystack) && !auth && !limited && !transientGateway;
4216
+ const unsupported = status === 400 && info.error.data?.isRetryable === false && isUnsupportedParameterText(haystack) && !transientUpstream && !auth && !limited && !transientGateway;
4057
4217
  const model = `${info.providerID ?? "?"}/${info.modelID ?? "?"}`;
4058
4218
  const ownMsg = claimFor(info.sessionID);
4059
4219
  const isTpm = isRequestTooLargeText(haystack);
@@ -4080,24 +4240,36 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
4080
4240
  if (!limited && !transientGateway && !auth && !missing) {
4081
4241
  const ownMsgUnclassified = ownMsg;
4082
4242
  if (ownMsgUnclassified) {
4083
- recordFailure(s, {
4084
- ts: Date.now(),
4085
- source: "observed",
4086
- base: ownMsgUnclassified.claim.v,
4087
- slot: ownMsgUnclassified.claim.k,
4088
- model: ownMsgUnclassified.claim.m,
4089
- session: info.sessionID,
4090
- reason: "provider error",
4091
- elapsedMs: Date.now() - ownMsgUnclassified.claim.t,
4092
- released: false,
4093
- detail: haystack.slice(0, 400)
4094
- });
4095
- if (!observedErrors.has(ownMsgUnclassified.key)) {
4096
- observedErrors.set(ownMsgUnclassified.key, { reason: "provider error", detail: haystack.slice(0, 400) });
4097
- if (observedErrors.size > 32) {
4098
- const oldest = observedErrors.keys().next().value;
4099
- if (oldest !== void 0) observedErrors.delete(oldest);
4243
+ const aborted = isAbortText(haystack);
4244
+ if (!aborted) {
4245
+ recordFailure(s, {
4246
+ ts: Date.now(),
4247
+ source: "observed",
4248
+ base: ownMsgUnclassified.claim.v,
4249
+ slot: ownMsgUnclassified.claim.k,
4250
+ model: ownMsgUnclassified.claim.m,
4251
+ session: info.sessionID,
4252
+ reason: "provider error",
4253
+ elapsedMs: Date.now() - ownMsgUnclassified.claim.t,
4254
+ released: true,
4255
+ detail: haystack.slice(0, 400)
4256
+ });
4257
+ if (!observedErrors.has(ownMsgUnclassified.key)) {
4258
+ observedErrors.set(ownMsgUnclassified.key, { reason: "provider error", detail: haystack.slice(0, 400) });
4259
+ if (observedErrors.size > 32) {
4260
+ const oldest = observedErrors.keys().next().value;
4261
+ if (oldest !== void 0) observedErrors.delete(oldest);
4262
+ }
4100
4263
  }
4264
+ const strike2 = recordStrike(s, ownMsgUnclassified.claim.m, "provider error");
4265
+ if (strike2) {
4266
+ await log(
4267
+ "warn",
4268
+ `bench ${ownMsgUnclassified.claim.m} (provider error, strike ${strike2.strikes}) after an unclassified terminal error`,
4269
+ { model: ownMsgUnclassified.claim.m, slot: ownMsgUnclassified.claim.k, session: info.sessionID, reason: haystack.slice(0, 400) }
4270
+ );
4271
+ }
4272
+ release(s, ownMsgUnclassified.key);
4101
4273
  }
4102
4274
  }
4103
4275
  return;
@@ -4130,6 +4302,39 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
4130
4302
  }
4131
4303
  return;
4132
4304
  }
4305
+ if (unsupported) {
4306
+ const ownUnsupported = ownMsg;
4307
+ if (ownUnsupported) {
4308
+ const strike2 = recordStrike(s, ownUnsupported.claim.m, INVALID_REQUEST_REASON, INVALID_REQUEST_COOLDOWN_MS);
4309
+ if (strike2) {
4310
+ await log(
4311
+ "warn",
4312
+ `bench ${ownUnsupported.claim.m} (${INVALID_REQUEST_REASON}, ${Math.round((strike2.until - Date.now()) / 1e3)}s) after a non-retryable unsupported-parameter error`,
4313
+ { model: ownUnsupported.claim.m, slot: ownUnsupported.claim.k, session: info.sessionID, reason: haystack.slice(0, 400) }
4314
+ );
4315
+ }
4316
+ recordFailure(s, {
4317
+ ts: Date.now(),
4318
+ source: "observed",
4319
+ base: ownUnsupported.claim.v,
4320
+ slot: ownUnsupported.claim.k,
4321
+ model: ownUnsupported.claim.m,
4322
+ session: info.sessionID,
4323
+ reason: INVALID_REQUEST_REASON,
4324
+ elapsedMs: Date.now() - ownUnsupported.claim.t,
4325
+ released: true,
4326
+ detail: haystack.slice(0, 400)
4327
+ });
4328
+ release(s, ownUnsupported.key);
4329
+ await log("warn", `released ${ownUnsupported.claim.v} claim on ${ownUnsupported.claim.m} after an unsupported-parameter error`, {
4330
+ base: ownUnsupported.claim.v,
4331
+ model: ownUnsupported.claim.m,
4332
+ slot: ownUnsupported.claim.k,
4333
+ session: info.sessionID
4334
+ });
4335
+ }
4336
+ return;
4337
+ }
4133
4338
  if (isUnavailableText(haystack) || !limited && !auth && transientGateway) {
4134
4339
  const transient = coolUnavailable(s, model);
4135
4340
  if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
@@ -4278,15 +4483,19 @@ ${detail}` : detail;
4278
4483
  const users = entry && entry.bases.size > 0 ? [...entry.bases].map(([b, n]) => `${b}x${n}`).join(" ") : "idle";
4279
4484
  const strike = snap.cooling.get(slot.model);
4280
4485
  const status = s.catalog.get(slot.model);
4486
+ const untilClock = strike ? new Date(strike.until).toISOString().slice(11, 19) + "Z" : "";
4281
4487
  const notes = [
4282
- strike ? `UNAVAILABLE (COOLING ${Math.ceil((strike.until - now) / 1e3)}s, ${strike.reason}, strike ${strike.strikes})` : "",
4488
+ strike ? `UNAVAILABLE (COOLING ${Math.ceil((strike.until - now) / 1e3)}s left, until ${untilClock}, ${strike.reason}, strike ${strike.strikes})` : "",
4283
4489
  !providerReady(s, slot.model) ? `UNAVAILABLE (provider-unconfigured: ${providerOf(slot.model)}; run \`opencode auth login ${providerOf(slot.model)}\`)` : "",
4284
4490
  status && status !== "active" && status !== "unknown" ? `catalog:${status}` : ""
4285
4491
  ].filter(Boolean);
4286
4492
  const marker = next && slot.index === next.index ? " <- next" : "";
4287
4493
  const load = entry?.total ?? 0;
4494
+ const ceiling = slotCeiling(slot);
4288
4495
  const atCeiling = !underCeiling(slot, snap.counts);
4289
- return ` slot ${slot.index} w${slot.weight} ${slot.model} load=${load}/${slotCeiling(slot)}${atCeiling ? " FULL" : ""}${marker} [${users}${notes.length ? "; " + notes.join("; ") : ""}]`;
4496
+ const cap = ceiling === slot.weight ? `w${slot.weight}` : `w${slot.weight} c${ceiling}`;
4497
+ const tier = slot.fallback ? " [fallback]" : "";
4498
+ return ` slot ${slot.index} ${cap} ${slot.model}${tier} load=${load}/${ceiling}${atCeiling ? " FULL" : ""}${marker} [${users}${notes.length ? "; " + notes.join("; ") : ""}]`;
4290
4499
  };
4291
4500
  const total = [...snap.counts.values()].reduce((sum, n) => sum + n, 0);
4292
4501
  const coolingNow = [...snap.cooling.entries()].filter(([, st]) => st.until > now);
@@ -4295,7 +4504,7 @@ ${detail}` : detail;
4295
4504
  for (const slot of slots) {
4296
4505
  const name = slotBucket(slot);
4297
4506
  const entry = buckets.get(name) ?? { seats: 0, slots: 0, cooling: false };
4298
- entry.seats += slot.weight;
4507
+ entry.seats += slotCeiling(slot);
4299
4508
  entry.slots++;
4300
4509
  if (snap.cooling.has(slot.model)) entry.cooling = true;
4301
4510
  buckets.set(name, entry);
@@ -4307,12 +4516,13 @@ ${detail}` : detail;
4307
4516
  return {
4308
4517
  title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
4309
4518
  output: [
4310
- `shared pool over ${slots.length} slot(s) from ${s.origin}; state ${pass}; bases: ${BASES.join(", ")}
4311
- one ordered pass in slot order: each slot fills to its weight, then overflow goes to the least-loaded slot. Capacity is the sum of the weights; repeat a model on another line to give it more (FULL = at weight).`,
4519
+ `shared pool over ${slots.filter((slot) => !slot.fallback).length} primary slot(s) + ${slots.filter((slot) => slot.fallback).length} fallback slot(s) from ${s.origin}; state ${pass}; bases: ${BASES.join(", ")}
4520
+ ordered fill in declaration order: each pass fills every usable slot to its weight, then the next pass deepens a slot toward its ceiling (max parallel sessions). The fallback section is used only when no primary slot is usable. Capacity is the sum of the ceilings; weight alone is the per-pass budget (FULL = at ceiling).`,
4312
4521
  ...next ? [] : ["next: none - no routable slot; every slot is cooling, catalog-retired, or provider-unconfigured. A spawn will fail explicitly instead of routing."],
4313
4522
  ...sharedBuckets.length ? [`buckets: ${sharedBuckets.map(([name, entry]) => `${name} ${entry.seats} seat(s)${entry.cooling ? " COOLING" : ""}`).join("; ")}`] : [],
4314
4523
  ...degradedProviders.length ? [`provider degraded: ${degradedProviders.join(", ")} (flat window; a probe re-admits a model when it answers)`] : [],
4315
4524
  ...slots.map(line),
4525
+ ...poolHealthLines(slots.filter((slot) => !slot.fallback), now),
4316
4526
  "",
4317
4527
  "by process:",
4318
4528
  ` ${s.id} (this process): ${snap.own.size} claim(s)`,
@@ -4380,11 +4590,11 @@ one ordered pass in slot order: each slot fills to its weight, then overflow goe
4380
4590
 
4381
4591
  // src/plugins/context-autoupdate.ts
4382
4592
  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";
4593
+ import { homedir as homedir6 } from "node:os";
4384
4594
  import * as path3 from "node:path";
4385
4595
 
4386
4596
  // src/lib/map-schema.ts
4387
- import { existsSync as existsSync3, readFileSync as readFileSync5, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
4597
+ import { existsSync as existsSync3, readFileSync as readFileSync6, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
4388
4598
  import * as path from "node:path";
4389
4599
  var PROVISIONAL_DAYS = 30;
4390
4600
  var MAP_SPECS = [
@@ -4537,7 +4747,7 @@ var resolvePointer = (root, pointer, section) => {
4537
4747
  if (!stat.isFile()) return { exists: false };
4538
4748
  let text;
4539
4749
  try {
4540
- if (stat.size <= 512 * 1024) text = readFileSync5(absolute, "utf8");
4750
+ if (stat.size <= 512 * 1024) text = readFileSync6(absolute, "utf8");
4541
4751
  } catch {
4542
4752
  text = void 0;
4543
4753
  }
@@ -4786,7 +4996,7 @@ var maintainMap = (root, mapDir, now = Date.now()) => {
4786
4996
  if (!existsSync3(absolute)) continue;
4787
4997
  let before;
4788
4998
  try {
4789
- before = readFileSync5(absolute, "utf8");
4999
+ before = readFileSync6(absolute, "utf8");
4790
5000
  } catch {
4791
5001
  continue;
4792
5002
  }
@@ -4814,7 +5024,7 @@ var auditMap = (root, mapDir, now = Date.now()) => {
4814
5024
  }
4815
5025
  let text;
4816
5026
  try {
4817
- text = readFileSync5(absolute, "utf8");
5027
+ text = readFileSync6(absolute, "utf8");
4818
5028
  } catch {
4819
5029
  violations.push({ file: spec.file, kind: "missing-file", detail: "unreadable" });
4820
5030
  continue;
@@ -4893,7 +5103,7 @@ var parseJsonc = (raw) => {
4893
5103
  };
4894
5104
 
4895
5105
  // src/lib/writer-rule.ts
4896
- import { readFileSync as readFileSync6 } from "node:fs";
5106
+ import { readFileSync as readFileSync7 } from "node:fs";
4897
5107
  import * as path2 from "node:path";
4898
5108
  var CONTEXT_DIR = path2.join(".opencode", "context");
4899
5109
  var WRITER = "context";
@@ -4933,7 +5143,7 @@ var agentsForm = (mapDir, projectRoot) => {
4933
5143
  var verifyWriterRule = (configPath, mapDir, projectRoot) => {
4934
5144
  let raw;
4935
5145
  try {
4936
- raw = readFileSync6(configPath, "utf8");
5146
+ raw = readFileSync7(configPath, "utf8");
4937
5147
  } catch {
4938
5148
  return void 0;
4939
5149
  }
@@ -4978,25 +5188,25 @@ var verifyWriterRule = (configPath, mapDir, projectRoot) => {
4978
5188
  };
4979
5189
 
4980
5190
  // 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";
5191
+ 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";
5192
+ import { homedir as homedir5 } from "node:os";
5193
+ import { dirname as dirname3, join as join9, resolve as resolve3 } from "node:path";
4984
5194
  var TIMELINE_STATE_VERSION = 1;
4985
5195
  function timelineDir() {
4986
5196
  const env = process.env.LACODE_TIMELINE_DIR;
4987
5197
  if (env) return resolve3(env);
4988
- return join8(homedir4(), ".local/share/opencode/lacode/timeline");
5198
+ return join9(homedir5(), ".local/share/opencode/lacode/timeline");
4989
5199
  }
4990
5200
  function assertValidSessionID(sessionID) {
4991
5201
  if (!/^[A-Za-z0-9_.-]+$/.test(sessionID)) throw new Error("invalid sessionID");
4992
5202
  }
4993
5203
  function timelineFile(sessionID) {
4994
5204
  assertValidSessionID(sessionID);
4995
- return join8(timelineDir(), sessionID + ".jsonl");
5205
+ return join9(timelineDir(), sessionID + ".jsonl");
4996
5206
  }
4997
5207
  function timelineStateFile(sessionID) {
4998
5208
  assertValidSessionID(sessionID);
4999
- return join8(timelineDir(), sessionID + ".state.json");
5209
+ return join9(timelineDir(), sessionID + ".state.json");
5000
5210
  }
5001
5211
  function normalizeThrough(through) {
5002
5212
  if (!through || typeof through !== "object") return void 0;
@@ -5043,7 +5253,7 @@ function appendEntriesAt(file, texts, now, through) {
5043
5253
  function readEntriesAt(file, limit) {
5044
5254
  let raw = "";
5045
5255
  try {
5046
- raw = readFileSync7(file, "utf8");
5256
+ raw = readFileSync8(file, "utf8");
5047
5257
  } catch {
5048
5258
  return [];
5049
5259
  }
@@ -5096,7 +5306,7 @@ function normalizePersistedState(raw) {
5096
5306
  }
5097
5307
  function readTimelineState(sessionID) {
5098
5308
  try {
5099
- const raw = readFileSync7(timelineStateFile(sessionID), "utf8");
5309
+ const raw = readFileSync8(timelineStateFile(sessionID), "utf8");
5100
5310
  return normalizePersistedState(JSON.parse(raw));
5101
5311
  } catch {
5102
5312
  return void 0;
@@ -5522,7 +5732,7 @@ var changedSince = (before, dir) => {
5522
5732
  return changed;
5523
5733
  };
5524
5734
  var RETAIN_TRANSCRIPTS = 20;
5525
- var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path3.join(homedir5(), ".local", "share", "opencode", "lacode", "cartographer");
5735
+ var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path3.join(homedir6(), ".local", "share", "opencode", "lacode", "cartographer");
5526
5736
  var retireSession = async (client, directory, sessionID, mode, log) => {
5527
5737
  try {
5528
5738
  const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
@@ -5778,15 +5988,15 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
5778
5988
  });
5779
5989
 
5780
5990
  // 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";
5991
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync9, renameSync as renameSync5, unlinkSync as unlinkSync6, writeFileSync as writeFileSync8 } from "node:fs";
5992
+ import { homedir as homedir7 } from "node:os";
5993
+ import { dirname as dirname5, join as join11 } from "node:path";
5784
5994
  var FEEDBACK_HINT_ENV = "LACODE_NO_FEEDBACK_HINT";
5785
5995
  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");
5996
+ var feedbackStatePath = () => join11(homedir7(), ".local", "share", "opencode", "lacode", "feedback.json");
5787
5997
  function readFeedbackState(path4 = feedbackStatePath()) {
5788
5998
  try {
5789
- const parsed = JSON.parse(readFileSync8(path4, "utf8"));
5999
+ const parsed = JSON.parse(readFileSync9(path4, "utf8"));
5790
6000
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
5791
6001
  return parsed;
5792
6002
  } catch (error) {
@@ -5860,9 +6070,9 @@ async function feedbackHooks(input, options, deps = {}) {
5860
6070
 
5861
6071
  // src/plugins/image-paste.ts
5862
6072
  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";
6073
+ import { globSync, mkdirSync as mkdirSync8, readFileSync as readFileSync10, renameSync as renameSync6, statSync as statSync7, writeFileSync as writeFileSync9 } from "node:fs";
6074
+ import { homedir as homedir8 } from "node:os";
6075
+ import { basename as basename3, join as join12 } from "node:path";
5866
6076
 
5867
6077
  // src/lib/clipboard.ts
5868
6078
  import { spawnSync } from "node:child_process";
@@ -6012,8 +6222,8 @@ function splitArguments(input) {
6012
6222
  return out;
6013
6223
  }
6014
6224
  function expandTilde(p) {
6015
- if (p === "~") return homedir7();
6016
- if (p.startsWith("~/")) return join11(homedir7(), p.slice(2));
6225
+ if (p === "~") return homedir8();
6226
+ if (p.startsWith("~/")) return join12(homedir8(), p.slice(2));
6017
6227
  return p;
6018
6228
  }
6019
6229
  function expandGlob(p) {
@@ -6029,7 +6239,7 @@ function storeImage(bytes, dir, sessionID, label) {
6029
6239
  if (kind === null) return null;
6030
6240
  const hash = createHash("sha256").update(bytes).digest("hex");
6031
6241
  mkdirSync8(dir, { recursive: true });
6032
- const target = join11(dir, `${hash}.${kind.ext}`);
6242
+ const target = join12(dir, `${hash}.${kind.ext}`);
6033
6243
  const tmp = `${target}.tmp`;
6034
6244
  writeFileSync9(tmp, bytes, { mode: 384 });
6035
6245
  renameSync6(tmp, target);
@@ -6060,7 +6270,7 @@ async function imagePasteHooks(input, options, deps) {
6060
6270
  if (opts.enabled === false) return {};
6061
6271
  const maxBytes = resolveMaxBytes(opts);
6062
6272
  const maxImages = resolveMaxImages(opts);
6063
- const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ?? join11(homedir7(), ".local/share/opencode/lacode/images");
6273
+ const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ?? join12(homedir8(), ".local/share/opencode/lacode/images");
6064
6274
  const readClipboard = (deps ?? {}).readClipboard ?? readClipboardImage;
6065
6275
  return {
6066
6276
  "command.execute.before": async (cmd, output) => {
@@ -6117,7 +6327,7 @@ async function imagePasteHooks(input, options, deps) {
6117
6327
  }
6118
6328
  let bytes;
6119
6329
  try {
6120
- bytes = readFileSync9(filePath);
6330
+ bytes = readFileSync10(filePath);
6121
6331
  } catch {
6122
6332
  skipped.push(name);
6123
6333
  continue;
@@ -6168,14 +6378,14 @@ async function imagePasteHooks(input, options, deps) {
6168
6378
  }
6169
6379
 
6170
6380
  // 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";
6381
+ import { existsSync as existsSync5, mkdirSync as mkdirSync9, readFileSync as readFileSync11, renameSync as renameSync7, writeFileSync as writeFileSync10 } from "node:fs";
6382
+ import { homedir as homedir9 } from "node:os";
6383
+ import { dirname as dirname6, join as join13 } from "node:path";
6174
6384
  import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
6175
6385
  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");
6386
+ const base = process.env.XDG_CONFIG_HOME ?? join13(homedir9(), ".config");
6387
+ const jsonc = join13(base, "opencode", "opencode.jsonc");
6388
+ return existsSync5(jsonc) ? jsonc : join13(base, "opencode", "opencode.json");
6179
6389
  }
6180
6390
  var OpenCodeConfigError = class extends Error {
6181
6391
  constructor(message) {
@@ -6188,7 +6398,7 @@ function isPlainObject(value) {
6188
6398
  }
6189
6399
  function readOpencodeConfig(path4 = globalOpencodeConfigPath()) {
6190
6400
  if (!existsSync5(path4)) return {};
6191
- const text = readFileSync10(path4, "utf8");
6401
+ const text = readFileSync11(path4, "utf8");
6192
6402
  const errors = [];
6193
6403
  const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
6194
6404
  if (errors.length > 0) {
@@ -7313,6 +7523,43 @@ function normalizeChunk(value) {
7313
7523
  }
7314
7524
  return changed;
7315
7525
  }
7526
+ var UPSTREAM_ATTEMPTS = 3;
7527
+ var UPSTREAM_RETRY_DELAY_MS = 250;
7528
+ var TRANSIENT_UPSTREAM_PATTERN2 = /reasoning_display/i;
7529
+ function requestUrl(input) {
7530
+ if (typeof input === "string") return input;
7531
+ if (input instanceof URL) return input.toString();
7532
+ if (input && typeof input === "object") {
7533
+ const url = input.url;
7534
+ if (typeof url === "string") return url;
7535
+ }
7536
+ return "";
7537
+ }
7538
+ function isOpencodeInferenceChat(raw) {
7539
+ if (!raw) return false;
7540
+ let url;
7541
+ try {
7542
+ url = new URL(raw);
7543
+ } catch {
7544
+ return false;
7545
+ }
7546
+ if (url.hostname !== "opencode.ai" && !url.hostname.endsWith(".opencode.ai")) return false;
7547
+ return url.pathname.includes("/inference/") && url.pathname.endsWith("/chat/completions");
7548
+ }
7549
+ function isReplayableInit(init2) {
7550
+ if (!init2 || typeof init2 !== "object") return false;
7551
+ const body = init2.body;
7552
+ return typeof body === "string" || body instanceof Uint8Array || body instanceof ArrayBuffer;
7553
+ }
7554
+ async function isTransientUpstreamResponse(response) {
7555
+ if (response.status !== 400) return false;
7556
+ try {
7557
+ const text = await response.clone().text();
7558
+ return TRANSIENT_UPSTREAM_PATTERN2.test(text) && /not\s+a\s+supported\s+value|unsupported/i.test(text);
7559
+ } catch {
7560
+ return false;
7561
+ }
7562
+ }
7316
7563
  function normalizeSseLine(line) {
7317
7564
  const trimmed = line.trim();
7318
7565
  if (!trimmed.startsWith("data:")) return line;
@@ -7349,9 +7596,22 @@ function createChunkNormalizer() {
7349
7596
  }
7350
7597
  });
7351
7598
  }
7352
- function wrapFetch(original) {
7599
+ var sleep = (ms) => ms > 0 ? new Promise((resolve5) => setTimeout(resolve5, ms)) : Promise.resolve();
7600
+ function wrapFetch(original, options = {}) {
7601
+ const attempts = Math.max(1, Math.trunc(options.attempts ?? UPSTREAM_ATTEMPTS));
7602
+ const delayMs = Math.max(0, Math.trunc(options.delayMs ?? UPSTREAM_RETRY_DELAY_MS));
7353
7603
  const wrapped = async (input, init2) => {
7354
- const response = await original(input, init2);
7604
+ const retryable = attempts > 1 && isReplayableInit(init2) && isOpencodeInferenceChat(requestUrl(input));
7605
+ let response = await original(input, init2);
7606
+ for (let attempt = 1; retryable && attempt < attempts; attempt++) {
7607
+ if (!await isTransientUpstreamResponse(response)) break;
7608
+ try {
7609
+ await response.body?.cancel();
7610
+ } catch {
7611
+ }
7612
+ await sleep(delayMs);
7613
+ response = await original(input, init2);
7614
+ }
7355
7615
  try {
7356
7616
  const contentType = response.headers.get("content-type") ?? "";
7357
7617
  if (!contentType.includes("text/event-stream") || !response.body) return response;
@@ -7424,7 +7684,7 @@ async function selfUpdateHooks(input, options) {
7424
7684
 
7425
7685
  // src/plugins/timeline.ts
7426
7686
  import { existsSync as existsSync6, writeFileSync as writeFileSync11, renameSync as renameSync8, rmSync as rmSync3 } from "node:fs";
7427
- import { join as join13 } from "node:path";
7687
+ import { join as join14 } from "node:path";
7428
7688
  import { tool as tool2 } from "@opencode-ai/plugin";
7429
7689
  var DEFAULT_INTERVAL_MS = 3e5;
7430
7690
  var MIN_INTERVAL_MS = 1e4;
@@ -7500,7 +7760,7 @@ function errorText(value) {
7500
7760
  }
7501
7761
  var MAX_TASK_CHILDREN_EXPANDED = 40;
7502
7762
  async function expandTaskSessions(client, messages, directory, owned, onError, cursor) {
7503
- const projectOwnsTooling = existsSync6(join13(directory, "src/plugins/timeline.ts"));
7763
+ const projectOwnsTooling = existsSync6(join14(directory, "src/plugins/timeline.ts"));
7504
7764
  let attempts = 0;
7505
7765
  const expandedMessages = [];
7506
7766
  let startIndex = 0;
@@ -8066,9 +8326,9 @@ function timelineHooks(input, options) {
8066
8326
 
8067
8327
  // src/lib/timer-migration.ts
8068
8328
  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";
8329
+ import { existsSync as existsSync7, mkdirSync as mkdirSync10, readFileSync as readFileSync12, renameSync as renameSync9, writeFileSync as writeFileSync12 } from "node:fs";
8330
+ import { homedir as homedir10 } from "node:os";
8331
+ import { basename as basename4, dirname as dirname7, join as join15 } from "node:path";
8072
8332
  var TIMER_MIGRATION_ENV = "LACODE_NO_TIMER_MIGRATION";
8073
8333
  var TIMER_MIGRATION_STATE_VERSION = 1;
8074
8334
  var defaultRun2 = (file, args, options) => new Promise((resolve5, reject) => {
@@ -8082,15 +8342,15 @@ function resolveNodeBinary(execPath = process.execPath) {
8082
8342
  if (existsSync7("/usr/bin/node")) return "/usr/bin/node";
8083
8343
  return "node";
8084
8344
  }
8085
- function timerMigrationStatePath(home = homedir9()) {
8086
- return join14(home, ".local", "share", "opencode", "lacode", "migrations.json");
8345
+ function timerMigrationStatePath(home = homedir10()) {
8346
+ return join15(home, ".local", "share", "opencode", "lacode", "migrations.json");
8087
8347
  }
8088
- function timerMigrationUnitPath(home = homedir9()) {
8089
- return join14(home, ".config", "systemd", "user", "lacode-refresh-pool.timer");
8348
+ function timerMigrationUnitPath(home = homedir10()) {
8349
+ return join15(home, ".config", "systemd", "user", "lacode-refresh-pool.timer");
8090
8350
  }
8091
8351
  function readState2(path4) {
8092
8352
  try {
8093
- return JSON.parse(readFileSync11(path4, "utf8"));
8353
+ return JSON.parse(readFileSync12(path4, "utf8"));
8094
8354
  } catch {
8095
8355
  return {};
8096
8356
  }
@@ -8109,7 +8369,7 @@ async function runTimerMigration(options = {}) {
8109
8369
  const packageRoot = options.packageRoot ?? PACKAGE_ROOT;
8110
8370
  const version = options.version ?? readInstalledVersion(packageRoot);
8111
8371
  if (!version) return { action: "skipped", reason: "no-version" };
8112
- const home = options.home ?? homedir9();
8372
+ const home = options.home ?? homedir10();
8113
8373
  const statePath = options.statePath ?? timerMigrationStatePath(home);
8114
8374
  const state2 = readState2(statePath);
8115
8375
  if (state2.timersVersion === version && existsSync7(timerMigrationUnitPath(home))) {
@@ -8121,7 +8381,7 @@ async function runTimerMigration(options = {}) {
8121
8381
  } catch {
8122
8382
  return { action: "skipped", reason: "no-systemd" };
8123
8383
  }
8124
- const script = join14(packageRoot, "scripts", "refresh-pool.mjs");
8384
+ const script = join15(packageRoot, "scripts", "refresh-pool.mjs");
8125
8385
  if (!existsSync7(script)) return { action: "failed", version, error: `timer script not found: ${script}` };
8126
8386
  try {
8127
8387
  await run(resolveNodeBinary(), [script, "--install-timer", "--stable"], { stdio: "ignore" });