@lawaty/lacode 0.2.2 → 0.4.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/README.md CHANGED
@@ -284,6 +284,7 @@ option is optional:
284
284
  | `modelsFile` | — | An explicit slot file; short-circuits the chain below. |
285
285
  | `reapMs` | `30000` | Hang-reaper interval. |
286
286
  | `selfUpdate` | `true` | Check for a newer release at most once per ~6h and install it in the background; it applies on the next opencode start. `false` — or `LACODE_NO_SELFUPDATE=1` — turns the check off. |
287
+ | `sound` | `true` | Play a short tone with each notification, one per outcome, so you can tell a finished session from a question or a permission prompt without reading the popup. It is opt-out: `false` — or `LACODE_SOUND=0` (`false`, `off`, `no` work too, and beat the option) — turns the sound off, and anything else, a typo included, leaves it on. |
287
288
  | `omniroute` | — | Optional [OmniRoute](#configuring-omniroute) gateway integration. Absent or `{ enabled: false }` changes nothing. |
288
289
 
289
290
  ### Staying up to date
@@ -336,6 +337,9 @@ as an opencode provider for you. It is **off by default**: if you do not set
336
337
  disabled for that run, and the integration is per-run.
337
338
  2. Add the plugin block to your opencode config:
338
339
  `["@lawaty/lacode", { "omniroute": { "enabled": true, "apiKey": "<your omniroute key>", "combo": "auto", "models": [] } }]`
340
+ `auto` is the zero-config default, but on a free tier it often resolves to a
341
+ slow chain. [A tested fast combo](#a-tested-fast-combo-free-mix) below is
342
+ ordered for latency and is worth applying on a new client.
339
343
  3. Optionally, to route pooled work through it, make `omniroute/auto 3` the
340
344
  **first** line of `~/.config/lacode/pool.txt`. `pool.txt` is optional user
341
345
  config; the bundled default preset does **not** include OmniRoute.
@@ -425,6 +429,98 @@ the only thing that changes when the gateway is down is that `omniroute/<id>`
425
429
  fails; every other slot keeps working. See
426
430
  [docs/USER-STORIES.md](docs/USER-STORIES.md) US-28.
427
431
 
432
+ ### A tested fast combo: `free-mix`
433
+
434
+ `combo: "auto"` is the zero-configuration default, but `auto` inherits whatever
435
+ the gateway chooses, and on a busy free tier that is frequently the slowest
436
+ chain available. The combo below was **measured** against a live free-tier
437
+ gateway and is ordered for latency rather than for breadth. Apply it during the
438
+ "Setting it up on a client machine" steps above and pooled work is usable
439
+ straight away, instead of starting from a gateway that queues for half a minute
440
+ per call.
441
+
442
+ ```jsonc
443
+ {
444
+ "name": "free-mix",
445
+ // Order decides. Do NOT use "weighted" here — weights let the gateway
446
+ // re-shuffle a model that is returning 429 into first position again.
447
+ "strategy": "priority",
448
+ "models": [
449
+ { "model": "gemini/gemini-3.1-flash-lite", "providerId": "gemini" },
450
+ { "model": "gemini/gemini-3.8-flash", "providerId": "gemini" },
451
+ { "model": "gemini/gemini-3.7-flash", "providerId": "gemini" },
452
+ { "model": "gemini/gemini-3-flash-preview", "providerId": "gemini" },
453
+ { "model": "openrouter/nvidia/nemotron-3-super-120b-a12b:free", "providerId": "openrouter" },
454
+ { "model": "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free", "providerId": "openrouter" },
455
+ { "model": "openrouter/nvidia/nemotron-3.5-lightning:free", "providerId": "openrouter" },
456
+ { "model": "openrouter/cohere/north-mini-code:free", "providerId": "openrouter" },
457
+ { "model": "openrouter/poolside/laguna-s-2.1:free", "providerId": "openrouter" },
458
+ { "model": "openrouter/dots-studio/dots-3-note-preview:free", "providerId": "openrouter" }
459
+ ]
460
+ }
461
+ ```
462
+
463
+ Why this order, from 859 calls over a single day against that gateway:
464
+
465
+ | Model | Observed behaviour | Position |
466
+ | --- | --- | --- |
467
+ | `gemini/gemini-3.1-flash-lite` | **165/172 succeeded (96%)**, ~4.5s median | **first** |
468
+ | `gemini-3.8-flash` / `3.7-flash` / `3-flash-preview` | succeed intermittently, but **burn exactly 30.0s** on a `503` queue-budget timeout when they do not | next |
469
+ | `openrouter/*:free` | **~90% `429`**, but fails *fast* (~1.8s) and occasionally succeeds | last, as a cheap final fallback |
470
+ | `gemini-2.5-flash`, `gemini-2.5-flash-lite` | **`404` on every attempt** — dead ids | **omitted entirely** |
471
+
472
+ The ordering matters because a combo pays for *every* step it walks past. Put the
473
+ 96%-success model first and the common case is a single hop; the 30s models sit
474
+ behind it where they cost nothing, and the 429s sit last where they give up in
475
+ under two seconds.
476
+
477
+ Measured on the same gateway, same day, `max_tokens: 10`:
478
+
479
+ | | Before | After |
480
+ | --- | --- | --- |
481
+ | `free-mix` response | 42.7s / 41.8s / 34.7s | 4.1s / 3.1s / 3.2s / 4.0s / 3.2s |
482
+ | Median | **~41.8s** | **~3.2s** |
483
+ | Provider time burned on calls >25s | **56.9 of 102.4 min (56%)** | — |
484
+
485
+ Before the change, the real call sequence was a `429` after 2.7s, then a `503`
486
+ after 30.0s, then the actual work in 5.7s — roughly **33 seconds of dead time in
487
+ front of every successful request**, and a p90 of 100s when three 30s steps
488
+ chained.
489
+
490
+ These free-tier endpoints change availability constantly, so treat the ordering
491
+ as a **starting point to re-measure, not a permanent truth**. Re-check it with
492
+ OmniRoute's own call logs before assuming it still holds:
493
+
494
+ ```bash
495
+ # one timed request through the gateway
496
+ curl -o /dev/null -w '%{http_code} %{time_starttransfer}s\n' \
497
+ -X POST http://localhost:20128/v1/chat/completions \
498
+ -H "Authorization: Bearer $OMNIROUTE_API_KEY" -H 'Content-Type: application/json' \
499
+ -d '{"model":"free-mix","messages":[{"role":"user","content":"Say OK"}],"max_tokens":10}'
500
+
501
+ # per-model status + duration, straight from OmniRoute's logs
502
+ python3 - <<'PY'
503
+ import json, glob, collections
504
+ d = "/home/<user>/.omniroute/call_logs/<YYYY-MM-DD>"
505
+ g = collections.defaultdict(collections.Counter)
506
+ for f in glob.glob(d + "/*.json"):
507
+ try: s = json.load(open(f))["summary"]
508
+ except Exception: continue
509
+ g[s.get("comboStepId")][s.get("status")] += 1
510
+ for k, v in sorted(g.items(), key=lambda x: str(x[0])):
511
+ print(f"{str(k)[:44]:44} {dict(v)}")
512
+ PY
513
+ ```
514
+
515
+ Apply it with the bundled CLI (`PUT /api/combos/<id>` updates in place, so the
516
+ combo is never briefly missing):
517
+
518
+ ```bash
519
+ omniroute api combos get-api-combos-id- --id <combo-id> --json > free-mix.json
520
+ # edit name/strategy/models in free-mix.json, then:
521
+ omniroute api combos put-api-combos-id- --id <combo-id> --body @free-mix.json
522
+ ```
523
+
428
524
  **A note on mid-session switching.** opencode 1.18.34 exposes no hook to change
429
525
  an agent's main model mid-request, so LaCode cannot swap models inside a running
430
526
  session; use an OmniRoute `combo` (or `auto`) if you want per-request fallback.
@@ -569,6 +665,28 @@ published, because none has been independently measured. The mechanism, the
569
665
  permission rule that makes one writer possible, and what the log lines mean are
570
666
  in [docs/CONTEXT.md](docs/CONTEXT.md).
571
667
 
668
+ ## Managerial directions
669
+
670
+ You can optionally add `manager.md` to `.opencode/context/`. This file is owned
671
+ by you alone and is never written, edited, pruned, or promoted by any agent. It
672
+ outranks the five generated files on focus and settled decisions, but it is
673
+ secondary to the source code: if the code disagrees, the code wins and the
674
+ conflict is reported back to you.
675
+
676
+ The file is per-clone, not committed, and entirely optional: if it is missing,
677
+ agents behave exactly as before. When present, it should contain these three
678
+ sections:
679
+
680
+ - `## Stage`: A snapshot of your current work-in-progress. Rewrite this section
681
+ rather than appending to it to keep it concise. If this file passes roughly 150
682
+ lines, run `/context-review`.
683
+ - `## Confirmed`: Facts and decisions you have personally ratified. Treat these
684
+ as settled, but not as permission to skip reading the code.
685
+ - `## Instructions`: Standing managerial directions for all agents.
686
+
687
+ No agent may write this file at all: the context agent's edit permission denies
688
+ `manager.md` explicitly, so ratification can only ever come from your hand.
689
+
572
690
  ## Cost
573
691
 
574
692
  The mechanism is specific and small: bulk exploration, implementation and
@@ -645,7 +763,7 @@ open are in [docs/TUNNEL.md](docs/TUNNEL.md).
645
763
 
646
764
  ## User stories
647
765
 
648
- Twenty-three stories across seven epics, each written as a behavioural contract
766
+ Twenty-nine stories across seven epics, each written as a behavioural contract
649
767
  with acceptance criteria a test can assert mechanically — the pains behind them,
650
768
  the Given/When/Then criteria, and the story-to-test mapping are in
651
769
  [docs/USER-STORIES.md](docs/USER-STORIES.md).
package/dist/agents.js CHANGED
@@ -43,6 +43,7 @@ var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
43
43
  var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
44
44
  var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
45
45
  var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
46
+ var CHILD_MEMORY_MS = 30 * 6e4;
46
47
 
47
48
  // src/agents.ts
48
49
  var POOLED_BASES = ["explore", "implement", "context"];
package/dist/index.js CHANGED
@@ -243,6 +243,7 @@ function state(opts = {}) {
243
243
  cooling: /* @__PURE__ */ new Map(),
244
244
  strikes: /* @__PURE__ */ new Map(),
245
245
  activity: /* @__PURE__ */ new Map(),
246
+ children: /* @__PURE__ */ new Map(),
246
247
  reaped: 0,
247
248
  reaping: false,
248
249
  lastSweep: 0,
@@ -284,7 +285,7 @@ function init(s, onWarn) {
284
285
  }
285
286
  function pruneOwn(s) {
286
287
  const now = Date.now();
287
- for (const [key, claim] of s.own) if (now - claim.t > CLAIM_TTL_MS) s.own.delete(key);
288
+ for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) > CLAIM_TTL_MS) s.own.delete(key);
288
289
  }
289
290
  function publish(s) {
290
291
  if (!s.dirReady) return;
@@ -349,7 +350,7 @@ function readSiblings(s) {
349
350
  try {
350
351
  const parsed = JSON.parse(readFileSync2(path4, "utf8"));
351
352
  const claims = Object.values(parsed).filter(
352
- (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - c.t <= CLAIM_TTL_MS
353
+ (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - (c.hb ?? c.t) <= CLAIM_TTL_MS
353
354
  );
354
355
  siblings.push({ id: match[1], claims });
355
356
  } catch {
@@ -463,26 +464,72 @@ function snapshot(s) {
463
464
  }
464
465
  var loadOf = (counts, model) => counts.get(model) ?? 0;
465
466
  var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slot.weight;
466
- function pickSlot(s, snap) {
467
+ function roundRobinSequence(slots) {
468
+ const seq = [];
469
+ for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
470
+ return seq;
471
+ }
472
+ var CURSOR_FILE = "cursor.json";
473
+ function readCursor(s) {
474
+ try {
475
+ const parsed = JSON.parse(readFileSync2(join2(s.dir, CURSOR_FILE), "utf8"));
476
+ if (typeof parsed.seq === "number" && Number.isInteger(parsed.seq) && parsed.seq >= 0) return parsed.seq;
477
+ } catch {
478
+ }
479
+ return 0;
480
+ }
481
+ function writeCursor(s, seq) {
482
+ if (!s.dirReady) return;
483
+ const path4 = join2(s.dir, CURSOR_FILE);
484
+ try {
485
+ writeFileSync(`${path4}.tmp`, JSON.stringify({ seq }));
486
+ renameSync(`${path4}.tmp`, path4);
487
+ } catch {
488
+ }
489
+ }
490
+ function pickSlot(s, snap, options) {
491
+ const advance = options?.advance !== false;
467
492
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
493
+ const seq = roundRobinSequence(slots);
494
+ if (seq.length > 0) {
495
+ const byIndex = new Map(slots.map((slot) => [slot.index, slot]));
496
+ const start = readCursor(s) % seq.length;
497
+ for (let step = 0; step < seq.length; step++) {
498
+ const at = (start + step) % seq.length;
499
+ const slot = byIndex.get(seq[at]);
500
+ if (slot && isAvailable(s, slot.model, snap.cooling) && underCeiling(slot, snap.counts)) {
501
+ if (advance) writeCursor(s, at + 1);
502
+ return slot;
503
+ }
504
+ }
505
+ }
468
506
  const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
469
507
  const pool = usable.length > 0 ? usable : slots;
470
- const open = pool.filter((slot) => underCeiling(slot, snap.counts));
471
- if (open.length > 0) return open.reduce((best, slot) => slot.weight > best.weight ? slot : best, open[0]);
472
508
  return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
473
509
  }
474
510
  function agentFor(base, slot) {
475
511
  return `${base}-${slot}`;
476
512
  }
477
513
  function acquire(s, base, slot, ownerSession, key, target) {
478
- s.own.set(key, { v: base, k: slot.index, m: slot.model, t: Date.now(), s: ownerSession, g: target });
479
- if (target) s.activity.set(target, Date.now());
514
+ const now = Date.now();
515
+ s.own.set(key, { v: base, k: slot.index, m: slot.model, t: now, hb: now, s: ownerSession, g: target });
516
+ if (target) s.activity.set(target, now);
480
517
  publish(s);
481
518
  }
482
519
  function release(s, key) {
483
520
  if (s.own.delete(key)) publish(s);
484
521
  }
522
+ var CHILD_RING = 8;
523
+ var CHILD_MEMORY_MS = 30 * 6e4;
524
+ var CHILD_SLACK_MS = 1e3;
525
+ function noteChild(s, parentID, childID) {
526
+ const ring = s.children.get(parentID) ?? [];
527
+ ring.push({ id: childID, t: Date.now() });
528
+ while (ring.length > CHILD_RING) ring.shift();
529
+ s.children.set(parentID, ring);
530
+ }
485
531
  function bindChild(s, parentID, childID) {
532
+ noteChild(s, parentID, childID);
486
533
  const candidates = [...s.own.entries()].filter(([, c]) => c.s === parentID && !c.g).sort((a, b) => a[1].t - b[1].t);
487
534
  if (candidates.length === 0) return false;
488
535
  candidates[0][1].g = childID;
@@ -490,8 +537,29 @@ function bindChild(s, parentID, childID) {
490
537
  publish(s);
491
538
  return true;
492
539
  }
540
+ function backfillChild(s, claim) {
541
+ if (claim.g || !claim.s) return false;
542
+ const now = Date.now();
543
+ const taken = new Set([...s.own.values()].map((c) => c.g).filter((g) => typeof g === "string"));
544
+ const ring = s.children.get(claim.s) ?? [];
545
+ for (let i = ring.length - 1; i >= 0; i--) {
546
+ const child = ring[i];
547
+ if (now - child.t > CHILD_MEMORY_MS) break;
548
+ if (child.t < claim.t - CHILD_SLACK_MS) continue;
549
+ if (taken.has(child.id)) continue;
550
+ claim.g = child.id;
551
+ s.activity.set(child.id, now);
552
+ publish(s);
553
+ return true;
554
+ }
555
+ return false;
556
+ }
493
557
  function noteActivity(s, sessionID) {
494
- s.activity.set(sessionID, Date.now());
558
+ const now = Date.now();
559
+ s.activity.set(sessionID, now);
560
+ let touched = false;
561
+ for (const claim of s.own.values()) if (claim.g === sessionID) claim.hb = now, touched = true;
562
+ if (touched) publish(s);
495
563
  }
496
564
  function idleFor(s, claim, now) {
497
565
  const target = claim.g;
@@ -1342,6 +1410,7 @@ import { tool } from "@opencode-ai/plugin";
1342
1410
  var SERVICE = "agent-pool";
1343
1411
  var REAP_INTERVAL_MS = 3e4;
1344
1412
  var HANG_FALLBACK_MS = 10 * 60 * 1e3;
1413
+ var ABORT_TIMEOUT_MS = 1e4;
1345
1414
  var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1346
1415
  const opts = options ?? {};
1347
1416
  const s = state(opts);
@@ -1355,14 +1424,28 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1355
1424
  };
1356
1425
  init(s, (m) => void warnOnce(m));
1357
1426
  const abortSession = async (sessionID) => {
1358
- if (!serverUrl) return false;
1427
+ if (!serverUrl) return { ok: false, why: "no serverUrl: this process has no HTTP server to abort through" };
1359
1428
  try {
1360
- await fetch(new URL(`/session/${sessionID}/abort`, serverUrl).toString(), { method: "POST" });
1361
- return true;
1362
- } catch {
1363
- return false;
1429
+ const res = await fetch(new URL(`/session/${sessionID}/abort`, serverUrl).toString(), {
1430
+ method: "POST",
1431
+ signal: AbortSignal.timeout(ABORT_TIMEOUT_MS)
1432
+ });
1433
+ if (!res.ok) return { ok: false, why: `gateway answered HTTP ${res.status}` };
1434
+ const body = (await res.text()).trim();
1435
+ if (body === "false") return { ok: false, why: "server reported the session was not aborted" };
1436
+ return { ok: true, why: "" };
1437
+ } catch (error) {
1438
+ return { ok: false, why: String(error) };
1364
1439
  }
1365
1440
  };
1441
+ const MAX_ABORT_ATTEMPTS = 3;
1442
+ const coolForHang = async (claim, log2) => {
1443
+ const now = Date.now();
1444
+ const prior = s.cooling.get(claim.m);
1445
+ if (prior && prior.until > now) return;
1446
+ const strike = recordStrike(s, claim.m, "hang", HANG_FALLBACK_MS);
1447
+ if (strike) await log2("warn", `cooling ${claim.m} for ${Math.round((strike.until - now) / 1e3)}s (hang)`);
1448
+ };
1366
1449
  const reapStuck = async () => {
1367
1450
  if (s.reaping) return;
1368
1451
  s.reaping = true;
@@ -1370,21 +1453,53 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1370
1453
  try {
1371
1454
  const stuck = findStuck(s);
1372
1455
  if (stuck.length === 0) return;
1373
- releaseStuck(s, stuck.map((x) => x.key));
1374
- for (const { claim } of stuck) {
1375
- const killed = claim.g ? await abortSession(claim.g) : false;
1456
+ const released = [];
1457
+ for (const { key, claim } of stuck) {
1458
+ if (!claim.g) backfillChild(s, claim);
1459
+ const idle = idleFor(s, claim, Date.now());
1460
+ const since = Number.isFinite(idle) ? `${Math.round(idle / 1e3)}s of silence` : "no child session ever bound";
1461
+ const age = Math.round((Date.now() - claim.t) / 1e3);
1462
+ if (claim.g) {
1463
+ const kill = await abortSession(claim.g);
1464
+ if (kill.ok) {
1465
+ released.push(key);
1466
+ s.reaped++;
1467
+ await log(
1468
+ "warn",
1469
+ `hung ${claim.v} on ${claim.m} after ${age}s with ${since}; aborted session ${claim.g}`,
1470
+ { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: true }
1471
+ );
1472
+ await coolForHang(claim, log);
1473
+ continue;
1474
+ }
1475
+ claim.n = (claim.n ?? 0) + 1;
1476
+ if (claim.n < MAX_ABORT_ATTEMPTS) {
1477
+ await log(
1478
+ "warn",
1479
+ `could not abort hung ${claim.v} session ${claim.g} on ${claim.m} (${kill.why}); claim held, retry ${claim.n}/${MAX_ABORT_ATTEMPTS}`,
1480
+ { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: false }
1481
+ );
1482
+ continue;
1483
+ }
1484
+ released.push(key);
1485
+ s.reaped++;
1486
+ await log(
1487
+ "error",
1488
+ `GIVING UP on hung ${claim.v} session ${claim.g} on ${claim.m}: ${kill.why} after ${claim.n} attempts. The claim is released but the session is still running and must be killed by hand.`,
1489
+ { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: false }
1490
+ );
1491
+ continue;
1492
+ }
1493
+ released.push(key);
1376
1494
  s.reaped++;
1377
- await log(
1378
- "warn",
1379
- `hung ${claim.v} on ${claim.m} after ${Math.round((Date.now() - claim.t) / 1e3)}s with ${Math.round(idleFor(s, claim, Date.now()) / 1e3)}s of silence; ${killed ? `aborted session ${claim.g}` : "no session to abort"}`,
1380
- { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: killed }
1381
- );
1382
- const now = Date.now();
1383
- const prior = s.cooling.get(claim.m);
1384
- if (prior && prior.until > now) continue;
1385
- const strike = recordStrike(s, claim.m, "hang", HANG_FALLBACK_MS);
1386
- if (strike) await log("warn", `cooling ${claim.m} for ${Math.round((strike.until - now) / 1e3)}s (hang)`);
1495
+ await log("warn", `released unbound ${claim.v} claim on ${claim.m} after ${age}s (${since}); model not cooled`, {
1496
+ base: claim.v,
1497
+ model: claim.m,
1498
+ slot: claim.k,
1499
+ aborted: false
1500
+ });
1387
1501
  }
1502
+ if (released.length > 0) releaseStuck(s, released);
1388
1503
  } catch (error) {
1389
1504
  await log("error", `reaper failed: ${String(error)}`);
1390
1505
  } finally {
@@ -1549,7 +1664,7 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1549
1664
  };
1550
1665
  for (const claim of snap.own.values()) add(claim.m, claim.v);
1551
1666
  for (const sibling of snap.siblings) for (const claim of sibling.claims) add(claim.m, claim.v);
1552
- const next = pickSlot(s, snap);
1667
+ const next = pickSlot(s, snap, { advance: false });
1553
1668
  const line = (slot) => {
1554
1669
  const entry = byModel.get(slot.model);
1555
1670
  const users = entry && entry.bases.size > 0 ? [...entry.bases].map(([b, n]) => `${b}x${n}`).join(" ") : "idle";
@@ -1571,7 +1686,7 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1571
1686
  title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
1572
1687
  output: [
1573
1688
  `shared pool over ${slots.length} model(s) from ${s.origin}; bases: ${BASES.join(", ")}
1574
- priority is weight: the heaviest slot under its ceiling takes the next spawn (FULL = at ceiling, used only when every slot is)`,
1689
+ 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)`,
1575
1690
  ...slots.map(line),
1576
1691
  "",
1577
1692
  "by process:",
@@ -1905,15 +2020,16 @@ var INTERNAL_TITLE_PREFIXES = [
1905
2020
  // it would announce the cartographer's own bookkeeping as a finished task.
1906
2021
  "context (auto"
1907
2022
  ];
2023
+ var NOTIFY_OUTCOMES = ["done", "question", "permission", "error"];
1908
2024
  var notifyMessage = (outcome, detail) => {
1909
2025
  if (outcome === "error") return detail ? `Failed \u2014 ${detail}` : "Failed";
1910
2026
  if (outcome === "question") return detail ? `Awaiting your response \u2014 ${detail}` : "Awaiting your response";
1911
2027
  if (outcome === "permission") return detail ? `Awaiting your permission \u2014 ${detail}` : "Awaiting your permission";
1912
2028
  return "Task finished, waiting for your review";
1913
2029
  };
1914
- var helperArgv = (helper, title, message, urgency, icon) => {
2030
+ var helperArgv = (helper, title, message, urgency, icon, sound) => {
1915
2031
  const name = helper.split(/[\\/]/).pop() ?? "";
1916
- return name === "lacode" || name === "lacode.js" ? ["notify", title, message, urgency, icon] : [title, message, urgency, icon];
2032
+ return name === "lacode" || name === "lacode.js" ? ["notify", title, message, urgency, icon, sound] : [title, message, urgency, icon, sound];
1917
2033
  };
1918
2034
  var safeStringify = (value) => {
1919
2035
  try {
@@ -2068,6 +2184,8 @@ var Notifier = class {
2068
2184
  // src/plugins/notify.ts
2069
2185
  var SERVICE3 = "notify";
2070
2186
  var COOLDOWN_MS2 = 5e3;
2187
+ var SOUND_ENV = "LACODE_SOUND";
2188
+ var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
2071
2189
  var findHelper = () => {
2072
2190
  const home = process.env.HOME ?? "";
2073
2191
  const candidates = [
@@ -2081,7 +2199,10 @@ var findHelper = () => {
2081
2199
  for (const candidate of candidates) if (existsSync3(candidate)) return candidate;
2082
2200
  return void 0;
2083
2201
  };
2084
- var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
2202
+ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2203
+ const opts = options ?? {};
2204
+ const soundEnv = (process.env[SOUND_ENV] ?? "").trim().toLowerCase();
2205
+ const soundOn = !SOUND_OFF_VALUES.includes(soundEnv) && opts.sound !== false;
2085
2206
  const notifier = new Notifier(COOLDOWN_MS2);
2086
2207
  const active = /* @__PURE__ */ new Set();
2087
2208
  const titles = /* @__PURE__ */ new Map();
@@ -2094,7 +2215,7 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
2094
2215
  const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
2095
2216
  return info;
2096
2217
  };
2097
- const deliverViaHelper = (title, message, urgency) => new Promise((resolve3) => {
2218
+ const deliverViaHelper = (title, message, urgency, sound) => new Promise((resolve3) => {
2098
2219
  const helper = findHelper();
2099
2220
  if (!helper) {
2100
2221
  if (!warnedHelper) {
@@ -2104,7 +2225,7 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
2104
2225
  resolve3({ ok: false, detail: "no oc-notify helper found" });
2105
2226
  return;
2106
2227
  }
2107
- const child = spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal"), {
2228
+ const child = spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
2108
2229
  stdio: ["ignore", "ignore", "pipe"],
2109
2230
  detached: true
2110
2231
  });
@@ -2163,7 +2284,8 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
2163
2284
  return;
2164
2285
  }
2165
2286
  const message = isRemote ? `${decision.message} (remote)` : decision.message;
2166
- const sent = await deliver(decision.title, message, decision.urgency);
2287
+ const sound = soundOn && NOTIFY_OUTCOMES.includes(outcome) ? outcome : "none";
2288
+ const sent = await deliver(decision.title, message, decision.urgency, sound);
2167
2289
  void log(sent.ok ? "info" : "error", sent.ok ? "notified" : "notification failed", {
2168
2290
  sessionID,
2169
2291
  outcome,