@lawaty/lacode 0.10.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ var STUCK_IDLE_MS = 3 * 60 * 1e3;
33
33
  var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
34
34
  var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
35
35
  var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
36
+ var UNAVAILABLE_REASON = "endpoint-unavailable";
36
37
  var MAX_WEIGHT = 100;
37
38
  var SLOT_CEILING_FACTOR = 2;
38
39
  var slotCeiling = (slot) => slot.weight * SLOT_CEILING_FACTOR;
@@ -44,6 +45,10 @@ var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
44
45
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
45
46
  var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
46
47
  var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
48
+ var MODEL_MISSING_REASON = "model-missing";
49
+ var MODEL_MISSING_COOLDOWN_MS = QUOTA_COOLDOWN_MAX_MS;
50
+ var MODEL_MISSING_PATTERN = /model\s+(?:not\s+found|does\s+not\s+exist|is\s+not\s+available)|(?:unknown|no\s+such)\s+model/i;
51
+ var isModelMissingText = (text) => MODEL_MISSING_PATTERN.test(text);
47
52
  var BASES = ["explore", "implement", "context"];
48
53
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
49
54
  var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
@@ -52,7 +57,7 @@ var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
52
57
  var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
53
58
  var FALLBACK_SLOTS = [
54
59
  { index: 1, model: "opencode/big-pickle", weight: 3 },
55
- { index: 2, model: "opencode-go/minimax-m3.1-flash-free", weight: 2 },
60
+ { index: 2, model: "opencode-go/beam-free", weight: 2 },
56
61
  { index: 3, model: "opencode/deepseek-v4.1-flash-free", weight: 1 },
57
62
  { index: 4, model: "opencode/glm-5.3-flash-free", weight: 1 }
58
63
  ];
@@ -205,7 +210,14 @@ var LIMIT_PATTERNS = [
205
210
  /overloaded/i,
206
211
  UNAVAILABLE_PATTERN
207
212
  ];
208
- var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text));
213
+ var LIMIT_REACHED_PATTERN = /\blimits?[\s_-]?(?:reached|exceeded)\b|\breached\s+(?:your|the|its)\s+limit\b/i;
214
+ var CONTEXT_LIMIT_PATTERN = /(?:context|token|length|window|prompt|char|character|byte|size)\s*(?:limit|length|size|window)/i;
215
+ var LIMIT_REASON_PATTERN = /rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded|limits?[\s_-]?(?:reached|exceeded)/i;
216
+ var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text)) || LIMIT_REACHED_PATTERN.test(text) && !CONTEXT_LIMIT_PATTERN.test(text);
217
+ var limitReason = (text) => {
218
+ if (!isLimitText(text)) return void 0;
219
+ return text.match(LIMIT_REASON_PATTERN)?.[0]?.toLowerCase().replace(/[\s_]/g, "-");
220
+ };
209
221
  var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
210
222
  var TRANSIENT_GATEWAY_PATTERNS = [/queue budget/i, /exceeded queue/i, /aborted due to timeout/i, /timeout/i];
211
223
  var isTransientGatewayText = (text) => TRANSIENT_GATEWAY_PATTERNS.some((re) => re.test(text));
@@ -276,6 +288,21 @@ function ensureSlots(s, onWarn) {
276
288
  for (const problem of problems) onWarn(problem);
277
289
  return slots;
278
290
  }
291
+ var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}`).join("|")}`;
292
+ function reloadSlots(s, onWarn) {
293
+ const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
294
+ const changed = slotSignature(origin, slots) !== slotSignature(s.origin, s.slots);
295
+ if (!changed) return { changed, slots, origin };
296
+ s.slots = slots;
297
+ s.origin = origin;
298
+ for (const problem of problems) onWarn(problem);
299
+ return { changed, slots, origin };
300
+ }
301
+ function slotSources(input = {}) {
302
+ if (input.models !== void 0) return [];
303
+ if (input.modelsFile) return [input.modelsFile];
304
+ return [USER_MODELS_TXT, USER_MODELS_PATH, DEFAULT_PRESET];
305
+ }
279
306
  var claimKey = (sessionID, callID) => `${sessionID}:${callID}`;
280
307
  var selfFile = (id) => `claims.${id}.json`;
281
308
  var limitFile = (model) => `limit.${model.replace(/[^a-zA-Z0-9._-]/g, "__")}.json`;
