@lawaty/lacode 0.11.0 → 1.0.1

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
@@ -626,48 +626,24 @@ function snapshot(s) {
626
626
  }
627
627
  var loadOf = (counts, model) => counts.get(model) ?? 0;
628
628
  var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
629
- function roundRobinSequence(slots) {
630
- const seq = [];
631
- for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
632
- return seq;
633
- }
634
- var CURSOR_FILE = "cursor.json";
635
- function readCursor(s) {
636
- try {
637
- const parsed = JSON.parse(readFileSync2(join2(s.dir, CURSOR_FILE), "utf8"));
638
- if (typeof parsed.seq === "number" && Number.isInteger(parsed.seq) && parsed.seq >= 0) return parsed.seq;
639
- } 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;
640
635
  }
641
- return 0;
642
- }
643
- function writeCursor(s, seq) {
644
- if (!s.dirReady) return;
645
- const path4 = join2(s.dir, CURSOR_FILE);
646
- try {
647
- writeFileSync(`${path4}.tmp`, JSON.stringify({ seq }));
648
- renameSync(`${path4}.tmp`, path4);
649
- } catch {
636
+ for (const slot of usable) {
637
+ if (loadOf(snap.counts, slot.model) < slotCeiling(slot)) return slot;
650
638
  }
639
+ return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
651
640
  }
652
- function pickSlot(s, snap, options) {
653
- const advance = options?.advance !== false;
641
+ function poolPass(s, snap) {
654
642
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
655
- const seq = roundRobinSequence(slots);
656
- if (seq.length > 0) {
657
- const byIndex = new Map(slots.map((slot) => [slot.index, slot]));
658
- const start = readCursor(s) % seq.length;
659
- for (let step = 0; step < seq.length; step++) {
660
- const at = (start + step) % seq.length;
661
- const slot = byIndex.get(seq[at]);
662
- if (slot && isAvailable(s, slot.model, snap.cooling) && underCeiling(slot, snap.counts)) {
663
- if (advance) writeCursor(s, at + 1);
664
- return slot;
665
- }
666
- }
667
- }
668
643
  const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
669
- const pool = usable.length > 0 ? usable : slots;
670
- 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";
671
647
  }
672
648
  function agentFor(base, slot) {
673
649
  return `${base}-${slot}`;
@@ -802,9 +778,12 @@ Every question of the form "what does this file do", "where is X handled", or
802
778
  that proves it. Never one whole refactor handed over in a single piece \u2014
803
779
  see \`# Task size\` below.
804
780
 
805
- Spawn the BARE pooled base \u2014 \`explore\`, \`implement\` or \`context\`. The pool
806
- refuses a \`-N\` variant, and a refusal means spawn the bare base, never a
807
- 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.
808
787
 
809
788
  # When the pool is unavailable
810
789
 
@@ -813,12 +792,12 @@ pool cannot serve a spawn \u2014 every slot cooling or exhausted, spawns repeate
813
792
  failing, or a runtime message saying no slot is available \u2014 that is not a reason
814
793
  to do the work yourself, and not a reason to stop and ask.
815
794
 
816
- **Retry.** A refused or cooling slot is transient and costs you nothing but a
817
- turn. Spawn the bare base again. Do not escalate to a numbered variant after a
818
- refusal \u2014 the refusal means the pool has a routing problem, not that you
819
- 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.
820
798
 
821
- 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
822
801
  "never wrong" is not "sufficient": looping silently against a pool with every
823
802
  model cooling goes nowhere and burns the slot doing it. So separate the two
824
803
  failures. **Transient** \u2014 a cooldown, a rate limit, one slot at its ceiling \u2014
@@ -826,7 +805,8 @@ means retry. **Persistent or structural** \u2014 every slot cooling, the same sp
826
805
  failing repeatedly with the same error, a malformed model id \u2014 means stop
827
806
  retrying and *report* it: say what failed (slot, model, error) and continue
828
807
  with whatever else the task needs, or ask the user. Surface the failure; do not
829
- 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.
830
810
 
831
811
  **Never fall back to reading, grepping, exploring or implementing on your own.**
832
812
  Do not "just quickly" inspect a file because a spawn failed. A failed spawn is
@@ -943,8 +923,11 @@ When you need to understand the codebase:
943
923
  2. Invoke @explore with specific, focused instructions (e.g. "read src/auth.ts", "grep for 'migration' in *.py files", "list the src/ directory")
944
924
  3. @explore will return its findings \u2014 you process them and plan accordingly
945
925
 
946
- Spawn the BARE pooled base (\`explore\`, \`implement\`, \`context\`); the pool
947
- 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.
948
931
 
949
932
  If the pool cannot serve a spawn \u2014 every slot cooling or exhausted, or repeated
950
933
  failures \u2014 do NOT fall back to reading or planning from your own inspection.
@@ -1272,7 +1255,7 @@ Trigger: \`.opencode/context/\` exists and work has just been done here.
1272
1255
  - an architectural decision
1273
1256
  - a new dependency between subsystems
1274
1257
  - a renamed or relocated subsystem
1275
- - a previously undocumented invariant
1258
+ - an invariant not yet recorded in the map
1276
1259
 
1277
1260
  Do not update documentation merely because source files changed. Do not
1278
1261
  rewrite files wholesale. Do not make timestamp-only edits. Do not add a fact
@@ -1578,7 +1561,7 @@ function buildAgentSet() {
1578
1561
  for (let slot = 1; slot <= MAX_SLOTS; slot++) {
1579
1562
  const variant = {
1580
1563
  ...def,
1581
- 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.`,
1582
1565
  hidden: true,
1583
1566
  // `mode: "primary"` is what keeps a variant OUT of the model's system
1584
1567
  // prompt. `hidden` cannot do it.
@@ -1590,9 +1573,10 @@ function buildAgentSet() {
1590
1573
  // and by default-agent resolution. A variant left as `mode: "subagent"`
1591
1574
  // is therefore listed to the model under its own name -- "explore-1:
1592
1575
  // Hidden pool variant explore-1" -- and the model copies that name
1593
- // straight into its next spawn. The pool then refuses it, because
1594
- // naming a variant is how a caller would pick the provider and skip the
1595
- // 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.
1596
1580
  //
1597
1581
  // `mode` is the only field that filter respects, and setting it here is
1598
1582
  // safe: the task tool never reads an agent's mode -- it resolves the
@@ -1666,7 +1650,7 @@ context file only if this work introduced or revealed information that will
1666
1650
  materially help future agents \u2014 a new boundary or module, a new or moved
1667
1651
  canonical entry point, a changed workflow, a new convention, an architectural
1668
1652
  decision, a new dependency between subsystems, a renamed or moved subsystem,
1669
- or a previously undocumented invariant.
1653
+ or an invariant not yet recorded.
1670
1654
 
1671
1655
  Do not edit documentation just because source files changed. No
1672
1656
  timestamp-only edits. If nothing material changed, make no edits and report
@@ -1753,11 +1737,11 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
1753
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:
1754
1738
  - one slot per line: \`<provider>/<model-id> [weight]\`
1755
1739
  - at most ${MAX_SLOTS} slots, and a model may appear only once
1756
- - 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.
1757
1741
  - \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
1758
1742
  If the instruction names a model ambiguously, or would need more than ${MAX_SLOTS} slots, stop and ask rather than guessing.
1759
1743
 
1760
- 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.
1761
1745
 
1762
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.
1763
1747
 
@@ -1780,6 +1764,14 @@ $ARGUMENTS
1780
1764
  - Otherwise call \`pool_recheck\` with no arguments; it probes every model that is currently cooling.
1781
1765
 
1782
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"
1783
1775
  }
1784
1776
  };
1785
1777
  function applyCommands(cfg, commands = COMMANDS) {
@@ -2240,15 +2232,18 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2240
2232
  };
2241
2233
  sweepDirIfDue();
2242
2234
  const abortSession = async (sessionID) => {
2243
- if (!serverUrl) return { ok: false, why: "no serverUrl: this process has no HTTP server to abort through" };
2244
2235
  try {
2245
- const res = await fetch(new URL(`/session/${sessionID}/abort`, serverUrl).toString(), {
2246
- method: "POST",
2236
+ const result = await client.session.abort({
2237
+ path: { id: sessionID },
2238
+ query: { directory },
2247
2239
  signal: AbortSignal.timeout(ABORT_TIMEOUT_MS)
2248
2240
  });
2249
- if (!res.ok) return { ok: false, why: `gateway answered HTTP ${res.status}` };
2250
- const body = (await res.text()).trim();
2251
- 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
+ }
2252
2247
  return { ok: true, why: "" };
2253
2248
  } catch (error) {
2254
2249
  return { ok: false, why: String(error) };
@@ -2267,20 +2262,90 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2267
2262
  for (const [key, claim] of s.own) if (claim.g === sessionID) return { key, claim };
2268
2263
  return void 0;
2269
2264
  };
2270
- const failOver = async (sessionID, model, reason) => {
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) => {
2271
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
+ }
2272
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
+ }
2273
2346
  await log("warn", `limit (${reason}) on ${model} for session ${sessionID ?? "?"}, which has no pool claim; nothing aborted`);
2274
2347
  return;
2275
2348
  }
2276
- const { key, claim } = entry;
2277
- const kill = await abortSession(sessionID);
2278
- release(s, key);
2279
- await log(
2280
- "warn",
2281
- 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})`,
2282
- { base: claim.v, model: claim.m, slot: claim.k, session: sessionID, aborted: kill.ok, reason }
2283
- );
2284
2349
  };
2285
2350
  const reapStuck = async () => {
2286
2351
  if (s.reaping) return;
@@ -2364,7 +2429,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2364
2429
  if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
2365
2430
  return [
2366
2431
  ...report.outcomes.map(
2367
- (outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the rotation` : 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` : ""}`
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` : ""}`
2368
2433
  ),
2369
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."
2370
2435
  ].join("\n");
@@ -2528,7 +2593,12 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2528
2593
  }
2529
2594
  }
2530
2595
  }
2531
- await failOver(sessionID, provider ?? own?.claim.m ?? "?", named);
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
+ }
2532
2602
  }
2533
2603
  return;
2534
2604
  }
@@ -2546,6 +2616,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2546
2616
  if (!limited2 && !outage) return;
2547
2617
  const own = claimFor(sessionID);
2548
2618
  const named = outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
2619
+ let stated2;
2549
2620
  if (own) {
2550
2621
  if (outage) {
2551
2622
  const transient = coolUnavailable(s, own.claim.m);
@@ -2553,7 +2624,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2553
2624
  await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason}) after a terminal error`);
2554
2625
  }
2555
2626
  } else {
2556
- const stated2 = statedCooldown(haystack2) ?? retryAfterMs(error?.data?.responseHeaders);
2627
+ stated2 = statedCooldown(haystack2) ?? retryAfterMs(error?.data?.responseHeaders);
2557
2628
  const strike2 = recordStrike(s, own.claim.m, named, stated2);
2558
2629
  if (strike2) {
2559
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`, {
@@ -2564,7 +2635,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2564
2635
  }
2565
2636
  }
2566
2637
  }
2567
- await failOver(sessionID, own?.claim.m ?? "?", named);
2638
+ await failOver(sessionID, own?.claim.m ?? "?", named, { stated: stated2, cool: !own });
2568
2639
  return;
2569
2640
  }
2570
2641
  if (event.type === "session.created") {
@@ -2592,23 +2663,25 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2592
2663
  await log("debug", `probe of ${model} hit a limit (${isLimitText(haystack) ? "limit" : "transient"}); leaving the cooldown to the probe`);
2593
2664
  return;
2594
2665
  }
2666
+ const ownMsg = claimFor(info.sessionID);
2595
2667
  if (isUnavailableText(haystack) || !limited && transientGateway) {
2596
2668
  const transient = coolUnavailable(s, model);
2597
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 });
2598
2671
  return;
2599
2672
  }
2600
2673
  const stated = statedCooldown(haystack) ?? retryAfterMs(info.error.data?.responseHeaders);
2601
2674
  const reason = limitReason(haystack);
2602
2675
  const strike = recordStrike(s, model, reason ?? "rate-limited", stated);
2603
2676
  if (strike) await log("warn", `cooling ${model} for ${Math.round((strike.until - Date.now()) / 1e3)}s (${reason ?? "rate-limited"}, strike ${strike.strikes})`);
2604
- await failOver(info.sessionID, model, reason ?? "rate-limited");
2677
+ await failOver(info.sessionID, model, reason ?? "rate-limited", { stated, cool: !ownMsg });
2605
2678
  } catch (error) {
2606
2679
  await log("error", `limit detection failed: ${String(error)}`);
2607
2680
  }
2608
2681
  },
2609
2682
  "tool.execute.before": async (input, output) => {
2610
2683
  if (input.tool !== "task") return;
2611
- const requested = output.args?.subagent_type;
2684
+ let requested = output.args?.subagent_type;
2612
2685
  if (typeof requested !== "string") {
2613
2686
  await warnOnce(
2614
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.`
@@ -2617,12 +2690,11 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2617
2690
  }
2618
2691
  const manual = manualVariant(requested);
2619
2692
  if (manual) {
2620
- 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`, {
2621
2694
  base: manual
2622
2695
  });
2623
- throw new Error(
2624
- `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.`
2625
- );
2696
+ requested = manual;
2697
+ output.args.subagent_type = manual;
2626
2698
  }
2627
2699
  try {
2628
2700
  await reapStuck();
@@ -2635,8 +2707,6 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2635
2707
  await warnOnce(`variant ${variant} missing from config; spawn left unchanged`);
2636
2708
  return;
2637
2709
  }
2638
- acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
2639
- output.args.subagent_type = variant;
2640
2710
  await log("info", `routed ${requested} to slot ${slot.index} on ${slot.model}`, {
2641
2711
  slot: slot.index,
2642
2712
  weight: slot.weight,
@@ -2644,6 +2714,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2644
2714
  ceiling: slotCeiling(slot),
2645
2715
  load: (snap.counts.get(slot.model) ?? 0) + 1
2646
2716
  });
2717
+ output.args.subagent_type = variant;
2718
+ acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
2647
2719
  return;
2648
2720
  }
2649
2721
  const pinned = s.models.get(requested);
@@ -2663,7 +2735,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2663
2735
  },
2664
2736
  tool: {
2665
2737
  pool_recheck: tool({
2666
- 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; 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.",
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.",
2667
2739
  args: {
2668
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.")
2669
2741
  },
@@ -2674,12 +2746,13 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2674
2746
  }
2675
2747
  }),
2676
2748
  pool_status: tool({
2677
- 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.",
2678
2750
  args: {},
2679
2751
  async execute() {
2680
2752
  refreshCatalog(s, serverUrl);
2681
2753
  const slots = ensureSlots(s, (m) => void warnOnce(m));
2682
2754
  const snap = snapshot(s);
2755
+ const pass = poolPass(s, snap);
2683
2756
  const now = Date.now();
2684
2757
  const byModel = /* @__PURE__ */ new Map();
2685
2758
  const add = (model, base) => {
@@ -2690,14 +2763,14 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2690
2763
  };
2691
2764
  for (const claim of snap.own.values()) add(claim.m, claim.v);
2692
2765
  for (const sibling of snap.siblings) for (const claim of sibling.claims) add(claim.m, claim.v);
2693
- const next = pickSlot(s, snap, { advance: false });
2766
+ const next = pickSlot(s, snap);
2694
2767
  const line = (slot) => {
2695
2768
  const entry = byModel.get(slot.model);
2696
2769
  const users = entry && entry.bases.size > 0 ? [...entry.bases].map(([b, n]) => `${b}x${n}`).join(" ") : "idle";
2697
2770
  const strike = snap.cooling.get(slot.model);
2698
2771
  const status = s.catalog.get(slot.model);
2699
2772
  const notes = [
2700
- 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})` : "",
2701
2774
  status && status !== "active" && status !== "unknown" ? `catalog:${status}` : ""
2702
2775
  ].filter(Boolean);
2703
2776
  const marker = slot.index === next.index ? " <- next" : "";
@@ -2713,8 +2786,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2713
2786
  return {
2714
2787
  title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
2715
2788
  output: [
2716
- `shared pool over ${slots.length} model(s) from ${s.origin}; bases: ${BASES.join(", ")}
2717
- 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 no other slot is available and under its ceiling)`,
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)`,
2718
2791
  ...slots.map(line),
2719
2792
  "",
2720
2793
  "by process:",
@@ -2730,7 +2803,7 @@ round robin in slot order, each slot taking as many turns as its weight says, so
2730
2803
  // different situations and this is the line that tells them apart.
2731
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.",
2732
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).",
2733
- coolingNow.length === 0 ? "no models cooling down." : `cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
2806
+ coolingNow.length === 0 ? "no models cooling down." : `unavailable/cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
2734
2807
  ].join("\n")
2735
2808
  };
2736
2809
  }
@@ -3158,7 +3231,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3158
3231
  "",
3159
3232
  NO_BULK_READS,
3160
3233
  "",
3161
- '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.'
3162
3235
  ].join("\n");
3163
3236
  return {
3164
3237
  event: async ({ event }) => {
@@ -3272,15 +3345,330 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3272
3345
  };
3273
3346
  });
3274
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
+
3275
3663
  // src/plugins/notify.ts
3276
3664
  import { spawn as spawn2 } from "node:child_process";
3277
3665
  import { existsSync as existsSync3 } from "node:fs";
3278
3666
  import * as path3 from "node:path";
3279
3667
 
3280
3668
  // src/lib/notify.ts
3281
- import { closeSync as closeSync2, mkdirSync as mkdirSync4, openSync as openSync2, readFileSync as readFileSync6, statSync as statSync4, unlinkSync as unlinkSync4, writeSync as writeSync2 } from "node:fs";
3282
- import { homedir as homedir4 } from "node:os";
3283
- import { join as join6 } from "node:path";
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 { basename as basename3, join as join7 } from "node:path";
3284
3672
  var INTERNAL_TITLE_PREFIXES = [
3285
3673
  // context-autoupdate spawns the cartographer through the session API, with no
3286
3674
  // parentID, so it looks exactly like a root session going idle. Notifying for
@@ -3360,8 +3748,8 @@ var shouldNotify = (input) => {
3360
3748
  const { info, outcome, detail } = input;
3361
3749
  if (!info?.id) return { notify: false, reason: "no session info" };
3362
3750
  if (info.parentID) return { notify: false, reason: "subagent" };
3363
- const title = safeTitle(info.title);
3364
- if (INTERNAL_TITLE_PREFIXES.some((prefix) => title.startsWith(prefix))) {
3751
+ const sessionTitle = safeTitle(info.title);
3752
+ if (INTERNAL_TITLE_PREFIXES.some((prefix) => sessionTitle.startsWith(prefix))) {
3365
3753
  return { notify: false, reason: "internal session" };
3366
3754
  }
3367
3755
  if (info.time?.compacting) return { notify: false, reason: "compacting" };
@@ -3371,12 +3759,10 @@ var shouldNotify = (input) => {
3371
3759
  if (outcome === "done" && input.awaitingResponse) {
3372
3760
  return { notify: false, reason: "awaiting your response" };
3373
3761
  }
3762
+ const workspace = safeTitle(info.directory ? basename3(info.directory) : "", "");
3763
+ const title = workspace ? safeTitle(`${workspace} - ${sessionTitle}`, sessionTitle) : sessionTitle;
3374
3764
  const notification = {
3375
3765
  notify: true,
3376
- // The session's name on its own, with nothing in front of it. The popup's
3377
- // header is the notification's APP name, and that is "LaCode" -- so a
3378
- // prefix here would only repeat the app, and it would repeat it wrongly:
3379
- // the app is not "opencode".
3380
3766
  title,
3381
3767
  message: notifyMessage(outcome, detail),
3382
3768
  urgency: outcome === "error" ? "critical" : "normal"
@@ -3459,7 +3845,7 @@ var Notifier = class {
3459
3845
  return this.lastSent.size;
3460
3846
  }
3461
3847
  };
3462
- var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join6(homedir4(), ".local", "share", "opencode", "lacode", "notify");
3848
+ var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join7(homedir5(), ".local", "share", "opencode", "lacode", "notify");
3463
3849
  var safeCorrelationKey = (correlationKey) => correlationKey.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
3464
3850
  var CLAIM_WINDOW_MS = 5e3;
3465
3851
  var isPidAlive2 = (pid) => {
@@ -3470,11 +3856,11 @@ var isPidAlive2 = (pid) => {
3470
3856
  return error.code === "EPERM";
3471
3857
  }
3472
3858
  };
3473
- var claimFile = (sessionID, kind) => join6(notificationStateDir(), `claim.${safeCorrelationKey(`${sessionID}.${kind}`)}`);
3859
+ var claimFile = (sessionID, kind) => join7(notificationStateDir(), `claim.${safeCorrelationKey(`${sessionID}.${kind}`)}`);
3474
3860
  var claimNotification = (sessionID, kind) => {
3475
3861
  const path4 = claimFile(sessionID, kind);
3476
3862
  try {
3477
- mkdirSync4(notificationStateDir(), { recursive: true });
3863
+ mkdirSync5(notificationStateDir(), { recursive: true });
3478
3864
  } catch {
3479
3865
  return true;
3480
3866
  }
@@ -3498,7 +3884,7 @@ var claimNotification = (sessionID, kind) => {
3498
3884
  const judge = () => {
3499
3885
  let raw;
3500
3886
  try {
3501
- raw = readFileSync6(path4, "utf8").trim();
3887
+ raw = readFileSync7(path4, "utf8").trim();
3502
3888
  } catch (error) {
3503
3889
  return error.code === "ENOENT" ? "gone" : "unknown";
3504
3890
  }
@@ -3511,7 +3897,7 @@ var claimNotification = (sessionID, kind) => {
3511
3897
  if (holder === process.pid) return "ours";
3512
3898
  let age = Number.POSITIVE_INFINITY;
3513
3899
  try {
3514
- age = Date.now() - statSync4(path4).mtimeMs;
3900
+ age = Date.now() - statSync5(path4).mtimeMs;
3515
3901
  } catch {
3516
3902
  return "gone";
3517
3903
  }
@@ -3554,9 +3940,9 @@ var claimNotification = (sessionID, kind) => {
3554
3940
  let lockHolder;
3555
3941
  let lockAge = Number.POSITIVE_INFINITY;
3556
3942
  try {
3557
- const parsed = Number.parseInt(readFileSync6(lockPath, "utf8").trim(), 10);
3943
+ const parsed = Number.parseInt(readFileSync7(lockPath, "utf8").trim(), 10);
3558
3944
  lockHolder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
3559
- lockAge = Date.now() - statSync4(lockPath).mtimeMs;
3945
+ lockAge = Date.now() - statSync5(lockPath).mtimeMs;
3560
3946
  } catch {
3561
3947
  return create();
3562
3948
  }
@@ -3589,13 +3975,48 @@ var claimNotification = (sessionID, kind) => {
3589
3975
  var releaseNotificationClaim = (sessionID, kind) => {
3590
3976
  const path4 = claimFile(sessionID, kind);
3591
3977
  try {
3592
- const parsed = Number.parseInt(readFileSync6(path4, "utf8").trim(), 10);
3978
+ const parsed = Number.parseInt(readFileSync7(path4, "utf8").trim(), 10);
3593
3979
  if (!Number.isFinite(parsed) || parsed <= 0) return;
3594
3980
  if (parsed !== process.pid) return;
3595
3981
  unlinkSync4(path4);
3596
3982
  } catch {
3597
3983
  }
3598
3984
  };
3985
+ var completionFile = (sessionID) => join7(notificationStateDir(), `completion.${safeCorrelationKey(sessionID)}`);
3986
+ var claimCompletion = (sessionID, kind) => {
3987
+ const path4 = completionFile(sessionID);
3988
+ try {
3989
+ mkdirSync5(notificationStateDir(), { recursive: true });
3990
+ const fd = openSync2(path4, "wx");
3991
+ try {
3992
+ writeFileSync5(fd, `${process.pid} ${kind}
3993
+ `);
3994
+ } finally {
3995
+ closeSync2(fd);
3996
+ }
3997
+ return true;
3998
+ } catch (error) {
3999
+ if (error.code === "EEXIST") return false;
4000
+ return true;
4001
+ }
4002
+ };
4003
+ var releaseCompletion = (sessionID) => {
4004
+ try {
4005
+ unlinkSync4(completionFile(sessionID));
4006
+ } catch {
4007
+ }
4008
+ };
4009
+ var markCompletion = (sessionID, kind) => {
4010
+ try {
4011
+ mkdirSync5(notificationStateDir(), { recursive: true });
4012
+ const path4 = completionFile(sessionID);
4013
+ const tmp = `${path4}.${process.pid}.tmp`;
4014
+ writeFileSync5(tmp, `${process.pid} ${kind}
4015
+ `);
4016
+ renameSync4(tmp, path4);
4017
+ } catch {
4018
+ }
4019
+ };
3599
4020
 
3600
4021
  // src/plugins/notify.ts
3601
4022
  var SERVICE3 = "notify";
@@ -3614,10 +4035,10 @@ var findHelper = () => {
3614
4035
  const candidates = [
3615
4036
  process.env.OC_NOTIFY_HELPER,
3616
4037
  path3.join(home, "bin", "lacode"),
3617
- path3.join(home, "bin", "oc-notify"),
3618
- path3.join(PACKAGE_ROOT, "bin", "oc-notify"),
3619
4038
  path3.join(PACKAGE_ROOT, "dist", "lacode.js"),
3620
- path3.join(PACKAGE_ROOT, "bin", "lacode")
4039
+ path3.join(PACKAGE_ROOT, "bin", "lacode"),
4040
+ path3.join(home, "bin", "oc-notify"),
4041
+ path3.join(PACKAGE_ROOT, "bin", "oc-notify")
3621
4042
  ].filter((candidate) => typeof candidate === "string" && candidate.length > 0);
3622
4043
  for (const candidate of candidates) if (existsSync3(candidate)) return candidate;
3623
4044
  return void 0;
@@ -3893,7 +4314,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3893
4314
  void log("debug", "session info unavailable; not notifying", { sessionID });
3894
4315
  return;
3895
4316
  }
3896
- const titled = info.title ? info : { ...info, title: titles.get(sessionID) };
4317
+ const titled = { ...info, title: info.title ?? titles.get(sessionID), directory: info.directory ?? directory };
3897
4318
  const isRemote = Boolean(process.env.SSH_CLIENT || process.env.SSH_TTY);
3898
4319
  const decision = shouldNotify({
3899
4320
  info: titled,
@@ -3919,22 +4340,32 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3919
4340
  void log("debug", "suppressed: cooldown", { sessionID });
3920
4341
  return;
3921
4342
  }
3922
- if (!claimNotification(sessionID, kind)) {
4343
+ if (outcome === "done") {
4344
+ if (!claimCompletion(sessionID, "done")) {
4345
+ void log("debug", "suppressed: completion already announced", { sessionID });
4346
+ return;
4347
+ }
4348
+ } else if (!claimNotification(sessionID, kind)) {
3923
4349
  void log("debug", "suppressed: another process is raising this", { sessionID, kind });
3924
4350
  return;
4351
+ } else if (outcome === "error") {
4352
+ markCompletion(sessionID, "error");
3925
4353
  }
3926
4354
  const message = isRemote ? `${decision.message} (remote)` : decision.message;
3927
4355
  const sound = soundOn && NOTIFY_OUTCOMES.includes(outcome) ? outcome : "none";
3928
- const previous = live.get(sessionID);
3929
- const popup = { raisedAt: Date.now(), interacted: previous?.interacted ?? false };
4356
+ const popup = { raisedAt: Date.now(), interacted: false };
3930
4357
  rememberLive(sessionID, popup);
3931
4358
  if (popup.interacted) scheduleDismiss(sessionID);
3932
4359
  let sent = { ok: false };
3933
4360
  try {
3934
4361
  sent = await deliver(decision.title, message, decision.urgency, sound, requestID, sessionID);
4362
+ } catch (error) {
4363
+ if (outcome === "done" || outcome === "error") releaseCompletion(sessionID);
4364
+ throw error;
3935
4365
  } finally {
3936
- scheduleClaimRelease(sessionID, kind);
4366
+ if (outcome !== "done") scheduleClaimRelease(sessionID, kind);
3937
4367
  }
4368
+ if ((outcome === "done" || outcome === "error") && !sent.ok) releaseCompletion(sessionID);
3938
4369
  if (sent.action && requestID) {
3939
4370
  const outstanding = permissions.get(sessionID);
3940
4371
  const ids = outstanding ? [...outstanding].filter((id) => id !== UNANSWERABLE_REQUEST) : [];
@@ -3958,6 +4389,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3958
4389
  const { sessionID: sessionID2, status } = event.properties;
3959
4390
  if (status.type === "busy" && sessionID2) {
3960
4391
  rememberActive(sessionID2);
4392
+ releaseCompletion(sessionID2);
3961
4393
  }
3962
4394
  return;
3963
4395
  }
@@ -4036,6 +4468,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
4036
4468
  permissions.delete(sessionID2);
4037
4469
  forgetLive(sessionID2);
4038
4470
  forgetPermissionPopup(sessionID2);
4471
+ releaseCompletion(sessionID2);
4039
4472
  }
4040
4473
  return;
4041
4474
  }
@@ -4486,10 +4919,10 @@ var omniRouteHooks = async (input, options, deps = {}) => {
4486
4919
  };
4487
4920
  let started = null;
4488
4921
  const initialize = () => started ??= probe();
4489
- const injectProvider = async (cfg, warn) => {
4922
+ const injectProvider = async (cfg, warn2) => {
4490
4923
  const providers = cfg.provider ??= {};
4491
4924
  if (providers.omniroute !== void 0) {
4492
- await warn("omniroute provider is already defined in your config; lacode left it untouched");
4925
+ await warn2("omniroute provider is already defined in your config; lacode left it untouched");
4493
4926
  return;
4494
4927
  }
4495
4928
  const exposed = selector.candidates();
@@ -4552,11 +4985,17 @@ var inertHooks = {
4552
4985
  dispose: async () => {
4553
4986
  }
4554
4987
  };
4988
+ var promptDriftWarned = false;
4555
4989
  var LaCode = (async (input, options) => {
4556
4990
  const opts = options ?? {};
4557
4991
  if (opts.enabled === false) return inertHooks;
4558
4992
  const log = (level, message, extra) => input.client.app.log({ body: { service: "lacode", level, message, extra } }).catch(() => {
4559
4993
  });
4994
+ const warnPromptDrift = async () => {
4995
+ if (promptDriftWarned) return;
4996
+ promptDriftWarned = true;
4997
+ 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.");
4998
+ };
4560
4999
  let version;
4561
5000
  try {
4562
5001
  version = readInstalledVersion(PACKAGE_ROOT);
@@ -4578,6 +5017,7 @@ var LaCode = (async (input, options) => {
4578
5017
  const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
4579
5018
  const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
4580
5019
  const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
5020
+ const imagepaste = await settled("image-paste", () => imagePasteHooks(input, opts.imagePaste)) ?? inertHooks;
4581
5021
  const config = async (cfg) => {
4582
5022
  try {
4583
5023
  const injected = applyAgents(cfg, agentSet);
@@ -4612,12 +5052,30 @@ var LaCode = (async (input, options) => {
4612
5052
  "tool.execute.before": pool["tool.execute.before"],
4613
5053
  "tool.execute.after": pool["tool.execute.after"],
4614
5054
  tool: pool.tool,
5055
+ "command.execute.before": imagepaste["command.execute.before"],
4615
5056
  dispose: async () => {
4616
5057
  await pool.dispose?.();
4617
5058
  await autoupdate.dispose?.();
4618
5059
  await notifier.dispose?.();
4619
5060
  await selfupdate.dispose?.();
4620
5061
  await omniroute.dispose?.();
5062
+ await imagepaste.dispose?.();
5063
+ },
5064
+ "experimental.chat.system.transform": async (_input, output) => {
5065
+ if (!Array.isArray(output?.system)) return;
5066
+ let changed = false;
5067
+ for (let i = 0; i < output.system.length; i++) {
5068
+ const text = output.system[i];
5069
+ if (typeof text !== "string") continue;
5070
+ const scrubbed = scrubSlotNames(text);
5071
+ if (scrubbed !== text) {
5072
+ output.system[i] = scrubbed;
5073
+ changed = true;
5074
+ }
5075
+ }
5076
+ if (changed) {
5077
+ await warnPromptDrift();
5078
+ }
4621
5079
  }
4622
5080
  };
4623
5081
  });