@lawaty/lacode 1.2.0 → 1.3.0-beta.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/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
@@ -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
@@ -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
@@ -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
  }
@@ -2439,6 +2461,63 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2439
2461
  s.warned.add(message);
2440
2462
  await log("warn", message);
2441
2463
  };
2464
+ const refreshProviders = async (force = false) => {
2465
+ if (!force && Date.now() - s.connectedAt < PROVIDER_LIST_TTL_MS) return;
2466
+ s.connectedAt = Date.now();
2467
+ try {
2468
+ const providerApi = client.provider;
2469
+ if (!providerApi || typeof providerApi.list !== "function") return;
2470
+ const result = await providerApi.list({ query: { directory } });
2471
+ const data = result?.data;
2472
+ const connected = data?.connected;
2473
+ const all = data?.all;
2474
+ if (Array.isArray(connected) && Array.isArray(all)) {
2475
+ s.connected = new Set(connected.filter((id) => typeof id === "string"));
2476
+ const credentialed = /* @__PURE__ */ new Set();
2477
+ for (const entry of all) {
2478
+ if (!entry || typeof entry !== "object") continue;
2479
+ const rec = entry;
2480
+ const id = typeof rec.id === "string" ? rec.id : void 0;
2481
+ if (!id) continue;
2482
+ if (typeof rec.key === "string" && rec.key.trim() !== "") {
2483
+ credentialed.add(id);
2484
+ continue;
2485
+ }
2486
+ const options2 = rec.options;
2487
+ if (options2 && typeof options2 === "object") {
2488
+ const opts2 = options2;
2489
+ if (typeof opts2.apiKey === "string" && opts2.apiKey.trim() !== "") {
2490
+ credentialed.add(id);
2491
+ continue;
2492
+ }
2493
+ const baseURL = typeof opts2.baseURL === "string" ? opts2.baseURL.trim() : "";
2494
+ if (baseURL !== "") {
2495
+ const envArr2 = Array.isArray(rec.env) ? rec.env : [];
2496
+ const hasCredEnv = envArr2.some((e) => typeof e === "string" && /key|token|secret|password|credential|auth/i.test(e));
2497
+ if (!hasCredEnv) {
2498
+ credentialed.add(id);
2499
+ continue;
2500
+ }
2501
+ }
2502
+ }
2503
+ const envArr = Array.isArray(rec.env) ? rec.env : [];
2504
+ for (const e of envArr) {
2505
+ if (typeof e === "string" && /key|token|secret|password|credential|auth/i.test(e)) {
2506
+ if (typeof process.env[e] === "string" && process.env[e].trim() !== "") {
2507
+ credentialed.add(id);
2508
+ break;
2509
+ }
2510
+ }
2511
+ }
2512
+ }
2513
+ s.credentialed = credentialed;
2514
+ s.connectedReady = true;
2515
+ } else if (Array.isArray(connected)) {
2516
+ s.connected = new Set(connected.filter((id) => typeof id === "string"));
2517
+ }
2518
+ } catch {
2519
+ }
2520
+ };
2442
2521
  init(s, (m) => void warnOnce(m));
2443
2522
  const sweepDirIfDue = () => {
2444
2523
  if (Date.now() - s.lastDirSweep < STATE_SWEEP_INTERVAL_MS) return;
@@ -2475,6 +2554,44 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2475
2554
  if (kind === "permission") return `permission denied after ${seconds}s`;
2476
2555
  return `provider error after ${seconds}s`;
2477
2556
  };