@@ -465,11 +492,12 @@ function readLimits(s) {
465
492
  try {
466
493
  const strike = JSON.parse(readFileSync2(path4, "utf8"));
467
494
  if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
495
+ const countsAsStrike = strike.reason !== UNAVAILABLE_REASON;
468
496
  if (strike.until > now) {
469
497
  cooling.set(strike.model, strike);
470
- memory.set(strike.model, strike);
498
+ if (countsAsStrike) memory.set(strike.model, strike);
471
499
  } else if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) {
472
- memory.set(strike.model, strike);
500
+ if (countsAsStrike) memory.set(strike.model, strike);
473
501
  } else {
474
502
  unlinkSync(path4);
475
503
  }
@@ -486,6 +514,7 @@ function readLimits(s) {
486
514
  }
487
515
  function recordStrike(s, model, reason, stated) {
488
516
  if (!s.dirReady) return;
517
+ if (reason === UNAVAILABLE_REASON) return coolUnavailable(s, model);
489
518
  const now = Date.now();
490
519
  const prior = s.strikes.get(model);
491
520
  const strikes = prior && now - prior.last < STRIKE_DECAY_MS ? prior.strikes + 1 : 1;
@@ -510,9 +539,8 @@ function coolUnavailable(s, model) {
510
539
  const prior = s.cooling.get(model);
511
540
  const until = now + UNAVAILABLE_COOLDOWN_MS;
512
541
  if (prior && prior.until > until) return prior;
513
- const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
542
+ const strike = { model, until, strikes: 1, reason: UNAVAILABLE_REASON, last: now };
514
543
  s.cooling.set(model, strike);
515
- s.strikes.set(model, strike);
516
544
  const path4 = join2(s.dir, limitFile(model));
517
545
  try {
518
546
  writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
@@ -598,48 +626,24 @@ function snapshot(s) {
598
626
  }
599
627
  var loadOf = (counts, model) => counts.get(model) ?? 0;
600
628
  var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
601
- function roundRobinSequence(slots) {
602
- const seq = [];
603
- for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
604
- return seq;
605
- }
606
- var CURSOR_FILE = "cursor.json";
607
- function readCursor(s) {
608
- try {
609
- const parsed = JSON.parse(readFileSync2(join2(s.dir, CURSOR_FILE), "utf8"));
610
- if (typeof parsed.seq === "number" && Number.isInteger(parsed.seq) && parsed.seq >= 0) return parsed.seq;
611
- } catch {
629
+ function pickSlot(s, snap) {
630
+ const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
631
+ const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
632
+ const pool = usable.length > 0 ? usable : slots;
633
+ for (const slot of usable) {
634
+ if (loadOf(snap.counts, slot.model) < slot.weight) return slot;
612
635
  }
613
- return 0;
614
- }
615
- function writeCursor(s, seq) {
616
- if (!s.dirReady) return;
617
- const path4 = join2(s.dir, CURSOR_FILE);
618
- try {
619
- writeFileSync(`${path4}.tmp`, JSON.stringify({ seq }));
620
- renameSync(`${path4}.tmp`, path4);
621
- } catch {
636
+ for (const slot of usable) {
637
+ if (loadOf(snap.counts, slot.model) < slotCeiling(slot)) return slot;
622
638
  }
639
+ return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
623
640
  }
624
- function pickSlot(s, snap, options) {
625
- const advance = options?.advance !== false;
641
+ function poolPass(s, snap) {
626
642
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
627
- const seq = roundRobinSequence(slots);
628
- if (seq.length > 0) {
629
- const byIndex = new Map(slots.map((slot) => [slot.index, slot]));
630
- const start = readCursor(s) % seq.length;
631
- for (let step = 0; step < seq.length; step++) {
632
- const at = (start + step) % seq.length;
633
- const slot = byIndex.get(seq[at]);
634
- if (slot && isAvailable(s, slot.model, snap.cooling) && underCeiling(slot, snap.counts)) {
635
- if (advance) writeCursor(s, at + 1);
636
- return slot;
637
- }
638
- }
639
- }
640
643
  const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
641
- const pool = usable.length > 0 ? usable : slots;
642
- return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
644
+ if (usable.some((slot) => loadOf(snap.counts, slot.model) < slot.weight)) return 1;
645
+ if (usable.some((slot) => loadOf(snap.counts, slot.model) < slotCeiling(slot))) return 2;
646
+ return "overflow";
643
647
  }
644
648
  function agentFor(base, slot) {
645
649
  return `${base}-${slot}`;
@@ -774,9 +778,12 @@ Every question of the form "what does this file do", "where is X handled", or
774
778
  that proves it. Never one whole refactor handed over in a single piece \u2014
775
779
  see \`# Task size\` below.
776
780
 
777
- Spawn the BARE pooled base \u2014 \`explore\`, \`implement\` or \`context\`. The pool
778
- refuses a \`-N\` variant, and a refusal means spawn the bare base, never a
779
- different number.
781
+ Spawn the pooled base \u2014 \`explore\`, \`implement\` or \`context\`. The plugin decides
782
+ which model runs the work; do not try to choose one yourself.
783
+
784
+ \`expert\` is not part of the pool and is expensive: spawn it only when the user
785
+ explicitly asks for it. Never escalate to it on your own, and never as a
786
+ fallback when a pooled spawn fails.
780
787
 
781
788
  # When the pool is unavailable
782
789
 
@@ -785,12 +792,12 @@ pool cannot serve a spawn \u2014 every slot cooling or exhausted, spawns repeate
785
792
  failing, or a runtime message saying no slot is available \u2014 that is not a reason
786
793
  to do the work yourself, and not a reason to stop and ask.
787
794
 
788
- **Retry.** A refused or cooling slot is transient and costs you nothing but a
789
- turn. Spawn the bare base again. Do not escalate to a numbered variant after a
790
- refusal \u2014 the refusal means the pool has a routing problem, not that you
791
- picked the wrong number.
795
+ **Retry.** A cooling model is transient and costs you nothing but a turn. Spawn
796
+ the same base again. If a spawn fails, do not switch to a different name \u2014 a
797
+ failure means the pool has a routing problem, not that you picked the wrong one.
792
798
 
793
- Retry is always available and never wrong, which is why it is the default. But
799
+ Retry is always available
800
+ and never wrong, which is why it is the default. But
794
801
  "never wrong" is not "sufficient": looping silently against a pool with every
795
802
  model cooling goes nowhere and burns the slot doing it. So separate the two
796
803
  failures. **Transient** \u2014 a cooldown, a rate limit, one slot at its ceiling \u2014
@@ -798,7 +805,8 @@ means retry. **Persistent or structural** \u2014 every slot cooling, the same sp
798
805
  failing repeatedly with the same error, a malformed model id \u2014 means stop
799
806
  retrying and *report* it: say what failed (slot, model, error) and continue
800
807
  with whatever else the task needs, or ask the user. Surface the failure; do not
801
- spin on it and do not let it end the whole task.
808
+ spin on it and do not let it end the whole task. Do not escalate to \`expert\` as
809
+ a workaround; it is opt-in by the user.
802
810
 
803
811
  **Never fall back to reading, grepping, exploring or implementing on your own.**
804
812
  Do not "just quickly" inspect a file because a spawn failed. A failed spawn is
@@ -915,8 +923,11 @@ When you need to understand the codebase:
915
923
  2. Invoke @explore with specific, focused instructions (e.g. "read src/auth.ts", "grep for 'migration' in *.py files", "list the src/ directory")
916
924
  3. @explore will return its findings \u2014 you process them and plan accordingly
917
925
 
918
- Spawn the BARE pooled base (\`explore\`, \`implement\`, \`context\`); the pool
919
- refuses \`-N\` variants, and a refusal means spawn the bare base, not another number.
926
+ Spawn the pooled base (\`explore\`, \`implement\`, \`context\`); the plugin decides
927
+ which model runs the work. Do not try to choose one yourself.
928
+
929
+ \`expert\` is not part of the pool and is expensive: spawn it only when the user
930
+ explicitly asks for it.
920
931
 
921
932
  If the pool cannot serve a spawn \u2014 every slot cooling or exhausted, or repeated
922
933
  failures \u2014 do NOT fall back to reading or planning from your own inspection.
@@ -1244,7 +1255,7 @@ Trigger: \`.opencode/context/\` exists and work has just been done here.
1244
1255
  - an architectural decision
1245
1256
  - a new dependency between subsystems
1246
1257
  - a renamed or relocated subsystem
1247
- - a previously undocumented invariant
1258
+ - an invariant not yet recorded in the map
1248
1259
 
1249
1260
  Do not update documentation merely because source files changed. Do not
1250
1261
  rewrite files wholesale. Do not make timestamp-only edits. Do not add a fact
@@ -1550,7 +1561,7 @@ function buildAgentSet() {
1550
1561
  for (let slot = 1; slot <= MAX_SLOTS; slot++) {
1551
1562
  const variant = {
1552
1563
  ...def,
1553
- description: `Hidden pool variant ${base}-${slot}: slot ${slot}. Its model is NOT set here -- LaCode injects it from the resolved pool preset at config time, so the preset is the only place a pool model is written down. Spawn '${base}' and let the pool route.`,
1564
+ description: `Hidden pool slot ${slot} for ${base}. Its model is NOT set here -- LaCode injects it from the resolved pool preset at config time, so the preset is the only place a pool model is written down. Spawn '${base}' and let the pool route.`,
1554
1565
  hidden: true,
1555
1566
  // `mode: "primary"` is what keeps a variant OUT of the model's system
1556
1567
  // prompt. `hidden` cannot do it.
@@ -1562,9 +1573,10 @@ function buildAgentSet() {
1562
1573
  // and by default-agent resolution. A variant left as `mode: "subagent"`
1563
1574
  // is therefore listed to the model under its own name -- "explore-1:
1564
1575
  // Hidden pool variant explore-1" -- and the model copies that name
1565
- // straight into its next spawn. The pool then refuses it, because
1566
- // naming a variant is how a caller would pick the provider and skip the
1567
- // rotation. The refusal is right; the leak that provokes it is not.
1576
+ // straight into its next spawn. The task hook coerces a hand-named
1577
+ // variant back to its base, because naming one is how a caller would
1578
+ // pick the provider and skip ordered slot fill and the slot ceiling. The coercion is right; the
1579
+ // leak that provokes it is not.
1568
1580
  //
1569
1581
  // `mode` is the only field that filter respects, and setting it here is
1570
1582
  // safe: the task tool never reads an agent's mode -- it resolves the
@@ -1638,7 +1650,7 @@ context file only if this work introduced or revealed information that will
1638
1650
  materially help future agents \u2014 a new boundary or module, a new or moved
1639
1651
  canonical entry point, a changed workflow, a new convention, an architectural
1640
1652
  decision, a new dependency between subsystems, a renamed or moved subsystem,
1641
- or a previously undocumented invariant.
1653
+ or an invariant not yet recorded.
1642
1654
 
1643
1655
  Do not edit documentation just because source files changed. No
1644
1656
  timestamp-only edits. If nothing material changed, make no edits and report
@@ -1713,7 +1725,7 @@ Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
1713
1725
  Change nothing. Print a final one-line summary of failures.`
1714
1726
  },
1715
1727
  "pool": {
1716
- description: "Modify this machine's LaCode pool from a short instruction \u2014 the shared free-model slot table in ~/.config/lacode/pool.txt. Restarts opencode afterwards.",
1728
+ description: "Modify this machine's LaCode pool from a short instruction \u2014 the shared free-model slot table in ~/.config/lacode/pool.txt. Picked up live; no restart.",
1717
1729
  template: `Apply this change to the LaCode pool on this machine:
1718
1730
 
1719
1731
  $ARGUMENTS
@@ -1725,22 +1737,41 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
1725
1737
  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:
1726
1738
  - one slot per line: \`<provider>/<model-id> [weight]\`
1727
1739
  - at most ${MAX_SLOTS} slots, and a model may appear only once
1728
- - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's share of the rotation \u2014 how many turns it takes per cycle \u2014 and its concurrency ceiling is twice that. NOT a priority; pick order follows line order.
1740
+ - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's active-session budget for one pass \u2014 how many turns it takes per cycle \u2014 and its concurrency ceiling is twice that. NOT a priority; pick order follows line order.
1729
1741
  - \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
1730
1742
  If the instruction names a model ambiguously, or would need more than ${MAX_SLOTS} slots, stop and ask rather than guessing.
1731
1743
 
1732
- 3. Write the WHOLE file, not a patch. This file is an override, not an edit target: any slot you drop disappears, and slot numbers are line positions, so a partial write silently renumbers the rotation. Keep the existing comment header. If the write fails on a missing directory, create \`~/.config/lacode\` first and retry.
1744
+ 3. Write the WHOLE file, not a patch. This file is an override, not an edit target: any slot you drop disappears, and slot numbers are line positions, so a partial write silently renumbers slots. Keep the existing comment header. If the write fails on a missing directory, create \`~/.config/lacode\` first and retry.
1733
1745
 
1734
- 4. Report the before \u2192 after slot table, the resolved path, and this reminder in substance: nothing hot-reloads, so every running opencode process must be restarted before the new pool takes effect.
1746
+ 4. Report the before \u2192 after slot table and the resolved path. The running process watches the pool files: it reloads the table, re-binds the variant agents and validates every slot, so no restart is needed. If a slot names a model the provider does not serve, it will be benched and named in the log and toast \u2014 report that instead of claiming the swap succeeded.
1735
1747
 
1736
1748
  Two traps that fail silently, so check and state them explicitly:
1737
1749
  - A slot's provider must already exist in the user's opencode config, or that slot cannot resolve at all. If the instruction adds a model on a provider that is not configured, say so.
1738
1750
  - If a slot model is \`omniroute/<id>\`, that id must also appear in the plugin's \`omniroute.models\` allowlist (or be the \`combo\` id, which needs no entry) \u2014 otherwise the pool resolves and every spawn fails with \`ProviderModelNotFoundError\`.
1739
1751
  - A provider \`whitelist\` is an allowlist, and the plugin appends your model to it at config time. Nothing for you to do there.
1740
1752
 
1741
- If the instruction is to RESET or REVERT rather than to change models, delete \`${USER_MODELS_TXT}\` instead, and report that the pool falls back to the bundled preset \u2014 with the same restart reminder.
1753
+ If the instruction is to RESET or REVERT rather than to change models, delete \`${USER_MODELS_TXT}\` instead, and report that the pool falls back to the bundled preset \u2014 the running process picks that up automatically too.
1742
1754
 
1743
1755
  Change nothing else. In particular do not touch \`presets/free-tier.json\`, \`~/.config/lacode/pool.json\`, \`FALLBACK_SLOTS\` in \`src/lib/pool.ts\`, or \`tests/models-snapshot.json\`: this command owns the per-machine override and nothing else.`
1756
+ },
1757
+ "pool-recheck": {
1758
+ description: "Probe the pool now \u2014 re-admit benched models that answer, keep the ones still limited, and bench a model the provider no longer serves.",
1759
+ template: `Probe the LaCode pool now and report the result. Run inline; do not spawn a subagent and do not edit the pool.
1760
+
1761
+ $ARGUMENTS
1762
+
1763
+ - If the text above names one or more models, call the \`pool_recheck\` tool with those ids in \`models\` (provider/model-id).
1764
+ - Otherwise call \`pool_recheck\` with no arguments; it probes every model that is currently cooling.
1765
+
1766
+ Report the tool's output as it came back, then add one short line naming what changed: re-admitted, still limited, or benched because the provider does not serve the model. If a model was benched as \`model-missing\`, say so and name the replacement in \`~/.config/lacode/pool.txt\` \u2014 but change nothing yourself.`
1767
+ },
1768
+ "image-in-cb": {
1769
+ description: "Attach the clipboard image to this message \u2014 macOS needs pngpaste, Linux needs wl-paste or xclip.",
1770
+ template: "$ARGUMENTS"
1771
+ },
1772
+ "image": {
1773
+ description: "Attach one or more image files to this message",
1774
+ template: "$ARGUMENTS"
1744
1775
  }
1745
1776
  };
1746
1777
  function applyCommands(cfg, commands = COMMANDS) {
@@ -1758,125 +1789,427 @@ function applyCommands(cfg, commands = COMMANDS) {
1758
1789
  return { added, kept };
1759
1790
  }
1760
1791
 
1761
- // src/plugins/agent-pool.ts
1762
- import { tool } from "@opencode-ai/plugin";
1763
-
1764
- // src/lib/probe.ts
1765
- var PROBE_SYSTEM = "You are a connection health check. Reply with the single word: ok";
1766
- var PROBE_PROMPT = "ok";
1767
- var PROBE_TIMEOUT_MS = 45e3;
1768
- var failureText = (error) => {
1769
- if (error === void 0 || error === null) return "";
1770
- if (typeof error === "string") return error;
1792
+ // src/lib/selfupdate.ts
1793
+ import { spawn } from "node:child_process";
1794
+ import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync3, renameSync as renameSync2, rmSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2, writeSync } from "node:fs";
1795
+ import { homedir as homedir2 } from "node:os";
1796
+ import { dirname as dirname2, join as join3, resolve } from "node:path";
1797
+ var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
1798
+ var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
1799
+ var FETCH_TIMEOUT_MS = 4e3;
1800
+ var INSTALL_TIMEOUT_MS = 12e4;
1801
+ var LOCK_STALE_MS = 10 * 60 * 1e3;
1802
+ var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
1803
+ var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
1804
+ var inTestRunner = (env = process.env) => typeof env.NODE_TEST_CONTEXT === "string" && env.NODE_TEST_CONTEXT.length > 0;
1805
+ var STATE_DIR = join3(homedir2(), ".local", "share", "opencode", "lacode");
1806
+ var STATE_PATH = join3(STATE_DIR, "selfupdate.json");
1807
+ var LOCK_PATH = join3(STATE_DIR, "selfupdate.lock");
1808
+ function compareSemver(a, b) {
1809
+ const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
1810
+ const left = parse(a);
1811
+ const right = parse(b);
1812
+ for (let i = 0; i < 3; i++) {
1813
+ const l = left[i] ?? 0;
1814
+ const r = right[i] ?? 0;
1815
+ if (l !== r) return l > r ? 1 : -1;
1816
+ }
1817
+ return 0;
1818
+ }
1819
+ function readInstalledVersion(packageRoot, read = (path4) => {
1771
1820
  try {
1772
- return JSON.stringify(error) ?? String(error);
1821
+ return readFileSync3(path4, "utf8");
1773
1822
  } catch {
1774
- return String(error);
1823
+ return void 0;
1775
1824
  }
1776
- };
1777
- var shortReason = (text) => {
1778
- if (!text) return "failed";
1779
- const matched = text.match(/rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded/i)?.[0];
1780
- return matched ? matched.toLowerCase().replace(/[\s_]/g, "-") : isLimitText(text) ? "limit" : "failed";
1781
- };
1782
- var probeOne = async (client, opts, model, leftSeconds) => {
1783
- const { state: s, directory } = opts;
1784
- const { provider, id: modelID } = splitModel(model);
1785
- const settle = (ok, reason, left) => ({
1786
- model,
1787
- ok,
1788
- reason,
1789
- hadSeconds: leftSeconds,
1790
- leftSeconds: left
1791
- });
1792
- const created = await client.session.create({ body: { title: `pool probe (${model})` }, query: { directory } }).then((r) => r?.data?.id).catch(() => void 0);
1793
- if (!created) return settle(false, "no probe session", leftSeconds);
1794
- markProbe(s, created);
1795
- let timer;
1825
+ }) {
1796
1826
  try {
1797
- const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
1798
- const clock = opts.now ?? Date.now;
1799
- let timedOut = false;
1800
- const deadline = new Promise((resolve3) => {
1801
- timer = setTimeout(() => {
1802
- timedOut = true;
1803
- void client.session.abort({ path: { id: created }, query: { directory } }).catch(() => {
1804
- });
1805
- resolve3(void 0);
1806
- }, timeoutMs);
1807
- });
1808
- const answer = client.session.prompt({
1809
- path: { id: created },
1810
- query: { directory },
1811
- body: {
1812
- model: { providerID: provider, modelID },
1813
- system: PROBE_SYSTEM,
1814
- parts: [{ type: "text", text: PROBE_PROMPT }]
1815
- }
1816
- }).then(
1817
- (result2) => result2,
1818
- (error) => ({ error })
1819
- );
1820
- const result = await Promise.race([answer, deadline]);
1821
- const failure = result?.error ?? result?.data?.info?.error;
1822
- if (!timedOut && failure === void 0 && result?.data?.info !== void 0) {
1823
- clearCooldowns(s, [model]);
1824
- return settle(true, "answered", 0);
1825
- }
1826
- const text = failureText(failure);
1827
- const stated = statedCooldown(text);
1828
- if (stated !== void 0) recordStrike(s, model, shortReason(text), stated);
1829
- const still = readLimits(s).get(model);
1830
- return settle(
1831
- false,
1832
- timedOut ? `no answer in ${Math.round(timeoutMs / 1e3)}s` : shortReason(text),
1833
- still && still.until > clock() ? Math.ceil((still.until - clock()) / 1e3) : 0
1834
- );
1827
+ const raw = read(join3(packageRoot, "package.json"));
1828
+ if (raw === void 0) return void 0;
1829
+ const manifest = JSON.parse(raw);
1830
+ if (manifest.name !== PACKAGE_NAME) return void 0;
1831
+ return typeof manifest.version === "string" ? manifest.version : void 0;
1832
+ } catch {
1833
+ return void 0;
1834
+ }
1835
+ }
1836
+ function resolveCacheRoot(packageRoot, exists = (p) => {
1837
+ try {
1838
+ return statSync2(p).isFile();
1839
+ } catch {
1840
+ return false;
1841
+ }
1842
+ }) {
1843
+ const tail = join3("node_modules", "@lawaty", "lacode");
1844
+ if (!packageRoot.endsWith(tail)) return void 0;
1845
+ const cacheRoot = resolve(packageRoot, "..", "..", "..");
1846
+ if (!exists(join3(cacheRoot, "package.json"))) return void 0;
1847
+ return cacheRoot;
1848
+ }
1849
+ function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
1850
+ try {
1851
+ return statSync2(p).isFile();
1852
+ } catch {
1853
+ return false;
1854
+ }
1855
+ }) {
1856
+ if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
1857
+ for (const dir of (env.PATH ?? "").split(":")) {
1858
+ if (!dir) continue;
1859
+ const candidate = join3(dir, "opencode");
1860
+ if (exists(candidate)) return candidate;
1861
+ }
1862
+ const fallback = join3(homedir2(), ".opencode", "bin", "opencode");
1863
+ return exists(fallback) ? fallback : void 0;
1864
+ }
1865
+ async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
1866
+ const controller = new AbortController();
1867
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1868
+ try {
1869
+ const response = await fetchImpl(REGISTRY_LATEST, { signal: controller.signal });
1870
+ if (!response.ok) return void 0;
1871
+ const body = await response.json();
1872
+ return typeof body.version === "string" ? body.version : void 0;
1873
+ } catch {
1874
+ return void 0;
1835
1875
  } finally {
1836
- if (timer) clearTimeout(timer);
1837
- const deleted = await client.session.delete({ path: { id: created }, query: { directory } }).then(() => true, () => false);
1838
- if (deleted) unmarkProbe(s, created);
1876
+ clearTimeout(timer);
1877
+ }
1878
+ }
1879
+ function readState(path4 = STATE_PATH) {
1880
+ try {
1881
+ return JSON.parse(readFileSync3(path4, "utf8"));
1882
+ } catch {
1883
+ return {};
1884
+ }
1885
+ }
1886
+ function writeState(state2, path4 = STATE_PATH) {
1887
+ try {
1888
+ mkdirSync2(dirname2(path4), { recursive: true });
1889
+ writeFileSync2(path4, JSON.stringify(state2));
1890
+ } catch {
1891
+ }
1892
+ }
1893
+ var isDue = (state2, now, interval = CHECK_INTERVAL_MS) => typeof state2.checkedAt !== "number" || now - state2.checkedAt >= interval;
1894
+ var isProcessAlive = (pid) => {
1895
+ try {
1896
+ process.kill(pid, 0);
1897
+ return true;
1898
+ } catch (error) {
1899
+ return error.code === "EPERM";
1839
1900
  }
1840
1901
  };
1841
- async function probeCooling(client, opts) {
1842
- const s = opts.state;
1843
- const now = opts.now ?? Date.now;
1844
- const empty = { outcomes: [], cleared: [], kept: [], changed: false };
1845
- if (s.probing) return empty;
1846
- const cooling = readLimits(s);
1847
- const at = now();
1848
- const wanted = opts.models && opts.models.length > 0 ? new Set(opts.models) : void 0;
1849
- const candidates = [...cooling.values()].filter(
1850
- (strike) => strike.until > at && (wanted === void 0 || wanted.has(strike.model))
1851
- );
1852
- if (candidates.length === 0) return empty;
1853
- s.probing = true;
1854
- const outcomes = [];
1902
+ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
1903
+ const claim = () => {
1904
+ const fd = openSync(path4, "wx");
1905
+ writeSync(fd, String(process.pid));
1906
+ closeSync(fd);
1907
+ return () => {
1908
+ try {
1909
+ unlinkSync2(path4);
1910
+ } catch {
1911
+ }
1912
+ };
1913
+ };
1855
1914
  try {
1856
- for (const strike of candidates.sort((a, b) => a.until - b.until)) {
1857
- outcomes.push(await probeOne(client, opts, strike.model, Math.ceil((strike.until - at) / 1e3)));
1915
+ mkdirSync2(dirname2(path4), { recursive: true });
1916
+ return claim();
1917
+ } catch {
1918
+ let orphaned = false;
1919
+ try {
1920
+ const pid = Number.parseInt(readFileSync3(path4, "utf8").trim(), 10);
1921
+ orphaned = !Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid);
1922
+ } catch {
1923
+ orphaned = true;
1924
+ }
1925
+ if (!orphaned) {
1926
+ try {
1927
+ orphaned = now - statSync2(path4).mtimeMs > LOCK_STALE_MS;
1928
+ } catch {
1929
+ orphaned = true;
1930
+ }
1931
+ }
1932
+ if (!orphaned) return void 0;
1933
+ try {
1934
+ unlinkSync2(path4);
1935
+ } catch {
1936
+ return void 0;
1937
+ }
1938
+ try {
1939
+ return claim();
1940
+ } catch {
1941
+ return void 0;
1858
1942
  }
1859
- } finally {
1860
- s.probing = false;
1861
- s.lastProbe = Date.now();
1862
1943
  }
1863
- const reAdmitted = outcomes.filter((o) => o.ok).map((o) => o.model);
1864
- const stillLimited = outcomes.filter((o) => !o.ok);
1865
- return {
1866
- outcomes,
1867
- cleared: reAdmitted,
1868
- kept: stillLimited.map((o) => o.model),
1869
- // A window the provider restated is a change too: the pool now waits a
1870
- // different length than it did, and the user is the one who set that.
1871
- changed: reAdmitted.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
1872
- };
1873
1944
  }
1874
-
1875
- // src/plugins/agent-pool.ts
1876
- var SERVICE = "agent-pool";
1877
- var REAP_INTERVAL_MS = 3e4;
1878
- var HANG_FALLBACK_MS = 10 * 60 * 1e3;
1879
- var PROBE_INTERVAL_MS = 15 * 60 * 1e3;
1945
+ async function checkForUpdate(opts) {
1946
+ const installed = readInstalledVersion(opts.packageRoot);
1947
+ const latest = await fetchLatest(opts.fetchImpl);
1948
+ return {
1949
+ installed,
1950
+ latest,
1951
+ newer: Boolean(installed && latest && compareSemver(latest, installed) > 0),
1952
+ cacheRoot: resolveCacheRoot(opts.packageRoot, opts.exists),
1953
+ bin: resolveOpencodeBin(opts.execPath, opts.env, opts.exists)
1954
+ };
1955
+ }
1956
+ async function refreshCache(opts) {
1957
+ const { cacheRoot, bin, latest } = opts;
1958
+ const spawnImpl = opts.spawnImpl ?? spawn;
1959
+ const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
1960
+ const fresh = join3(cacheRoot, "node_modules", "@lawaty", "lacode");
1961
+ const aside = `${cacheRoot}.stale-${Date.now()}`;
1962
+ try {
1963
+ rmSync(aside, { recursive: true, force: true });
1964
+ renameSync2(cacheRoot, aside);
1965
+ } catch (error) {
1966
+ return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
1967
+ }
1968
+ const exited = await new Promise((settle) => {
1969
+ let done = false;
1970
+ const finish = (value) => {
1971
+ if (done) return;
1972
+ done = true;
1973
+ clearTimeout(timer);
1974
+ settle(value);
1975
+ };
1976
+ const child = spawnImpl(bin, ["plugin", PACKAGE_NAME, "-g"], {
1977
+ env: { ...process.env, [CHILD_ENV]: "1" },
1978
+ stdio: "ignore"
1979
+ });
1980
+ const timer = setTimeout(() => {
1981
+ try {
1982
+ child.kill("SIGKILL");
1983
+ } catch {
1984
+ }
1985
+ finish(false);
1986
+ }, timeoutMs);
1987
+ child.on("error", () => finish(false));
1988
+ child.on("exit", (code) => finish(code === 0));
1989
+ });
1990
+ const installed = readInstalledVersion(fresh);
1991
+ const ok = exited && installed === latest;
1992
+ if (ok) {
1993
+ try {
1994
+ rmSync(aside, { recursive: true, force: true });
1995
+ } catch {
1996
+ }
1997
+ } else {
1998
+ try {
1999
+ renameSync2(aside, cacheRoot);
2000
+ } catch {
2001
+ }
2002
+ }
2003
+ return {
2004
+ ok,
2005
+ detail: ok ? `installed ${installed}` : `installer exited ${exited ? "0" : "non-zero"}, cache now ${installed ?? "missing"}`
2006
+ };
2007
+ }
2008
+ async function maybeSelfUpdate(opts) {
2009
+ const env = opts.env ?? process.env;
2010
+ if (opts.enabled === false || env[CHILD_ENV] === "1" || env[OPT_OUT_ENV] === "1" || inTestRunner(env)) {
2011
+ return { action: "disabled" };
2012
+ }
2013
+ const now = (opts.now ?? Date.now)();
2014
+ const statePath = opts.statePath ?? STATE_PATH;
2015
+ const state2 = readState(statePath);
2016
+ if (!isDue(state2, now, opts.intervalMs ?? CHECK_INTERVAL_MS)) return { action: "skipped", detail: "checked recently" };
2017
+ const release2 = acquireLock(opts.lockPath, now);
2018
+ if (!release2) return { action: "skipped", detail: "another process is updating" };
2019
+ try {
2020
+ const check = await checkForUpdate(opts);
2021
+ if (!check.installed) return { action: "skipped", detail: "no installed version" };
2022
+ if (!check.latest) {
2023
+ writeState({ ...state2, checkedAt: now }, statePath);
2024
+ return { action: "skipped", detail: "registry unreachable" };
2025
+ }
2026
+ if (!check.newer) {
2027
+ writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
2028
+ return { action: "current", detail: `${check.installed} is current` };
2029
+ }
2030
+ if (!check.cacheRoot) {
2031
+ writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
2032
+ return { action: "skipped", detail: "not an opencode cache install" };
2033
+ }
2034
+ if (!check.bin) {
2035
+ writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
2036
+ opts.log?.("warn", `LaCode ${check.latest} is available but no opencode binary was found to install it`);
2037
+ return { action: "failed", detail: "no opencode binary" };
2038
+ }
2039
+ const result = await refreshCache({ cacheRoot: check.cacheRoot, bin: check.bin, latest: check.latest, spawnImpl: opts.spawnImpl });
2040
+ if (result.ok) {
2041
+ writeState({ ...state2, checkedAt: now, latest: check.latest, updatedTo: check.latest }, statePath);
2042
+ opts.log?.("info", `LaCode updated ${check.installed} -> ${check.latest}; restart opencode to apply`);
2043
+ return { action: "updated", detail: `${check.installed} -> ${check.latest}` };
2044
+ }
2045
+ writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
2046
+ opts.log?.("warn", `LaCode update to ${check.latest} failed: ${result.detail}`);
2047
+ return { action: "failed", detail: result.detail };
2048
+ } finally {
2049
+ release2();
2050
+ }
2051
+ }
2052
+
2053
+ // src/plugins/agent-pool.ts
2054
+ import { unwatchFile, watchFile } from "node:fs";
2055
+ import { tool } from "@opencode-ai/plugin";
2056
+
2057
+ // src/lib/probe.ts
2058
+ var PROBE_SYSTEM = "You are a connection health check. Reply with the single word: ok";
2059
+ var PROBE_PROMPT = "ok";
2060
+ var PROBE_TIMEOUT_MS = 45e3;
2061
+ var failureText = (error) => {
2062
+ if (error === void 0 || error === null) return "";
2063
+ if (typeof error === "string") return error;
2064
+ try {
2065
+ return JSON.stringify(error) ?? String(error);
2066
+ } catch {
2067
+ return String(error);
2068
+ }
2069
+ };
2070
+ var shortReason = (text) => {
2071
+ if (!text) return "failed";
2072
+ if (isModelMissingText(text)) return MODEL_MISSING_REASON;
2073
+ return limitReason(text) ?? (isLimitText(text) ? "limit" : "failed");
2074
+ };
2075
+ var probeOne = async (client, opts, model, leftSeconds) => {
2076
+ const { state: s, directory } = opts;
2077
+ const { provider, id: modelID } = splitModel(model);
2078
+ const settle = (ok, reason, left) => ({
2079
+ model,
2080
+ ok,
2081
+ reason,
2082
+ hadSeconds: leftSeconds,
2083
+ leftSeconds: left
2084
+ });
2085
+ const created = await client.session.create({ body: { title: `pool probe (${model})` }, query: { directory } }).then((r) => r?.data?.id).catch(() => void 0);
2086
+ if (!created) return settle(false, "no probe session", leftSeconds);
2087
+ markProbe(s, created);
2088
+ let timer;
2089
+ try {
2090
+ const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
2091
+ const clock = opts.now ?? Date.now;
2092
+ let timedOut = false;
2093
+ const deadline = new Promise((resolve3) => {
2094
+ timer = setTimeout(() => {
2095
+ timedOut = true;
2096
+ void client.session.abort({ path: { id: created }, query: { directory } }).catch(() => {
2097
+ });
2098
+ resolve3(void 0);
2099
+ }, timeoutMs);
2100
+ });
2101
+ const answer = client.session.prompt({
2102
+ path: { id: created },
2103
+ query: { directory },
2104
+ body: {
2105
+ model: { providerID: provider, modelID },
2106
+ system: PROBE_SYSTEM,
2107
+ parts: [{ type: "text", text: PROBE_PROMPT }]
2108
+ }
2109
+ }).then(
2110
+ (result2) => result2,
2111
+ (error) => ({ error })
2112
+ );
2113
+ const result = await Promise.race([answer, deadline]);
2114
+ const failure = result?.error ?? result?.data?.info?.error;
2115
+ if (!timedOut && failure === void 0 && result?.data?.info !== void 0) {
2116
+ clearCooldowns(s, [model]);
2117
+ return settle(true, "answered", 0);
2118
+ }
2119
+ const text = failureText(failure);
2120
+ if (isModelMissingText(text)) {
2121
+ recordStrike(s, model, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
2122
+ } else {
2123
+ const stated = statedCooldown(text);
2124
+ if (stated !== void 0) recordStrike(s, model, shortReason(text), stated);
2125
+ }
2126
+ const still = readLimits(s).get(model);
2127
+ return settle(
2128
+ false,
2129
+ timedOut ? `no answer in ${Math.round(timeoutMs / 1e3)}s` : shortReason(text),
2130
+ still && still.until > clock() ? Math.ceil((still.until - clock()) / 1e3) : 0
2131
+ );
2132
+ } finally {
2133
+ if (timer) clearTimeout(timer);
2134
+ const deleted = await client.session.delete({ path: { id: created }, query: { directory } }).then(() => true, () => false);
2135
+ if (deleted) unmarkProbe(s, created);
2136
+ }
2137
+ };
2138
+ async function probeModels(client, opts) {
2139
+ const s = opts.state;
2140
+ const now = opts.now ?? Date.now;
2141
+ const empty = { outcomes: [], cleared: [], kept: [], missing: [], changed: false };
2142
+ if (s.probing) return empty;
2143
+ const models = [...new Set(opts.models)];
2144
+ if (models.length === 0) return empty;
2145
+ const cooling = readLimits(s);
2146
+ const at = now();
2147
+ const wasCooling = new Set([...cooling.values()].filter((strike) => strike.until > at).map((strike) => strike.model));
2148
+ s.probing = true;
2149
+ const outcomes = [];
2150
+ try {
2151
+ for (const model of models) {
2152
+ const strike = cooling.get(model);
2153
+ outcomes.push(await probeOne(client, opts, model, strike && strike.until > at ? Math.ceil((strike.until - at) / 1e3) : 0));
2154
+ }
2155
+ } finally {
2156
+ s.probing = false;
2157
+ s.lastProbe = Date.now();
2158
+ }
2159
+ const reAdmitted = outcomes.filter((o) => o.ok && wasCooling.has(o.model)).map((o) => o.model);
2160
+ const missing = outcomes.filter((o) => o.reason === MODEL_MISSING_REASON).map((o) => o.model);
2161
+ const stillLimited = outcomes.filter((o) => !o.ok && o.reason !== MODEL_MISSING_REASON);
2162
+ return {
2163
+ outcomes,
2164
+ cleared: reAdmitted,
2165
+ kept: stillLimited.map((o) => o.model),
2166
+ missing,
2167
+ changed: reAdmitted.length > 0 || missing.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
2168
+ };
2169
+ }
2170
+ async function probeCooling(client, opts) {
2171
+ const s = opts.state;
2172
+ const now = opts.now ?? Date.now;
2173
+ const empty = { outcomes: [], cleared: [], kept: [], missing: [], changed: false };
2174
+ if (s.probing) return empty;
2175
+ const cooling = readLimits(s);
2176
+ const at = now();
2177
+ const wanted = opts.models && opts.models.length > 0 ? new Set(opts.models) : void 0;
2178
+ const candidates = [...cooling.values()].filter(
2179
+ (strike) => strike.until > at && (wanted === void 0 || wanted.has(strike.model))
2180
+ );
2181
+ if (candidates.length === 0) return empty;
2182
+ s.probing = true;
2183
+ const outcomes = [];
2184
+ try {
2185
+ for (const strike of candidates.sort((a, b) => a.until - b.until)) {
2186
+ outcomes.push(await probeOne(client, opts, strike.model, Math.ceil((strike.until - at) / 1e3)));
2187
+ }
2188
+ } finally {
2189
+ s.probing = false;
2190
+ s.lastProbe = Date.now();
2191
+ }
2192
+ const reAdmitted = outcomes.filter((o) => o.ok).map((o) => o.model);
2193
+ const missing = outcomes.filter((o) => o.reason === MODEL_MISSING_REASON).map((o) => o.model);
2194
+ const stillLimited = outcomes.filter((o) => !o.ok && o.reason !== MODEL_MISSING_REASON);
2195
+ return {
2196
+ outcomes,
2197
+ cleared: reAdmitted,
2198
+ kept: stillLimited.map((o) => o.model),
2199
+ missing,
2200
+ // A window the provider restated is a change too: the pool now waits a
2201
+ // different length than it did, and the user is the one who set that.
2202
+ changed: reAdmitted.length > 0 || missing.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
2203
+ };
2204
+ }
2205
+
2206
+ // src/plugins/agent-pool.ts
2207
+ var SERVICE = "agent-pool";
2208
+ var REAP_INTERVAL_MS = 3e4;
2209
+ var HANG_FALLBACK_MS = 10 * 60 * 1e3;
2210
+ var PROBE_INTERVAL_MS = 15 * 60 * 1e3;
2211
+ var SLOT_WATCH_MS = 1e3;
2212
+ var SLOT_WATCH_DEBOUNCE_MS = 250;
1880
2213
  var ABORT_TIMEOUT_MS = 1e4;
1881
2214
  var formatWindow = (seconds) => seconds < 60 ? `${seconds}s` : `${Math.ceil(seconds / 60)}m`;
1882
2215
  var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
@@ -1899,15 +2232,18 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
1899
2232
  };
1900
2233
  sweepDirIfDue();
1901
2234
  const abortSession = async (sessionID) => {
1902
- if (!serverUrl) return { ok: false, why: "no serverUrl: this process has no HTTP server to abort through" };
1903
2235
  try {
1904
- const res = await fetch(new URL(`/session/${sessionID}/abort`, serverUrl).toString(), {
1905
- method: "POST",
2236
+ const result = await client.session.abort({
2237
+ path: { id: sessionID },
2238
+ query: { directory },
1906
2239
  signal: AbortSignal.timeout(ABORT_TIMEOUT_MS)
1907
2240
  });
1908
- if (!res.ok) return { ok: false, why: `gateway answered HTTP ${res.status}` };
1909
- const body = (await res.text()).trim();
1910
- if (body === "false") return { ok: false, why: "server reported the session was not aborted" };
2241
+ if (!result.response?.ok) {
2242
+ return { ok: false, why: `server answered HTTP ${result.response?.status ?? "?"}` };
2243
+ }
2244
+ if (result.data === false) {
2245
+ return { ok: false, why: "server reported the session was not aborted" };
2246
+ }
1911
2247
  return { ok: true, why: "" };
1912
2248
  } catch (error) {
1913
2249
  return { ok: false, why: String(error) };
@@ -1921,18 +2257,95 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
1921
2257
  const strike = recordStrike(s, claim.m, "hang", HANG_FALLBACK_MS);
1922
2258
  if (strike) await log2("warn", `cooling ${claim.m} for ${Math.round((strike.until - now) / 1e3)}s (hang)`);
1923
2259
  };
1924
- const failOver = async (sessionID, model, reason) => {
1925
- if (!sessionID) return;
1926
- const entry = [...s.own.entries()].find(([, claim2]) => claim2.g === sessionID);
1927
- if (!entry) return;
1928
- const [key, claim] = entry;
1929
- const kill = await abortSession(sessionID);
1930
- release(s, key);
1931
- await log(
1932
- "warn",
1933
- kill.ok ? `limit (${reason}) on ${model}; aborted in-flight ${claim.v} session ${sessionID} to fail over now` : `limit (${reason}) on ${model}; released in-flight ${claim.v} claim after a failed abort (${kill.why})`,
1934
- { base: claim.v, model: claim.m, slot: claim.k, session: sessionID, aborted: kill.ok, reason }
1935
- );
2260
+ const claimFor = (sessionID) => {
2261
+ if (!sessionID) return void 0;
2262
+ for (const [key, claim] of s.own) if (claim.g === sessionID) return { key, claim };
2263
+ return void 0;
2264
+ };
2265
+ const identifyPooledSession = async (sessionID) => {
2266
+ try {
2267
+ const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
2268
+ if (!info?.parentID) return void 0;
2269
+ const msgs = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then(
2270
+ (r) => r.data
2271
+ ).catch(() => void 0);
2272
+ if (!Array.isArray(msgs)) return void 0;
2273
+ const userMsg = msgs.find((m) => m?.info?.role === "user");
2274
+ if (!userMsg?.info) return void 0;
2275
+ const agent = userMsg.info.agent;
2276
+ if (typeof agent !== "string") return void 0;
2277
+ const modelInfo = userMsg.info.model;
2278
+ const userModel = modelInfo?.providerID && modelInfo?.modelID ? `${modelInfo.providerID}/${modelInfo.modelID}` : void 0;
2279
+ if (s.known.size > 0 && !s.known.has(agent)) return void 0;
2280
+ if (s.known.size === 0 && manualVariant(agent) === void 0 && !(BASES.includes(agent) && userModel)) {
2281
+ return void 0;
2282
+ }
2283
+ const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
2284
+ if (!slots || slots.length === 0) return void 0;
2285
+ let slot;
2286
+ const variantBase = manualVariant(agent);
2287
+ if (variantBase) {
2288
+ const idx = Number(agent.slice(agent.lastIndexOf("-") + 1));
2289
+ if (!Number.isFinite(idx)) return void 0;
2290
+ slot = slots.find((sl) => sl.index === idx);
2291
+ } else if (BASES.includes(agent) && userModel) {
2292
+ slot = slots.find((sl) => sl.model === userModel);
2293
+ }
2294
+ if (!slot) return void 0;
2295
+ return { agent, slot: slot.index, model: slot.model };
2296
+ } catch {
2297
+ return void 0;
2298
+ }
2299
+ };
2300
+ const failOver = async (sessionID, model, reason, opts2) => {
2301
+ const entry = claimFor(sessionID);
2302
+ if (entry) {
2303
+ const { key, claim } = entry;
2304
+ const kill = await abortSession(sessionID);
2305
+ if (kill.ok) {
2306
+ release(s, key);
2307
+ await log(
2308
+ "warn",
2309
+ `limit (${reason}) on ${model}; aborted in-flight ${claim.v} session ${sessionID} to fail over now`,
2310
+ { base: claim.v, model: claim.m, slot: claim.k, session: sessionID, aborted: true, reason }
2311
+ );
2312
+ return;
2313
+ }
2314
+ await log(
2315
+ "warn",
2316
+ `limit (${reason}) on ${model}; abort failed (${kill.why}); claim on slot ${claim.k} held for retry`,
2317
+ { base: claim.v, model: claim.m, slot: claim.k, session: sessionID, aborted: false, reason }
2318
+ );
2319
+ return;
2320
+ }
2321
+ if (!entry) {
2322
+ if (opts2?.cool !== false && sessionID) {
2323
+ const pooled = await identifyPooledSession(sessionID);
2324
+ if (pooled) {
2325
+ recordStrike(s, pooled.model, reason, opts2?.stated);
2326
+ const kill = await abortSession(sessionID);
2327
+ await log(
2328
+ "warn",
2329
+ `limit (${reason}) on ${pooled.model}; aborted pooled ${pooled.agent} session ${sessionID} (slot ${pooled.slot}) to fail over now`,
2330
+ { base: pooled.agent, model: pooled.model, slot: pooled.slot, session: sessionID, aborted: kill.ok, reason }
2331
+ );
2332
+ return;
2333
+ }
2334
+ } else if (opts2?.cool === false && sessionID) {
2335
+ const pooled = await identifyPooledSession(sessionID);
2336
+ if (pooled) {
2337
+ const kill = await abortSession(sessionID);
2338
+ await log(
2339
+ "warn",
2340
+ `limit (${reason}) on ${pooled.model}; aborted pooled ${pooled.agent} session ${sessionID} (slot ${pooled.slot}) to fail over now`,
2341
+ { base: pooled.agent, model: pooled.model, slot: pooled.slot, session: sessionID, aborted: kill.ok, reason }
2342
+ );
2343
+ return;
2344
+ }
2345
+ }
2346
+ await log("warn", `limit (${reason}) on ${model} for session ${sessionID ?? "?"}, which has no pool claim; nothing aborted`);
2347
+ return;
2348
+ }
1936
2349
  };
1937
2350
  const reapStuck = async () => {
1938
2351
  if (s.reaping) return;
@@ -2005,20 +2418,25 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2005
2418
  "info",
2006
2419
  `reaper armed: every ${Math.round(reapMs / 1e3)}s, aborts a claim older than ${Math.round(STUCK_MIN_AGE_MS / 6e4)}m whose target has been silent ${Math.round(STUCK_IDLE_MS / 6e4)}m, or any claim past the ${Math.round(MAX_CLAIM_AGE_MS / 6e4)}m ceiling however chatty`
2007
2420
  );
2008
- const EMPTY_REPORT = { outcomes: [], cleared: [], kept: [], changed: false };
2009
- const reportTitle = (report) => report.outcomes.length === 0 ? "nothing cooling to probe" : `probed ${report.outcomes.length} cooling model(s), re-admitted ${report.cleared.length}`;
2421
+ const EMPTY_REPORT = { outcomes: [], cleared: [], kept: [], missing: [], changed: false };
2422
+ const reportTitle = (report) => {
2423
+ if (report.outcomes.length === 0) return "nothing cooling to probe";
2424
+ const parts = [`probed ${report.outcomes.length} model(s)`, `re-admitted ${report.cleared.length}`];
2425
+ if (report.missing.length > 0) parts.push(`benched ${report.missing.length} missing`);
2426
+ return parts.join(", ");
2427
+ };
2010
2428
  const reportText = (report) => {
2011
2429
  if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
2012
2430
  return [
2013
2431
  ...report.outcomes.map(
2014
- (outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the rotation` : `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`
2432
+ (outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the pool` : outcome.reason === MODEL_MISSING_REASON ? `${outcome.model}: not served by the provider (${outcome.reason}) \u2014 benched${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}` : `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`
2015
2433
  ),
2016
- report.cleared.length > 0 ? `re-admitted: ${report.cleared.join(", ")}. They route again on the next pooled spawn \u2014 nothing needs restarting or reloading.` : "nothing was re-admitted; the pool is short exactly as much as it was."
2434
+ report.cleared.length > 0 ? `re-admitted: ${report.cleared.join(", ")}. They route again on the next pooled spawn \u2014 nothing needs restarting or reloading.` : report.missing.length > 0 ? `benched: ${report.missing.join(", ")}. The provider does not serve them; replace them in the pool.` : report.outcomes.every((outcome) => outcome.ok) ? "every model that was asked answered; nothing needed re-admitting." : "nothing was re-admitted; the pool is short exactly as much as it was."
2017
2435
  ].join("\n");
2018
2436
  };
2019
2437
  const announce = async (report) => {
2020
2438
  if (!report.changed) return;
2021
- const message = report.cleared.length > 0 ? `Re-admitted ${report.cleared.join(", ")} \u2014 they answer again.` : `${report.kept.length} cooling model(s) re-checked, still limited.`;
2439
+ const message = report.cleared.length > 0 ? `Re-admitted ${report.cleared.join(", ")} \u2014 they answer again.` : report.missing.length > 0 ? `Benched ${report.missing.join(", ")} \u2014 the provider does not serve them.` : `${report.kept.length} cooling model(s) re-checked, still limited.`;
2022
2440
  await log("info", `pool probe: ${message}`, { cleared: report.cleared, kept: report.kept });
2023
2441
  try {
2024
2442
  await client.tui?.showToast?.({
@@ -2045,47 +2463,90 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2045
2463
  "info",
2046
2464
  probeMs > 0 ? `cooldown probe armed: every ${Math.round(probeMs / 6e4)}m, checks the models that are cooling by using them (one request each, one at a time); 0 to disable` : "cooldown probe disabled (probeMs: 0); use the pool_recheck tool to check on demand"
2047
2465
  );
2048
- return {
2049
- config: async (cfg) => {
2050
- const slots = ensureSlots(s, (m) => void warnOnce(m));
2051
- const agents = cfg.agent;
2052
- const providers = cfg.provider;
2053
- const known = /* @__PURE__ */ new Set();
2054
- for (const [name, def] of Object.entries(cfg.agent ?? {})) if (def?.model) s.models.set(name, def.model);
2055
- for (const slot of slots) {
2056
- for (const base of BASES) {
2057
- const name = agentFor(base, slot.index);
2058
- const def = agents?.[name];
2059
- if (!def) {
2060
- await warnOnce(`variant ${name} is missing from the config; spawns of ${base} that route to slot ${slot.index} would fail`);
2061
- continue;
2062
- }
2063
- if (def.model && def.model !== slot.model) {
2064
- await warnOnce(`${name} still hard-codes ${def.model}; the resolved pool preset wins (${slot.model}). Delete the line to keep one source of truth.`);
2065
- }
2066
- def.model = slot.model;
2067
- known.add(name);
2068
- s.models.set(name, slot.model);
2069
- }
2070
- const slash = slot.model.indexOf("/");
2071
- const provider = slot.model.slice(0, slash);
2072
- const id = slot.model.slice(slash + 1);
2073
- const entry = providers?.[provider];
2074
- if (!entry) {
2075
- await warnOnce(`provider ${provider} is not configured in opencode.jsonc; slot ${slot.index} (${slot.model}) cannot resolve`);
2466
+ let liveConfig;
2467
+ const bind = async (cfg) => {
2468
+ const slots = ensureSlots(s, (m) => void warnOnce(m));
2469
+ const agents = cfg.agent;
2470
+ const providers = cfg.provider;
2471
+ const known = /* @__PURE__ */ new Set();
2472
+ for (const [name, def] of Object.entries(cfg.agent ?? {})) if (def?.model) s.models.set(name, def.model);
2473
+ for (const slot of slots) {
2474
+ for (const base of BASES) {
2475
+ const name = agentFor(base, slot.index);
2476
+ const def = agents?.[name];
2477
+ if (!def) {
2478
+ await warnOnce(`variant ${name} is missing from the config; spawns of ${base} that route to slot ${slot.index} would fail`);
2076
2479
  continue;
2077
2480
  }
2078
- const list = entry.whitelist;
2079
- if (list && !list.includes(id)) {
2080
- list.push(id);
2081
- await log("info", `whitelisted ${id} on ${provider} (${s.origin} slot ${slot.index})`);
2481
+ if (def.model && def.model !== slot.model) {
2482
+ await warnOnce(`${name} still hard-codes ${def.model}; the resolved pool preset wins (${slot.model}). Delete the line to keep one source of truth.`);
2082
2483
  }
2484
+ def.model = slot.model;
2485
+ known.add(name);
2486
+ s.models.set(name, slot.model);
2083
2487
  }
2084
- s.known = known;
2085
- await log(
2086
- "info",
2087
- `${s.origin}: bound ${known.size} variant agent(s) to ${slots.length} slot(s): ${slots.map((sl) => `${sl.index}=${sl.model} w${sl.weight}`).join(", ")}`
2088
- );
2488
+ const slash = slot.model.indexOf("/");
2489
+ const provider = slot.model.slice(0, slash);
2490
+ const id = slot.model.slice(slash + 1);
2491
+ const entry = providers?.[provider];
2492
+ if (!entry) {
2493
+ await warnOnce(`provider ${provider} is not configured in opencode.jsonc; slot ${slot.index} (${slot.model}) cannot resolve`);
2494
+ continue;
2495
+ }
2496
+ const list = entry.whitelist;
2497
+ if (list && !list.includes(id)) {
2498
+ list.push(id);
2499
+ await log("info", `whitelisted ${id} on ${provider} (${s.origin} slot ${slot.index})`);
2500
+ }
2501
+ }
2502
+ s.known = known;
2503
+ await log(
2504
+ "info",
2505
+ `${s.origin}: bound ${known.size} variant agent(s) to ${slots.length} slot(s): ${slots.map((sl) => `${sl.index}=${sl.model} w${sl.weight}`).join(", ")}`
2506
+ );
2507
+ };
2508
+ const runValidation = async (models) => {
2509
+ let report = EMPTY_REPORT;
2510
+ try {
2511
+ report = await probeModels(client, { state: s, directory, models });
2512
+ } catch (error) {
2513
+ await log("error", `pool validation failed: ${String(error)}`);
2514
+ return EMPTY_REPORT;
2515
+ }
2516
+ await announce(report);
2517
+ return report;
2518
+ };
2519
+ const watchMs = opts.watchMs ?? SLOT_WATCH_MS;
2520
+ const watchDebounceMs = opts.watchDebounceMs ?? SLOT_WATCH_DEBOUNCE_MS;
2521
+ const sources = watchMs > 0 ? slotSources(opts) : [];
2522
+ let reloadTimer;
2523
+ const reloadFromDisk = async () => {
2524
+ const reload = reloadSlots(s, (m) => void warnOnce(m));
2525
+ if (!reload.changed) return;
2526
+ await log(
2527
+ "info",
2528
+ `pool changed (${reload.origin}): ${reload.slots.map((sl) => `${sl.index}=${sl.model} w${sl.weight}`).join(", ")}`
2529
+ );
2530
+ if (liveConfig) await bind(liveConfig);
2531
+ await runValidation(reload.slots.map((slot) => slot.model));
2532
+ };
2533
+ const scheduleReload = () => {
2534
+ if (reloadTimer) return;
2535
+ reloadTimer = setTimeout(() => {
2536
+ reloadTimer = void 0;
2537
+ void reloadFromDisk();
2538
+ }, watchDebounceMs);
2539
+ reloadTimer.unref?.();
2540
+ };
2541
+ for (const source of sources) watchFile(source, { interval: watchMs, persistent: false }, scheduleReload);
2542
+ void log(
2543
+ "info",
2544
+ sources.length > 0 ? `pool config watch armed: ${sources.join(", ")} every ${watchMs}ms; a change reloads the slots and validates every slot` : "pool config watch disabled (watchMs: 0)"
2545
+ );
2546
+ return {
2547
+ config: async (cfg) => {
2548
+ liveConfig = cfg;
2549
+ await bind(cfg);
2089
2550
  },
2090
2551
  event: async ({ event }) => {
2091
2552
  try {
@@ -2093,24 +2554,88 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2093
2554
  if (event.type === "session.status") {
2094
2555
  const sessionID = typeof props.sessionID === "string" ? props.sessionID : void 0;
2095
2556
  const status2 = props.status;
2096
- const reason2 = status2?.action?.reason ?? "";
2097
- if (status2?.type === "retry" && status2.action?.provider && (reason2 === "free_tier_limit" || isLimitText(reason2))) {
2098
- const provider = status2.action.provider;
2557
+ const action = status2?.action;
2558
+ const provider = typeof action?.provider === "string" ? action.provider : void 0;
2559
+ const reason2 = action?.reason ?? "";
2560
+ const message = typeof status2?.message === "string" ? status2.message : "";
2561
+ const limited2 = reason2 === "free_tier_limit" || isLimitText(reason2) || isLimitText(message);
2562
+ if (status2?.type === "retry" && limited2) {
2099
2563
  if (sessionID && isProbe(s, sessionID)) {
2100
- await log("debug", `probe on provider ${provider} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
2564
+ await log("debug", `probe on provider ${provider ?? "?"} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
2101
2565
  return;
2102
2566
  }
2103
- const until = typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
2104
- const cooled = coolProvider(s, provider, until, reason2 || "rate-limited");
2105
- if (cooled.length > 0) {
2106
- await log("warn", `limit on provider ${provider} (${reason2 || "rate-limited"}); cooling ${cooled.length} slot(s) until ${new Date(until).toISOString().slice(11, 19)}Z`, {
2107
- provider,
2108
- until,
2109
- models: cooled.map((c) => c.model)
2110
- });
2567
+ const own = claimFor(sessionID);
2568
+ const outage = isUnavailableText(message) && !isQuotaReason(reason2);
2569
+ const named = outage ? UNAVAILABLE_REASON : reason2 || limitReason(message) || "rate-limited";
2570
+ if (provider) {
2571
+ const until = outage ? Date.now() + UNAVAILABLE_COOLDOWN_MS : typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
2572
+ const cooled = coolProvider(s, provider, until, named);
2573
+ if (cooled.length > 0) {
2574
+ await log("warn", `limit on provider ${provider} (${named}); cooling ${cooled.length} slot(s) until ${new Date(until).toISOString().slice(11, 19)}Z`, {
2575
+ provider,
2576
+ until,
2577
+ models: cooled.map((c) => c.model)
2578
+ });
2579
+ }
2580
+ } else if (own) {
2581
+ if (outage) {
2582
+ const transient = coolUnavailable(s, own.claim.m);
2583
+ if (transient) await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
2584
+ } else {
2585
+ const stated2 = statedCooldown(message);
2586
+ const strike2 = recordStrike(s, own.claim.m, named, stated2);
2587
+ if (strike2) {
2588
+ await log("warn", `limit on ${own.claim.m} (${named}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s`, {
2589
+ model: own.claim.m,
2590
+ slot: own.claim.k,
2591
+ session: sessionID
2592
+ });
2593
+ }
2594
+ }
2595
+ }
2596
+ if (provider) {
2597
+ await failOver(sessionID, provider ?? own?.claim.m ?? "?", named, { stated: void 0, cool: false });
2598
+ } else {
2599
+ const stated2 = outage ? void 0 : statedCooldown(message);
2600
+ await failOver(sessionID, own?.claim.m ?? "?", named, { stated: stated2, cool: !own });
2601
+ }
2602
+ }
2603
+ return;
2604
+ }
2605
+ if (event.type === "session.error") {
2606
+ const sessionID = typeof props.sessionID === "string" ? props.sessionID : void 0;
2607
+ const error = props.error;
2608
+ const haystack2 = JSON.stringify(error) ?? "";
2609
+ if (sessionID && isProbe(s, sessionID)) {
2610
+ await log("debug", `probe session ${sessionID} ended with an error; not cooling anything`);
2611
+ return;
2612
+ }
2613
+ const status2 = typeof error?.data?.statusCode === "number" ? error.data.statusCode : void 0;
2614
+ const limited2 = isLimitText(haystack2) || status2 === 429;
2615
+ const outage = isUnavailableText(haystack2);
2616
+ if (!limited2 && !outage) return;
2617
+ const own = claimFor(sessionID);
2618
+ const named = outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
2619
+ let stated2;
2620
+ if (own) {
2621
+ if (outage) {
2622
+ const transient = coolUnavailable(s, own.claim.m);
2623
+ if (transient) {
2624
+ await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason}) after a terminal error`);
2625
+ }
2626
+ } else {
2627
+ stated2 = statedCooldown(haystack2) ?? retryAfterMs(error?.data?.responseHeaders);
2628
+ const strike2 = recordStrike(s, own.claim.m, named, stated2);
2629
+ if (strike2) {
2630
+ await log("warn", `limit on ${own.claim.m} (${named}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s from a terminal session.error`, {
2631
+ model: own.claim.m,
2632
+ slot: own.claim.k,
2633
+ session: sessionID
2634
+ });
2635
+ }
2111
2636
  }
2112
- await failOver(sessionID, provider, reason2 || "rate-limited");
2113
2637
  }
2638
+ await failOver(sessionID, own?.claim.m ?? "?", named, { stated: stated2, cool: !own });
2114
2639
  return;
2115
2640
  }
2116
2641
  if (event.type === "session.created") {
@@ -2138,32 +2663,38 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2138
2663
  await log("debug", `probe of ${model} hit a limit (${isLimitText(haystack) ? "limit" : "transient"}); leaving the cooldown to the probe`);
2139
2664
  return;
2140
2665
  }
2666
+ const ownMsg = claimFor(info.sessionID);
2141
2667
  if (isUnavailableText(haystack) || !limited && transientGateway) {
2142
2668
  const transient = coolUnavailable(s, model);
2143
2669
  if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
2670
+ await failOver(info.sessionID, model, transient?.reason ?? UNAVAILABLE_REASON, { cool: false });
2144
2671
  return;
2145
2672
  }
2146
2673
  const stated = statedCooldown(haystack) ?? retryAfterMs(info.error.data?.responseHeaders);
2147
- const reason = haystack.match(/rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded/i)?.[0]?.toLowerCase().replace(/[\s_]/g, "-");
2674
+ const reason = limitReason(haystack);
2148
2675
  const strike = recordStrike(s, model, reason ?? "rate-limited", stated);
2149
2676
  if (strike) await log("warn", `cooling ${model} for ${Math.round((strike.until - Date.now()) / 1e3)}s (${reason ?? "rate-limited"}, strike ${strike.strikes})`);
2150
- await failOver(info.sessionID, model, reason ?? "rate-limited");
2677
+ await failOver(info.sessionID, model, reason ?? "rate-limited", { stated, cool: !ownMsg });
2151
2678
  } catch (error) {
2152
2679
  await log("error", `limit detection failed: ${String(error)}`);
2153
2680
  }
2154
2681
  },
2155
2682
  "tool.execute.before": async (input, output) => {
2156
2683
  if (input.tool !== "task") return;
2157
- const requested = output.args?.subagent_type;
2158
- if (typeof requested !== "string") return;
2684
+ let requested = output.args?.subagent_type;
2685
+ if (typeof requested !== "string") {
2686
+ await warnOnce(
2687
+ `task spawn has no subagent_type, so the pool cannot route it -- it will run on the caller's own model. Pass subagent_type "explore" or "implement" to use a slot.`
2688
+ );
2689
+ return;
2690
+ }
2159
2691
  const manual = manualVariant(requested);
2160
2692
  if (manual) {
2161
- await log("warn", `refused manual spawn of a pool slot variant; spawn "${manual}" instead`, {
2693
+ await log("warn", `rewrote a hand-named pool slot to its base; the pool owns slot choice`, {
2162
2694
  base: manual
2163
2695
  });
2164
- throw new Error(
2165
- `That agent is a pool slot variant and cannot be spawned by name. Naming it lets the caller pick the provider: it skips the round-robin rotation and the slot's ceiling, so one provider takes every concurrent claim. Spawn "${manual}" and let the pool choose the slot.`
2166
- );
2696
+ requested = manual;
2697
+ output.args.subagent_type = manual;
2167
2698
  }
2168
2699
  try {
2169
2700
  await reapStuck();
@@ -2176,8 +2707,6 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2176
2707
  await warnOnce(`variant ${variant} missing from config; spawn left unchanged`);
2177
2708
  return;
2178
2709
  }
2179
- acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
2180
- output.args.subagent_type = variant;
2181
2710
  await log("info", `routed ${requested} to slot ${slot.index} on ${slot.model}`, {
2182
2711
  slot: slot.index,
2183
2712
  weight: slot.weight,
@@ -2185,6 +2714,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2185
2714
  ceiling: slotCeiling(slot),
2186
2715
  load: (snap.counts.get(slot.model) ?? 0) + 1
2187
2716
  });
2717
+ output.args.subagent_type = variant;
2718
+ acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
2188
2719
  return;
2189
2720
  }
2190
2721
  const pinned = s.models.get(requested);
@@ -2204,22 +2735,24 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2204
2735
  },
2205
2736
  tool: {
2206
2737
  pool_recheck: tool({
2207
- description: "Probe the models the pool has benched and re-admit the ones that answer. Each probe is a real request on that exact model, so it settles what a cooldown can only guess: if the model replies, its cooldown is cleared and it is back in the rotation; if it is still limited, the existing cooldown stands. Call it after resetting a quota in the provider's own console, when the pool feels short of capacity, or to check now instead of waiting for the next automatic pass.",
2738
+ description: "Probe the models the pool has benched and re-admit the ones that answer. Each probe is a real request on that exact model, so it settles what a cooldown can only guess: if the model replies, its cooldown is cleared and it is back in the pool; if it is still limited, the existing cooldown stands; if the provider does not serve it at all, it is benched so routing stops choosing it. Pass `models` to validate those even when they are not cooling. Call it after editing the pool, after resetting a quota in the provider's own console, when the pool feels short of capacity, or to check now instead of waiting for the next automatic pass.",
2208
2739
  args: {
2209
- models: tool.schema.array(tool.schema.string()).optional().describe("Model ids to probe, as provider/model-id. Omit to probe every model that is cooling.")
2740
+ models: tool.schema.array(tool.schema.string()).optional().describe("Model ids to validate, as provider/model-id. Omit to probe only the models that are cooling.")
2210
2741
  },
2211
2742
  async execute(args) {
2212
- const report = await runProbe(args?.models);
2743
+ const models = args?.models;
2744
+ const report = models && models.length > 0 ? await runValidation(models) : await runProbe();
2213
2745
  return { title: `agent-pool: ${reportTitle(report)}`, output: reportText(report) };
2214
2746
  }
2215
2747
  }),
2216
2748
  pool_status: tool({
2217
- description: "Show live pool load across every opencode process, per free model and per agent type, each slot's load against its ceiling, the slot marked <- next that the next spawn would take, and which models are cooling after a rate limit or a stuck task. Check it before launching many parallel subagents.",
2749
+ description: "Show live pool load across every opencode process, per free model and per agent type, each slot's load against its ceiling, the slot marked <- next that the next spawn would take, and which models are cooling after a rate limit or a stuck task; COOLING/UNAVAILABLE = not routable. Check it before launching many parallel subagents.",
2218
2750
  args: {},
2219
2751
  async execute() {
2220
2752
  refreshCatalog(s, serverUrl);
2221
2753
  const slots = ensureSlots(s, (m) => void warnOnce(m));
2222
2754
  const snap = snapshot(s);
2755
+ const pass = poolPass(s, snap);
2223
2756
  const now = Date.now();
2224
2757
  const byModel = /* @__PURE__ */ new Map();
2225
2758
  const add = (model, base) => {
@@ -2230,14 +2763,14 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2230
2763
  };
2231
2764
  for (const claim of snap.own.values()) add(claim.m, claim.v);
2232
2765
  for (const sibling of snap.siblings) for (const claim of sibling.claims) add(claim.m, claim.v);
2233
- const next = pickSlot(s, snap, { advance: false });
2766
+ const next = pickSlot(s, snap);
2234
2767
  const line = (slot) => {
2235
2768
  const entry = byModel.get(slot.model);
2236
2769
  const users = entry && entry.bases.size > 0 ? [...entry.bases].map(([b, n]) => `${b}x${n}`).join(" ") : "idle";
2237
2770
  const strike = snap.cooling.get(slot.model);
2238
2771
  const status = s.catalog.get(slot.model);
2239
2772
  const notes = [
2240
- strike ? `COOLING ${Math.ceil((strike.until - now) / 1e3)}s (${strike.reason}, strike ${strike.strikes})` : "",
2773
+ strike ? `UNAVAILABLE (COOLING ${Math.ceil((strike.until - now) / 1e3)}s, ${strike.reason}, strike ${strike.strikes})` : "",
2241
2774
  status && status !== "active" && status !== "unknown" ? `catalog:${status}` : ""
2242
2775
  ].filter(Boolean);
2243
2776
  const marker = slot.index === next.index ? " <- next" : "";
@@ -2253,8 +2786,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2253
2786
  return {
2254
2787
  title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
2255
2788
  output: [
2256
- `shared pool over ${slots.length} model(s) from ${s.origin}; bases: ${BASES.join(", ")}
2257
- round robin in slot order, each slot taking as many turns as its weight says, so no slot is starved while another drains (FULL = at ceiling, used only when every slot is)`,
2789
+ `shared pool over ${slots.length} model(s) from ${s.origin}; pass ${pass}; bases: ${BASES.join(", ")}
2790
+ two ordered passes in slot order: pass 1 fills each slot to its weight, pass 2 to its ceiling, then overflow goes to the least-loaded slot (FULL = at ceiling, used only when no other slot is available and under its ceiling)`,
2258
2791
  ...slots.map(line),
2259
2792
  "",
2260
2793
  "by process:",
@@ -2268,8 +2801,9 @@ round robin in slot order, each slot taking as many turns as its weight says, so
2268
2801
  // When the cooled models were last USED rather than inferred, because
2269
2802
  // "still cooling" and "nobody has actually asked it lately" are
2270
2803
  // different situations and this is the line that tells them apart.
2271
- probeMs > 0 ? `cooldown probe: last ${s.lastProbe ? `${Math.max(0, Math.round((now - s.lastProbe) / 1e3))}s ago` : "never (nothing has been cooling)"}, every ${Math.round(probeMs / 6e4)}m; call the pool_recheck tool to ask now.` : "cooldown probe: off (probeMs: 0); call the pool_recheck tool to ask now.",
2272
- coolingNow.length === 0 ? "no models cooling down." : `cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
2804
+ probeMs > 0 ? `cooldown probe: last ${s.lastProbe ? `${Math.max(0, Math.round((now - s.lastProbe) / 1e3))}s ago` : coolingNow.length > 0 ? "never in this process (cooldowns came from disk)" : "never (nothing has been cooling)"}, every ${Math.round(probeMs / 6e4)}m; call the pool_recheck tool (or run /pool-recheck) to ask now.` : "cooldown probe: off (probeMs: 0); call the pool_recheck tool (or run /pool-recheck) to ask now.",
2805
+ watchMs > 0 ? `pool config watch: ${sources.join(", ")} every ${watchMs}ms; a change reloads the slot table and validates every slot.` : "pool config watch: off (watchMs: 0).",
2806
+ coolingNow.length === 0 ? "no models cooling down." : `unavailable/cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
2273
2807
  ].join("\n")
2274
2808
  };
2275
2809
  }
@@ -2278,6 +2812,8 @@ round robin in slot order, each slot taking as many turns as its weight says, so
2278
2812
  dispose: async () => {
2279
2813
  clearInterval(timer);
2280
2814
  if (probeTimer) clearInterval(probeTimer);
2815
+ if (reloadTimer) clearTimeout(reloadTimer);
2816
+ for (const source of sources) unwatchFile(source, scheduleReload);
2281
2817
  s.own.clear();
2282
2818
  s.activity.clear();
2283
2819
  s.children.clear();
@@ -2287,8 +2823,8 @@ round robin in slot order, each slot taking as many turns as its weight says, so
2287
2823
  });
2288
2824
 
2289
2825
  // src/plugins/context-autoupdate.ts
2290
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
2291
- import { homedir as homedir2 } from "node:os";
2826
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync5, statSync as statSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3 } from "node:fs";
2827
+ import { homedir as homedir3 } from "node:os";
2292
2828
  import * as path2 from "node:path";
2293
2829
 
2294
2830
  // src/lib/active-backlog.ts
@@ -2439,7 +2975,7 @@ var parseJsonc = (raw) => {
2439
2975
  };
2440
2976
 
2441
2977
  // src/lib/writer-rule.ts
2442
- import { readFileSync as readFileSync3 } from "node:fs";
2978
+ import { readFileSync as readFileSync4 } from "node:fs";
2443
2979
  import * as path from "node:path";
2444
2980
  var CONTEXT_DIR = path.join(".opencode", "context");
2445
2981
  var WRITER = "context";
@@ -2479,7 +3015,7 @@ var agentsForm = (mapDir, projectRoot) => {
2479
3015
  var verifyWriterRule = (configPath, mapDir, projectRoot) => {
2480
3016
  let raw;
2481
3017
  try {
2482
- raw = readFileSync3(configPath, "utf8");
3018
+ raw = readFileSync4(configPath, "utf8");
2483
3019
  } catch {
2484
3020
  return void 0;
2485
3021
  }
@@ -2580,7 +3116,7 @@ var snapshotMap = (dir) => {
2580
3116
  for (const name of readdirSync2(dir)) {
2581
3117
  const file = path2.join(dir, name);
2582
3118
  try {
2583
- const st = statSync2(file);
3119
+ const st = statSync3(file);
2584
3120
  if (st.isFile()) out.set(name, `${st.size}:${st.mtimeMs}`);
2585
3121
  } catch {
2586
3122
  }
@@ -2597,15 +3133,15 @@ var changedSince = (before, dir) => {
2597
3133
  return changed;
2598
3134
  };
2599
3135
  var RETAIN_TRANSCRIPTS = 20;
2600
- var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(homedir2(), ".local", "share", "opencode", "lacode", "cartographer");
3136
+ var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(homedir3(), ".local", "share", "opencode", "lacode", "cartographer");
2601
3137
  var retireSession = async (client, directory, sessionID, mode, log) => {
2602
3138
  try {
2603
3139
  const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
2604
3140
  const dir = transcriptDir();
2605
- mkdirSync2(dir, { recursive: true });
3141
+ mkdirSync3(dir, { recursive: true });
2606
3142
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2607
3143
  const file = path2.join(dir, `${stamp}-${mode}.json`);
2608
- writeFileSync2(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
3144
+ writeFileSync3(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
2609
3145
  pruneTranscripts(dir);
2610
3146
  const gone = async () => await client.session.get({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0) === void 0;
2611
3147
  await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
@@ -2626,7 +3162,7 @@ var pruneTranscripts = (dir) => {
2626
3162
  const entries = readdirSync2(dir).filter((name) => name.endsWith(".json")).sort();
2627
3163
  for (const name of entries.slice(0, Math.max(0, entries.length - RETAIN_TRANSCRIPTS))) {
2628
3164
  try {
2629
- unlinkSync2(path2.join(dir, name));
3165
+ unlinkSync3(path2.join(dir, name));
2630
3166
  } catch {
2631
3167
  }
2632
3168
  }
@@ -2655,9 +3191,9 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2655
3191
  const normalizeActiveBacklog = async () => {
2656
3192
  try {
2657
3193
  if (!existsSync2(activeBacklogPath)) return { removed: 0 };
2658
- const before = readFileSync4(activeBacklogPath, "utf8");
3194
+ const before = readFileSync5(activeBacklogPath, "utf8");
2659
3195
  const { text, removed } = normalizeBacklog(before);
2660
- if (text !== before) writeFileSync2(activeBacklogPath, text);
3196
+ if (text !== before) writeFileSync3(activeBacklogPath, text);
2661
3197
  if (removed.length > 0) {
2662
3198
  await log("info", `active backlog: dropped ${removed.length} task(s) finished more than a week ago`, {
2663
3199
  removed: removed.length
@@ -2695,7 +3231,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2695
3231
  "",
2696
3232
  NO_BULK_READS,
2697
3233
  "",
2698
- 'Follow the incremental-update procedure in your system prompt. Update a context file only if this work introduced or revealed information that will materially help future agents: a new boundary or module, a new or moved canonical entry point, a changed workflow, a new convention, an architectural decision, a new dependency between subsystems, a renamed or moved subsystem, or a previously undocumented invariant. Do not edit documentation merely because source files changed. If nothing material changed, make no edits and report "no update needed". Also keep `.opencode/context/active.md` current: record work that is in flight, and mark a task finished \u2014 with the date it finished \u2014 when it is.'
3234
+ 'Follow the incremental-update procedure in your system prompt. Update a context file only if this work introduced or revealed information that will materially help future agents: a new boundary or module, a new or moved canonical entry point, a changed workflow, a new convention, an architectural decision, a new dependency between subsystems, a renamed or moved subsystem, or an invariant not yet recorded. Do not edit documentation merely because source files changed. If nothing material changed, make no edits and report "no update needed". Also keep `.opencode/context/active.md` current: record work that is in flight, and mark a task finished \u2014 with the date it finished \u2014 when it is.'
2699
3235
  ].join("\n");
2700
3236
  return {
2701
3237
  event: async ({ event }) => {
@@ -2809,12 +3345,330 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2809
3345
  };
2810
3346
  });
2811
3347
 
3348
+ // src/plugins/image-paste.ts
3349
+ import { createHash, randomUUID } from "node:crypto";
3350
+ import { globSync, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync as renameSync3, statSync as statSync4, writeFileSync as writeFileSync4 } from "node:fs";
3351
+ import { homedir as homedir4 } from "node:os";
3352
+ import { basename as basename2, join as join6 } from "node:path";
3353
+
3354
+ // src/lib/clipboard.ts
3355
+ import { spawnSync } from "node:child_process";
3356
+ import { readFile, rm } from "node:fs/promises";
3357
+ var RUN_TIMEOUT_MS = 3e3;
3358
+ var defaultRun = async (cmd, args) => {
3359
+ try {
3360
+ const result = spawnSync(cmd, args, {
3361
+ timeout: RUN_TIMEOUT_MS,
3362
+ killSignal: "SIGKILL",
3363
+ stdio: ["ignore", "pipe", "pipe"]
3364
+ });
3365
+ const stderr = result.stderr ? result.stderr.toString().trim() : "";
3366
+ const detail = result.error ? String(result.error.code ?? result.error.message) : "";
3367
+ return {
3368
+ ok: result.status === 0,
3369
+ stdout: result.stdout ?? Buffer.alloc(0),
3370
+ stderr: [stderr, detail].filter(Boolean).join("\n")
3371
+ };
3372
+ } catch (error) {
3373
+ return { ok: false, stdout: Buffer.alloc(0), stderr: String(error) };
3374
+ }
3375
+ };
3376
+ var defaultHave = (cmd) => {
3377
+ try {
3378
+ return spawnSync("sh", ["-c", `command -v ${cmd}`], { encoding: "utf8" }).status === 0;
3379
+ } catch {
3380
+ return false;
3381
+ }
3382
+ };
3383
+ var hasImageSignature = (bytes) => {
3384
+ const head = bytes.toString("latin1", 0, 12);
3385
+ if (head.startsWith("\x89PNG")) return true;
3386
+ if (head.startsWith("\xFF\xD8\xFF")) return true;
3387
+ if (head.startsWith("GIF8")) return true;
3388
+ if (head.startsWith("RIFF") && head.slice(8, 12) === "WEBP") return true;
3389
+ return false;
3390
+ };
3391
+ var validImage = (result) => {
3392
+ if (!result.ok || result.stdout.length === 0) return null;
3393
+ return hasImageSignature(result.stdout) ? result.stdout : null;
3394
+ };
3395
+ var linuxCandidates = (env) => {
3396
+ const xclip = { cmd: "xclip", args: ["-selection", "clipboard", "-t", "image/png", "-o"] };
3397
+ const wlPaste = { cmd: "wl-paste", args: ["--type", "image/png"] };
3398
+ const wayland = Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland";
3399
+ return wayland ? [wlPaste, xclip] : [xclip, wlPaste];
3400
+ };
3401
+ var WINDOWS_SCRIPT = [
3402
+ "Add-Type -AssemblyName System.Windows.Forms",
3403
+ "Add-Type -AssemblyName System.Drawing",
3404
+ "$img = [System.Windows.Forms.Clipboard]::GetImage()",
3405
+ "if ($null -eq $img) { exit 1 }",
3406
+ "$path = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName()) + '.png'",
3407
+ "$img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)",
3408
+ "Write-Output $path"
3409
+ ].join("\n");
3410
+ var readWindows = async (run) => {
3411
+ const result = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT]);
3412
+ if (!result.ok) return null;
3413
+ const path4 = result.stdout.toString("utf8").replace(/^\uFEFF/, "").trim();
3414
+ if (!path4) return null;
3415
+ try {
3416
+ return validImage({ ok: true, stdout: await readFile(path4) });
3417
+ } finally {
3418
+ await rm(path4, { force: true }).catch(() => {
3419
+ });
3420
+ }
3421
+ };
3422
+ async function readClipboardImage(deps = {}) {
3423
+ try {
3424
+ const platform = deps.platform ?? process.platform;
3425
+ const env = deps.env ?? process.env;
3426
+ const run = deps.run ?? defaultRun;
3427
+ const have = deps.have ?? defaultHave;
3428
+ if (platform === "darwin") {
3429
+ if (!have("pngpaste")) return null;
3430
+ return validImage(await run("pngpaste", ["-"]));
3431
+ }
3432
+ if (platform === "win32") {
3433
+ return await readWindows(run);
3434
+ }
3435
+ if (platform === "linux") {
3436
+ for (const candidate of linuxCandidates(env)) {
3437
+ if (!have(candidate.cmd)) continue;
3438
+ const bytes = validImage(await run(candidate.cmd, candidate.args));
3439
+ if (bytes) return bytes;
3440
+ }
3441
+ }
3442
+ return null;
3443
+ } catch {
3444
+ return null;
3445
+ }
3446
+ }
3447
+
3448
+ // src/plugins/image-paste.ts
3449
+ var DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
3450
+ function resolveMaxBytes(opts) {
3451
+ if (typeof opts.maxBytes === "number" && Number.isFinite(opts.maxBytes)) return opts.maxBytes;
3452
+ const parsed = Number.parseInt(process.env.LACODE_IMAGE_MAX_BYTES ?? "", 10);
3453
+ return Number.isNaN(parsed) ? DEFAULT_MAX_BYTES : parsed;
3454
+ }
3455
+ var DEFAULT_MAX_IMAGES = 10;
3456
+ var MAX_TOTAL_BYTES = 20 * 1024 * 1024;
3457
+ function resolveMaxImages(opts) {
3458
+ if (typeof opts.maxImages === "number" && Number.isFinite(opts.maxImages)) return opts.maxImages;
3459
+ const parsed = Number.parseInt(process.env.LACODE_IMAGE_MAX_IMAGES ?? "", 10);
3460
+ return Number.isNaN(parsed) ? DEFAULT_MAX_IMAGES : parsed;
3461
+ }
3462
+ function detectImageKind(bytes) {
3463
+ if (bytes.length >= 4 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) {
3464
+ return { mime: "image/png", ext: "png" };
3465
+ }
3466
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
3467
+ return { mime: "image/jpeg", ext: "jpg" };
3468
+ }
3469
+ if (bytes.length >= 4 && bytes.toString("latin1", 0, 4) === "GIF8") {
3470
+ return { mime: "image/gif", ext: "gif" };
3471
+ }
3472
+ if (bytes.length >= 12 && bytes.toString("latin1", 0, 4) === "RIFF" && bytes.toString("latin1", 8, 12) === "WEBP") {
3473
+ return { mime: "image/webp", ext: "webp" };
3474
+ }
3475
+ return null;
3476
+ }
3477
+ function splitArguments(input) {
3478
+ const out = [];
3479
+ let current = "";
3480
+ let quote = null;
3481
+ for (const ch of input) {
3482
+ if (quote !== null) {
3483
+ if (ch === quote) quote = null;
3484
+ else current += ch;
3485
+ continue;
3486
+ }
3487
+ if (ch === "'" || ch === '"') {
3488
+ quote = ch;
3489
+ continue;
3490
+ }
3491
+ if (/\s/.test(ch)) {
3492
+ if (current !== "") out.push(current);
3493
+ current = "";
3494
+ continue;
3495
+ }
3496
+ current += ch;
3497
+ }
3498
+ if (current !== "") out.push(current);
3499
+ return out;
3500
+ }
3501
+ function expandTilde(p) {
3502
+ if (p === "~") return homedir4();
3503
+ if (p.startsWith("~/")) return join6(homedir4(), p.slice(2));
3504
+ return p;
3505
+ }
3506
+ function expandGlob(p) {
3507
+ try {
3508
+ const matches = globSync(p);
3509
+ if (matches.length > 0) return [...matches].sort();
3510
+ } catch {
3511
+ }
3512
+ return [p];
3513
+ }
3514
+ function storeImage(bytes, dir, sessionID, label) {
3515
+ const kind = detectImageKind(bytes);
3516
+ if (kind === null) return null;
3517
+ const hash = createHash("sha256").update(bytes).digest("hex");
3518
+ mkdirSync4(dir, { recursive: true });
3519
+ const target = join6(dir, `${hash}.${kind.ext}`);
3520
+ const tmp = `${target}.tmp`;
3521
+ writeFileSync4(tmp, bytes, { mode: 384 });
3522
+ renameSync3(tmp, target);
3523
+ const part = {
3524
+ id: randomUUID(),
3525
+ sessionID,
3526
+ messageID: randomUUID(),
3527
+ type: "file",
3528
+ mime: kind.mime,
3529
+ filename: `${label}-${hash.slice(0, 8)}.${kind.ext}`,
3530
+ url: `data:${kind.mime};base64,${bytes.toString("base64")}`
3531
+ };
3532
+ return { part, hash };
3533
+ }
3534
+ function warn(input, message) {
3535
+ input.client.app.log({ body: { service: "image-paste", level: "warn", message } }).catch(() => {
3536
+ });
3537
+ }
3538
+ async function toast(input, message) {
3539
+ try {
3540
+ await input.client.tui.showToast({ body: { title: "lacode", message, variant: "warning" } });
3541
+ } catch {
3542
+ }
3543
+ }
3544
+ async function imagePasteHooks(input, options, deps) {
3545
+ try {
3546
+ const opts = options ?? {};
3547
+ if (opts.enabled === false) return {};
3548
+ const maxBytes = resolveMaxBytes(opts);
3549
+ const maxImages = resolveMaxImages(opts);
3550
+ const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ?? join6(homedir4(), ".local/share/opencode/lacode/images");
3551
+ const readClipboard = (deps ?? {}).readClipboard ?? readClipboardImage;
3552
+ return {
3553
+ "command.execute.before": async (cmd, output) => {
3554
+ try {
3555
+ if (cmd.command === "image-in-cb") {
3556
+ const bytes = await readClipboard();
3557
+ if (bytes === null) {
3558
+ warn(input, "No image on the clipboard");
3559
+ await toast(input, "No image on the clipboard");
3560
+ return;
3561
+ }
3562
+ if (bytes.length > maxBytes) {
3563
+ const message = `image too large: ${bytes.length} bytes, max ${maxBytes}`;
3564
+ warn(input, message);
3565
+ await toast(input, message);
3566
+ return;
3567
+ }
3568
+ const stored = storeImage(bytes, dir, cmd.sessionID, "clipboard");
3569
+ if (stored === null) {
3570
+ warn(input, "unsupported clipboard image format (expected PNG, JPEG, GIF or WebP)");
3571
+ await toast(input, "unsupported clipboard image format (expected PNG, JPEG, GIF or WebP)");
3572
+ return;
3573
+ }
3574
+ const firstText = output.parts.findIndex((p) => p.type === "text");
3575
+ if (firstText === -1) output.parts.push(stored.part);
3576
+ else output.parts.splice(firstText, 0, stored.part);
3577
+ return;
3578
+ }
3579
+ if (cmd.command === "image") {
3580
+ const rawArgs = typeof cmd.arguments === "string" ? cmd.arguments : "";
3581
+ const resolved = [];
3582
+ for (const arg of splitArguments(rawArgs)) resolved.push(...expandGlob(expandTilde(arg)));
3583
+ const parts = [];
3584
+ const seen = /* @__PURE__ */ new Set();
3585
+ let totalBytes = 0;
3586
+ const skipped = [];
3587
+ for (const filePath of resolved) {
3588
+ const name = basename2(filePath);
3589
+ let size;
3590
+ try {
3591
+ const stat = statSync4(filePath);
3592
+ if (!stat.isFile()) {
3593
+ skipped.push(name);
3594
+ continue;
3595
+ }
3596
+ size = stat.size;
3597
+ } catch {
3598
+ skipped.push(name);
3599
+ continue;
3600
+ }
3601
+ if (size > maxBytes) {
3602
+ skipped.push(name);
3603
+ continue;
3604
+ }
3605
+ let bytes;
3606
+ try {
3607
+ bytes = readFileSync6(filePath);
3608
+ } catch {
3609
+ skipped.push(name);
3610
+ continue;
3611
+ }
3612
+ if (detectImageKind(bytes) === null) {
3613
+ skipped.push(name);
3614
+ continue;
3615
+ }
3616
+ const hash = createHash("sha256").update(bytes).digest("hex");
3617
+ if (seen.has(hash)) continue;
3618
+ if (parts.length >= maxImages || totalBytes + bytes.length > MAX_TOTAL_BYTES) {
3619
+ skipped.push(name);
3620
+ continue;
3621
+ }
3622
+ const stored = storeImage(bytes, dir, cmd.sessionID, name.replace(/\.[^.]+$/, "") || "image");
3623
+ if (stored === null) {
3624
+ skipped.push(name);
3625
+ continue;
3626
+ }
3627
+ seen.add(hash);
3628
+ parts.push(stored.part);
3629
+ totalBytes += bytes.length;
3630
+ }
3631
+ if (skipped.length > 0) {
3632
+ const message = `skipped ${skipped.length} image file${skipped.length === 1 ? "" : "s"}: ${skipped.slice(0, 3).join(", ")}${skipped.length > 3 ? ", \u2026" : ""}`;
3633
+ warn(input, message);
3634
+ await toast(input, message);
3635
+ }
3636
+ if (parts.length > 0) {
3637
+ const firstText = output.parts.findIndex((p) => p.type === "text");
3638
+ if (firstText === -1) output.parts.push(...parts);
3639
+ else output.parts.splice(firstText, 0, ...parts);
3640
+ }
3641
+ return;
3642
+ }
3643
+ } catch (error) {
3644
+ try {
3645
+ input.client.app.log({ body: { service: "image-paste", level: "error", message: `image-paste failed: ${String(error)}` } }).catch(() => {
3646
+ });
3647
+ } catch {
3648
+ }
3649
+ }
3650
+ }
3651
+ };
3652
+ } catch {
3653
+ return {};
3654
+ }
3655
+ }
3656
+
3657
+ // src/lib/prompt-guard.ts
3658
+ var SLOT = new RegExp(`\\b(${BASES.join("|")})(?:-[1-9][0-9]*)+\\b`, "g");
3659
+ function scrubSlotNames(text) {
3660
+ return text.replace(SLOT, "$1");
3661
+ }
3662
+
2812
3663
  // src/plugins/notify.ts
2813
- import { spawn } from "node:child_process";
3664
+ import { spawn as spawn2 } from "node:child_process";
2814
3665
  import { existsSync as existsSync3 } from "node:fs";
2815
3666
  import * as path3 from "node:path";
2816
3667
 
2817
3668
  // src/lib/notify.ts
3669
+ import { closeSync as closeSync2, mkdirSync as mkdirSync5, openSync as openSync2, readFileSync as readFileSync7, renameSync as renameSync4, statSync as statSync5, unlinkSync as unlinkSync4, writeFileSync as writeFileSync5, writeSync as writeSync2 } from "node:fs";
3670
+ import { homedir as homedir5 } from "node:os";
3671
+ import { join as join7 } from "node:path";
2818
3672
  var INTERNAL_TITLE_PREFIXES = [
2819
3673
  // context-autoupdate spawns the cartographer through the session API, with no
2820
3674
  // parentID, so it looks exactly like a root session going idle. Notifying for
@@ -2834,6 +3688,8 @@ var helperArgv = (helper, title, message, urgency, icon, sound) => {
2834
3688
  };
2835
3689
  var PERMISSION_REPLIES = ["once", "always", "reject"];
2836
3690
  var REQUEST_ID_ENV = "LACODE_REQUEST_ID";
3691
+ var NOTIFY_SESSION_ENV = "LACODE_NOTIFY_SESSION";
3692
+ var NOTIFY_CLOSE_ENV = "LACODE_NOTIFY_CLOSE";
2837
3693
  var safeStringify = (value) => {
2838
3694
  try {
2839
3695
  return JSON.stringify(value) ?? void 0;
@@ -2905,7 +3761,11 @@ var shouldNotify = (input) => {
2905
3761
  }
2906
3762
  const notification = {
2907
3763
  notify: true,
2908
- title: `opencode \u2014 ${title}`,
3764
+ // The session's name on its own, with nothing in front of it. The popup's
3765
+ // header is the notification's APP name, and that is "LaCode" -- so a
3766
+ // prefix here would only repeat the app, and it would repeat it wrongly:
3767
+ // the app is not "opencode".
3768
+ title,
2909
3769
  message: notifyMessage(outcome, detail),
2910
3770
  urgency: outcome === "error" ? "critical" : "normal"
2911
3771
  };
@@ -2987,6 +3847,178 @@ var Notifier = class {
2987
3847
  return this.lastSent.size;
2988
3848
  }
2989
3849
  };
3850
+ var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join7(homedir5(), ".local", "share", "opencode", "lacode", "notify");
3851
+ var safeCorrelationKey = (correlationKey) => correlationKey.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
3852
+ var CLAIM_WINDOW_MS = 5e3;
3853
+ var isPidAlive2 = (pid) => {
3854
+ try {
3855
+ process.kill(pid, 0);
3856
+ return true;
3857
+ } catch (error) {
3858
+ return error.code === "EPERM";
3859
+ }
3860
+ };
3861
+ var claimFile = (sessionID, kind) => join7(notificationStateDir(), `claim.${safeCorrelationKey(`${sessionID}.${kind}`)}`);
3862
+ var claimNotification = (sessionID, kind) => {
3863
+ const path4 = claimFile(sessionID, kind);
3864
+ try {
3865
+ mkdirSync5(notificationStateDir(), { recursive: true });
3866
+ } catch {
3867
+ return true;
3868
+ }
3869
+ const take = () => {
3870
+ try {
3871
+ const fd = openSync2(path4, "wx");
3872
+ try {
3873
+ writeSync2(fd, String(process.pid));
3874
+ } finally {
3875
+ closeSync2(fd);
3876
+ }
3877
+ return "taken";
3878
+ } catch (error) {
3879
+ return error.code === "EEXIST" ? "held" : "impossible";
3880
+ }
3881
+ };
3882
+ const first = take();
3883
+ if (first !== "held") {
3884
+ return true;
3885
+ }
3886
+ const judge = () => {
3887
+ let raw;
3888
+ try {
3889
+ raw = readFileSync7(path4, "utf8").trim();
3890
+ } catch (error) {
3891
+ return error.code === "ENOENT" ? "gone" : "unknown";
3892
+ }
3893
+ let holder;
3894
+ const empty = raw === "";
3895
+ if (!empty) {
3896
+ const parsed = Number.parseInt(raw, 10);
3897
+ holder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
3898
+ }
3899
+ if (holder === process.pid) return "ours";
3900
+ let age = Number.POSITIVE_INFINITY;
3901
+ try {
3902
+ age = Date.now() - statSync5(path4).mtimeMs;
3903
+ } catch {
3904
+ return "gone";
3905
+ }
3906
+ if (empty && age < CLAIM_WINDOW_MS) {
3907
+ return "held";
3908
+ }
3909
+ if (holder !== void 0 && isPidAlive2(holder) && age < CLAIM_WINDOW_MS) {
3910
+ return "held";
3911
+ }
3912
+ return "stale";
3913
+ };
3914
+ const judged = judge();
3915
+ if (judged === "ours") return true;
3916
+ if (judged === "held") return false;
3917
+ if (judged === "unknown") return true;
3918
+ if (judged === "gone") return take() !== "held";
3919
+ const lockPath = `${path4}.takeover`;
3920
+ const takeOver = () => {
3921
+ const create = () => {
3922
+ try {
3923
+ const fd = openSync2(lockPath, "wx");
3924
+ try {
3925
+ writeSync2(fd, String(process.pid));
3926
+ } finally {
3927
+ closeSync2(fd);
3928
+ }
3929
+ return () => {
3930
+ try {
3931
+ unlinkSync4(lockPath);
3932
+ } catch {
3933
+ }
3934
+ };
3935
+ } catch (error) {
3936
+ return error.code === "EEXIST" ? void 0 : () => {
3937
+ };
3938
+ }
3939
+ };
3940
+ const release3 = create();
3941
+ if (release3 !== void 0) return release3;
3942
+ let lockHolder;
3943
+ let lockAge = Number.POSITIVE_INFINITY;
3944
+ try {
3945
+ const parsed = Number.parseInt(readFileSync7(lockPath, "utf8").trim(), 10);
3946
+ lockHolder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
3947
+ lockAge = Date.now() - statSync5(lockPath).mtimeMs;
3948
+ } catch {
3949
+ return create();
3950
+ }
3951
+ if (lockAge < CLAIM_WINDOW_MS && (lockHolder === void 0 || isPidAlive2(lockHolder))) {
3952
+ return void 0;
3953
+ }
3954
+ try {
3955
+ unlinkSync4(lockPath);
3956
+ } catch {
3957
+ return void 0;
3958
+ }
3959
+ return create();
3960
+ };
3961
+ const release2 = takeOver();
3962
+ if (release2 === void 0) return false;
3963
+ try {
3964
+ const current = judge();
3965
+ if (current === "ours") return true;
3966
+ if (current === "held") return false;
3967
+ if (current === "unknown") return true;
3968
+ try {
3969
+ unlinkSync4(path4);
3970
+ } catch {
3971
+ }
3972
+ return take() !== "held";
3973
+ } finally {
3974
+ release2();
3975
+ }
3976
+ };
3977
+ var releaseNotificationClaim = (sessionID, kind) => {
3978
+ const path4 = claimFile(sessionID, kind);
3979
+ try {
3980
+ const parsed = Number.parseInt(readFileSync7(path4, "utf8").trim(), 10);
3981
+ if (!Number.isFinite(parsed) || parsed <= 0) return;
3982
+ if (parsed !== process.pid) return;
3983
+ unlinkSync4(path4);
3984
+ } catch {
3985
+ }
3986
+ };
3987
+ var completionFile = (sessionID) => join7(notificationStateDir(), `completion.${safeCorrelationKey(sessionID)}`);
3988
+ var claimCompletion = (sessionID, kind) => {
3989
+ const path4 = completionFile(sessionID);
3990
+ try {
3991
+ mkdirSync5(notificationStateDir(), { recursive: true });
3992
+ const fd = openSync2(path4, "wx");
3993
+ try {
3994
+ writeFileSync5(fd, `${process.pid} ${kind}
3995
+ `);
3996
+ } finally {
3997
+ closeSync2(fd);
3998
+ }
3999
+ return true;
4000
+ } catch (error) {
4001
+ if (error.code === "EEXIST") return false;
4002
+ return true;
4003
+ }
4004
+ };
4005
+ var releaseCompletion = (sessionID) => {
4006
+ try {
4007
+ unlinkSync4(completionFile(sessionID));
4008
+ } catch {
4009
+ }
4010
+ };
4011
+ var markCompletion = (sessionID, kind) => {
4012
+ try {
4013
+ mkdirSync5(notificationStateDir(), { recursive: true });
4014
+ const path4 = completionFile(sessionID);
4015
+ const tmp = `${path4}.${process.pid}.tmp`;
4016
+ writeFileSync5(tmp, `${process.pid} ${kind}
4017
+ `);
4018
+ renameSync4(tmp, path4);
4019
+ } catch {
4020
+ }
4021
+ };
2990
4022
 
2991
4023
  // src/plugins/notify.ts
2992
4024
  var SERVICE3 = "notify";
@@ -2995,6 +4027,9 @@ var MAX_INFLIGHT_HELPERS = 4;
2995
4027
  var HELPER_TIMEOUT_MS = 3e4;
2996
4028
  var ACTION_TIMEOUT_MS = 10 * 6e4 + 3e4;
2997
4029
  var MAX_TRACKED_SESSIONS = 500;
4030
+ var MIN_VISIBLE_MS = 5e3;
4031
+ var PERMISSION_SETTLE_MS = 250;
4032
+ var UNANSWERABLE_REQUEST = "";
2998
4033
  var SOUND_ENV = "LACODE_SOUND";
2999
4034
  var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
3000
4035
  var findHelper = () => {
@@ -3020,7 +4055,9 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3020
4055
  const active = /* @__PURE__ */ new Set();
3021
4056
  const titles = /* @__PURE__ */ new Map();
3022
4057
  const pending = /* @__PURE__ */ new Set();
3023
- const permissions = /* @__PURE__ */ new Set();
4058
+ const permissions = /* @__PURE__ */ new Map();
4059
+ const pendingPermissionPopups = /* @__PURE__ */ new Map();
4060
+ const live = /* @__PURE__ */ new Map();
3024
4061
  let warnedHelper = false;
3025
4062
  let inflight = 0;
3026
4063
  const rememberActive = (sessionID) => {
@@ -3031,17 +4068,43 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3031
4068
  titles.set(id, title);
3032
4069
  while (titles.size > MAX_TRACKED_SESSIONS) titles.delete(titles.keys().next().value);
3033
4070
  };
4071
+ const rememberLive = (sessionID, popup) => {
4072
+ const previous = live.get(sessionID);
4073
+ if (previous?.timer) clearTimeout(previous.timer);
4074
+ live.set(sessionID, popup);
4075
+ while (live.size > MAX_TRACKED_SESSIONS) {
4076
+ const oldest = live.keys().next().value;
4077
+ const forgotten = live.get(oldest);
4078
+ if (forgotten?.timer) clearTimeout(forgotten.timer);
4079
+ live.delete(oldest);
4080
+ }
4081
+ };
4082
+ const forgetLive = (sessionID) => {
4083
+ const popup = live.get(sessionID);
4084
+ if (popup?.timer) clearTimeout(popup.timer);
4085
+ live.delete(sessionID);
4086
+ };
4087
+ const rememberPermissions = (sessionID, requests) => {
4088
+ permissions.set(sessionID, requests);
4089
+ while (permissions.size > MAX_TRACKED_SESSIONS) {
4090
+ const oldest = permissions.keys().next().value;
4091
+ permissions.delete(oldest);
4092
+ }
4093
+ };
4094
+ const forgetPermission = (sessionID, requestID) => {
4095
+ const outstanding = permissions.get(sessionID);
4096
+ if (!outstanding) return;
4097
+ outstanding.delete(requestID);
4098
+ if (outstanding.size === 0) permissions.delete(sessionID);
4099
+ };
4100
+ const forgetPermissionPopup = (sessionID) => {
4101
+ const timer = pendingPermissionPopups.get(sessionID);
4102
+ if (timer) clearTimeout(timer);
4103
+ pendingPermissionPopups.delete(sessionID);
4104
+ };
3034
4105
  const log = (level, message, extra) => client.app.log({ body: { service: SERVICE3, level, message, extra } }).catch(() => {
3035
4106
  });
3036
- const answerPermission = async (sessionID, requestID, action) => {
3037
- const response = PERMISSION_REPLIES.find((candidate) => candidate === action);
3038
- if (!response) {
3039
- void log("warn", `notification action ignored: ${JSON.stringify(action)} is not a permission reply`, {
3040
- sessionID,
3041
- requestID
3042
- });
3043
- return;
3044
- }
4107
+ const replyPermission = async (sessionID, requestID, response) => {
3045
4108
  const errorStatus = (error) => {
3046
4109
  const candidate = error;
3047
4110
  const status2 = candidate?.response?.status ?? candidate?.cause?.status ?? candidate?.status;
@@ -3054,6 +4117,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3054
4117
  const status = "result" in reply ? reply.result.response?.status : errorStatus(reply.error);
3055
4118
  if (status === 404) {
3056
4119
  void log("debug", "permission already answered", { sessionID, requestID });
4120
+ forgetPermission(sessionID, requestID);
3057
4121
  return;
3058
4122
  }
3059
4123
  if ("error" in reply) {
@@ -3070,13 +4134,27 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3070
4134
  });
3071
4135
  return;
3072
4136
  }
4137
+ forgetPermission(sessionID, requestID);
3073
4138
  void log("info", "permission answered from the notification", { sessionID, requestID, response });
3074
4139
  };
4140
+ const answerPermission = async (sessionID, requestIDs, action) => {
4141
+ const response = PERMISSION_REPLIES.find((candidate) => candidate === action);
4142
+ if (!response) {
4143
+ void log("warn", `notification action ignored: ${JSON.stringify(action)} is not a permission reply`, {
4144
+ sessionID,
4145
+ requestIDs
4146
+ });
4147
+ return;
4148
+ }
4149
+ for (const requestID of requestIDs) {
4150
+ await replyPermission(sessionID, requestID, response);
4151
+ }
4152
+ };
3075
4153
  const fetchInfo = async (sessionID) => {
3076
4154
  const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
3077
4155
  return info;
3078
4156
  };
3079
- const deliverViaHelper = (title, message, urgency, sound, requestID) => new Promise((resolve3) => {
4157
+ const deliverViaHelper = (title, message, urgency, sound, requestID, sessionID) => new Promise((resolve3) => {
3080
4158
  if (inflight >= maxInflight) {
3081
4159
  void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
3082
4160
  resolve3({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
@@ -3091,15 +4169,17 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3091
4169
  resolve3({ ok: false, detail: "no oc-notify helper found" });
3092
4170
  return;
3093
4171
  }
4172
+ const childEnv = { ...process.env, ...sessionID ? { [NOTIFY_SESSION_ENV]: sessionID } : {} };
3094
4173
  const actionable = Boolean(requestID);
3095
4174
  const timeoutMs = actionable ? ACTION_TIMEOUT_MS : helperTimeoutMs;
3096
- const child = actionable ? spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
4175
+ const child = actionable ? spawn2(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
3097
4176
  stdio: ["ignore", "pipe", "pipe"],
3098
4177
  detached: false,
3099
- env: { ...process.env, [REQUEST_ID_ENV]: requestID }
3100
- }) : spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
4178
+ env: { ...childEnv, [REQUEST_ID_ENV]: requestID }
4179
+ }) : spawn2(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
3101
4180
  stdio: ["ignore", "ignore", "pipe"],
3102
- detached: true
4181
+ detached: true,
4182
+ env: childEnv
3103
4183
  });
3104
4184
  inflight++;
3105
4185
  let settled = false;
@@ -3147,6 +4227,88 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3147
4227
  if (!actionable) child.unref();
3148
4228
  });
3149
4229
  const deliver = deps.deliver ?? deliverViaHelper;
4230
+ const closeViaHelper = (sessionID) => {
4231
+ try {
4232
+ const helper = findHelper();
4233
+ if (!helper) {
4234
+ if (!warnedHelper) {
4235
+ warnedHelper = true;
4236
+ void log("warn", "no oc-notify helper found; run 'bin/oc-sync' to install one");
4237
+ }
4238
+ return;
4239
+ }
4240
+ const child = spawn2(helper, helperArgv(helper, "opencode", "", "normal", "utilities-terminal", "none"), {
4241
+ stdio: ["ignore", "ignore", "pipe"],
4242
+ detached: true,
4243
+ env: { ...process.env, [NOTIFY_CLOSE_ENV]: sessionID }
4244
+ });
4245
+ child.on("error", (error) => {
4246
+ void log("warn", `notification close failed: ${String(error)}`, { sessionID });
4247
+ });
4248
+ child.on("exit", (code) => {
4249
+ if (code !== 0) void log("warn", `notification not closed: helper exit ${code}`, { sessionID });
4250
+ });
4251
+ child.unref();
4252
+ } catch (error) {
4253
+ void log("warn", `notification close failed: ${String(error)}`, { sessionID });
4254
+ }
4255
+ };
4256
+ const close = deps.close ?? closeViaHelper;
4257
+ const dismiss = (sessionID) => {
4258
+ const popup = live.get(sessionID);
4259
+ if (!popup) return;
4260
+ if (popup.timer) {
4261
+ clearTimeout(popup.timer);
4262
+ popup.timer = void 0;
4263
+ }
4264
+ popup.raisedAt = Date.now();
4265
+ const failed = (error) => void log("warn", `notification dismiss failed: ${String(error)}`, { sessionID });
4266
+ try {
4267
+ const outcome = close(sessionID);
4268
+ if (outcome && typeof outcome.catch === "function") void outcome.catch(failed);
4269
+ } catch (error) {
4270
+ failed(error);
4271
+ }
4272
+ };
4273
+ const scheduleDismiss = (sessionID) => {
4274
+ const popup = live.get(sessionID);
4275
+ if (!popup) return;
4276
+ if (popup.timer) return;
4277
+ const delay = Math.max(0, popup.raisedAt + MIN_VISIBLE_MS - Date.now());
4278
+ const timer = setTimeout(() => dismiss(sessionID), delay);
4279
+ timer.unref?.();
4280
+ popup.timer = timer;
4281
+ };
4282
+ const noteInteraction = (sessionID) => {
4283
+ const popup = live.get(sessionID);
4284
+ if (!popup) return;
4285
+ popup.interacted = true;
4286
+ scheduleDismiss(sessionID);
4287
+ };
4288
+ const claimReleases = /* @__PURE__ */ new Map();
4289
+ const scheduleClaimRelease = (sessionID, kind) => {
4290
+ const key = `${kind}:${sessionID}`;
4291
+ if (claimReleases.has(key)) return;
4292
+ const timer = setTimeout(() => {
4293
+ claimReleases.delete(key);
4294
+ releaseNotificationClaim(sessionID, kind);
4295
+ }, COOLDOWN_MS2);
4296
+ timer.unref?.();
4297
+ claimReleases.set(key, timer);
4298
+ };
4299
+ const raisePermissionPopup = (sessionID, requestID, headline) => {
4300
+ forgetPermissionPopup(sessionID);
4301
+ const timer = setTimeout(() => {
4302
+ if (pendingPermissionPopups.get(sessionID) !== timer) return;
4303
+ pendingPermissionPopups.delete(sessionID);
4304
+ const outstanding = permissions.get(sessionID);
4305
+ if (!outstanding || outstanding.size === 0) return;
4306
+ const detail = outstanding.size > 1 ? `${outstanding.size} requests` : headline;
4307
+ void announce(sessionID, "permission", detail || void 0, requestID);
4308
+ }, PERMISSION_SETTLE_MS);
4309
+ timer.unref?.();
4310
+ pendingPermissionPopups.set(sessionID, timer);
4311
+ };
3150
4312
  const announce = async (sessionID, outcome, detail, requestID) => {
3151
4313
  try {
3152
4314
  const info = await fetchInfo(sessionID);
@@ -3180,10 +4342,38 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3180
4342
  void log("debug", "suppressed: cooldown", { sessionID });
3181
4343
  return;
3182
4344
  }
4345
+ if (outcome === "done") {
4346
+ if (!claimCompletion(sessionID, "done")) {
4347
+ void log("debug", "suppressed: completion already announced", { sessionID });
4348
+ return;
4349
+ }
4350
+ } else if (!claimNotification(sessionID, kind)) {
4351
+ void log("debug", "suppressed: another process is raising this", { sessionID, kind });
4352
+ return;
4353
+ } else if (outcome === "error") {
4354
+ markCompletion(sessionID, "error");
4355
+ }
3183
4356
  const message = isRemote ? `${decision.message} (remote)` : decision.message;
3184
4357
  const sound = soundOn && NOTIFY_OUTCOMES.includes(outcome) ? outcome : "none";
3185
- const sent = await deliver(decision.title, message, decision.urgency, sound, requestID);
3186
- if (sent.action && requestID) await answerPermission(sessionID, requestID, sent.action);
4358
+ const previous = live.get(sessionID);
4359
+ const popup = { raisedAt: Date.now(), interacted: previous?.interacted ?? false };
4360
+ rememberLive(sessionID, popup);
4361
+ if (popup.interacted) scheduleDismiss(sessionID);
4362
+ let sent = { ok: false };
4363
+ try {
4364
+ sent = await deliver(decision.title, message, decision.urgency, sound, requestID, sessionID);
4365
+ } catch (error) {
4366
+ if (outcome === "done" || outcome === "error") releaseCompletion(sessionID);
4367
+ throw error;
4368
+ } finally {
4369
+ if (outcome !== "done") scheduleClaimRelease(sessionID, kind);
4370
+ }
4371
+ if ((outcome === "done" || outcome === "error") && !sent.ok) releaseCompletion(sessionID);
4372
+ if (sent.action && requestID) {
4373
+ const outstanding = permissions.get(sessionID);
4374
+ const ids = outstanding ? [...outstanding].filter((id) => id !== UNANSWERABLE_REQUEST) : [];
4375
+ await answerPermission(sessionID, ids.length > 0 ? ids : [requestID], sent.action);
4376
+ }
3187
4377
  const answered = Boolean(sent.action);
3188
4378
  void log(answered || sent.ok ? "info" : "error", answered ? "permission answered from the notification" : sent.ok ? "notified" : "notification failed", {
3189
4379
  sessionID,
@@ -3200,7 +4390,15 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3200
4390
  try {
3201
4391
  if (event.type === "session.status") {
3202
4392
  const { sessionID: sessionID2, status } = event.properties;
3203
- if (status.type === "busy" && sessionID2) rememberActive(sessionID2);
4393
+ if (status.type === "busy" && sessionID2) {
4394
+ rememberActive(sessionID2);
4395
+ releaseCompletion(sessionID2);
4396
+ }
4397
+ return;
4398
+ }
4399
+ if (event.type === "message.updated") {
4400
+ const info = event.properties.info;
4401
+ if (info?.role === "user" && info.sessionID) noteInteraction(info.sessionID);
3204
4402
  return;
3205
4403
  }
3206
4404
  if (event.type === "session.updated") {
@@ -3220,22 +4418,34 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3220
4418
  }
3221
4419
  if (event.type === "question.replied" || event.type === "question.rejected") {
3222
4420
  const sessionID2 = event.properties?.sessionID;
3223
- if (sessionID2) pending.delete(sessionID2);
4421
+ if (sessionID2) {
4422
+ pending.delete(sessionID2);
4423
+ noteInteraction(sessionID2);
4424
+ }
3224
4425
  return;
3225
4426
  }
3226
4427
  if (event.type === "permission.asked") {
3227
4428
  const properties = event.properties;
3228
4429
  const sessionID2 = properties?.sessionID;
3229
4430
  if (!sessionID2) return;
3230
- permissions.add(sessionID2);
3231
- rememberActive(sessionID2);
3232
4431
  const requestID = typeof properties?.id === "string" && properties.id ? properties.id : void 0;
3233
- void announce(sessionID2, "permission", permissionHeadline(properties) || void 0, requestID);
4432
+ const outstanding = permissions.get(sessionID2) ?? /* @__PURE__ */ new Set();
4433
+ const first = outstanding.size === 0;
4434
+ outstanding.add(requestID ?? UNANSWERABLE_REQUEST);
4435
+ rememberPermissions(sessionID2, outstanding);
4436
+ rememberActive(sessionID2);
4437
+ if (!first) return;
4438
+ raisePermissionPopup(sessionID2, requestID, permissionHeadline(properties));
3234
4439
  return;
3235
4440
  }
3236
4441
  if (event.type === "permission.replied") {
3237
4442
  const sessionID2 = event.properties?.sessionID;
3238
- if (sessionID2) permissions.delete(sessionID2);
4443
+ if (sessionID2) {
4444
+ const requestID = event.properties?.requestID;
4445
+ if (requestID) forgetPermission(sessionID2, requestID);
4446
+ else permissions.delete(sessionID2);
4447
+ noteInteraction(sessionID2);
4448
+ }
3239
4449
  return;
3240
4450
  }
3241
4451
  if (event.type === "session.error") {
@@ -3259,6 +4469,9 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3259
4469
  titles.delete(sessionID2);
3260
4470
  pending.delete(sessionID2);
3261
4471
  permissions.delete(sessionID2);
4472
+ forgetLive(sessionID2);
4473
+ forgetPermissionPopup(sessionID2);
4474
+ releaseCompletion(sessionID2);
3262
4475
  }
3263
4476
  return;
3264
4477
  }
@@ -3709,10 +4922,10 @@ var omniRouteHooks = async (input, options, deps = {}) => {
3709
4922
  };
3710
4923
  let started = null;
3711
4924
  const initialize = () => started ??= probe();
3712
- const injectProvider = async (cfg, warn) => {
4925
+ const injectProvider = async (cfg, warn2) => {
3713
4926
  const providers = cfg.provider ??= {};
3714
4927
  if (providers.omniroute !== void 0) {
3715
- await warn("omniroute provider is already defined in your config; lacode left it untouched");
4928
+ await warn2("omniroute provider is already defined in your config; lacode left it untouched");
3716
4929
  return;
3717
4930
  }
3718
4931
  const exposed = selector.candidates();
@@ -3754,267 +4967,6 @@ var omniRouteHooks = async (input, options, deps = {}) => {
3754
4967
  };
3755
4968
  };
3756
4969
 
3757
- // src/lib/selfupdate.ts
3758
- import { spawn as spawn2 } from "node:child_process";
3759
- import { closeSync, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync5, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3, writeSync } from "node:fs";
3760
- import { homedir as homedir3 } from "node:os";
3761
- import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
3762
- var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
3763
- var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
3764
- var FETCH_TIMEOUT_MS = 4e3;
3765
- var INSTALL_TIMEOUT_MS = 12e4;
3766
- var LOCK_STALE_MS = 10 * 60 * 1e3;
3767
- var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
3768
- var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
3769
- var inTestRunner = (env = process.env) => typeof env.NODE_TEST_CONTEXT === "string" && env.NODE_TEST_CONTEXT.length > 0;
3770
- var STATE_DIR = join6(homedir3(), ".local", "share", "opencode", "lacode");
3771
- var STATE_PATH = join6(STATE_DIR, "selfupdate.json");
3772
- var LOCK_PATH = join6(STATE_DIR, "selfupdate.lock");
3773
- function compareSemver(a, b) {
3774
- const parse = (v) => v.split("-")[0].split("+")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
3775
- const left = parse(a);
3776
- const right = parse(b);
3777
- for (let i = 0; i < 3; i++) {
3778
- const l = left[i] ?? 0;
3779
- const r = right[i] ?? 0;
3780
- if (l !== r) return l > r ? 1 : -1;
3781
- }
3782
- return 0;
3783
- }
3784
- function readInstalledVersion(packageRoot, read = (path4) => {
3785
- try {
3786
- return readFileSync5(path4, "utf8");
3787
- } catch {
3788
- return void 0;
3789
- }
3790
- }) {
3791
- try {
3792
- const raw = read(join6(packageRoot, "package.json"));
3793
- if (raw === void 0) return void 0;
3794
- const manifest = JSON.parse(raw);
3795
- if (manifest.name !== PACKAGE_NAME) return void 0;
3796
- return typeof manifest.version === "string" ? manifest.version : void 0;
3797
- } catch {
3798
- return void 0;
3799
- }
3800
- }
3801
- function resolveCacheRoot(packageRoot, exists = (p) => {
3802
- try {
3803
- return statSync3(p).isFile();
3804
- } catch {
3805
- return false;
3806
- }
3807
- }) {
3808
- const tail = join6("node_modules", "@lawaty", "lacode");
3809
- if (!packageRoot.endsWith(tail)) return void 0;
3810
- const cacheRoot = resolve2(packageRoot, "..", "..", "..");
3811
- if (!exists(join6(cacheRoot, "package.json"))) return void 0;
3812
- return cacheRoot;
3813
- }
3814
- function resolveOpencodeBin(execPath = process.execPath, env = process.env, exists = (p) => {
3815
- try {
3816
- return statSync3(p).isFile();
3817
- } catch {
3818
- return false;
3819
- }
3820
- }) {
3821
- if (execPath && /(^|[\\/])opencode(\.exe)?$/.test(execPath)) return execPath;
3822
- for (const dir of (env.PATH ?? "").split(":")) {
3823
- if (!dir) continue;
3824
- const candidate = join6(dir, "opencode");
3825
- if (exists(candidate)) return candidate;
3826
- }
3827
- const fallback = join6(homedir3(), ".opencode", "bin", "opencode");
3828
- return exists(fallback) ? fallback : void 0;
3829
- }
3830
- async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
3831
- const controller = new AbortController();
3832
- const timer = setTimeout(() => controller.abort(), timeoutMs);
3833
- try {
3834
- const response = await fetchImpl(REGISTRY_LATEST, { signal: controller.signal });
3835
- if (!response.ok) return void 0;
3836
- const body = await response.json();
3837
- return typeof body.version === "string" ? body.version : void 0;
3838
- } catch {
3839
- return void 0;
3840
- } finally {
3841
- clearTimeout(timer);
3842
- }
3843
- }
3844
- function readState(path4 = STATE_PATH) {
3845
- try {
3846
- return JSON.parse(readFileSync5(path4, "utf8"));
3847
- } catch {
3848
- return {};
3849
- }
3850
- }
3851
- function writeState(state2, path4 = STATE_PATH) {
3852
- try {
3853
- mkdirSync3(dirname3(path4), { recursive: true });
3854
- writeFileSync3(path4, JSON.stringify(state2));
3855
- } catch {
3856
- }
3857
- }
3858
- var isDue = (state2, now, interval = CHECK_INTERVAL_MS) => typeof state2.checkedAt !== "number" || now - state2.checkedAt >= interval;
3859
- var isProcessAlive = (pid) => {
3860
- try {
3861
- process.kill(pid, 0);
3862
- return true;
3863
- } catch (error) {
3864
- return error.code === "EPERM";
3865
- }
3866
- };
3867
- function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
3868
- const claim = () => {
3869
- const fd = openSync(path4, "wx");
3870
- writeSync(fd, String(process.pid));
3871
- closeSync(fd);
3872
- return () => {
3873
- try {
3874
- unlinkSync3(path4);
3875
- } catch {
3876
- }
3877
- };
3878
- };
3879
- try {
3880
- mkdirSync3(dirname3(path4), { recursive: true });
3881
- return claim();
3882
- } catch {
3883
- let orphaned = false;
3884
- try {
3885
- const pid = Number.parseInt(readFileSync5(path4, "utf8").trim(), 10);
3886
- orphaned = !Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid);
3887
- } catch {
3888
- orphaned = true;
3889
- }
3890
- if (!orphaned) {
3891
- try {
3892
- orphaned = now - statSync3(path4).mtimeMs > LOCK_STALE_MS;
3893
- } catch {
3894
- orphaned = true;
3895
- }
3896
- }
3897
- if (!orphaned) return void 0;
3898
- try {
3899
- unlinkSync3(path4);
3900
- } catch {
3901
- return void 0;
3902
- }
3903
- try {
3904
- return claim();
3905
- } catch {
3906
- return void 0;
3907
- }
3908
- }
3909
- }
3910
- async function checkForUpdate(opts) {
3911
- const installed = readInstalledVersion(opts.packageRoot);
3912
- const latest = await fetchLatest(opts.fetchImpl);
3913
- return {
3914
- installed,
3915
- latest,
3916
- newer: Boolean(installed && latest && compareSemver(latest, installed) > 0),
3917
- cacheRoot: resolveCacheRoot(opts.packageRoot, opts.exists),
3918
- bin: resolveOpencodeBin(opts.execPath, opts.env, opts.exists)
3919
- };
3920
- }
3921
- async function refreshCache(opts) {
3922
- const { cacheRoot, bin, latest } = opts;
3923
- const spawnImpl = opts.spawnImpl ?? spawn2;
3924
- const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
3925
- const fresh = join6(cacheRoot, "node_modules", "@lawaty", "lacode");
3926
- const aside = `${cacheRoot}.stale-${Date.now()}`;
3927
- try {
3928
- rmSync(aside, { recursive: true, force: true });
3929
- renameSync2(cacheRoot, aside);
3930
- } catch (error) {
3931
- return { ok: false, detail: `could not set the old cache aside: ${String(error)}` };
3932
- }
3933
- const exited = await new Promise((settle) => {
3934
- let done = false;
3935
- const finish = (value) => {
3936
- if (done) return;
3937
- done = true;
3938
- clearTimeout(timer);
3939
- settle(value);
3940
- };
3941
- const child = spawnImpl(bin, ["plugin", PACKAGE_NAME, "-g"], {
3942
- env: { ...process.env, [CHILD_ENV]: "1" },
3943
- stdio: "ignore"
3944
- });
3945
- const timer = setTimeout(() => {
3946
- try {
3947
- child.kill("SIGKILL");
3948
- } catch {
3949
- }
3950
- finish(false);
3951
- }, timeoutMs);
3952
- child.on("error", () => finish(false));
3953
- child.on("exit", (code) => finish(code === 0));
3954
- });
3955
- const installed = readInstalledVersion(fresh);
3956
- const ok = exited && installed === latest;
3957
- if (ok) {
3958
- try {
3959
- rmSync(aside, { recursive: true, force: true });
3960
- } catch {
3961
- }
3962
- } else {
3963
- try {
3964
- renameSync2(aside, cacheRoot);
3965
- } catch {
3966
- }
3967
- }
3968
- return {
3969
- ok,
3970
- detail: ok ? `installed ${installed}` : `installer exited ${exited ? "0" : "non-zero"}, cache now ${installed ?? "missing"}`
3971
- };
3972
- }
3973
- async function maybeSelfUpdate(opts) {
3974
- const env = opts.env ?? process.env;
3975
- if (opts.enabled === false || env[CHILD_ENV] === "1" || env[OPT_OUT_ENV] === "1" || inTestRunner(env)) {
3976
- return { action: "disabled" };
3977
- }
3978
- const now = (opts.now ?? Date.now)();
3979
- const statePath = opts.statePath ?? STATE_PATH;
3980
- const state2 = readState(statePath);
3981
- if (!isDue(state2, now, opts.intervalMs ?? CHECK_INTERVAL_MS)) return { action: "skipped", detail: "checked recently" };
3982
- const release2 = acquireLock(opts.lockPath, now);
3983
- if (!release2) return { action: "skipped", detail: "another process is updating" };
3984
- try {
3985
- const check = await checkForUpdate(opts);
3986
- if (!check.installed) return { action: "skipped", detail: "no installed version" };
3987
- if (!check.latest) {
3988
- writeState({ ...state2, checkedAt: now }, statePath);
3989
- return { action: "skipped", detail: "registry unreachable" };
3990
- }
3991
- if (!check.newer) {
3992
- writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
3993
- return { action: "current", detail: `${check.installed} is current` };
3994
- }
3995
- if (!check.cacheRoot) {
3996
- writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
3997
- return { action: "skipped", detail: "not an opencode cache install" };
3998
- }
3999
- if (!check.bin) {
4000
- writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
4001
- opts.log?.("warn", `LaCode ${check.latest} is available but no opencode binary was found to install it`);
4002
- return { action: "failed", detail: "no opencode binary" };
4003
- }
4004
- const result = await refreshCache({ cacheRoot: check.cacheRoot, bin: check.bin, latest: check.latest, spawnImpl: opts.spawnImpl });
4005
- if (result.ok) {
4006
- writeState({ ...state2, checkedAt: now, latest: check.latest, updatedTo: check.latest }, statePath);
4007
- opts.log?.("info", `LaCode updated ${check.installed} -> ${check.latest}; restart opencode to apply`);
4008
- return { action: "updated", detail: `${check.installed} -> ${check.latest}` };
4009
- }
4010
- writeState({ ...state2, checkedAt: now, latest: check.latest }, statePath);
4011
- opts.log?.("warn", `LaCode update to ${check.latest} failed: ${result.detail}`);
4012
- return { action: "failed", detail: result.detail };
4013
- } finally {
4014
- release2();
4015
- }
4016
- }
4017
-
4018
4970
  // src/plugins/selfupdate.ts
4019
4971
  async function selfUpdateHooks(input, options) {
4020
4972
  const opts = options ?? {};
@@ -4036,11 +4988,24 @@ var inertHooks = {
4036
4988
  dispose: async () => {
4037
4989
  }
4038
4990
  };
4991
+ var promptDriftWarned = false;
4039
4992
  var LaCode = (async (input, options) => {
4040
4993
  const opts = options ?? {};
4041
4994
  if (opts.enabled === false) return inertHooks;
4042
4995
  const log = (level, message, extra) => input.client.app.log({ body: { service: "lacode", level, message, extra } }).catch(() => {
4043
4996
  });
4997
+ const warnPromptDrift = async () => {
4998
+ if (promptDriftWarned) return;
4999
+ promptDriftWarned = true;
5000
+ await log("warn", "a system prompt named a pool slot variant; stripped it before the model saw it. Remove the literal from the prompt source.");
5001
+ };
5002
+ let version;
5003
+ try {
5004
+ version = readInstalledVersion(PACKAGE_ROOT);
5005
+ } catch {
5006
+ version = void 0;
5007
+ }
5008
+ await log("info", version ? `lacode ${version} started` : "lacode started");
4044
5009
  const settled = async (label, make) => {
4045
5010
  try {
4046
5011
  return await make();
@@ -4055,6 +5020,7 @@ var LaCode = (async (input, options) => {
4055
5020
  const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
4056
5021
  const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
4057
5022
  const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
5023
+ const imagepaste = await settled("image-paste", () => imagePasteHooks(input, opts.imagePaste)) ?? inertHooks;
4058
5024
  const config = async (cfg) => {
4059
5025
  try {
4060
5026
  const injected = applyAgents(cfg, agentSet);
@@ -4089,12 +5055,30 @@ var LaCode = (async (input, options) => {
4089
5055
  "tool.execute.before": pool["tool.execute.before"],
4090
5056
  "tool.execute.after": pool["tool.execute.after"],
4091
5057
  tool: pool.tool,
5058
+ "command.execute.before": imagepaste["command.execute.before"],
4092
5059
  dispose: async () => {
4093
5060
  await pool.dispose?.();
4094
5061
  await autoupdate.dispose?.();
4095
5062
  await notifier.dispose?.();
4096
5063
  await selfupdate.dispose?.();
4097
5064
  await omniroute.dispose?.();
5065
+ await imagepaste.dispose?.();
5066
+ },
5067
+ "experimental.chat.system.transform": async (_input, output) => {
5068
+ if (!Array.isArray(output?.system)) return;
5069
+ let changed = false;
5070
+ for (let i = 0; i < output.system.length; i++) {
5071
+ const text = output.system[i];
5072
+ if (typeof text !== "string") continue;
5073
+ const scrubbed = scrubSlotNames(text);
5074
+ if (scrubbed !== text) {
5075
+ output.system[i] = scrubbed;
5076
+ changed = true;
5077
+ }
5078
+ }
5079
+ if (changed) {
5080
+ await warnPromptDrift();
5081
+ }
4098
5082
  }
4099
5083
  };
4100
5084
  });