@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 +119 -1
- package/dist/agents.js +1 -0
- package/dist/index.js +156 -34
- package/dist/lacode.js +84 -36
- package/dist/pool.js +78 -8
- package/dist/tunnel.js +76 -8
- package/docs/TUNNEL.md +37 -10
- package/docs/USER-STORIES.md +18 -5
- package/package.json +2 -1
- package/presets/README.md +18 -7
- package/presets/free-tier.json +20 -6
- package/sounds/done.wav +0 -0
- package/sounds/error.wav +0 -0
- package/sounds/permission.wav +0 -0
- package/sounds/question.wav +0 -0
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-
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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(), {
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
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
|
-
|
|
1374
|
-
for (const { claim } of stuck) {
|
|
1375
|
-
|
|
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
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
-
|
|
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 },
|
|
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
|
|
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,
|