@lawaty/lacode 1.2.0 → 1.3.0-beta.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/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ All notable changes to LaCode are recorded here. The format follows
7
7
  Versions come from git release commits; the npm version is bumped in the same
8
8
  commit as the changelog entry it describes.
9
9
 
10
+ ## [1.3.0-beta.0] — provider readiness, timeline roots, and a reason on cancelled tasks
11
+
12
+ ### Added
13
+
14
+ - **The pool gates slots on provider readiness.** Before routing, LaCode asks
15
+ opencode which providers are connected and which carry credentials, then
16
+ excludes slots whose provider is unconfigured or unauthenticated. Readiness
17
+ fails open until the first successful probe, so an offline start still routes;
18
+ `pool_status` names the provider to log in to.
19
+ - **`/reprocess-timeline` rebuilds the root session.** A reprocess started from a
20
+ subagent walks the `parentID` chain (with cycle protection) and summarizes the
21
+ root session, instead of only the child it was invoked from.
22
+ - **Host sync merges a Cloudflare Workers AI provider.** `bin/oc-sync` reads
23
+ `CLOUDFLARE_ACCOUNT_ID` (or derives it from the local provider URL) and merges
24
+ the provider into each host's `opencode.jsonc` after the auth sync; existing
25
+ providers are preserved and the write is backed up.
26
+ - **`OC_AUTH_PROVIDERS` can come from the env file.** The post-publish host sync
27
+ falls back to `~/.config/lacode/env` when the variable is unset.
28
+
29
+ ### Changed
30
+
31
+ - **A pool-cancelled subtask now carries its cause.** The bare `Task cancelled`
32
+ card is rewritten in place to `Task Cancelled by LaCode: <cause>` — slot,
33
+ model, elapsed time, failure kind and claim state. The old warning toast is
34
+ gone; the reason lives on the task itself. The rewrite is best-effort: if
35
+ opencode rejects the part update, a warning is logged and the original text
36
+ stays.
37
+ - **A model that disappears is benched, not retried forever.** A terminal
38
+ model-not-found error cools the model for the cap and releases the claim.
39
+
40
+ ### Fixed
41
+
42
+ - **Startup no longer waits on the provider endpoint.** The config hook used to
43
+ await `client.provider.list` while opencode was still bootstrapping the same
44
+ instance, which deadlocked startup. The first spawn now refreshes readiness
45
+ before routing, and `pool_status`/`pool_recheck` still force a refresh.
46
+ - **A terminal session error, or an abort that does not confirm, releases the
47
+ claim** instead of holding it until the reaper.
48
+
10
49
  ## [1.2.0] — voice, a per-session timeline, and a pool without a cap
11
50
 
12
51
  ### Added
@@ -920,6 +959,7 @@ experimental; `latest` remains 0.4.2.
920
959
  - First published release: pooled subagents over a shared set of models.
921
960
 
922
961
  [Unreleased]: https://github.com/lawaty/lacode/compare/v1.2.0...HEAD
962
+ [1.3.0-beta.0]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.0
923
963
  [1.2.0]: https://github.com/lawaty/lacode/compare/v1.1.0...v1.2.0
924
964
  [1.1.0]: https://github.com/lawaty/lacode/compare/v1.0.0...v1.1.0
925
965
  [1.0.0]: https://github.com/lawaty/lacode/compare/v0.11.0...v1.0.0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Ultimate opencode setup for Go users
4
4
 
5
- > LaCode 1.0.0 ships on the npm **`latest`** dist-tag. Prereleases use
5
+ > LaCode 1.2.0 ships on the npm **`latest`** dist-tag. Prereleases use
6
6
  > `beta`: install `@lawaty/lacode@beta`, or pin an exact version.
7
7
 
8
8
  LaCode is an opencode plugin. It routes delegated work onto a weighted pool of
@@ -170,7 +170,7 @@ The running session keeps its loaded code; the new version takes effect on the
170
170
  next start, and the log says so. Run `lacode update` to do it on demand, or
171
171
  `lacode update --check` to just report.
172
172
 
173
- 1.0.0 is published to the `latest` dist-tag; prereleases use `beta`. This check
173
+ 1.2.0 is published to the `latest` dist-tag; prereleases use `beta`. This check
174
174
  follows `latest`. A beta install is therefore reported as current even when a
175
175
  newer beta exists,
176
176
  and `lacode update` will not move it: pin the version you want in the `plugin`
@@ -186,7 +186,7 @@ lacode version
186
186
  ```
187
187
 
188
188
  ```
189
- version 1.0.0
189
+ version 1.2.0
190
190
  root /home/you/lacode
191
191
  source checkout
192
192
  commit <hash> (dirty)
package/dist/agents.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/lib/pool.ts
2
- import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join as join2 } from "node:path";
5
5
 
@@ -25,9 +25,11 @@ var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
25
25
 
26
26
  // src/lib/pool.ts
27
27
  var CLAIM_TTL_MS = 10 * 60 * 1e3;
28
+ var UNBOUND_CLAIM_MS = 2 * 60 * 1e3;
28
29
  var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
29
30
  var ORPHAN_TMP_MS = 60 * 1e3;
30
31
  var CATALOG_TTL_MS = 10 * 60 * 1e3;
32
+ var PROVIDER_LIST_TTL_MS = 60 * 1e3;
31
33
  var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
32
34
  var STUCK_IDLE_MS = 3 * 60 * 1e3;
33
35
  var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
package/dist/commands.js CHANGED
@@ -3,7 +3,7 @@ var AGENT_INSTRUCTIONS_COMMAND = "agent-instructions";
3
3
  var AGENT_INSTRUCTIONS_WRITER = "instructions";
4
4
 
5
5
  // src/lib/pool.ts
6
- import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { join as join2 } from "node:path";
9
9
 
@@ -29,9 +29,11 @@ var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
29
29
 
30
30
  // src/lib/pool.ts
31
31
  var CLAIM_TTL_MS = 10 * 60 * 1e3;
32
+ var UNBOUND_CLAIM_MS = 2 * 60 * 1e3;
32
33
  var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
33
34
  var ORPHAN_TMP_MS = 60 * 1e3;
34
35
  var CATALOG_TTL_MS = 10 * 60 * 1e3;
36
+ var PROVIDER_LIST_TTL_MS = 60 * 1e3;
35
37
  var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
36
38
  var STUCK_IDLE_MS = 3 * 60 * 1e3;
37
39
  var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/lib/pool.ts
2
- import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join as join2 } from "node:path";
5
5
 
@@ -25,9 +25,11 @@ var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
25
25
 
26
26
  // src/lib/pool.ts
27
27
  var CLAIM_TTL_MS = 10 * 60 * 1e3;
28
+ var UNBOUND_CLAIM_MS = 2 * 60 * 1e3;
28
29
  var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
29
30
  var ORPHAN_TMP_MS = 60 * 1e3;
30
31
  var CATALOG_TTL_MS = 10 * 60 * 1e3;
32
+ var PROVIDER_LIST_TTL_MS = 60 * 1e3;
31
33
  var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
32
34
  var STUCK_IDLE_MS = 3 * 60 * 1e3;