2557
+ const pendingCancelledParts = /* @__PURE__ */ new Map();
2558
+ const patchedCancelledParts = /* @__PURE__ */ new Set();
2559
+ const cancelledPrefix = "Task Cancelled by LaCode:";
2560
+ const patchCancelledPart = async (part, key, report) => {
2561
+ if (patchedCancelledParts.has(key)) return;
2562
+ patchedCancelledParts.add(key);
2563
+ if (patchedCancelledParts.size > 64) {
2564
+ const oldest = patchedCancelledParts.values().next().value;
2565
+ if (oldest !== void 0) patchedCancelledParts.delete(oldest);
2566
+ }
2567
+ const error = `${cancelledPrefix} ${report.message}`;
2568
+ try {
2569
+ const url = new URL(
2570
+ `/session/${encodeURIComponent(part.sessionID)}/message/${encodeURIComponent(part.messageID)}/part/${encodeURIComponent(part.id)}`,
2571
+ serverUrl
2572
+ );
2573
+ if (directory) url.searchParams.set("directory", directory);
2574
+ const headers = { "Content-Type": "application/json" };
2575
+ const password = process.env.OPENCODE_SERVER_PASSWORD;
2576
+ if (password) {
2577
+ const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode";
2578
+ headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
2579
+ }
2580
+ const response = await fetch(url, {
2581
+ method: "PATCH",
2582
+ headers,
2583
+ body: JSON.stringify({ ...part, state: { ...part.state, error } }),
2584
+ signal: AbortSignal.timeout(5e3)
2585
+ });
2586
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim());
2587
+ abortReports.delete(key);
2588
+ } catch (cause) {
2589
+ await log("warn", `could not rewrite cancelled task ${part.callID}: ${cause instanceof Error ? cause.message : String(cause)}`, {
2590
+ session: part.sessionID,
2591
+ callID: part.callID
2592
+ });
2593
+ }
2594
+ };
2478
2595
  const noteAbort = async (key, report) => {
2479
2596
  abortReports.set(key, report);
2480
2597
  if (abortReports.size > 32) {
@@ -2488,11 +2605,10 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2488
2605
  failure: report.kind,
2489
2606
  released: report.released
2490
2607
  });
2491
- try {
2492
- await client.tui?.showToast?.({
2493
- body: { title: "pool spawn", message: report.message, variant: "warning", duration: 8e3 }
2494
- });
2495
- } catch {
2608
+ const pending = pendingCancelledParts.get(key);
2609
+ if (pending) {
2610
+ pendingCancelledParts.delete(key);
2611
+ await patchCancelledPart(pending, key, report);
2496
2612
  }
2497
2613
  };
2498
2614
  const MAX_ABORT_ATTEMPTS = 3;
@@ -2726,6 +2842,12 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2726
2842
  const slots = ensureSlots(s, (m) => void warnOnce(m));
2727
2843
  const agents = cfg.agent;
2728
2844
  const providers = cfg.provider;
2845
+ const configCreds = /* @__PURE__ */ new Set();
2846
+ for (const [id, entry] of Object.entries(providers ?? {})) {
2847
+ const options2 = entry.options;
2848
+ if (typeof options2?.apiKey === "string" && options2.apiKey.trim() !== "") configCreds.add(id);
2849
+ }
2850
+ s.configCreds = configCreds;
2729
2851
  const known = /* @__PURE__ */ new Set();
2730
2852
  for (const [name, def] of Object.entries(cfg.agent ?? {})) if (def?.model) s.models.set(name, def.model);