33
35
  var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
@@ -294,6 +296,11 @@ function state(opts = {}) {
294
296
  reaping: false,
295
297
  lastSweep: 0,
296
298
  lastDirSweep: 0,
299
+ connected: /* @__PURE__ */ new Set(),
300
+ connectedAt: 0,
301
+ connectedReady: false,
302
+ credentialed: /* @__PURE__ */ new Set(),
303
+ configCreds: /* @__PURE__ */ new Set(),
297
304
  id,
298
305
  dir,
299
306
  modelsFile,
@@ -401,9 +408,16 @@ function sweepStateDir(s) {
401
408
  }
402
409
  return removed;
403
410
  }
411
+ var claimExpired = (claim, now) => now - (claim.hb ?? claim.t) > CLAIM_TTL_MS || !claim.g && now - claim.t > UNBOUND_CLAIM_MS;
404
412
  function pruneOwn(s) {
405
413
  const now = Date.now();
406
- for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) > CLAIM_TTL_MS) s.own.delete(key);
414
+ let removed = false;
415
+ for (const [key, claim] of s.own)
416
+ if (claimExpired(claim, now)) {
417
+ s.own.delete(key);
418
+ removed = true;
419
+ }
420
+ if (removed) publish(s);
407
421
  }
408
422
  function pruneActivity(s, now) {
409
423
  if (s.activity.size === 0) return;
@@ -431,7 +445,7 @@ function publish(s) {
431
445
  if (!s.dirReady) return;
432
446
  const now = Date.now();
433
447
  const payload = {};
434
- for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) <= CLAIM_TTL_MS) payload[key] = claim;
448
+ for (const [key, claim] of s.own) if (!claimExpired(claim, now)) payload[key] = claim;
435
449
  try {
436
450
  writeFileSync(join2(s.dir, `${selfFile(s.id)}.tmp`), JSON.stringify(payload));
437
451
  renameSync(join2(s.dir, `${selfFile(s.id)}.tmp`), join2(s.dir, selfFile(s.id)));
@@ -490,7 +504,7 @@ function readSiblings(s) {
490
504
  try {
491
505
  const parsed = JSON.parse(readFileSync2(path5, "utf8"));
492
506
  const claims = Object.values(parsed).filter(
493
- (c) => c && typeof c.v === "string" && typeof c.m === "string" && typeof c.k === "number" && Number.isFinite(c.k) && now - (c.hb ?? c.t) <= CLAIM_TTL_MS
507
+ (c) => c && typeof c.v === "string" && typeof c.m === "string" && typeof c.k === "number" && Number.isFinite(c.k) && !claimExpired(c, now)
494
508
  );
495
509
  siblings.push({ id: match[1], claims });
496
510
  } catch {
@@ -629,6 +643,12 @@ function clearCooldowns(s, models) {
629
643
  var markProbe = (s, sessionID) => s.probes.add(sessionID);
630
644
  var unmarkProbe = (s, sessionID) => s.probes.delete(sessionID);
631
645
  var isProbe = (s, sessionID) => s.probes.has(sessionID);
646
+ function providerReady(s, model) {
647
+ if (!s.connectedReady) return true;
648
+ const provider = providerOf(model);
649
+ if (!s.connected.has(provider)) return false;
650
+ return s.credentialed.has(provider) || s.configCreds.has(provider) || provider === "opencode";
651
+ }
632
652
  function refreshCatalog(s, serverUrl) {
633
653
  if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
634
654
  s.catalogAt = Date.now();
@@ -645,6 +665,7 @@ function refreshCatalog(s, serverUrl) {
645
665
  })();
646
666
  }
647
667
  function isAvailable(s, model, cooling) {
668
+ if (!providerReady(s, model)) return false;
648
669
  if (cooling.has(model)) return false;
649
670
  const status = s.catalog.get(model);
650
671
  return status === void 0 || status === "active" || status === "unknown";
@@ -664,8 +685,9 @@ var loadOf = (counts, slot) => counts.get(slot) ?? 0;
664
685
  var underCeiling = (slot, counts) => loadOf(counts, slot.index) < slotCeiling(slot);
665
686
  function pickSlot(s, snap) {
666
687
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
667
- const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
668
- const pool = usable.length > 0 ? usable : slots;
688
+ const configured = slots.filter((slot) => providerReady(s, slot.model));
689
+ const usable = configured.filter((slot) => isAvailable(s, slot.model, snap.cooling));
690
+ const pool = usable.length > 0 ? usable : configured.length > 0 ? configured : slots;
669
691
  for (const slot of usable) {
670
692
  if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
671
693
  }
@@ -777,6 +799,56 @@ function coolProvider(s, provider, until, reason) {
777
799
  }
778
800
  return affected;
779
801
  }
802
+ function recordFailure(s, f) {
803
+ try {
804
+ const path5 = join2(s.dir, "failures.jsonl");
805
+ const trimSize = 64 * 1024;
806
+ const keepLines = 200;
807
+ if (existsSync(path5)) {
808
+ const st = statSync(path5);
809
+ if (typeof st.size === "number" && st.size > trimSize) {
810
+ try {
811
+ const content = readFileSync2(path5, "utf8");
812
+ const parts = content.split("\n");
813
+ const lines = parts.filter((line) => line.length > 0);
814
+ if (lines.length > keepLines) {
815
+ const kept = lines.slice(lines.length - keepLines);
816
+ writeFileSync(`${path5}.tmp`, kept.join("\n") + "\n");
817
+ renameSync(`${path5}.tmp`, path5);
818
+ } else if (lines.length === 0 && parts.length >= 1) {
819
+ }
820
+ } catch {
821
+ }
822
+ }
823
+ }
824
+ appendFileSync(path5, JSON.stringify(f) + "\n");
825
+ } catch (error) {
826
+ console.error("[agent-pool] failed to record failure:", String(error));
827
+ }
828
+ }
829
+ function readFailures(s, limit) {
830
+ const path5 = join2(s.dir, "failures.jsonl");
831
+ try {
832
+ if (!existsSync(path5)) return [];
833
+ const content = readFileSync2(path5, "utf8");
834
+ const parts = content.split("\n");
835
+ const records = [];
836
+ for (let i = 0; i < parts.length; i++) {
837
+ const line = parts[i];
838
+ if (line.length === 0) continue;
839
+ try {
840
+ const parsed = JSON.parse(line);
841
+ records.push(parsed);
842
+ } catch {
843
+ }
844
+ }
845
+ if (limit <= 0) return [];
846
+ if (records.length <= limit) return records;
847
+ return records.slice(records.length - limit);
848
+ } catch {
849
+ return [];
850
+ }
851
+ }
780
852
 
781
853
  // src/lib/agent-instructions.ts
782
854
  var AGENT_INSTRUCTIONS_COMMAND = "agent-instructions";
@@ -2439,6 +2511,63 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2439
2511
  s.warned.add(message);
2440
2512
  await log("warn", message);
2441
2513
  };
2514
+ const refreshProviders = async (force = false) => {
2515
+ if (!force && Date.now() - s.connectedAt < PROVIDER_LIST_TTL_MS) return;
2516
+ s.connectedAt = Date.now();
2517
+ try {
2518
+ const providerApi = client.provider;
2519
+ if (!providerApi || typeof providerApi.list !== "function") return;
2520
+ const result = await providerApi.list({ query: { directory } });
2521
+ const data = result?.data;
2522
+ const connected = data?.connected;
2523
+ const all = data?.all;
2524
+ if (Array.isArray(connected) && Array.isArray(all)) {
2525
+ s.connected = new Set(connected.filter((id) => typeof id === "string"));
2526
+ const credentialed = /* @__PURE__ */ new Set();
2527
+ for (const entry of all) {
2528
+ if (!entry || typeof entry !== "object") continue;
2529
+ const rec = entry;
2530
+ const id = typeof rec.id === "string" ? rec.id : void 0;
2531
+ if (!id) continue;
2532
+ if (typeof rec.key === "string" && rec.key.trim() !== "") {
2533
+ credentialed.add(id);
2534
+ continue;
2535
+ }
2536
+ const options2 = rec.options;
2537
+ if (options2 && typeof options2 === "object") {
2538
+ const opts2 = options2;
2539
+ if (typeof opts2.apiKey === "string" && opts2.apiKey.trim() !== "") {
2540
+ credentialed.add(id);
2541
+ continue;
2542
+ }
2543
+ const baseURL = typeof opts2.baseURL === "string" ? opts2.baseURL.trim() : "";
2544
+ if (baseURL !== "") {
2545
+ const envArr2 = Array.isArray(rec.env) ? rec.env : [];
2546
+ const hasCredEnv = envArr2.some((e) => typeof e === "string" && /key|token|secret|password|credential|auth/i.test(e));
2547
+ if (!hasCredEnv) {
2548
+ credentialed.add(id);
2549
+ continue;
2550
+ }
2551
+ }
2552
+ }
2553
+ const envArr = Array.isArray(rec.env) ? rec.env : [];
2554
+ for (const e of envArr) {
2555
+ if (typeof e === "string" && /key|token|secret|password|credential|auth/i.test(e)) {
2556
+ if (typeof process.env[e] === "string" && process.env[e].trim() !== "") {
2557
+ credentialed.add(id);
2558
+ break;
2559
+ }
2560
+ }
2561
+ }
2562
+ }
2563
+ s.credentialed = credentialed;
2564
+ s.connectedReady = true;
2565
+ } else if (Array.isArray(connected)) {
2566
+ s.connected = new Set(connected.filter((id) => typeof id === "string"));
2567
+ }
2568
+ } catch {
2569
+ }
2570
+ };
2442
2571
  init(s, (m) => void warnOnce(m));
2443
2572
  const sweepDirIfDue = () => {
2444
2573
  if (Date.now() - s.lastDirSweep < STATE_SWEEP_INTERVAL_MS) return;
@@ -2466,6 +2595,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2466
2595
  }
2467
2596
  };
2468
2597
  const abortReports = /* @__PURE__ */ new Map();
2598
+ const spawns = /* @__PURE__ */ new Map();
2599
+ const observedErrors = /* @__PURE__ */ new Map();
2469
2600
  const abortKind = (reason) => isQuotaReason(reason) || isLimitText(reason) ? "rate-limit" : "provider error";
2470
2601
  const abortLabel = (kind, elapsedMs) => {
2471
2602
  const seconds = Math.max(1, Math.round(elapsedMs / 1e3));
@@ -2475,6 +2606,60 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2475
2606
  if (kind === "permission") return `permission denied after ${seconds}s`;
2476
2607
  return `provider error after ${seconds}s`;
2477
2608
  };
2609
+ const pendingCancelledParts = /* @__PURE__ */ new Map();
2610
+ const patchedCancelledParts = /* @__PURE__ */ new Set();
2611
+ const cancelledPrefix = "Task Cancelled by LaCode:";
2612
+ const patchCancelledPart = async (part, key, report) => {
2613
+ if (patchedCancelledParts.has(key)) return;
2614
+ patchedCancelledParts.add(key);
2615
+ if (patchedCancelledParts.size > 64) {
2616
+ const oldest = patchedCancelledParts.values().next().value;
2617
+ if (oldest !== void 0) patchedCancelledParts.delete(oldest);
2618
+ }
2619
+ const error = `${cancelledPrefix} ${report.message}`;
2620
+ const updated = { ...part, state: { ...part.state, error } };
2621
+ try {
2622
+ const updater = client.part?.update;
2623
+ if (typeof updater === "function") {
2624
+ const result = await updater({
2625
+ sessionID: part.sessionID,
2626
+ messageID: part.messageID,
2627
+ partID: part.id,
2628
+ directory,
2629
+ part: updated,
2630
+ signal: AbortSignal.timeout(5e3)
2631
+ });
2632
+ if (!result?.response?.ok) throw new Error(`server answered HTTP ${result?.response?.status ?? "?"}`);
2633
+ abortReports.delete(key);
2634
+ return;
2635
+ }
2636
+ const url = new URL(
2637
+ `/session/${encodeURIComponent(part.sessionID)}/message/${encodeURIComponent(part.messageID)}/part/${encodeURIComponent(part.id)}`,
2638
+ serverUrl
2639
+ );
2640
+ if (directory) url.searchParams.set("directory", directory);
2641
+ const headers = { "Content-Type": "application/json" };
2642
+ if (directory) headers["x-opencode-directory"] = directory;
2643
+ const password = process.env.OPENCODE_SERVER_PASSWORD;
2644
+ if (password) {
2645
+ const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode";
2646
+ headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
2647
+ }
2648
+ const response = await fetch(url, {
2649
+ method: "PATCH",
2650
+ headers,
2651
+ body: JSON.stringify({ ...part, state: { ...part.state, error } }),
2652
+ signal: AbortSignal.timeout(5e3)
2653
+ });
2654
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim());
2655
+ abortReports.delete(key);
2656
+ } catch (cause) {
2657
+ await log("warn", `could not rewrite cancelled task ${part.callID}: ${cause instanceof Error ? cause.message : String(cause)}`, {
2658
+ session: part.sessionID,
2659
+ callID: part.callID
2660
+ });
2661
+ }
2662
+ };
2478
2663
  const noteAbort = async (key, report) => {
2479
2664
  abortReports.set(key, report);
2480
2665
  if (abortReports.size > 32) {
@@ -2488,11 +2673,10 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2488
2673
  failure: report.kind,
2489
2674
  released: report.released
2490
2675
  });
2491
- try {
2492
- await client.tui?.showToast?.({
2493
- body: { title: "pool spawn", message: report.message, variant: "warning", duration: 8e3 }
2494
- });
2495
- } catch {
2676
+ const pending = pendingCancelledParts.get(key);
2677
+ if (pending) {
2678
+ pendingCancelledParts.delete(key);
2679
+ await patchCancelledPart(pending, key, report);
2496
2680
  }
2497
2681
  };
2498
2682
  const MAX_ABORT_ATTEMPTS = 3;
@@ -2726,6 +2910,12 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2726
2910
  const slots = ensureSlots(s, (m) => void warnOnce(m));
2727
2911
  const agents = cfg.agent;
2728
2912
  const providers = cfg.provider;
2913
+ const configCreds = /* @__PURE__ */ new Set();
2914
+ for (const [id, entry] of Object.entries(providers ?? {})) {
2915
+ const options2 = entry.options;
2916
+ if (typeof options2?.apiKey === "string" && options2.apiKey.trim() !== "") configCreds.add(id);
2917
+ }
2918
+ s.configCreds = configCreds;
2729
2919
  const known = /* @__PURE__ */ new Set();
2730
2920
  for (const [name, def] of Object.entries(cfg.agent ?? {})) if (def?.model) s.models.set(name, def.model);
2731
2921
  for (const slot of slots) {
@@ -2760,6 +2950,11 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2760
2950
  const provider = slot.model.slice(0, slash);
2761
2951
  const id = slot.model.slice(slash + 1);
2762
2952
  const entry = providers?.[provider];
2953
+ if (!providerReady(s, slot.model)) {
2954
+ await warnOnce(
2955
+ `provider ${provider} is not connected in opencode; slot ${slot.index} (${slot.model}) is excluded from routing until it is authenticated (run \`opencode auth login ${provider}\` or remove the line)`
2956
+ );
2957
+ }
2763
2958
  if (!entry) {
2764
2959
  await warnOnce(`provider ${provider} is not configured in opencode.jsonc; slot ${slot.index} (${slot.model}) cannot resolve`);
2765
2960
  continue;
@@ -2891,12 +3086,69 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2891
3086
  await log("debug", `probe session ${sessionID} ended with an error; not cooling anything`);
2892
3087
  return;
2893
3088
  }
3089
+ const own = claimFor(sessionID);
3090
+ const missing2 = isModelMissingText(haystack2);
2894
3091
  const status2 = typeof error?.data?.statusCode === "number" ? error.data.statusCode : void 0;
2895
3092
  const auth2 = isAuthFailureText(haystack2) || status2 === 401 || status2 === 403;
2896
3093
  const limited2 = isLimitText(haystack2) || status2 === 429;
2897
3094
  const outage = isUnavailableText(haystack2);
2898
- if (!limited2 && !outage && !auth2) return;
2899
- const own = claimFor(sessionID);
3095
+ if (missing2) {
3096
+ const unbound = !own && sessionID ? [...s.own.entries()].find(([, claim]) => !claim.g && claim.s === sessionID && haystack2.includes(claim.m)) : void 0;
3097
+ const target = own ?? (unbound ? { key: unbound[0], claim: unbound[1] } : void 0);
3098
+ const model2 = target?.claim.m;
3099
+ if (model2) {
3100
+ const strike2 = recordStrike(s, model2, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
3101
+ if (strike2) {
3102
+ await log("warn", `bench ${model2} (${MODEL_MISSING_REASON}, strike ${strike2.strikes}) after a terminal model-not-found error`, {
3103
+ model: model2,
3104
+ slot: target?.claim.k,
3105
+ session: sessionID
3106
+ });
3107
+ }
3108
+ }
3109
+ if (target) {
3110
+ release(s, target.key);
3111
+ await log("warn", `released ${target.claim.v} claim on ${target.claim.m} after a terminal model-not-found error`, {
3112
+ base: target.claim.v,
3113
+ model: target.claim.m,
3114
+ slot: target.claim.k,
3115
+ session: sessionID
3116
+ });
3117
+ }
3118
+ return;
3119
+ }
3120
+ if (!limited2 && !outage && !auth2) {
3121
+ if (own) {
3122
+ const detail = haystack2.slice(0, 400);
3123
+ recordFailure(s, {
3124
+ ts: Date.now(),
3125
+ source: "observed",
3126
+ base: own.claim.v,
3127
+ slot: own.claim.k,
3128
+ model: own.claim.m,
3129
+ session: sessionID,
3130
+ reason: "provider error",
3131
+ elapsedMs: Date.now() - own.claim.t,
3132
+ released: true,
3133
+ detail
3134
+ });
3135
+ if (!observedErrors.has(own.key)) {
3136
+ observedErrors.set(own.key, { reason: "provider error", detail });
3137
+ if (observedErrors.size > 32) {
3138
+ const oldest = observedErrors.keys().next().value;
3139
+ if (oldest !== void 0) observedErrors.delete(oldest);
3140
+ }
3141
+ }
3142
+ release(s, own.key);
3143
+ await log("warn", `released ${own.claim.v} claim on ${own.claim.m} after a terminal session error`, {
3144
+ base: own.claim.v,
3145
+ model: own.claim.m,
3146
+ slot: own.claim.k,
3147
+ session: sessionID
3148
+ });
3149
+ }
3150
+ return;
3151
+ }
2900
3152
  const named = auth2 ? AUTH_FAILURE_REASON : outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
2901
3153
  let stated2;
2902
3154
  if (own) {
@@ -2928,6 +3180,15 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2928
3180
  }
2929
3181
  }
2930
3182
  await failOver(sessionID, own?.claim.m ?? "?", named, { stated: stated2, cool: !own });
3183
+ if (own && s.own.has(own.key)) {
3184
+ release(s, own.key);
3185
+ await log("warn", `released ${own.claim.v} claim on ${own.claim.m} after a terminal session error (abort did not confirm)`, {
3186
+ base: own.claim.v,
3187
+ model: own.claim.m,
3188
+ slot: own.claim.k,
3189
+ session: sessionID
3190
+ });
3191
+ }
2931
3192
  return;
2932
3193
  }
2933
3194
  if (event.type === "session.created") {
@@ -2941,6 +3202,39 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2941
3202
  const sessionID = info2?.sessionID ?? part?.sessionID;
2942
3203
  if (sessionID) noteActivity(s, sessionID);
2943
3204
  }
3205
+ if (event.type === "message.part.updated") {
3206
+ const part = props.part;
3207
+ if (part?.type === "tool" && part.tool === "task" && part.state?.status === "error" && part.state.error === "Task cancelled" && part.id && part.sessionID && part.messageID && part.callID) {
3208
+ const key = claimKey(part.sessionID, part.callID);
3209
+ const full = part;
3210
+ const report = abortReports.get(key);
3211
+ if (report) {
3212
+ await patchCancelledPart(full, key, report);
3213
+ } else if (observedErrors.has(key)) {
3214
+ const spawn3 = spawns.get(key);
3215
+ const seen = observedErrors.get(key);
3216
+ const elapsedMs = spawn3 ? Date.now() - spawn3.t : 0;
3217
+ const failureReport = {
3218
+ slot: spawn3?.slot ?? 0,
3219
+ model: spawn3?.model ?? "?",
3220
+ elapsedMs,
3221
+ kind: seen.reason,
3222
+ released: true,
3223
+ message: `${spawn3?.model ?? "?"} \u2014 ${seen.reason}${seen.detail ? `: ${seen.detail.slice(0, 200)}` : ""}`
3224
+ };
3225
+ await patchCancelledPart(full, key, failureReport);
3226
+ observedErrors.delete(key);
3227
+ spawns.delete(key);
3228
+ } else {
3229
+ pendingCancelledParts.set(key, full);
3230
+ if (pendingCancelledParts.size > 32) {
3231
+ const oldest = pendingCancelledParts.keys().next().value;
3232
+ if (oldest !== void 0) pendingCancelledParts.delete(oldest);
3233
+ }
3234
+ }
3235
+ }
3236
+ return;
3237
+ }
2944
3238
  if (event.type !== "message.updated") return;
2945
3239
  const info = props.info;
2946
3240
  if (!info || info.role !== "assistant" || !info.error) return;
@@ -2950,13 +3244,62 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2950
3244
  const limited = isLimitText(haystack) || status === 429;
2951
3245
  const gateway5xx = status !== void 0 && status >= 500 && status <= 599;
2952
3246
  const transientGateway = gateway5xx || isTransientGatewayText(haystack);
2953
- if (!limited && !transientGateway && !auth) return;
3247
+ const missing = isModelMissingText(haystack);
3248
+ if (!limited && !transientGateway && !auth && !missing) {
3249
+ const ownMsgUnclassified = claimFor(info.sessionID);
3250
+ if (ownMsgUnclassified) {
3251
+ recordFailure(s, {
3252
+ ts: Date.now(),
3253
+ source: "observed",
3254
+ base: ownMsgUnclassified.claim.v,
3255
+ slot: ownMsgUnclassified.claim.k,
3256
+ model: ownMsgUnclassified.claim.m,
3257
+ session: info.sessionID,
3258
+ reason: "provider error",
3259
+ elapsedMs: Date.now() - ownMsgUnclassified.claim.t,
3260
+ released: false,
3261
+ detail: haystack.slice(0, 400)
3262
+ });
3263
+ if (!observedErrors.has(ownMsgUnclassified.key)) {
3264
+ observedErrors.set(ownMsgUnclassified.key, { reason: "provider error", detail: haystack.slice(0, 400) });
3265
+ if (observedErrors.size > 32) {
3266
+ const oldest = observedErrors.keys().next().value;
3267
+ if (oldest !== void 0) observedErrors.delete(oldest);
3268
+ }
3269
+ }
3270
+ }
3271
+ return;
3272
+ }
2954
3273
  const model = `${info.providerID ?? "?"}/${info.modelID ?? "?"}`;
2955
3274
  if (info.sessionID && isProbe(s, info.sessionID)) {
2956
3275
  await log("debug", `probe of ${model} hit a limit (${isLimitText(haystack) ? "limit" : "transient"}); leaving the cooldown to the probe`);
2957
3276
  return;
2958
3277
  }
2959
3278
  const ownMsg = claimFor(info.sessionID);
3279
+ if (missing) {
3280
+ const ownMsgMissing = ownMsg;
3281
+ const missingModel = ownMsgMissing?.claim.m ?? model;
3282
+ if (missingModel !== "?/?") {
3283
+ const strike2 = recordStrike(s, missingModel, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
3284
+ if (strike2) {
3285
+ await log("warn", `bench ${missingModel} (${MODEL_MISSING_REASON}, strike ${strike2.strikes}) after a model-not-found error`, {
3286
+ model: missingModel,
3287
+ slot: ownMsgMissing?.claim.k,
3288
+ session: info.sessionID
3289
+ });
3290
+ }
3291
+ }
3292
+ if (ownMsgMissing) {
3293
+ release(s, ownMsgMissing.key);
3294
+ await log("warn", `released ${ownMsgMissing.claim.v} claim on ${ownMsgMissing.claim.m} after a model-not-found error`, {
3295
+ base: ownMsgMissing.claim.v,
3296
+ model: ownMsgMissing.claim.m,
3297
+ slot: ownMsgMissing.claim.k,
3298
+ session: info.sessionID
3299
+ });
3300
+ }
3301
+ return;
3302
+ }
2960
3303
  if (isUnavailableText(haystack) || !limited && !auth && transientGateway) {
2961
3304
  const transient = coolUnavailable(s, model);
2962
3305
  if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
@@ -2991,6 +3334,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2991
3334
  }
2992
3335
  try {
2993
3336
  await reapStuck();
3337
+ await refreshProviders();
2994
3338
  refreshCatalog(s, serverUrl);
2995
3339
  const snap = snapshot(s);
2996
3340
  if (BASES.includes(requested)) {
@@ -3008,7 +3352,9 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3008
3352
  load: (snap.counts.get(slot.index) ?? 0) + 1
3009
3353
  });
3010
3354
  output.args.subagent_type = variant;
3011
- acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
3355
+ const routeKey = claimKey(input.sessionID, input.callID);
3356
+ acquire(s, requested, slot, input.sessionID, routeKey);
3357
+ spawns.set(routeKey, { base: requested, slot: slot.index, model: slot.model, t: Date.now() });
3012
3358
  return;
3013
3359
  }
3014
3360
  const pinned = s.models.get(requested);
@@ -3025,15 +3371,30 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3025
3371
  "tool.execute.after": async (input, output) => {
3026
3372
  if (input.tool !== "task") return;
3027
3373
  const key = claimKey(input.sessionID, input.callID);
3374
+ const spawn3 = spawns.get(key);
3028
3375
  release(s, key);
3376
+ const text = typeof output.output === "string" ? output.output : "";
3377
+ const failed = abortReports.has(key) || /task\s+cancel+ed/i.test(text);
3378
+ spawns.delete(key);
3029
3379
  const report = abortReports.get(key);
3030
3380
  if (!report) return;
3031
3381
  abortReports.delete(key);
3382
+ recordFailure(s, {
3383
+ ts: Date.now(),
3384
+ source: "pool",
3385
+ base: void 0,
3386
+ slot: report.slot,
3387
+ model: report.model,
3388
+ session: input.sessionID,
3389
+ reason: report.kind,
3390
+ elapsedMs: report.elapsedMs,
3391
+ released: report.released,
3392
+ detail: report.message
3393
+ });
3032
3394
  const detail = [
3033
- `pool spawn: ${report.message}`,
3395
+ `Task Cancelled by LaCode: ${report.message}`,
3034
3396
  `slot ${report.slot}; model ${report.model}; elapsed ${report.elapsedMs}ms; failure ${report.kind}; claim ${report.released ? "released" : "held"}`
3035
3397
  ].join("\n");
3036
- const text = typeof output.output === "string" ? output.output : "";
3037
3398
  output.output = /task\s+cancel+ed/i.test(text) ? detail : text ? `${text}
3038
3399
 
3039
3400
  ${detail}` : detail;
@@ -3045,6 +3406,7 @@ ${detail}` : detail;
3045
3406
  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.")
3046
3407
  },
3047
3408
  async execute(args) {
3409
+ await refreshProviders(true);
3048
3410
  const models = args?.models;
3049
3411
  const report = models && models.length > 0 ? await runValidation(models) : await runProbe();
3050
3412
  return { title: `agent-pool: ${reportTitle(report)}`, output: reportText(report) };
@@ -3054,6 +3416,7 @@ ${detail}` : detail;
3054
3416
  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.",
3055
3417
  args: {},
3056
3418
  async execute() {
3419
+ await refreshProviders(true);
3057
3420
  refreshCatalog(s, serverUrl);
3058
3421
  const slots = ensureSlots(s, (m) => void warnOnce(m));
3059
3422
  const snap = snapshot(s);
@@ -3076,6 +3439,7 @@ ${detail}` : detail;
3076
3439
  const status = s.catalog.get(slot.model);
3077
3440
  const notes = [
3078
3441
  strike ? `UNAVAILABLE (COOLING ${Math.ceil((strike.until - now) / 1e3)}s, ${strike.reason}, strike ${strike.strikes})` : "",
3442
+ !providerReady(s, slot.model) ? `UNAVAILABLE (provider-unconfigured: ${providerOf(slot.model)}; run \`opencode auth login ${providerOf(slot.model)}\`)` : "",
3079
3443
  status && status !== "active" && status !== "unknown" ? `catalog:${status}` : ""
3080
3444
  ].filter(Boolean);
3081
3445
  const marker = slot.index === next.index ? " <- next" : "";
@@ -3119,7 +3483,38 @@ one ordered pass in slot order: each slot fills to its weight, then overflow goe
3119
3483
  // different situations and this is the line that tells them apart.
3120
3484
  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.",
3121
3485
  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).",
3122
- coolingNow.length === 0 ? "no models cooling down." : `unavailable/cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
3486
+ coolingNow.length === 0 ? "no models cooling down." : `unavailable/cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`,
3487
+ (() => {
3488
+ const recent = readFailures(s, 5);
3489
+ if (recent.length === 0) {
3490
+ return "recent failures: none recorded";
3491
+ }
3492
+ const all = readFailures(s, 1e3);
3493
+ const total2 = all.length;
3494
+ const fmtAge = (ts) => {
3495
+ const diff = Math.max(0, Date.now() - ts);
3496
+ const sAge = Math.floor(diff / 1e3);
3497
+ if (sAge < 60) return `${sAge}s`;
3498
+ const mAge = Math.floor(sAge / 60);
3499
+ if (mAge < 60) return `${mAge}m`;
3500
+ const hAge = Math.floor(mAge / 60);
3501
+ if (hAge < 24) return `${hAge}h`;
3502
+ const dAge = Math.floor(hAge / 24);
3503
+ return `${dAge}d`;
3504
+ };
3505
+ const lines = [`recent failures: ${total2} recorded in ${s.dir}/failures.jsonl`];
3506
+ const ordered = [...recent].reverse();
3507
+ for (const rec of ordered) {
3508
+ const age = fmtAge(rec.ts);
3509
+ const slot = rec.slot === void 0 || rec.slot === null ? "-" : rec.slot;
3510
+ const model = rec.model;
3511
+ const reason = rec.reason;
3512
+ const source = rec.source;
3513
+ const suffix = source === "observed" ? " (unclassified)" : "";
3514
+ lines.push(` ${age} ago slot ${slot} ${model} ${reason} [${source}]${suffix}`);
3515
+ }
3516
+ return lines.join("\n");
3517
+ })()
3123
3518
  ].join("\n")
3124
3519
  };
3125
3520
  }
@@ -5940,7 +6335,7 @@ import { writeFileSync as writeFileSync8, renameSync as renameSync6, rmSync as r
5940
6335
  import { tool as tool2 } from "@opencode-ai/plugin";
5941
6336
 
5942
6337
  // src/lib/timeline.ts
5943
- import { appendFileSync, mkdirSync as mkdirSync7, readFileSync as readFileSync10, rmSync as rmSync2 } from "node:fs";
6338
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync7, readFileSync as readFileSync10, rmSync as rmSync2 } from "node:fs";
5944
6339
  import { homedir as homedir7 } from "node:os";
5945
6340
  import { dirname as dirname5, join as join11, resolve as resolve4 } from "node:path";
5946
6341
  function timelineDir() {
@@ -5989,7 +6384,7 @@ function appendEntriesAt(file, texts, now, through) {
5989
6384
  const ts = now ?? Date.now();
5990
6385
  const line = { ts, text: t };
5991
6386
  if (validThrough) line.through = validThrough;
5992
- appendFileSync(file, JSON.stringify(line) + "\n");
6387
+ appendFileSync2(file, JSON.stringify(line) + "\n");
5993
6388
  count++;
5994
6389
  }
5995
6390
  return count;
@@ -6529,20 +6924,32 @@ function timelineHooks(input, options) {
6529
6924
  }
6530
6925
  }
6531
6926
  }
6927
+ async function resolveRootSession(id) {
6928
+ let current = id;
6929
+ const seen = /* @__PURE__ */ new Set();
6930
+ while (true) {
6931
+ if (seen.has(current)) return current;
6932
+ seen.add(current);
6933
+ const info = await client.session.get({ path: { id: current }, query: { directory } }).then((result) => result?.data).catch(() => void 0);
6934
+ if (!info?.parentID) return current;
6935
+ current = info.parentID;
6936
+ }
6937
+ }
6532
6938
  async function reprocess(id, abort) {
6533
- if (reprocessing.has(id)) return "A timeline reprocess is already running for this session.";
6534
- if (sessions.get(id)?.running) return "The timeline is currently summarizing this session; try again in a moment.";
6535
- reprocessing.add(id);
6536
- const targetFile = timelineFile(id);
6939
+ const rootId = await resolveRootSession(id);
6940
+ if (reprocessing.has(rootId)) return "A timeline reprocess is already running for this session.";
6941
+ if (sessions.get(rootId)?.running) return "The timeline is currently summarizing this session; try again in a moment.";
6942
+ reprocessing.add(rootId);
6943
+ const targetFile = timelineFile(rootId);
6537
6944
  const temp = targetFile + ".reprocess." + process.pid + "." + Date.now() + ".tmp";
6538
6945
  let cursor;
6539
6946
  let entries = 0;
6540
6947
  let calls = 0;
6541
6948
  try {
6542
- const infoRes = await client.session.get({ path: { id }, query: { directory } });
6949
+ const infoRes = await client.session.get({ path: { id: rootId }, query: { directory } });
6543
6950
  const info = infoRes?.data;
6544
6951
  if (!info) return "Session not found.";
6545
- const msgRes = await client.session.messages({ path: { id }, query: { directory } });
6952
+ const msgRes = await client.session.messages({ path: { id: rootId }, query: { directory } });
6546
6953
  const messages = msgRes?.data ?? [];
6547
6954
  writeFileSync8(temp, "");
6548
6955
  for (let i = 0; i < MAX_REPROCESS_SLICES; i++) {
@@ -6556,7 +6963,7 @@ function timelineHooks(input, options) {
6556
6963
  const excerpt = buildExcerpt(messages, cursor);
6557
6964
  if (!excerpt.text) break;
6558
6965
  const prior = readEntriesAt(temp, 4);
6559
- const result = await summarize(id, info.title ?? "untitled", excerpt.text, prior, abort);
6966
+ const result = await summarize(rootId, info.title ?? "untitled", excerpt.text, prior, abort);
6560
6967
  calls++;
6561
6968
  if (!result.ok) {
6562
6969
  try {
@@ -6585,7 +6992,7 @@ function timelineHooks(input, options) {
6585
6992
  return "No entries generated; previous timeline kept.";
6586
6993
  }
6587
6994
  renameSync6(temp, targetFile);
6588
- const state2 = sessions.get(id);
6995
+ const state2 = sessions.get(rootId);
6589
6996
  if (state2) {
6590
6997
  state2.cursor = cursor;
6591
6998
  state2.failures = 0;
@@ -6599,11 +7006,11 @@ function timelineHooks(input, options) {
6599
7006
  }
6600
7007
  return `Reprocess failed: ${String(error)}. Previous timeline kept.`;
6601
7008
  } finally {
6602
- reprocessing.delete(id);
7009
+ reprocessing.delete(rootId);
6603
7010
  }
6604
7011
  }
6605
7012
  const timelineReprocess = tool2({
6606
- description: "Rebuild the current session's entire timeline from its transcript, replacing the existing entries. Used by /reprocess-timeline.",
7013
+ description: "Rebuild the root session's entire timeline from its transcript, replacing the existing entries. Used by /reprocess-timeline.",
6607
7014
  args: {},
6608
7015
  execute: async (_args, ctx) => {
6609
7016
  const output = await reprocess(ctx.sessionID, ctx.abort);
package/dist/lacode.js CHANGED
@@ -7,7 +7,7 @@ import { spawnSync as spawnSync3 } from "node:child_process";
7
7
 
8
8
  // src/tunnel/index.ts
9
9
  import { spawn, spawnSync } from "node:child_process";
10
- import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, unlinkSync as unlinkSync3 } from "node:fs";
10
+ import { appendFileSync as appendFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, unlinkSync as unlinkSync3 } from "node:fs";
11
11
  import { homedir as homedir3 } from "node:os";
12
12
  import { join as join4 } from "node:path";
13
13
 
@@ -17,7 +17,7 @@ import { homedir as homedir2 } from "node:os";
17
17
  import { basename, join as join3 } from "node:path";
18
18
 
19
19
  // src/lib/pool.ts
20
- import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
20
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
21
21
  import { homedir } from "node:os";
22
22
  import { join as join2 } from "node:path";
23
23
 
@@ -43,9 +43,11 @@ var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
43
43
 
44
44
  // src/lib/pool.ts
45
45
  var CLAIM_TTL_MS = 10 * 60 * 1e3;
46
+ var UNBOUND_CLAIM_MS = 2 * 60 * 1e3;
46
47
  var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
47
48
  var ORPHAN_TMP_MS = 60 * 1e3;
48
49
  var CATALOG_TTL_MS = 10 * 60 * 1e3;
50
+ var PROVIDER_LIST_TTL_MS = 60 * 1e3;
49
51
  var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
50
52
  var STUCK_IDLE_MS = 3 * 60 * 1e3;
51
53
  var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
@@ -262,7 +264,7 @@ var recordNotificationId = (correlationKey, id) => {
262
264
  if (!isNotificationId(id)) return false;
263
265
  try {
264
266
  mkdirSync3(notificationStateDir(), { recursive: true });
265
- appendFileSync(notificationIdFile(correlationKey), `${id}
267
+ appendFileSync2(notificationIdFile(correlationKey), `${id}
266
268
  `, "utf8");
267
269
  return true;
268
270
  } catch {
package/dist/pool.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/lib/pool.ts
2
- import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join as join2 } from "node:path";
5
5
 
@@ -25,9 +25,11 @@ var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
25
25
 
26
26
  // src/lib/pool.ts
27
27
  var CLAIM_TTL_MS = 10 * 60 * 1e3;
28
+ var UNBOUND_CLAIM_MS = 2 * 60 * 1e3;
28
29
  var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
29
30
  var ORPHAN_TMP_MS = 60 * 1e3;
30
31
  var CATALOG_TTL_MS = 10 * 60 * 1e3;
32
+ var PROVIDER_LIST_TTL_MS = 60 * 1e3;
31
33
  var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
32
34
  var STUCK_IDLE_MS = 3 * 60 * 1e3;
33
35
  var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
@@ -294,6 +296,11 @@ function state(opts = {}) {
294
296
  reaping: false,
295
297
  lastSweep: 0,
296
298
  lastDirSweep: 0,
299
+ connected: /* @__PURE__ */ new Set(),
300
+ connectedAt: 0,
301
+ connectedReady: false,
302
+ credentialed: /* @__PURE__ */ new Set(),
303
+ configCreds: /* @__PURE__ */ new Set(),
297
304
  id,
298
305
  dir,
299
306
  modelsFile,
@@ -401,9 +408,16 @@ function sweepStateDir(s) {
401
408
  }
402
409
  return removed;
403
410
  }
411
+ var claimExpired = (claim, now) => now - (claim.hb ?? claim.t) > CLAIM_TTL_MS || !claim.g && now - claim.t > UNBOUND_CLAIM_MS;
404
412
  function pruneOwn(s) {
405
413
  const now = Date.now();
406
- for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) > CLAIM_TTL_MS) s.own.delete(key);
414
+ let removed = false;
415
+ for (const [key, claim] of s.own)
416
+ if (claimExpired(claim, now)) {
417
+ s.own.delete(key);
418
+ removed = true;
419
+ }
420
+ if (removed) publish(s);
407
421
  }
408
422
  function pruneActivity(s, now) {
409
423
  if (s.activity.size === 0) return;
@@ -431,7 +445,7 @@ function publish(s) {
431
445
  if (!s.dirReady) return;
432
446
  const now = Date.now();
433
447
  const payload = {};
434
- for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) <= CLAIM_TTL_MS) payload[key] = claim;
448
+ for (const [key, claim] of s.own) if (!claimExpired(claim, now)) payload[key] = claim;
435
449
  try {
436
450
  writeFileSync(join2(s.dir, `${selfFile(s.id)}.tmp`), JSON.stringify(payload));
437
451
  renameSync(join2(s.dir, `${selfFile(s.id)}.tmp`), join2(s.dir, selfFile(s.id)));
@@ -490,7 +504,7 @@ function readSiblings(s) {
490
504
  try {
491
505
  const parsed = JSON.parse(readFileSync2(path, "utf8"));
492
506
  const claims = Object.values(parsed).filter(
493
- (c) => c && typeof c.v === "string" && typeof c.m === "string" && typeof c.k === "number" && Number.isFinite(c.k) && now - (c.hb ?? c.t) <= CLAIM_TTL_MS
507
+ (c) => c && typeof c.v === "string" && typeof c.m === "string" && typeof c.k === "number" && Number.isFinite(c.k) && !claimExpired(c, now)
494
508
  );
495
509
  siblings.push({ id: match[1], claims });
496
510
  } catch {
@@ -629,6 +643,12 @@ function clearCooldowns(s, models) {
629
643
  var markProbe = (s, sessionID) => s.probes.add(sessionID);
630
644
  var unmarkProbe = (s, sessionID) => s.probes.delete(sessionID);
631
645
  var isProbe = (s, sessionID) => s.probes.has(sessionID);
646
+ function providerReady(s, model) {
647
+ if (!s.connectedReady) return true;
648
+ const provider = providerOf(model);
649
+ if (!s.connected.has(provider)) return false;
650
+ return s.credentialed.has(provider) || s.configCreds.has(provider) || provider === "opencode";
651
+ }
632
652
  function refreshCatalog(s, serverUrl) {
633
653
  if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
634
654
  s.catalogAt = Date.now();
@@ -645,6 +665,7 @@ function refreshCatalog(s, serverUrl) {
645
665
  })();
646
666
  }
647
667
  function isAvailable(s, model, cooling) {
668
+ if (!providerReady(s, model)) return false;
648
669
  if (cooling.has(model)) return false;
649
670
  const status = s.catalog.get(model);
650
671
  return status === void 0 || status === "active" || status === "unknown";
@@ -664,8 +685,9 @@ var loadOf = (counts, slot) => counts.get(slot) ?? 0;
664
685
  var underCeiling = (slot, counts) => loadOf(counts, slot.index) < slotCeiling(slot);
665
686
  function pickSlot(s, snap) {
666
687
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
667
- const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
668
- const pool = usable.length > 0 ? usable : slots;
688
+ const configured = slots.filter((slot) => providerReady(s, slot.model));
689
+ const usable = configured.filter((slot) => isAvailable(s, slot.model, snap.cooling));
690
+ const pool = usable.length > 0 ? usable : configured.length > 0 ? configured : slots;
669
691
  for (const slot of usable) {
670
692
  if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
671
693
  }
@@ -780,6 +802,56 @@ function coolProvider(s, provider, until, reason) {
780
802
  }
781
803
  return affected;
782
804
  }
805
+ function recordFailure(s, f) {
806
+ try {
807
+ const path = join2(s.dir, "failures.jsonl");
808
+ const trimSize = 64 * 1024;
809
+ const keepLines = 200;
810
+ if (existsSync(path)) {
811
+ const st = statSync(path);
812
+ if (typeof st.size === "number" && st.size > trimSize) {
813
+ try {
814
+ const content = readFileSync2(path, "utf8");
815
+ const parts = content.split("\n");
816
+ const lines = parts.filter((line) => line.length > 0);
817
+ if (lines.length > keepLines) {
818
+ const kept = lines.slice(lines.length - keepLines);
819
+ writeFileSync(`${path}.tmp`, kept.join("\n") + "\n");
820
+ renameSync(`${path}.tmp`, path);
821
+ } else if (lines.length === 0 && parts.length >= 1) {
822
+ }
823
+ } catch {
824
+ }
825
+ }
826
+ }
827
+ appendFileSync(path, JSON.stringify(f) + "\n");
828
+ } catch (error) {
829
+ console.error("[agent-pool] failed to record failure:", String(error));
830
+ }
831
+ }
832
+ function readFailures(s, limit) {
833
+ const path = join2(s.dir, "failures.jsonl");
834
+ try {
835
+ if (!existsSync(path)) return [];
836
+ const content = readFileSync2(path, "utf8");
837
+ const parts = content.split("\n");
838
+ const records = [];
839
+ for (let i = 0; i < parts.length; i++) {
840
+ const line = parts[i];
841
+ if (line.length === 0) continue;
842
+ try {
843
+ const parsed = JSON.parse(line);
844
+ records.push(parsed);
845
+ } catch {
846
+ }
847
+ }
848
+ if (limit <= 0) return [];
849
+ if (records.length <= limit) return records;
850
+ return records.slice(records.length - limit);
851
+ } catch {
852
+ return [];
853
+ }
854
+ }
783
855
  export {
784
856
  AUTH_FAILURE_COOLDOWN_MS,
785
857
  AUTH_FAILURE_REASON,
@@ -797,6 +869,7 @@ export {
797
869
  ORPHAN_TMP_MS,
798
870
  PACKAGE_ROOT,
799
871
  PRESETS_DIR,
872
+ PROVIDER_LIST_TTL_MS,
800
873
  QUOTA_COOLDOWN_BASE_MS,
801
874
  QUOTA_COOLDOWN_MAX_MS,
802
875
  SLOTS_WARN_AT,
@@ -806,6 +879,7 @@ export {
806
879
  STUCK_MIN_AGE_MS,
807
880
  UNAVAILABLE_COOLDOWN_MS,
808
881
  UNAVAILABLE_REASON,
882
+ UNBOUND_CLAIM_MS,
809
883
  USER_CONFIG_DIR,
810
884
  USER_MODELS_PATH,
811
885
  USER_MODELS_TXT,
@@ -841,12 +915,15 @@ export {
841
915
  pickSlot,
842
916
  poolPass,
843
917
  providerOf,
918
+ providerReady,
844
919
  pruneOwn,
845
920
  pruneSessions,
846
921
  publish,
922
+ readFailures,
847
923
  readLimits,
848
924
  readSiblings,
849
925
  readSlotFile,
926
+ recordFailure,
850
927
  recordStrike,
851
928
  refreshCatalog,
852
929
  release,
package/dist/tunnel.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/tunnel/index.ts
2
2
  import { spawn, spawnSync } from "node:child_process";
3
- import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, unlinkSync as unlinkSync3 } from "node:fs";
3
+ import { appendFileSync as appendFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, unlinkSync as unlinkSync3 } from "node:fs";
4
4
  import { homedir as homedir3 } from "node:os";
5
5
  import { join as join4 } from "node:path";
6
6
 
@@ -10,7 +10,7 @@ import { homedir as homedir2 } from "node:os";
10
10
  import { basename, join as join3 } from "node:path";
11
11
 
12
12
  // src/lib/pool.ts
13
- import { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
13
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { join as join2 } from "node:path";
16
16
 
@@ -36,9 +36,11 @@ var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
36
36
 
37
37
  // src/lib/pool.ts
38
38
  var CLAIM_TTL_MS = 10 * 60 * 1e3;
39
+ var UNBOUND_CLAIM_MS = 2 * 60 * 1e3;
39
40
  var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
40
41
  var ORPHAN_TMP_MS = 60 * 1e3;
41
42
  var CATALOG_TTL_MS = 10 * 60 * 1e3;
43
+ var PROVIDER_LIST_TTL_MS = 60 * 1e3;
42
44
  var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
43
45
  var STUCK_IDLE_MS = 3 * 60 * 1e3;
44
46
  var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
@@ -255,7 +257,7 @@ var recordNotificationId = (correlationKey, id) => {
255
257
  if (!isNotificationId(id)) return false;
256
258
  try {
257
259
  mkdirSync3(notificationStateDir(), { recursive: true });
258
- appendFileSync(notificationIdFile(correlationKey), `${id}
260
+ appendFileSync2(notificationIdFile(correlationKey), `${id}
259
261
  `, "utf8");
260
262
  return true;
261
263
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "1.2.0",
3
+ "version": "1.3.0-beta.1",
4
4
  "description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
5
5
  "license": "MIT",
6
6
  "type": "module",