2731
2853
  for (const slot of slots) {
@@ -2760,6 +2882,11 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2760
2882
  const provider = slot.model.slice(0, slash);
2761
2883
  const id = slot.model.slice(slash + 1);
2762
2884
  const entry = providers?.[provider];
2885
+ if (!providerReady(s, slot.model)) {
2886
+ await warnOnce(
2887
+ `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)`
2888
+ );
2889
+ }
2763
2890
  if (!entry) {
2764
2891
  await warnOnce(`provider ${provider} is not configured in opencode.jsonc; slot ${slot.index} (${slot.model}) cannot resolve`);
2765
2892
  continue;
@@ -2891,12 +3018,49 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2891
3018
  await log("debug", `probe session ${sessionID} ended with an error; not cooling anything`);
2892
3019
  return;
2893
3020
  }
3021
+ const own = claimFor(sessionID);
3022
+ const missing2 = isModelMissingText(haystack2);
2894
3023
  const status2 = typeof error?.data?.statusCode === "number" ? error.data.statusCode : void 0;
2895
3024
  const auth2 = isAuthFailureText(haystack2) || status2 === 401 || status2 === 403;
2896
3025
  const limited2 = isLimitText(haystack2) || status2 === 429;
2897
3026
  const outage = isUnavailableText(haystack2);
2898
- if (!limited2 && !outage && !auth2) return;
2899
- const own = claimFor(sessionID);
3027
+ if (missing2) {
3028
+ const unbound = !own && sessionID ? [...s.own.entries()].find(([, claim]) => !claim.g && claim.s === sessionID && haystack2.includes(claim.m)) : void 0;
3029
+ const target = own ?? (unbound ? { key: unbound[0], claim: unbound[1] } : void 0);
3030
+ const model2 = target?.claim.m;
3031
+ if (model2) {
3032
+ const strike2 = recordStrike(s, model2, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
3033
+ if (strike2) {
3034
+ await log("warn", `bench ${model2} (${MODEL_MISSING_REASON}, strike ${strike2.strikes}) after a terminal model-not-found error`, {
3035
+ model: model2,
3036
+ slot: target?.claim.k,
3037
+ session: sessionID
3038
+ });
3039
+ }
3040
+ }
3041
+ if (target) {
3042
+ release(s, target.key);
3043
+ await log("warn", `released ${target.claim.v} claim on ${target.claim.m} after a terminal model-not-found error`, {
3044
+ base: target.claim.v,
3045
+ model: target.claim.m,
3046
+ slot: target.claim.k,
3047
+ session: sessionID
3048
+ });
3049
+ }
3050
+ return;
3051
+ }
3052
+ if (!limited2 && !outage && !auth2) {
3053
+ if (own) {
3054
+ release(s, own.key);
3055
+ await log("warn", `released ${own.claim.v} claim on ${own.claim.m} after a terminal session error`, {
3056
+ base: own.claim.v,
3057
+ model: own.claim.m,
3058
+ slot: own.claim.k,
3059
+ session: sessionID
3060
+ });
3061
+ }
3062
+ return;
3063
+ }
2900
3064
  const named = auth2 ? AUTH_FAILURE_REASON : outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
2901
3065
  let stated2;
2902
3066
  if (own) {
@@ -2928,6 +3092,15 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2928
3092
  }
2929
3093
  }
2930
3094
  await failOver(sessionID, own?.claim.m ?? "?", named, { stated: stated2, cool: !own });
3095
+ if (own && s.own.has(own.key)) {
3096
+ release(s, own.key);
3097
+ await log("warn", `released ${own.claim.v} claim on ${own.claim.m} after a terminal session error (abort did not confirm)`, {
3098
+ base: own.claim.v,
3099
+ model: own.claim.m,
3100
+ slot: own.claim.k,
3101
+ session: sessionID
3102
+ });
3103
+ }
2931
3104
  return;
2932
3105
  }
2933
3106
  if (event.type === "session.created") {
@@ -2941,6 +3114,24 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2941
3114
  const sessionID = info2?.sessionID ?? part?.sessionID;
2942
3115
  if (sessionID) noteActivity(s, sessionID);
2943
3116
  }
3117
+ if (event.type === "message.part.updated") {
3118
+ const part = props.part;
3119
+ if (part?.type === "tool" && part.tool === "task" && part.state?.status === "error" && part.state.error === "Task cancelled" && part.id && part.sessionID && part.messageID && part.callID) {
3120
+ const key = claimKey(part.sessionID, part.callID);
3121
+ const full = part;
3122
+ const report = abortReports.get(key);
3123
+ if (report) {
3124
+ await patchCancelledPart(full, key, report);
3125
+ } else {
3126
+ pendingCancelledParts.set(key, full);
3127
+ if (pendingCancelledParts.size > 32) {
3128
+ const oldest = pendingCancelledParts.keys().next().value;
3129
+ if (oldest !== void 0) pendingCancelledParts.delete(oldest);
3130
+ }
3131
+ }
3132
+ }
3133
+ return;
3134
+ }
2944
3135
  if (event.type !== "message.updated") return;
2945
3136
  const info = props.info;
2946
3137
  if (!info || info.role !== "assistant" || !info.error) return;
@@ -2950,13 +3141,38 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2950
3141
  const limited = isLimitText(haystack) || status === 429;
2951
3142
  const gateway5xx = status !== void 0 && status >= 500 && status <= 599;
2952
3143
  const transientGateway = gateway5xx || isTransientGatewayText(haystack);
2953
- if (!limited && !transientGateway && !auth) return;
3144
+ const missing = isModelMissingText(haystack);
3145
+ if (!limited && !transientGateway && !auth && !missing) return;
2954
3146
  const model = `${info.providerID ?? "?"}/${info.modelID ?? "?"}`;
2955
3147
  if (info.sessionID && isProbe(s, info.sessionID)) {
2956
3148
  await log("debug", `probe of ${model} hit a limit (${isLimitText(haystack) ? "limit" : "transient"}); leaving the cooldown to the probe`);
2957
3149
  return;
2958
3150
  }
2959
3151
  const ownMsg = claimFor(info.sessionID);
3152
+ if (missing) {
3153
+ const ownMsgMissing = ownMsg;
3154
+ const missingModel = ownMsgMissing?.claim.m ?? model;
3155
+ if (missingModel !== "?/?") {
3156
+ const strike2 = recordStrike(s, missingModel, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
3157
+ if (strike2) {
3158
+ await log("warn", `bench ${missingModel} (${MODEL_MISSING_REASON}, strike ${strike2.strikes}) after a model-not-found error`, {
3159
+ model: missingModel,
3160
+ slot: ownMsgMissing?.claim.k,
3161
+ session: info.sessionID
3162
+ });
3163
+ }
3164
+ }
3165
+ if (ownMsgMissing) {
3166
+ release(s, ownMsgMissing.key);
3167
+ await log("warn", `released ${ownMsgMissing.claim.v} claim on ${ownMsgMissing.claim.m} after a model-not-found error`, {
3168
+ base: ownMsgMissing.claim.v,
3169
+ model: ownMsgMissing.claim.m,
3170
+ slot: ownMsgMissing.claim.k,
3171
+ session: info.sessionID
3172
+ });
3173
+ }
3174
+ return;
3175
+ }
2960
3176
  if (isUnavailableText(haystack) || !limited && !auth && transientGateway) {
2961
3177
  const transient = coolUnavailable(s, model);
2962
3178
  if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
@@ -2991,6 +3207,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2991
3207
  }
2992
3208
  try {
2993
3209
  await reapStuck();
3210
+ await refreshProviders();
2994
3211
  refreshCatalog(s, serverUrl);
2995
3212
  const snap = snapshot(s);
2996
3213
  if (BASES.includes(requested)) {
@@ -3030,7 +3247,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3030
3247
  if (!report) return;
3031
3248
  abortReports.delete(key);
3032
3249
  const detail = [
3033
- `pool spawn: ${report.message}`,
3250
+ `Task Cancelled by LaCode: ${report.message}`,
3034
3251
  `slot ${report.slot}; model ${report.model}; elapsed ${report.elapsedMs}ms; failure ${report.kind}; claim ${report.released ? "released" : "held"}`
3035
3252
  ].join("\n");
3036
3253
  const text = typeof output.output === "string" ? output.output : "";
@@ -3045,6 +3262,7 @@ ${detail}` : detail;
3045
3262
  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
3263
  },
3047
3264
  async execute(args) {
3265
+ await refreshProviders(true);
3048
3266
  const models = args?.models;
3049
3267
  const report = models && models.length > 0 ? await runValidation(models) : await runProbe();
3050
3268
  return { title: `agent-pool: ${reportTitle(report)}`, output: reportText(report) };
@@ -3054,6 +3272,7 @@ ${detail}` : detail;
3054
3272
  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
3273
  args: {},
3056
3274
  async execute() {
3275
+ await refreshProviders(true);
3057
3276
  refreshCatalog(s, serverUrl);
3058
3277
  const slots = ensureSlots(s, (m) => void warnOnce(m));
3059
3278
  const snap = snapshot(s);
@@ -3076,6 +3295,7 @@ ${detail}` : detail;
3076
3295
  const status = s.catalog.get(slot.model);
3077
3296
  const notes = [
3078
3297
  strike ? `UNAVAILABLE (COOLING ${Math.ceil((strike.until - now) / 1e3)}s, ${strike.reason}, strike ${strike.strikes})` : "",
3298
+ !providerReady(s, slot.model) ? `UNAVAILABLE (provider-unconfigured: ${providerOf(slot.model)}; run \`opencode auth login ${providerOf(slot.model)}\`)` : "",
3079
3299
  status && status !== "active" && status !== "unknown" ? `catalog:${status}` : ""
3080
3300
  ].filter(Boolean);
3081
3301
  const marker = slot.index === next.index ? " <- next" : "";
@@ -6529,20 +6749,32 @@ function timelineHooks(input, options) {
6529
6749
  }
6530
6750
  }
6531
6751
  }
6752
+ async function resolveRootSession(id) {
6753
+ let current = id;
6754
+ const seen = /* @__PURE__ */ new Set();
6755
+ while (true) {
6756
+ if (seen.has(current)) return current;
6757
+ seen.add(current);
6758
+ const info = await client.session.get({ path: { id: current }, query: { directory } }).then((result) => result?.data).catch(() => void 0);
6759
+ if (!info?.parentID) return current;
6760
+ current = info.parentID;
6761
+ }
6762
+ }
6532
6763
  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);
6764
+ const rootId = await resolveRootSession(id);
6765
+ if (reprocessing.has(rootId)) return "A timeline reprocess is already running for this session.";
6766
+ if (sessions.get(rootId)?.running) return "The timeline is currently summarizing this session; try again in a moment.";
6767
+ reprocessing.add(rootId);
6768
+ const targetFile = timelineFile(rootId);
6537
6769
  const temp = targetFile + ".reprocess." + process.pid + "." + Date.now() + ".tmp";
6538
6770
  let cursor;
6539
6771
  let entries = 0;
6540
6772
  let calls = 0;
6541
6773
  try {
6542
- const infoRes = await client.session.get({ path: { id }, query: { directory } });
6774
+ const infoRes = await client.session.get({ path: { id: rootId }, query: { directory } });
6543
6775
  const info = infoRes?.data;
6544
6776
  if (!info) return "Session not found.";
6545
- const msgRes = await client.session.messages({ path: { id }, query: { directory } });
6777
+ const msgRes = await client.session.messages({ path: { id: rootId }, query: { directory } });
6546
6778
  const messages = msgRes?.data ?? [];
6547
6779
  writeFileSync8(temp, "");
6548
6780
  for (let i = 0; i < MAX_REPROCESS_SLICES; i++) {
@@ -6556,7 +6788,7 @@ function timelineHooks(input, options) {
6556
6788
  const excerpt = buildExcerpt(messages, cursor);
6557
6789
  if (!excerpt.text) break;
6558
6790
  const prior = readEntriesAt(temp, 4);
6559
- const result = await summarize(id, info.title ?? "untitled", excerpt.text, prior, abort);
6791
+ const result = await summarize(rootId, info.title ?? "untitled", excerpt.text, prior, abort);
6560
6792
  calls++;
6561
6793
  if (!result.ok) {
6562
6794
  try {
@@ -6585,7 +6817,7 @@ function timelineHooks(input, options) {
6585
6817
  return "No entries generated; previous timeline kept.";
6586
6818
  }
6587
6819
  renameSync6(temp, targetFile);
6588
- const state2 = sessions.get(id);
6820
+ const state2 = sessions.get(rootId);
6589
6821
  if (state2) {
6590
6822
  state2.cursor = cursor;
6591
6823
  state2.failures = 0;
@@ -6599,11 +6831,11 @@ function timelineHooks(input, options) {
6599
6831
  }
6600
6832
  return `Reprocess failed: ${String(error)}. Previous timeline kept.`;
6601
6833
  } finally {
6602
- reprocessing.delete(id);
6834
+ reprocessing.delete(rootId);
6603
6835
  }
6604
6836
  }
6605
6837
  const timelineReprocess = tool2({
6606
- description: "Rebuild the current session's entire timeline from its transcript, replacing the existing entries. Used by /reprocess-timeline.",
6838
+ description: "Rebuild the root session's entire timeline from its transcript, replacing the existing entries. Used by /reprocess-timeline.",
6607
6839
  args: {},
6608
6840
  execute: async (_args, ctx) => {
6609
6841
  const output = await reprocess(ctx.sessionID, ctx.abort);
package/dist/lacode.js CHANGED
@@ -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;
package/dist/pool.js CHANGED
@@ -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
  }
@@ -797,6 +819,7 @@ export {
797
819
  ORPHAN_TMP_MS,
798
820
  PACKAGE_ROOT,
799
821
  PRESETS_DIR,
822
+ PROVIDER_LIST_TTL_MS,
800
823
  QUOTA_COOLDOWN_BASE_MS,
801
824
  QUOTA_COOLDOWN_MAX_MS,
802
825
  SLOTS_WARN_AT,
@@ -806,6 +829,7 @@ export {
806
829
  STUCK_MIN_AGE_MS,
807
830
  UNAVAILABLE_COOLDOWN_MS,
808
831
  UNAVAILABLE_REASON,
832
+ UNBOUND_CLAIM_MS,
809
833
  USER_CONFIG_DIR,
810
834
  USER_MODELS_PATH,
811
835
  USER_MODELS_TXT,
@@ -841,6 +865,7 @@ export {
841
865
  pickSlot,
842
866
  poolPass,
843
867
  providerOf,
868
+ providerReady,
844
869
  pruneOwn,
845
870
  pruneSessions,
846
871
  publish,
package/dist/tunnel.js CHANGED
@@ -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;
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.0",
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",