@lawaty/lacode 1.1.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/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;
@@ -35,9 +37,9 @@ var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
35
37
  var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
36
38
  var UNAVAILABLE_REASON = "endpoint-unavailable";
37
39
  var MAX_WEIGHT = 100;
38
- var SLOT_CEILING_FACTOR = 2;
39
- var slotCeiling = (slot) => slot.weight * SLOT_CEILING_FACTOR;
40
- var MAX_SLOTS = 4;
40
+ var slotCeiling = (slot) => slot.weight;
41
+ var slotBucket = (slot) => slot.bucket ?? slot.model;
42
+ var SLOTS_WARN_AT = 64;
41
43
  var COOLDOWN_BASE_MS = 60 * 1e3;
42
44
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
43
45
  var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
@@ -47,8 +49,12 @@ var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
47
49
  var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
48
50
  var MODEL_MISSING_REASON = "model-missing";
49
51
  var MODEL_MISSING_COOLDOWN_MS = QUOTA_COOLDOWN_MAX_MS;
52
+ var AUTH_FAILURE_REASON = "auth-failed";
53
+ var AUTH_FAILURE_COOLDOWN_MS = QUOTA_COOLDOWN_MAX_MS;
50
54
  var MODEL_MISSING_PATTERN = /model\s+(?:not\s+found|does\s+not\s+exist|is\s+not\s+available)|(?:unknown|no\s+such)\s+model/i;
51
55
  var isModelMissingText = (text) => MODEL_MISSING_PATTERN.test(text);
56
+ var AUTH_FAILURE_PATTERN = /\b401\b|\b403\b|unauthori[sz]ed|not\s+authori[sz]ed|invalid[\s_-]+(?:api[\s_-]?(?:key|token)|token|credential|bearer)|api[\s_-]?key\s+(?:is\s+)?(?:invalid|missing|expired|not\s+valid)|authentication\s+(?:failed|error|required)|incorrect[\s_-]+api[\s_-]?key/i;
57
+ var isAuthFailureText = (text) => AUTH_FAILURE_PATTERN.test(text);
52
58
  var BASES = ["explore", "implement", "context"];
53
59
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
54
60
  var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
@@ -71,15 +77,18 @@ function parseSlots(parsed, origin) {
71
77
  entries.forEach((entry, i) => {
72
78
  const model = typeof entry?.model === "string" ? entry.model.trim() : "";
73
79
  const at = `slot ${i + 1}`;
74
- if (slots.length >= MAX_SLOTS) {
75
- problems.push(`${at} (${model || "?"}): the pool is capped at ${MAX_SLOTS} slots (one variant agent per base per slot is declared); entry ignored`);
76
- return;
77
- }
78
80
  if (!model.includes("/")) return problems.push(`${at}: model must be written provider/model-id, got "${model}"`);
79
81
  const weight = readWeight(entry, at, model, problems);
80
- if (slots.some((s) => s.model === model)) return problems.push(`${at}: ${model} is already used by another slot; slots must be distinct models`);
81
- slots.push({ index: slots.length + 1, model, weight });
82
+ const rawBucket = entry?.bucket;
83
+ const bucket = typeof rawBucket === "string" ? rawBucket.trim() : void 0;
84
+ if (rawBucket !== void 0 && !bucket) {
85
+ problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
86
+ }
87
+ slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {} });
82
88
  });
89
+ if (slots.length > SLOTS_WARN_AT) {
90
+ problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
91
+ }
83
92
  if (slots.length === 0) {
84
93
  problems.push(`${origin} yielded no usable slot; using built-in defaults`);
85
94
  return { slots: FALLBACK_SLOTS, problems };
@@ -91,8 +100,17 @@ function parseTextSlots(text, origin) {
91
100
  for (const raw of text.split(/\r?\n/)) {
92
101
  const line = raw.split("#")[0].trim();
93
102
  if (!line) continue;
94
- const [model, weight] = line.split(/\s+/);
95
- entries.push(weight === void 0 ? { model } : { model, weight });
103
+ const [model, ...rest] = line.split(/\s+/);
104
+ let weight;
105
+ let bucket;
106
+ for (const token of rest) {
107
+ if (token.startsWith("b:")) {
108
+ if (bucket === void 0) bucket = token.slice(2);
109
+ } else if (weight === void 0) {
110
+ weight = token;
111
+ }
112
+ }
113
+ entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket } });
96
114
  }
97
115
  return parseSlots({ slots: entries }, origin);
98
116
  }
@@ -236,9 +254,17 @@ var retryAfterMs = (headers) => {
236
254
  for (const [key, value] of Object.entries(headers)) {
237
255
  if (key.toLowerCase() !== "retry-after") continue;
238
256
  const seconds = Number(value);
239
- if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds * 1e3, COOLDOWN_MAX_MS);
257
+ if (Number.isFinite(seconds) && seconds > 0) {
258
+ const ms = seconds * 1e3;
259
+ if (ms > STATED_COOLDOWN_MAX_MS) return void 0;
260
+ return ms;
261
+ }
240
262
  const when = Date.parse(value);
241
- if (Number.isFinite(when)) return Math.min(Math.max(when - Date.now(), 1e3), COOLDOWN_MAX_MS);
263
+ if (Number.isFinite(when)) {
264
+ const delay = when - Date.now();
265
+ if (delay > STATED_COOLDOWN_MAX_MS) return void 0;
266
+ return Math.max(delay, 1e3);
267
+ }
242
268
  }
243
269
  return void 0;
244
270
  };
@@ -270,6 +296,11 @@ function state(opts = {}) {
270
296
  reaping: false,
271
297
  lastSweep: 0,
272
298
  lastDirSweep: 0,
299
+ connected: /* @__PURE__ */ new Set(),
300
+ connectedAt: 0,
301
+ connectedReady: false,
302
+ credentialed: /* @__PURE__ */ new Set(),
303
+ configCreds: /* @__PURE__ */ new Set(),
273
304
  id,
274
305
  dir,
275
306
  modelsFile,
@@ -288,7 +319,7 @@ function ensureSlots(s, onWarn) {
288
319
  for (const problem of problems) onWarn(problem);
289
320
  return slots;
290
321
  }
291
- var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}`).join("|")}`;
322
+ var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}`).join("|")}`;
292
323
  function reloadSlots(s, onWarn) {
293
324
  const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
294
325
  const changed = slotSignature(origin, slots) !== slotSignature(s.origin, s.slots);
@@ -377,9 +408,16 @@ function sweepStateDir(s) {
377
408
  }
378
409
  return removed;
379
410
  }
411
+ var claimExpired = (claim, now) => now - (claim.hb ?? claim.t) > CLAIM_TTL_MS || !claim.g && now - claim.t > UNBOUND_CLAIM_MS;
380
412
  function pruneOwn(s) {
381
413
  const now = Date.now();
382
- 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);
383
421
  }
384
422
  function pruneActivity(s, now) {
385
423
  if (s.activity.size === 0) return;
@@ -407,7 +445,7 @@ function publish(s) {
407
445
  if (!s.dirReady) return;
408
446
  const now = Date.now();
409
447
  const payload = {};
410
- 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;
411
449
  try {
412
450
  writeFileSync(join2(s.dir, `${selfFile(s.id)}.tmp`), JSON.stringify(payload));
413
451
  renameSync(join2(s.dir, `${selfFile(s.id)}.tmp`), join2(s.dir, selfFile(s.id)));
@@ -466,7 +504,7 @@ function readSiblings(s) {
466
504
  try {
467
505
  const parsed = JSON.parse(readFileSync2(path5, "utf8"));
468
506
  const claims = Object.values(parsed).filter(
469
- (c) => c && typeof c.v === "string" && typeof c.m === "string" && 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)
470
508
  );
471
509
  siblings.push({ id: match[1], claims });
472
510
  } catch {
@@ -516,20 +554,32 @@ function recordStrike(s, model, reason, stated) {
516
554
  if (!s.dirReady) return;
517
555
  if (reason === UNAVAILABLE_REASON) return coolUnavailable(s, model);
518
556
  const now = Date.now();
519
- const prior = s.strikes.get(model);
557
+ const slotsForModel = s.slots.filter((candidate) => candidate.model === model);
558
+ const buckets = [...new Set(slotsForModel.map((slot) => slotBucket(slot)))];
559
+ const targets = buckets.length === 0 ? [model] : [...new Set(
560
+ s.slots.filter((candidate) => buckets.includes(slotBucket(candidate))).map((candidate) => candidate.model)
561
+ )];
562
+ if (targets.length === 0) targets.push(model);
563
+ const prior = targets.map((target) => s.strikes.get(target)).filter((candidate) => Boolean(candidate)).reduce((worst, candidate) => !worst || candidate.strikes > worst.strikes ? candidate : worst, void 0);
520
564
  const strikes = prior && now - prior.last < STRIKE_DECAY_MS ? prior.strikes + 1 : 1;
521
565
  const quota = isQuotaReason(reason);
522
566
  const base = quota ? QUOTA_COOLDOWN_BASE_MS : COOLDOWN_BASE_MS;
523
567
  const cap = quota ? QUOTA_COOLDOWN_MAX_MS : COOLDOWN_MAX_MS;
524
568
  const backoff = Math.min(cap, base * 2 ** (strikes - 1));
525
- const strike = { model, until: now + (stated ?? backoff), strikes, reason, last: now };
526
- s.cooling.set(model, strike);
527
- s.strikes.set(model, strike);
528
- const path5 = join2(s.dir, limitFile(model));
529
- try {
530
- writeFileSync(`${path5}.tmp`, JSON.stringify(strike));
531
- renameSync(`${path5}.tmp`, path5);
532
- } catch {
569
+ const statedOk = typeof stated === "number" && Number.isFinite(stated) && stated > 0 && stated <= STATED_COOLDOWN_MAX_MS;
570
+ const until = now + (statedOk ? stated : backoff);
571
+ let strike;
572
+ for (const target of targets) {
573
+ const record = { model: target, until, strikes, reason, last: now };
574
+ s.cooling.set(target, record);
575
+ s.strikes.set(target, record);
576
+ const path5 = join2(s.dir, limitFile(target));
577
+ try {
578
+ writeFileSync(`${path5}.tmp`, JSON.stringify(record));
579
+ renameSync(`${path5}.tmp`, path5);
580
+ } catch {
581
+ }
582
+ if (target === model) strike = record;
533
583
  }
534
584
  return strike;
535
585
  }
@@ -593,6 +643,12 @@ function clearCooldowns(s, models) {
593
643
  var markProbe = (s, sessionID) => s.probes.add(sessionID);
594
644
  var unmarkProbe = (s, sessionID) => s.probes.delete(sessionID);
595
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
+ }
596
652
  function refreshCatalog(s, serverUrl) {
597
653
  if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
598
654
  s.catalogAt = Date.now();
@@ -609,6 +665,7 @@ function refreshCatalog(s, serverUrl) {
609
665
  })();
610
666
  }
611
667
  function isAvailable(s, model, cooling) {
668
+ if (!providerReady(s, model)) return false;
612
669
  if (cooling.has(model)) return false;
613
670
  const status = s.catalog.get(model);
614
671
  return status === void 0 || status === "active" || status === "unknown";
@@ -619,30 +676,27 @@ function snapshot(s) {
619
676
  const siblings = readSiblings(s);
620
677
  const cooling = readLimits(s);
621
678
  const counts = /* @__PURE__ */ new Map();
622
- const bump = (m) => counts.set(m, (counts.get(m) ?? 0) + 1);
623
- for (const claim of s.own.values()) bump(claim.m);
624
- for (const sibling of siblings) for (const claim of sibling.claims) bump(claim.m);
679
+ const bump = (k) => counts.set(k, (counts.get(k) ?? 0) + 1);
680
+ for (const claim of s.own.values()) bump(claim.k);
681
+ for (const sibling of siblings) for (const claim of sibling.claims) bump(claim.k);
625
682
  return { counts, cooling, siblings, own: s.own, dirReady: s.dirReady };
626
683
  }
627
- var loadOf = (counts, model) => counts.get(model) ?? 0;
628
- var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
684
+ var loadOf = (counts, slot) => counts.get(slot) ?? 0;
685
+ var underCeiling = (slot, counts) => loadOf(counts, slot.index) < slotCeiling(slot);
629
686
  function pickSlot(s, snap) {
630
687
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
631
- const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
632
- const pool = usable.length > 0 ? usable : slots;
633
- for (const slot of usable) {
634
- if (loadOf(snap.counts, slot.model) < slot.weight) return slot;
635
- }
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;
636
691
  for (const slot of usable) {
637
- if (loadOf(snap.counts, slot.model) < slotCeiling(slot)) return slot;
692
+ if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
638
693
  }
639
- return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
694
+ return pool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, pool[0]);
640
695
  }
641
696
  function poolPass(s, snap) {
642
697
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
643
698
  const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
644
- if (usable.some((slot) => loadOf(snap.counts, slot.model) < slot.weight)) return 1;
645
- if (usable.some((slot) => loadOf(snap.counts, slot.model) < slotCeiling(slot))) return 2;
699
+ if (usable.some((slot) => loadOf(snap.counts, slot.index) < slot.weight)) return 1;
646
700
  return "overflow";
647
701
  }
648
702
  function agentFor(base, slot) {
@@ -1677,12 +1731,12 @@ Report briefly what you changed and where. Do not edit any other file.`,
1677
1731
  }
1678
1732
  }
1679
1733
  };
1680
- function buildAgentSet() {
1734
+ function buildAgentSet(slotCount = FALLBACK_SLOTS.length) {
1681
1735
  const set = { ...AGENTS };
1682
1736
  for (const base of POOLED_BASES) {
1683
1737
  const def = AGENTS[base];
1684
1738
  if (!def) continue;
1685
- for (let slot = 1; slot <= MAX_SLOTS; slot++) {
1739
+ for (let slot = 1; slot <= Math.max(1, slotCount); slot++) {
1686
1740
  const variant = {
1687
1741
  ...def,
1688
1742
  description: `Hidden pool slot ${slot} for ${base}. Its model is NOT set here -- LaCode injects it from the resolved pool preset at config time, so the preset is the only place a pool model is written down. Spawn '${base}' and let the pool route.`,
@@ -1839,7 +1893,7 @@ Follow the rules for the file in your system prompt, then report what you change
1839
1893
  Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
1840
1894
 
1841
1895
  1. Pool status \u2014 call the \`pool_status\` tool. Report the slot list, each
1842
- slot's load against its ceiling (twice the slot's weight), and which models
1896
+ slot's load against its budget (its weight), and which models
1843
1897
  are cooling.
1844
1898
 
1845
1899
  2. OmniRoute configured \u2014 inspect the \`@lawaty/lacode\` plugin options in the
@@ -1876,10 +1930,10 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
1876
1930
 
1877
1931
  2. Resolve the instruction above into a target slot table. These are the rules the loader enforces \u2014 never write a file that breaks one:
1878
1932
  - one slot per line: \`<provider>/<model-id> [weight]\`
1879
- - at most ${MAX_SLOTS} slots, and a model may appear only once
1880
- - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's active-session budget for one pass \u2014 how many turns it takes per cycle \u2014 and its concurrency ceiling is twice that. NOT a priority; pick order follows line order.
1933
+ - any number of slots; a model may appear on more than one line, and each line is its own slot with its own budget
1934
+ - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's active-session budget \u2014 how many concurrent sessions it may hold. The pool's total capacity is the sum of the weights. NOT a priority; pick order follows line order.
1881
1935
  - \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
1882
- If the instruction names a model ambiguously, or would need more than ${MAX_SLOTS} slots, stop and ask rather than guessing.
1936
+ If the instruction names a model ambiguously, stop and ask rather than guessing.
1883
1937
 
1884
1938
  3. Write the WHOLE file, not a patch. This file is an override, not an edit target: any slot you drop disappears, and slot numbers are line positions, so a partial write silently renumbers slots. Keep the existing comment header. If the write fails on a missing directory, create \`~/.config/lacode\` first and retry.
1885
1939
 
@@ -1905,6 +1959,11 @@ $ARGUMENTS
1905
1959
 
1906
1960
  Report the tool's output as it came back, then add one short line naming what changed: re-admitted, still limited, or benched because the provider does not serve the model. If a model was benched as \`model-missing\`, say so and name the replacement in \`~/.config/lacode/pool.txt\` \u2014 but change nothing yourself.`
1907
1961
  },
1962
+ "reprocess-timeline": {
1963
+ description: "Rebuild this session's timeline from its full transcript",
1964
+ agent: "implement",
1965
+ template: "Call the `timeline_reprocess` tool exactly once with no arguments. Then reply with only its output."
1966
+ },
1908
1967
  "image-in-cb": {
1909
1968
  description: "Attach the clipboard image to this message \u2014 macOS needs pngpaste, Linux needs wl-paste or xclip.",
1910
1969
  template: "$ARGUMENTS"
@@ -2198,6 +2257,7 @@ import { tool } from "@opencode-ai/plugin";
2198
2257
  var PROBE_SYSTEM = "You are a connection health check. Reply with the single word: ok";
2199
2258
  var PROBE_PROMPT = "ok";
2200
2259
  var PROBE_TIMEOUT_MS = 45e3;
2260
+ var REASON_UNRESPONSIVE = "no answer";
2201
2261
  var failureText = (error) => {
2202
2262
  if (error === void 0 || error === null) return "";
2203
2263
  if (typeof error === "string") return error;
@@ -2210,32 +2270,35 @@ var failureText = (error) => {
2210
2270
  var shortReason = (text) => {
2211
2271
  if (!text) return "failed";
2212
2272
  if (isModelMissingText(text)) return MODEL_MISSING_REASON;
2273
+ if (isAuthFailureText(text)) return AUTH_FAILURE_REASON;
2213
2274
  return limitReason(text) ?? (isLimitText(text) ? "limit" : "failed");
2214
2275
  };
2215
2276
  var probeOne = async (client, opts, model, leftSeconds) => {
2216
2277
  const { state: s, directory } = opts;
2217
2278
  const { provider, id: modelID } = splitModel(model);
2218
- const settle = (ok, reason, left) => ({
2279
+ const settle = (ok, reason, left, kind, strikes) => ({
2219
2280
  model,
2220
2281
  ok,
2221
2282
  reason,
2222
2283
  hadSeconds: leftSeconds,
2223
- leftSeconds: left
2284
+ leftSeconds: left,
2285
+ kind,
2286
+ strikes
2224
2287
  });
2225
2288
  const created = await client.session.create({ body: { title: `pool probe (${model})` }, query: { directory } }).then((r) => r?.data?.id).catch(() => void 0);
2226
- if (!created) return settle(false, "no probe session", leftSeconds);
2289
+ if (!created) return settle(false, "no probe session", leftSeconds, "cooling");
2227
2290
  markProbe(s, created);
2228
2291
  let timer;
2229
2292
  try {
2230
2293
  const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
2231
2294
  const clock = opts.now ?? Date.now;
2232
2295
  let timedOut = false;
2233
- const deadline = new Promise((resolve4) => {
2296
+ const deadline = new Promise((resolve5) => {
2234
2297
  timer = setTimeout(() => {
2235
2298
  timedOut = true;
2236
2299
  void client.session.abort({ path: { id: created }, query: { directory } }).catch(() => {
2237
2300
  });
2238
- resolve4(void 0);
2301
+ resolve5(void 0);
2239
2302
  }, timeoutMs);
2240
2303
  });
2241
2304
  const answer = client.session.prompt({
@@ -2254,20 +2317,37 @@ var probeOne = async (client, opts, model, leftSeconds) => {
2254
2317
  const failure = result?.error ?? result?.data?.info?.error;
2255
2318
  if (!timedOut && failure === void 0 && result?.data?.info !== void 0) {
2256
2319
  clearCooldowns(s, [model]);
2257
- return settle(true, "answered", 0);
2320
+ return settle(true, "answered", 0, "answered");
2258
2321
  }
2259
2322
  const text = failureText(failure);
2323
+ let kind = "cooling";
2324
+ let strikes;
2260
2325
  if (isModelMissingText(text)) {
2261
- recordStrike(s, model, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
2326
+ const recorded = recordStrike(s, model, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
2327
+ strikes = recorded?.strikes;
2328
+ kind = "benched";
2329
+ } else if (isAuthFailureText(text)) {
2330
+ const recorded = recordStrike(s, model, AUTH_FAILURE_REASON, AUTH_FAILURE_COOLDOWN_MS);
2331
+ strikes = recorded?.strikes;
2332
+ kind = "benched";
2333
+ } else if (timedOut || text === "") {
2334
+ const recorded = recordStrike(s, model, REASON_UNRESPONSIVE, 0);
2335
+ strikes = recorded?.strikes;
2336
+ kind = "benched";
2262
2337
  } else {
2263
2338
  const stated = statedCooldown(text);
2264
- if (stated !== void 0) recordStrike(s, model, shortReason(text), stated);
2339
+ if (stated !== void 0) {
2340
+ const recorded = recordStrike(s, model, shortReason(text), stated);
2341
+ strikes = recorded?.strikes;
2342
+ }
2265
2343
  }
2266
2344
  const still = readLimits(s).get(model);
2267
2345
  return settle(
2268
2346
  false,
2269
2347
  timedOut ? `no answer in ${Math.round(timeoutMs / 1e3)}s` : shortReason(text),
2270
- still && still.until > clock() ? Math.ceil((still.until - clock()) / 1e3) : 0
2348
+ still && still.until > clock() ? Math.ceil((still.until - clock()) / 1e3) : 0,
2349
+ kind,
2350
+ strikes
2271
2351
  );
2272
2352
  } finally {
2273
2353
  if (timer) clearTimeout(timer);
@@ -2352,6 +2432,24 @@ var SLOT_WATCH_MS = 1e3;
2352
2432
  var SLOT_WATCH_DEBOUNCE_MS = 250;
2353
2433
  var ABORT_TIMEOUT_MS = 1e4;
2354
2434
  var formatWindow = (seconds) => seconds < 60 ? `${seconds}s` : `${Math.ceil(seconds / 60)}m`;
2435
+ var reportText = (report) => {
2436
+ if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
2437
+ const answered = report.outcomes.filter((outcome) => outcome.ok).length;
2438
+ const benched = report.outcomes.filter((outcome) => outcome.kind === "benched").length;
2439
+ const cooling = report.outcomes.length - answered - benched;
2440
+ return [
2441
+ ...report.outcomes.map((outcome) => {
2442
+ if (outcome.ok) return `${outcome.model}: answered, cooldown cleared`;
2443
+ if (outcome.kind === "benched") {
2444
+ const window = outcome.leftSeconds > 0 ? ` for ${Math.round(outcome.leftSeconds)}s` : "";
2445
+ const strike = outcome.strikes !== void 0 ? ` (strike ${outcome.strikes})` : "";
2446
+ return `${outcome.model}: ${outcome.reason} \u2014 benched${window}${strike}`;
2447
+ }
2448
+ return `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`;
2449
+ }),
2450
+ `summary: ${answered} answered, ${benched} benched, ${cooling} still cooling`
2451
+ ].join("\n");
2452
+ };
2355
2453
  var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2356
2454
  const opts = options ?? {};
2357
2455
  const s = state(opts);
@@ -2363,6 +2461,63 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2363
2461
  s.warned.add(message);
2364
2462
  await log("warn", message);
2365
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
+ };
2366
2521
  init(s, (m) => void warnOnce(m));
2367
2522
  const sweepDirIfDue = () => {
2368
2523
  if (Date.now() - s.lastDirSweep < STATE_SWEEP_INTERVAL_MS) return;
@@ -2389,6 +2544,73 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2389
2544
  return { ok: false, why: String(error) };
2390
2545
  }
2391
2546
  };
2547
+ const abortReports = /* @__PURE__ */ new Map();
2548
+ const abortKind = (reason) => isQuotaReason(reason) || isLimitText(reason) ? "rate-limit" : "provider error";
2549
+ const abortLabel = (kind, elapsedMs) => {
2550
+ const seconds = Math.max(1, Math.round(elapsedMs / 1e3));
2551
+ if (kind === "no-answer") return `no answer in ${seconds}s`;
2552
+ if (kind === "rate-limit") return `rate limited after ${seconds}s`;
2553
+ if (kind === "empty response") return `empty response after ${seconds}s`;
2554
+ if (kind === "permission") return `permission denied after ${seconds}s`;
2555
+ return `provider error after ${seconds}s`;
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
+ };
2595
+ const noteAbort = async (key, report) => {
2596
+ abortReports.set(key, report);
2597
+ if (abortReports.size > 32) {
2598
+ const oldest = abortReports.keys().next().value;
2599
+ if (oldest !== void 0) abortReports.delete(oldest);
2600
+ }
2601
+ await log("warn", report.message, {
2602
+ slot: report.slot,
2603
+ model: report.model,
2604
+ elapsedMs: report.elapsedMs,
2605
+ failure: report.kind,
2606
+ released: report.released
2607
+ });
2608
+ const pending = pendingCancelledParts.get(key);
2609
+ if (pending) {
2610
+ pendingCancelledParts.delete(key);
2611
+ await patchCancelledPart(pending, key, report);
2612
+ }
2613
+ };
2392
2614
  const MAX_ABORT_ATTEMPTS = 3;
2393
2615
  const coolForHang = async (claim, log2) => {
2394
2616
  const now = Date.now();
@@ -2444,6 +2666,16 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2444
2666
  const kill = await abortSession(sessionID);
2445
2667
  if (kill.ok) {
2446
2668
  release(s, key);
2669
+ const elapsedMs = Date.now() - claim.t;
2670
+ const kind = abortKind(reason);
2671
+ await noteAbort(key, {
2672
+ slot: claim.k,
2673
+ model: claim.m,
2674
+ elapsedMs,
2675
+ kind,
2676
+ released: true,
2677
+ message: `slot ${claim.k} ${claim.m} \u2014 ${abortLabel(kind, elapsedMs)}; claim released`
2678
+ });
2447
2679
  await log(
2448
2680
  "warn",
2449
2681
  `limit (${reason}) on ${model}; aborted in-flight ${claim.v} session ${sessionID} to fail over now`,
@@ -2512,6 +2744,15 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2512
2744
  reason === "overran" ? `overran ${claim.v} on ${claim.m} after ${age}s; aborted session ${claim.g}` : `hung ${claim.v} on ${claim.m} after ${age}s with ${since}; aborted session ${claim.g}`,
2513
2745
  { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: true }
2514
2746
  );
2747
+ const elapsedMs = Date.now() - claim.t;
2748
+ await noteAbort(key, {
2749
+ slot: claim.k,
2750
+ model: claim.m,
2751
+ elapsedMs,
2752
+ kind: "no-answer",
2753
+ released: true,
2754
+ message: `slot ${claim.k} ${claim.m} \u2014 ${reason === "overran" ? `overran after ${Math.max(1, Math.round(elapsedMs / 1e3))}s` : abortLabel("no-answer", elapsedMs)}; claim released`
2755
+ });
2515
2756
  await coolForHang(claim, log);
2516
2757
  continue;
2517
2758
  }
@@ -2563,17 +2804,10 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2563
2804
  if (report.outcomes.length === 0) return "nothing cooling to probe";
2564
2805
  const parts = [`probed ${report.outcomes.length} model(s)`, `re-admitted ${report.cleared.length}`];
2565
2806
  if (report.missing.length > 0) parts.push(`benched ${report.missing.length} missing`);
2807
+ const authBenched = report.outcomes.filter((outcome) => outcome.reason === AUTH_FAILURE_REASON).length;
2808
+ if (authBenched > 0) parts.push(`benched ${authBenched} auth-failed`);
2566
2809
  return parts.join(", ");
2567
2810
  };
2568
- const reportText = (report) => {
2569
- if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
2570
- return [
2571
- ...report.outcomes.map(
2572
- (outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the pool` : outcome.reason === MODEL_MISSING_REASON ? `${outcome.model}: not served by the provider (${outcome.reason}) \u2014 benched${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}` : `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`
2573
- ),
2574
- report.cleared.length > 0 ? `re-admitted: ${report.cleared.join(", ")}. They route again on the next pooled spawn \u2014 nothing needs restarting or reloading.` : report.missing.length > 0 ? `benched: ${report.missing.join(", ")}. The provider does not serve them; replace them in the pool.` : report.outcomes.every((outcome) => outcome.ok) ? "every model that was asked answered; nothing needed re-admitting." : "nothing was re-admitted; the pool is short exactly as much as it was."
2575
- ].join("\n");
2576
- };
2577
2811
  const announce = async (report) => {
2578
2812
  if (!report.changed) return;
2579
2813
  const message = report.cleared.length > 0 ? `Re-admitted ${report.cleared.join(", ")} \u2014 they answer again.` : report.missing.length > 0 ? `Benched ${report.missing.join(", ")} \u2014 the provider does not serve them.` : `${report.kept.length} cooling model(s) re-checked, still limited.`;
@@ -2608,15 +2842,34 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2608
2842
  const slots = ensureSlots(s, (m) => void warnOnce(m));
2609
2843
  const agents = cfg.agent;
2610
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;
2611
2851
  const known = /* @__PURE__ */ new Set();
2612
2852
  for (const [name, def] of Object.entries(cfg.agent ?? {})) if (def?.model) s.models.set(name, def.model);
2613
2853
  for (const slot of slots) {
2614
2854
  for (const base of BASES) {
2615
2855
  const name = agentFor(base, slot.index);
2616
- const def = agents?.[name];
2856
+ let def = agents?.[name];
2617
2857
  if (!def) {
2618
- await warnOnce(`variant ${name} is missing from the config; spawns of ${base} that route to slot ${slot.index} would fail`);
2619
- continue;
2858
+ const baseDef = agents?.[base];
2859
+ if (!agents || !baseDef) {
2860
+ await warnOnce(`variant ${name} is missing from the config; spawns of ${base} that route to slot ${slot.index} would fail`);
2861
+ continue;
2862
+ }
2863
+ const created = {
2864
+ ...baseDef,
2865
+ description: `Hidden pool slot ${slot.index} for ${base}. Its model is set by the pool at config time. Spawn '${base}' and let the pool route.`,
2866
+ hidden: true,
2867
+ mode: "primary"
2868
+ };
2869
+ delete created.model;
2870
+ agents[name] = created;
2871
+ def = created;
2872
+ await log("info", `declared variant ${name} for slot ${slot.index} (table grew after startup)`);
2620
2873
  }
2621
2874
  if (def.model && def.model !== slot.model) {
2622
2875
  await warnOnce(`${name} still hard-codes ${def.model}; the resolved pool preset wins (${slot.model}). Delete the line to keep one source of truth.`);
@@ -2629,6 +2882,11 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2629
2882
  const provider = slot.model.slice(0, slash);
2630
2883
  const id = slot.model.slice(slash + 1);
2631
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
+ }
2632
2890
  if (!entry) {
2633
2891
  await warnOnce(`provider ${provider} is not configured in opencode.jsonc; slot ${slot.index} (${slot.model}) cannot resolve`);
2634
2892
  continue;
@@ -2698,7 +2956,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2698
2956
  const provider = typeof action?.provider === "string" ? action.provider : void 0;
2699
2957
  const reason2 = action?.reason ?? "";
2700
2958
  const message = typeof status2?.message === "string" ? status2.message : "";
2701
- const limited2 = reason2 === "free_tier_limit" || isLimitText(reason2) || isLimitText(message);
2959
+ const auth2 = isAuthFailureText(reason2) || isAuthFailureText(message);
2960
+ const limited2 = reason2 === "free_tier_limit" || isLimitText(reason2) || isLimitText(message) || auth2;
2702
2961
  if (status2?.type === "retry" && limited2) {
2703
2962
  if (sessionID && isProbe(s, sessionID)) {
2704
2963
  await log("debug", `probe on provider ${provider ?? "?"} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
@@ -2706,9 +2965,9 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2706
2965
  }
2707
2966
  const own = claimFor(sessionID);
2708
2967
  const outage = isUnavailableText(message) && !isQuotaReason(reason2);
2709
- const named = outage ? UNAVAILABLE_REASON : reason2 || limitReason(message) || "rate-limited";
2968
+ const named = auth2 ? AUTH_FAILURE_REASON : outage ? UNAVAILABLE_REASON : reason2 || limitReason(message) || "rate-limited";
2710
2969
  if (provider) {
2711
- const until = outage ? Date.now() + UNAVAILABLE_COOLDOWN_MS : typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
2970
+ const until = auth2 ? Date.now() + AUTH_FAILURE_COOLDOWN_MS : outage ? Date.now() + UNAVAILABLE_COOLDOWN_MS : typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
2712
2971
  const cooled = coolProvider(s, provider, until, named);
2713
2972
  if (cooled.length > 0) {
2714
2973
  await log("warn", `limit on provider ${provider} (${named}); cooling ${cooled.length} slot(s) until ${new Date(until).toISOString().slice(11, 19)}Z`, {
@@ -2718,7 +2977,16 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2718
2977
  });
2719
2978
  }
2720
2979
  } else if (own) {
2721
- if (outage) {
2980
+ if (auth2) {
2981
+ const strike2 = recordStrike(s, own.claim.m, AUTH_FAILURE_REASON, AUTH_FAILURE_COOLDOWN_MS);
2982
+ if (strike2) {
2983
+ await log("warn", `limit on ${own.claim.m} (${AUTH_FAILURE_REASON}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s`, {
2984
+ model: own.claim.m,
2985
+ slot: own.claim.k,
2986
+ session: sessionID
2987
+ });
2988
+ }
2989
+ } else if (outage) {
2722
2990
  const transient = coolUnavailable(s, own.claim.m);
2723
2991
  if (transient) await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
2724
2992
  } else {
@@ -2750,15 +3018,63 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2750
3018
  await log("debug", `probe session ${sessionID} ended with an error; not cooling anything`);
2751
3019
  return;
2752
3020
  }
3021
+ const own = claimFor(sessionID);
3022
+ const missing2 = isModelMissingText(haystack2);
2753
3023
  const status2 = typeof error?.data?.statusCode === "number" ? error.data.statusCode : void 0;
3024
+ const auth2 = isAuthFailureText(haystack2) || status2 === 401 || status2 === 403;
2754
3025
  const limited2 = isLimitText(haystack2) || status2 === 429;
2755
3026
  const outage = isUnavailableText(haystack2);
2756
- if (!limited2 && !outage) return;
2757
- const own = claimFor(sessionID);
2758
- const named = outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
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
+ }
3064
+ const named = auth2 ? AUTH_FAILURE_REASON : outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
2759
3065
  let stated2;
2760
3066
  if (own) {
2761
- if (outage) {
3067
+ if (auth2) {
3068
+ stated2 = AUTH_FAILURE_COOLDOWN_MS;
3069
+ const strike2 = recordStrike(s, own.claim.m, AUTH_FAILURE_REASON, stated2);
3070
+ if (strike2) {
3071
+ await log("warn", `limit on ${own.claim.m} (${AUTH_FAILURE_REASON}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s from a terminal session.error`, {
3072
+ model: own.claim.m,
3073
+ slot: own.claim.k,
3074
+ session: sessionID
3075
+ });
3076
+ }
3077
+ } else if (outage) {
2762
3078
  const transient = coolUnavailable(s, own.claim.m);
2763
3079
  if (transient) {
2764
3080
  await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason}) after a terminal error`);
@@ -2776,6 +3092,15 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2776
3092
  }
2777
3093
  }
2778
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
+ }
2779
3104
  return;
2780
3105
  }
2781
3106
  if (event.type === "session.created") {
@@ -2789,29 +3114,73 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2789
3114
  const sessionID = info2?.sessionID ?? part?.sessionID;
2790
3115
  if (sessionID) noteActivity(s, sessionID);
2791
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
+ }
2792
3135
  if (event.type !== "message.updated") return;
2793
3136
  const info = props.info;
2794
3137
  if (!info || info.role !== "assistant" || !info.error) return;
2795
3138
  const haystack = JSON.stringify(info.error) ?? "";
2796
3139
  const status = typeof info.error.data?.statusCode === "number" ? info.error.data.statusCode : void 0;
3140
+ const auth = isAuthFailureText(haystack) || status === 401 || status === 403;
2797
3141
  const limited = isLimitText(haystack) || status === 429;
2798
3142
  const gateway5xx = status !== void 0 && status >= 500 && status <= 599;
2799
3143
  const transientGateway = gateway5xx || isTransientGatewayText(haystack);
2800
- if (!limited && !transientGateway) return;
3144
+ const missing = isModelMissingText(haystack);
3145
+ if (!limited && !transientGateway && !auth && !missing) return;
2801
3146
  const model = `${info.providerID ?? "?"}/${info.modelID ?? "?"}`;
2802
3147
  if (info.sessionID && isProbe(s, info.sessionID)) {
2803
3148
  await log("debug", `probe of ${model} hit a limit (${isLimitText(haystack) ? "limit" : "transient"}); leaving the cooldown to the probe`);
2804
3149
  return;
2805
3150
  }
2806
3151
  const ownMsg = claimFor(info.sessionID);
2807
- if (isUnavailableText(haystack) || !limited && transientGateway) {
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
+ }
3176
+ if (isUnavailableText(haystack) || !limited && !auth && transientGateway) {
2808
3177
  const transient = coolUnavailable(s, model);
2809
3178
  if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
2810
3179
  await failOver(info.sessionID, model, transient?.reason ?? UNAVAILABLE_REASON, { cool: false });
2811
3180
  return;
2812
3181
  }
2813
- const stated = statedCooldown(haystack) ?? retryAfterMs(info.error.data?.responseHeaders);
2814
- const reason = limitReason(haystack);
3182
+ const stated = auth ? AUTH_FAILURE_COOLDOWN_MS : statedCooldown(haystack) ?? retryAfterMs(info.error.data?.responseHeaders);
3183
+ const reason = auth ? AUTH_FAILURE_REASON : limitReason(haystack);
2815
3184
  const strike = recordStrike(s, model, reason ?? "rate-limited", stated);
2816
3185
  if (strike) await log("warn", `cooling ${model} for ${Math.round((strike.until - Date.now()) / 1e3)}s (${reason ?? "rate-limited"}, strike ${strike.strikes})`);
2817
3186
  await failOver(info.sessionID, model, reason ?? "rate-limited", { stated, cool: !ownMsg });
@@ -2838,6 +3207,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2838
3207
  }
2839
3208
  try {
2840
3209
  await reapStuck();
3210
+ await refreshProviders();
2841
3211
  refreshCatalog(s, serverUrl);
2842
3212
  const snap = snapshot(s);
2843
3213
  if (BASES.includes(requested)) {
@@ -2852,7 +3222,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2852
3222
  weight: slot.weight,
2853
3223
  model: slot.model,
2854
3224
  ceiling: slotCeiling(slot),
2855
- load: (snap.counts.get(slot.model) ?? 0) + 1
3225
+ load: (snap.counts.get(slot.index) ?? 0) + 1
2856
3226
  });
2857
3227
  output.args.subagent_type = variant;
2858
3228
  acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
@@ -2869,9 +3239,21 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2869
3239
  await log("error", `routing failed, spawn unchanged: ${String(error)}`);
2870
3240
  }
2871
3241
  },
2872
- "tool.execute.after": async (input) => {
3242
+ "tool.execute.after": async (input, output) => {
2873
3243
  if (input.tool !== "task") return;
2874
- release(s, claimKey(input.sessionID, input.callID));
3244
+ const key = claimKey(input.sessionID, input.callID);
3245
+ release(s, key);
3246
+ const report = abortReports.get(key);
3247
+ if (!report) return;
3248
+ abortReports.delete(key);
3249
+ const detail = [
3250
+ `Task Cancelled by LaCode: ${report.message}`,
3251
+ `slot ${report.slot}; model ${report.model}; elapsed ${report.elapsedMs}ms; failure ${report.kind}; claim ${report.released ? "released" : "held"}`
3252
+ ].join("\n");
3253
+ const text = typeof output.output === "string" ? output.output : "";
3254
+ output.output = /task\s+cancel+ed/i.test(text) ? detail : text ? `${text}
3255
+
3256
+ ${detail}` : detail;
2875
3257
  },
2876
3258
  tool: {
2877
3259
  pool_recheck: tool({
@@ -2880,6 +3262,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2880
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.")
2881
3263
  },
2882
3264
  async execute(args) {
3265
+ await refreshProviders(true);
2883
3266
  const models = args?.models;
2884
3267
  const report = models && models.length > 0 ? await runValidation(models) : await runProbe();
2885
3268
  return { title: `agent-pool: ${reportTitle(report)}`, output: reportText(report) };
@@ -2889,28 +3272,30 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2889
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.",
2890
3273
  args: {},
2891
3274
  async execute() {
3275
+ await refreshProviders(true);
2892
3276
  refreshCatalog(s, serverUrl);
2893
3277
  const slots = ensureSlots(s, (m) => void warnOnce(m));
2894
3278
  const snap = snapshot(s);
2895
3279
  const pass = poolPass(s, snap);
2896
3280
  const now = Date.now();
2897
- const byModel = /* @__PURE__ */ new Map();
2898
- const add = (model, base) => {
2899
- const entry = byModel.get(model) ?? { total: 0, bases: /* @__PURE__ */ new Map() };
3281
+ const bySlot = /* @__PURE__ */ new Map();
3282
+ const add = (k, base) => {
3283
+ const entry = bySlot.get(k) ?? { total: 0, bases: /* @__PURE__ */ new Map() };
2900
3284
  entry.total++;
2901
3285
  entry.bases.set(base, (entry.bases.get(base) ?? 0) + 1);
2902
- byModel.set(model, entry);
3286
+ bySlot.set(k, entry);
2903
3287
  };
2904
- for (const claim of snap.own.values()) add(claim.m, claim.v);
2905
- for (const sibling of snap.siblings) for (const claim of sibling.claims) add(claim.m, claim.v);
3288
+ for (const claim of snap.own.values()) add(claim.k, claim.v);
3289
+ for (const sibling of snap.siblings) for (const claim of sibling.claims) add(claim.k, claim.v);
2906
3290
  const next = pickSlot(s, snap);
2907
3291
  const line = (slot) => {
2908
- const entry = byModel.get(slot.model);
3292
+ const entry = bySlot.get(slot.index);
2909
3293
  const users = entry && entry.bases.size > 0 ? [...entry.bases].map(([b, n]) => `${b}x${n}`).join(" ") : "idle";
2910
3294
  const strike = snap.cooling.get(slot.model);
2911
3295
  const status = s.catalog.get(slot.model);
2912
3296
  const notes = [
2913
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)}\`)` : "",
2914
3299
  status && status !== "active" && status !== "unknown" ? `catalog:${status}` : ""
2915
3300
  ].filter(Boolean);
2916
3301
  const marker = slot.index === next.index ? " <- next" : "";
@@ -2920,14 +3305,25 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2920
3305
  };
2921
3306
  const total = [...snap.counts.values()].reduce((sum, n) => sum + n, 0);
2922
3307
  const coolingNow = [...snap.cooling.entries()].filter(([, st]) => st.until > now);
3308
+ const buckets = /* @__PURE__ */ new Map();
3309
+ for (const slot of slots) {
3310
+ const name = slotBucket(slot);
3311
+ const entry = buckets.get(name) ?? { seats: 0, slots: 0, cooling: false };
3312
+ entry.seats += slot.weight;
3313
+ entry.slots++;
3314
+ if (snap.cooling.has(slot.model)) entry.cooling = true;
3315
+ buckets.set(name, entry);
3316
+ }
3317
+ const sharedBuckets = [...buckets].filter(([, entry]) => entry.slots > 1);
2923
3318
  const verdicts = [...snap.own.values()].map((claim) => condemnReason(s, claim, now));
2924
3319
  const hung = verdicts.filter((verdict) => verdict === "hung").length;
2925
3320
  const overran = verdicts.filter((verdict) => verdict === "overran").length;
2926
3321
  return {
2927
3322
  title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
2928
3323
  output: [
2929
- `shared pool over ${slots.length} model(s) from ${s.origin}; pass ${pass}; bases: ${BASES.join(", ")}
2930
- two ordered passes in slot order: pass 1 fills each slot to its weight, pass 2 to its ceiling, then overflow goes to the least-loaded slot (FULL = at ceiling, used only when no other slot is available and under its ceiling)`,
3324
+ `shared pool over ${slots.length} slot(s) from ${s.origin}; state ${pass}; bases: ${BASES.join(", ")}
3325
+ one ordered pass in slot order: each slot fills to its weight, then overflow goes to the least-loaded slot. Capacity is the sum of the weights; repeat a model on another line to give it more (FULL = at weight).`,
3326
+ ...sharedBuckets.length ? [`buckets: ${sharedBuckets.map(([name, entry]) => `${name} ${entry.seats} seat(s)${entry.cooling ? " COOLING" : ""}`).join("; ")}`] : [],
2931
3327
  ...slots.map(line),
2932
3328
  "",
2933
3329
  "by process:",
@@ -3780,7 +4176,7 @@ var retireSession = async (client, directory, sessionID, mode, log) => {
3780
4176
  const gone = async () => await client.session.get({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0) === void 0;
3781
4177
  await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
3782
4178
  if (await gone()) return;
3783
- await new Promise((resolve4) => setTimeout(resolve4, 500));
4179
+ await new Promise((resolve5) => setTimeout(resolve5, 500));
3784
4180
  await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
3785
4181
  if (await gone()) return;
3786
4182
  await log?.("warn", "the cartographer's session could not be removed from the session list; it stays as an empty entry", {
@@ -4439,7 +4835,15 @@ var INTERNAL_TITLE_PREFIXES = [
4439
4835
  // context-autoupdate spawns the cartographer through the session API, with no
4440
4836
  // parentID, so it looks exactly like a root session going idle. Notifying for
4441
4837
  // it would announce the cartographer's own bookkeeping as a finished task.
4442
- "context (auto"
4838
+ "context (auto",
4839
+ // timeline spawns its summarizer through the session API with the same shape,
4840
+ // so it would otherwise announce the summarizer's own bookkeeping as a task.
4841
+ "timeline (auto",
4842
+ // pool probe sessions are created as root sessions with no parentID and the
4843
+ // title `pool probe (<model>)` (`src/lib/probe.ts`), so notification for
4844
+ // their idle/error would announce Lacode's own health-check as finished user
4845
+ // work.
4846
+ "pool probe"
4443
4847
  ];
4444
4848
  var NOTIFY_OUTCOMES = ["done", "question", "permission", "error"];
4445
4849
  var notifyMessage = (outcome, detail) => {
@@ -4927,10 +5331,10 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
4927
5331
  const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
4928
5332
  return info;
4929
5333
  };
4930
- const deliverViaHelper = (title, message, urgency, sound, requestID, sessionID) => new Promise((resolve4) => {
5334
+ const deliverViaHelper = (title, message, urgency, sound, requestID, sessionID) => new Promise((resolve5) => {
4931
5335
  if (inflight >= maxInflight) {
4932
5336
  void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
4933
- resolve4({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
5337
+ resolve5({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
4934
5338
  return;
4935
5339
  }
4936
5340
  const helper = findHelper();
@@ -4939,7 +5343,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
4939
5343
  warnedHelper = true;
4940
5344
  void log("warn", "no oc-notify helper found; run 'bin/oc-sync' to install one");
4941
5345
  }
4942
- resolve4({ ok: false, detail: "no oc-notify helper found" });
5346
+ resolve5({ ok: false, detail: "no oc-notify helper found" });
4943
5347
  return;
4944
5348
  }
4945
5349
  const childEnv = { ...process.env, ...sessionID ? { [NOTIFY_SESSION_ENV]: sessionID } : {} };
@@ -4962,7 +5366,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
4962
5366
  settled = true;
4963
5367
  inflight--;
4964
5368
  if (timer) clearTimeout(timer);
4965
- resolve4(result);
5369
+ resolve5(result);
4966
5370
  };
4967
5371
  timer = setTimeout(() => {
4968
5372
  try {
@@ -5751,6 +6155,753 @@ async function selfUpdateHooks(input, options) {
5751
6155
  return {};
5752
6156
  }
5753
6157
 
6158
+ // src/plugins/timeline.ts
6159
+ import { writeFileSync as writeFileSync8, renameSync as renameSync6, rmSync as rmSync3 } from "node:fs";
6160
+ import { tool as tool2 } from "@opencode-ai/plugin";
6161
+
6162
+ // src/lib/timeline.ts
6163
+ import { appendFileSync, mkdirSync as mkdirSync7, readFileSync as readFileSync10, rmSync as rmSync2 } from "node:fs";
6164
+ import { homedir as homedir7 } from "node:os";
6165
+ import { dirname as dirname5, join as join11, resolve as resolve4 } from "node:path";
6166
+ function timelineDir() {
6167
+ const env = process.env.LACODE_TIMELINE_DIR;
6168
+ if (env) return resolve4(env);
6169
+ return join11(homedir7(), ".local/share/opencode/lacode/timeline");
6170
+ }
6171
+ function timelineFile(sessionID) {
6172
+ if (!/^[A-Za-z0-9_.-]+$/.test(sessionID)) throw new Error("invalid sessionID");
6173
+ return join11(timelineDir(), sessionID + ".jsonl");
6174
+ }
6175
+ function normalizeThrough(through) {
6176
+ if (!through || typeof through !== "object") return void 0;
6177
+ const out = {};
6178
+ if (typeof through.messageID === "string") out.messageID = through.messageID;
6179
+ if (typeof through.time === "number") out.time = through.time;
6180
+ if (out.messageID === void 0 && out.time === void 0) return void 0;
6181
+ return out;
6182
+ }
6183
+ function appendEntriesAt(file, texts, now, through) {
6184
+ mkdirSync7(dirname5(file), { recursive: true });
6185
+ const cleaned = [];
6186
+ for (const t of texts) {
6187
+ if (typeof t !== "string" || t.length === 0) continue;
6188
+ cleaned.push(t.slice(0, 320));
6189
+ }
6190
+ let recent = [];
6191
+ try {
6192
+ recent = readEntriesAt(file, 20);
6193
+ } catch {
6194
+ recent = [];
6195
+ }
6196
+ const recentSet = new Set(recent.map((e) => e.text.toLowerCase()));
6197
+ const batchSeen = /* @__PURE__ */ new Set();
6198
+ const toAppend = [];
6199
+ for (const t of cleaned) {
6200
+ const key = t.toLowerCase();
6201
+ if (recentSet.has(key)) continue;
6202
+ if (batchSeen.has(key)) continue;
6203
+ batchSeen.add(key);
6204
+ toAppend.push(t);
6205
+ }
6206
+ const validThrough = normalizeThrough(through);
6207
+ let count = 0;
6208
+ for (const t of toAppend) {
6209
+ const ts = now ?? Date.now();
6210
+ const line = { ts, text: t };
6211
+ if (validThrough) line.through = validThrough;
6212
+ appendFileSync(file, JSON.stringify(line) + "\n");
6213
+ count++;
6214
+ }
6215
+ return count;
6216
+ }
6217
+ function readEntriesAt(file, limit) {
6218
+ let raw = "";
6219
+ try {
6220
+ raw = readFileSync10(file, "utf8");
6221
+ } catch {
6222
+ return [];
6223
+ }
6224
+ const entries = [];
6225
+ for (const line of raw.split("\n")) {
6226
+ if (line.trim() === "") continue;
6227
+ try {
6228
+ const obj = JSON.parse(line);
6229
+ if (obj && typeof obj.ts === "number" && typeof obj.text === "string") {
6230
+ const entry = { ts: obj.ts, text: obj.text };
6231
+ const t = normalizeThrough(obj.through);
6232
+ if (t) entry.through = t;
6233
+ entries.push(entry);
6234
+ }
6235
+ } catch {
6236
+ }
6237
+ }
6238
+ if (limit != null && limit <= 0) return [];
6239
+ const lim = limit == null ? 50 : limit;
6240
+ return entries.slice(-lim);
6241
+ }
6242
+ function appendEntries(sessionID, texts, now, through) {
6243
+ return appendEntriesAt(timelineFile(sessionID), texts, now, through);
6244
+ }
6245
+ function readEntries(sessionID, limit) {
6246
+ return readEntriesAt(timelineFile(sessionID), limit);
6247
+ }
6248
+ function removeEntries(sessionID) {
6249
+ try {
6250
+ rmSync2(timelineFile(sessionID), { force: true });
6251
+ } catch {
6252
+ }
6253
+ }
6254
+ function collapse(s) {
6255
+ return s.replace(/\s+/g, " ").trim();
6256
+ }
6257
+ function trunc(s, n) {
6258
+ return s.length > n ? s.slice(0, n) : s;
6259
+ }
6260
+ function stripAnsiAndControls(s) {
6261
+ let out = "";
6262
+ for (let i = 0; i < s.length; i++) {
6263
+ const code = s.charCodeAt(i);
6264
+ if (code === 27) {
6265
+ if (i + 1 < s.length && s[i + 1] === "[") {
6266
+ i += 2;
6267
+ while (i < s.length) {
6268
+ const c = s.charCodeAt(i);
6269
+ if (c >= 64 && c <= 126) {
6270
+ break;
6271
+ }
6272
+ i++;
6273
+ }
6274
+ continue;
6275
+ }
6276
+ if (i + 1 < s.length && s[i + 1] === "]") {
6277
+ i += 2;
6278
+ while (i < s.length) {
6279
+ const c = s.charCodeAt(i);
6280
+ if (c === 7) break;
6281
+ if (c === 27 && i + 1 < s.length && s[i + 1] === "\\") {
6282
+ i += 1;
6283
+ break;
6284
+ }
6285
+ i++;
6286
+ }
6287
+ continue;
6288
+ }
6289
+ if (i + 1 < s.length) {
6290
+ i += 1;
6291
+ }
6292
+ continue;
6293
+ }
6294
+ if (code === 9 || code === 10) {
6295
+ out += s[i];
6296
+ continue;
6297
+ }
6298
+ if (code < 32 || code >= 127 && code <= 159) {
6299
+ continue;
6300
+ }
6301
+ out += s[i];
6302
+ }
6303
+ return out;
6304
+ }
6305
+ function inputSummary(input) {
6306
+ if (input === null || input === void 0) return "";
6307
+ if (typeof input !== "object") {
6308
+ try {
6309
+ return trunc(collapse(JSON.stringify(input)), 160);
6310
+ } catch {
6311
+ return "";
6312
+ }
6313
+ }
6314
+ const rec = input;
6315
+ const keys = ["filePath", "path", "command", "description", "pattern", "query", "url", "name", "title"];
6316
+ for (const k of keys) {
6317
+ if (Object.prototype.hasOwnProperty.call(rec, k) && rec[k] != null) {
6318
+ const v = rec[k];
6319
+ const s = typeof v === "string" ? v : JSON.stringify(v);
6320
+ return trunc(collapse(s), 160);
6321
+ }
6322
+ }
6323
+ try {
6324
+ return trunc(collapse(JSON.stringify(rec)), 160);
6325
+ } catch {
6326
+ return "";
6327
+ }
6328
+ }
6329
+ function summarizeToolResult(state2) {
6330
+ if (!state2) return "";
6331
+ const err = state2.error;
6332
+ if (typeof err === "string") {
6333
+ if (err.trim() === "") return "";
6334
+ return `error: ${trunc(err, 180)}`;
6335
+ }
6336
+ if (err && typeof err === "object") {
6337
+ try {
6338
+ const s = JSON.stringify(err);
6339
+ if (typeof s === "string" && s.trim() !== "") {
6340
+ return trunc(s, 180);
6341
+ }
6342
+ } catch {
6343
+ }
6344
+ return "";
6345
+ }
6346
+ if (typeof state2.output === "string") {
6347
+ for (const raw of state2.output.split("\n")) {
6348
+ const line = raw.trim();
6349
+ if (line.length > 0) {
6350
+ return trunc(line, 180);
6351
+ }
6352
+ }
6353
+ }
6354
+ if (typeof state2.title === "string" && state2.title.trim() !== "") {
6355
+ return trunc(state2.title, 180);
6356
+ }
6357
+ return "";
6358
+ }
6359
+ function renderMessage(m) {
6360
+ const role = m.info.role;
6361
+ const lines = [];
6362
+ if (role === "user") {
6363
+ for (const p of m.parts) {
6364
+ if (p.type === "text" && !p.synthetic && !p.ignored) {
6365
+ if ((p.text ?? "").includes("timeline_reprocess")) continue;
6366
+ const text = collapse(p.text ?? "");
6367
+ lines.push(`[user] ${trunc(text, 2e3)}`);
6368
+ }
6369
+ }
6370
+ } else if (role === "assistant") {
6371
+ for (const p of m.parts) {
6372
+ if (p.type === "text") {
6373
+ const text = collapse(p.text ?? "");
6374
+ lines.push(`[assistant] ${trunc(text, 1500)}`);
6375
+ } else if (p.type === "tool") {
6376
+ const tool3 = p.tool ?? "";
6377
+ if (tool3 === "timeline_reprocess") continue;
6378
+ const status = p.state?.status;
6379
+ const summary = inputSummary(p.state?.input);
6380
+ const result = summarizeToolResult(p.state);
6381
+ let line = `[tool] ${tool3}`;
6382
+ if (status) line += ` (${status})`;
6383
+ const inputPart = summary ? trunc(summary, 220) : "";
6384
+ let rest = "";
6385
+ if (inputPart) rest += ` ${inputPart}`;
6386
+ if (result) rest += ` -> ${result}`;
6387
+ line += rest;
6388
+ lines.push(line);
6389
+ }
6390
+ }
6391
+ }
6392
+ return lines;
6393
+ }
6394
+ function buildExcerpt(messages, cursor, budget = 8e3) {
6395
+ let start = 0;
6396
+ if (cursor?.messageID) {
6397
+ const idx = messages.findIndex((m) => m.info.id === cursor.messageID);
6398
+ if (idx >= 0) {
6399
+ start = idx + 1;
6400
+ } else if (cursor.time != null) {
6401
+ const i = messages.findIndex((m) => (m.info.time?.created ?? -Infinity) > cursor.time);
6402
+ start = i < 0 ? messages.length : i;
6403
+ }
6404
+ } else if (cursor?.time != null) {
6405
+ const i = messages.findIndex((m) => (m.info.time?.created ?? -Infinity) > cursor.time);
6406
+ start = i < 0 ? messages.length : i;
6407
+ }
6408
+ const outLines = [];
6409
+ const included = [];
6410
+ for (let i = start; i < messages.length; i++) {
6411
+ const m = messages[i];
6412
+ const msgLines = renderMessage(m);
6413
+ if (msgLines.length === 0) continue;
6414
+ const candidateLines = outLines.length === 0 ? [...msgLines] : [...outLines, ...msgLines];
6415
+ const candidateText = candidateLines.join("\n");
6416
+ if (included.length > 0 && candidateText.length > budget) {
6417
+ break;
6418
+ }
6419
+ if (included.length === 0 && candidateText.length > budget) {
6420
+ const block = msgLines.join("\n");
6421
+ outLines.push(block.slice(0, budget));
6422
+ included.push(m);
6423
+ break;
6424
+ }
6425
+ outLines.push(...msgLines);
6426
+ included.push(m);
6427
+ }
6428
+ let model;
6429
+ for (let i = messages.length - 1; i >= 0; i--) {
6430
+ const info = messages[i].info;
6431
+ if (info.role !== "user") continue;
6432
+ if (info.model) {
6433
+ model = { providerID: info.model.providerID, modelID: info.model.modelID };
6434
+ break;
6435
+ }
6436
+ if (info.providerID && info.modelID) {
6437
+ model = { providerID: info.providerID, modelID: info.modelID };
6438
+ break;
6439
+ }
6440
+ }
6441
+ const lastIncluded = included[included.length - 1];
6442
+ const next = lastIncluded ? { messageID: lastIncluded.info.id, time: lastIncluded.info.time?.created } : void 0;
6443
+ return {
6444
+ text: outLines.join("\n"),
6445
+ next,
6446
+ count: included.length,
6447
+ model
6448
+ };
6449
+ }
6450
+ function parseBullets(text, max = 6) {
6451
+ const result = [];
6452
+ const seen = /* @__PURE__ */ new Set();
6453
+ for (const raw of text.split("\n")) {
6454
+ const line = raw.trim();
6455
+ if (!line) continue;
6456
+ if (line.startsWith("```")) continue;
6457
+ if (line.length > 320) continue;
6458
+ const m = line.match(/^([-*•]|\d+[.)])\s+(.*)$/);
6459
+ if (!m) continue;
6460
+ const stripped = m[2].trim().replace(/`/g, "").replace(/\*\*/g, "").trim();
6461
+ if (!stripped) continue;
6462
+ const cleaned = stripAnsiAndControls(stripped).trim();
6463
+ if (!cleaned) continue;
6464
+ if (cleaned.length > 320) continue;
6465
+ const key = cleaned.toLowerCase();
6466
+ if (seen.has(key)) continue;
6467
+ seen.add(key);
6468
+ result.push(cleaned);
6469
+ if (result.length >= max) break;
6470
+ }
6471
+ return result;
6472
+ }
6473
+ function isInternalTitle(title) {
6474
+ const t = (title ?? "").trim();
6475
+ return t.startsWith("context (auto") || t.startsWith("timeline (auto") || t.startsWith("pool probe");
6476
+ }
6477
+
6478
+ // src/plugins/timeline.ts
6479
+ var DEFAULT_INTERVAL_MS = 3e5;
6480
+ var MIN_INTERVAL_MS = 1e4;
6481
+ var DEFAULT_MAX_ENTRIES = 8;
6482
+ var MAX_ENTRIES = 10;
6483
+ var PROMPT_TIMEOUT_MS = 9e4;
6484
+ var SYSTEM = `You are the session timeline summarizer. You receive a slice of a coding session transcript.
6485
+ Write 1-8 timeline entries describing the work as tasks, features, fixes, or stages.
6486
+
6487
+ Rules:
6488
+ - One entry per line, each starting with "- ".
6489
+ - Each entry is 6-16 words, past tense, plain text.
6490
+ - Cover each distinct task in the slice. Do not drop a provider, feature, fix, config, docs, or test change; merge only exact duplicates.
6491
+ - Describe the TASK or OUTCOME, not the implementation. "Pool: added bucket-based routing" is right; "added b:<name> to pool.ts and agent-pool.ts" is too detailed.
6492
+ - Never describe the mechanism: no flags, aliases, overrides, endpoints, probes, config keys, declarations, tags, or file changes. State what changed for the system: which capability now works, which bug is fixed, which behavior changed.
6493
+ - Never use these words in an entry: tag, alias, override, flag, probe, endpoint, config key, catalog, file, line, count.
6494
+ - For configuration plumbing, write the purpose: "Config: silenced warnings for unused providers", not "declared empty overrides".
6495
+ - Use a short area prefix when it clarifies: "Pool:", "Providers:", "Config:", "Docs:", "Tests:", "Fix:", "Feature:".
6496
+ - Name the capability, behavior, feature, bug, or decision. Do not name files, paths, functions, commands, environment variables, API keys, raw model IDs, test file names, or build artifacts. Provider and product names are allowed when they identify the task.
6497
+ - Do not include counts of slots, providers, tests, lines, or files.
6498
+ - Omit internal housekeeping: builds, dist rebuilds, test-file additions, formatting, and dependency churn. If the slice only contains housekeeping, output nothing.
6499
+ - Describe what was DONE or DECIDED. Never write "User asked...", "Assistant said...", "Discussed...", "Reviewed...", "Explained...", or "Considered...".
6500
+ - Merge related changes into one stage; do not repeat the same task in different words.
6501
+ - No markdown: no bold, italics, backticks, or code blocks.
6502
+ - If a technical detail is the essence of the task, name it at the capability level: "Added Cloudflare Workers AI as a provider", not its account-id variables.
6503
+ - If the slice contains no substantive work or decision, output nothing.
6504
+ - Output only the entries. No headings, no preamble, no closing sentence.
6505
+
6506
+ Example output:
6507
+ - Pool: added bucket-based routing for model groups.
6508
+ - Providers: added Gemini 3.5 Flash after the previous model timed out.
6509
+ - Providers: added Cloudflare Workers AI with a tool-compatible model.
6510
+ - Config: silenced warnings for unused providers.
6511
+ - Config: standardized provider key naming and added Groq and Google.
6512
+ - Docs: aligned the routing documentation with the implemented behavior.
6513
+
6514
+ Not this:
6515
+ - Config: declared empty OpenRouter overrides to silence warnings.
6516
+ - Providers: added a Google API key alias after Gemini 2.5-flash closure.
6517
+ - Config: standardized provider keys to _KEY-only naming.
6518
+ - Pool: reordered slots after tagging pool entries.
6519
+ - Pool: added bucket support and kept tags in pool.
6520
+ - Pool: standardized slots on opencode catalog.
6521
+ - Providers: added Gemini 3.5 Flash and a Google API key alias.
6522
+
6523
+ This:
6524
+ - Config: silenced warnings for unused providers.
6525
+ - Providers: replaced the retired Gemini 2.5 Flash with an available model.
6526
+ - Config: standardized provider key naming.
6527
+ - Pool: moved shared-window models to the end.
6528
+ - Pool: added named bucket support for model groups.
6529
+ - Pool: moved shared-window models to the end.
6530
+ - Providers: replaced the retired Gemini 2.5 Flash with Gemini 3.5 Flash.
6531
+
6532
+ Your entire response must consist only of lines beginning with "- ".`;
6533
+ function timelineHooks(input, options) {
6534
+ const { client, directory } = input;
6535
+ const opts = options ?? {};
6536
+ const config = typeof opts.timeline === "object" && opts.timeline !== null ? opts.timeline : void 0;
6537
+ const disabled = opts.timeline === false || config?.enabled === false || process.env.LACODE_TIMELINE === "0";
6538
+ const SERVICE5 = "timeline";
6539
+ const poolState = state(options);
6540
+ const log = (level, message, extra) => client.app.log({ body: { service: SERVICE5, level, message, extra } }).catch(() => {
6541
+ });
6542
+ const debug = (...args) => {
6543
+ if (process.env.LACODE_TIMELINE_DEBUG === "1") console.error("[timeline]", ...args);
6544
+ };
6545
+ const rawInterval = Number(process.env.LACODE_TIMELINE_INTERVAL_MS ?? config?.intervalMs ?? DEFAULT_INTERVAL_MS);
6546
+ const intervalMs = Number.isFinite(rawInterval) ? Math.max(MIN_INTERVAL_MS, rawInterval) : DEFAULT_INTERVAL_MS;
6547
+ const rawMaxEntries = Number(config?.maxEntries ?? DEFAULT_MAX_ENTRIES);
6548
+ const maxEntries = Number.isFinite(rawMaxEntries) ? Math.min(MAX_ENTRIES, Math.max(1, Math.trunc(rawMaxEntries))) : DEFAULT_MAX_ENTRIES;
6549
+ const shared = globalThis[/* @__PURE__ */ Symbol.for("lacode:timeline:state")] ??= {
6550
+ sessions: /* @__PURE__ */ new Map(),
6551
+ owned: /* @__PURE__ */ new Set(),
6552
+ reprocessing: /* @__PURE__ */ new Set()
6553
+ };
6554
+ const sessions = shared.sessions;
6555
+ const owned = shared.owned;
6556
+ const reprocessing = shared.reprocessing;
6557
+ const MAX_REPROCESS_SLICES = 100;
6558
+ let disposed = false;
6559
+ const promptModel = (raw) => {
6560
+ if (typeof raw !== "string" || !raw.includes("/")) return void 0;
6561
+ const { provider, id } = splitModel(raw);
6562
+ if (!id) return void 0;
6563
+ return { providerID: provider, modelID: id };
6564
+ };
6565
+ const stateFor = (id) => {
6566
+ let state2 = sessions.get(id);
6567
+ if (!state2) {
6568
+ state2 = {
6569
+ running: false,
6570
+ pending: false,
6571
+ failures: 0,
6572
+ cursor: readEntries(id, 1)[0]?.through
6573
+ };
6574
+ sessions.set(id, state2);
6575
+ while (sessions.size > 500) {
6576
+ let evicted = false;
6577
+ for (const [key, st] of sessions) {
6578
+ if (key === id) continue;
6579
+ if (!st.timer && !st.running) {
6580
+ sessions.delete(key);
6581
+ evicted = true;
6582
+ break;
6583
+ }
6584
+ }
6585
+ if (!evicted) break;
6586
+ }
6587
+ }
6588
+ return state2;
6589
+ };
6590
+ const note = (id) => {
6591
+ if (disabled || disposed) return;
6592
+ const state2 = stateFor(id);
6593
+ if (state2.root === false) return;
6594
+ state2.pending = true;
6595
+ if (state2.timer || state2.running) return;
6596
+ schedule(id, state2);
6597
+ };
6598
+ function schedule(id, state2) {
6599
+ if (state2.timer) clearTimeout(state2.timer);
6600
+ state2.timer = setTimeout(() => {
6601
+ state2.timer = void 0;
6602
+ void tick(id, state2);
6603
+ }, intervalMs);
6604
+ state2.timer.unref?.();
6605
+ }
6606
+ async function summarize(id, title, excerptText, prior, abort) {
6607
+ if (abort?.aborted) return { ok: false, bullets: [], error: "aborted" };
6608
+ let slot;
6609
+ try {
6610
+ init(poolState, (m) => log("warn", m));
6611
+ const snap = snapshot(poolState);
6612
+ slot = pickSlot(poolState, snap);
6613
+ } catch (error) {
6614
+ log("warn", String(error));
6615
+ return { ok: false, bullets: [], error: String(error) };
6616
+ }
6617
+ const model = promptModel(slot?.model);
6618
+ if (!model) {
6619
+ log("warn", "timeline: slot model cannot be split into provider/model", { model: slot?.model });
6620
+ return { ok: false, bullets: [], error: "slot model cannot be split into provider/model" };
6621
+ }
6622
+ const created = await client.session.create({
6623
+ body: { title: "timeline (auto)" },
6624
+ query: { directory }
6625
+ });
6626
+ const target = created?.data?.id;
6627
+ if (!target) {
6628
+ return { ok: false, bullets: [], error: "session create returned no id" };
6629
+ }
6630
+ const claimKey2 = claimKey(target, "timeline");
6631
+ let claimed = false;
6632
+ owned.add(target);
6633
+ let abortListener;
6634
+ try {
6635
+ claimed = true;
6636
+ acquire(poolState, "context", slot, target, claimKey2, target);
6637
+ const prompt = [
6638
+ `Session: ${title}`,
6639
+ ...prior.length > 0 ? ["Existing timeline (do not repeat):", ...prior.map((entry) => `- ${entry.text}`)] : [],
6640
+ "Transcript slice:",
6641
+ excerptText
6642
+ ].join("\n");
6643
+ if (abort) {
6644
+ abortListener = () => {
6645
+ void client.session.abort({ path: { id: target }, query: { directory } }).catch(() => {
6646
+ });
6647
+ };
6648
+ abort.addEventListener("abort", abortListener);
6649
+ }
6650
+ let timer;
6651
+ let timedOut = false;
6652
+ let result;
6653
+ try {
6654
+ const answer = client.session.prompt({
6655
+ path: { id: target },
6656
+ query: { directory },
6657
+ body: {
6658
+ model,
6659
+ tools: { "*": false },
6660
+ system: SYSTEM,
6661
+ parts: [{ type: "text", text: prompt }]
6662
+ }
6663
+ }).then(
6664
+ (value) => value,
6665
+ (error) => ({ error })
6666
+ );
6667
+ const deadline = new Promise((resolve5) => {
6668
+ timer = setTimeout(() => {
6669
+ timedOut = true;
6670
+ void client.session.abort({ path: { id: target }, query: { directory } }).catch(() => {
6671
+ });
6672
+ resolve5(void 0);
6673
+ }, PROMPT_TIMEOUT_MS);
6674
+ });
6675
+ result = await Promise.race([answer, deadline]);
6676
+ } finally {
6677
+ if (timer) clearTimeout(timer);
6678
+ }
6679
+ if (abort?.aborted) {
6680
+ return { ok: false, bullets: [], error: "aborted" };
6681
+ }
6682
+ const failure = result?.error ?? result?.data?.info?.error;
6683
+ if (timedOut || failure !== void 0) {
6684
+ return { ok: false, bullets: [], error: timedOut ? "timed out" : String(failure) };
6685
+ } else {
6686
+ const text = (result?.data?.parts ?? []).map((part) => part.type === "text" ? part.text ?? "" : "").join("\n");
6687
+ const bullets = parseBullets(text, maxEntries);
6688
+ if (bullets.length > 0) {
6689
+ return { ok: true, bullets };
6690
+ } else if (text.trim() === "") {
6691
+ return { ok: true, bullets: [] };
6692
+ } else {
6693
+ if (process.env.LACODE_TIMELINE_DEBUG === "1") {
6694
+ log("no bullet lines", { sessionID: id, text: text.slice(0, 300) });
6695
+ }
6696
+ return { ok: false, bullets: [], error: "no bullet lines" };
6697
+ }
6698
+ }
6699
+ } finally {
6700
+ if (abort && abortListener) abort.removeEventListener("abort", abortListener);
6701
+ if (claimed && claimKey2) release(poolState, claimKey2);
6702
+ await client.session.delete({ path: { id: target }, query: { directory } }).catch(() => {
6703
+ });
6704
+ owned.delete(target);
6705
+ }
6706
+ }
6707
+ async function tick(id, state2) {
6708
+ if (disposed) return;
6709
+ if (reprocessing.has(id)) return;
6710
+ try {
6711
+ state2.running = true;
6712
+ state2.pending = false;
6713
+ let title;
6714
+ let info = {};
6715
+ if (state2.root === void 0) {
6716
+ info = await client.session.get({ path: { id }, query: { directory } }).then((result2) => result2?.data).catch(() => void 0);
6717
+ if (!info) {
6718
+ sessions.delete(id);
6719
+ return;
6720
+ }
6721
+ title = info.title;
6722
+ state2.root = !info.parentID && !isInternalTitle(info.title);
6723
+ if (!state2.root) {
6724
+ sessions.delete(id);
6725
+ return;
6726
+ }
6727
+ }
6728
+ const response = await client.session.messages({ path: { id }, query: { directory } });
6729
+ const messages = response?.data ?? [];
6730
+ const excerpt = buildExcerpt(messages, state2.cursor);
6731
+ if (!excerpt.text) {
6732
+ return;
6733
+ }
6734
+ const result = await summarize(id, info.title ?? "untitled", excerpt.text, readEntries(id, 4));
6735
+ if (result.ok) {
6736
+ if (result.bullets.length) appendEntries(id, result.bullets, Date.now(), excerpt.next);
6737
+ state2.cursor = excerpt.next;
6738
+ state2.failures = 0;
6739
+ } else {
6740
+ state2.failures++;
6741
+ log("summarize failed", { sessionID: id, error: result.error });
6742
+ }
6743
+ } catch (error) {
6744
+ debug("tick failed", { sessionID: id, error });
6745
+ } finally {
6746
+ state2.running = false;
6747
+ if (sessions.get(id) === state2 && state2.pending && !disabled && !disposed && state2.root !== false) {
6748
+ schedule(id, state2);
6749
+ }
6750
+ }
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
+ }
6763
+ async function reprocess(id, abort) {
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);
6769
+ const temp = targetFile + ".reprocess." + process.pid + "." + Date.now() + ".tmp";
6770
+ let cursor;
6771
+ let entries = 0;
6772
+ let calls = 0;
6773
+ try {
6774
+ const infoRes = await client.session.get({ path: { id: rootId }, query: { directory } });
6775
+ const info = infoRes?.data;
6776
+ if (!info) return "Session not found.";
6777
+ const msgRes = await client.session.messages({ path: { id: rootId }, query: { directory } });
6778
+ const messages = msgRes?.data ?? [];
6779
+ writeFileSync8(temp, "");
6780
+ for (let i = 0; i < MAX_REPROCESS_SLICES; i++) {
6781
+ if (abort?.aborted) {
6782
+ try {
6783
+ rmSync3(temp, { force: true });
6784
+ } catch {
6785
+ }
6786
+ return `Aborted after ${calls} summarizer calls; previous timeline kept.`;
6787
+ }
6788
+ const excerpt = buildExcerpt(messages, cursor);
6789
+ if (!excerpt.text) break;
6790
+ const prior = readEntriesAt(temp, 4);
6791
+ const result = await summarize(rootId, info.title ?? "untitled", excerpt.text, prior, abort);
6792
+ calls++;
6793
+ if (!result.ok) {
6794
+ try {
6795
+ rmSync3(temp, { force: true });
6796
+ } catch {
6797
+ }
6798
+ return `Stopped after ${calls} summarizer calls: ${result.error}. Previous timeline kept.`;
6799
+ }
6800
+ if (result.bullets.length) {
6801
+ entries += appendEntriesAt(temp, result.bullets, Date.now(), excerpt.next);
6802
+ }
6803
+ cursor = excerpt.next;
6804
+ }
6805
+ if (abort?.aborted) {
6806
+ try {
6807
+ rmSync3(temp, { force: true });
6808
+ } catch {
6809
+ }
6810
+ return `Aborted after ${calls} summarizer calls; previous timeline kept.`;
6811
+ }
6812
+ if (entries === 0) {
6813
+ try {
6814
+ rmSync3(temp, { force: true });
6815
+ } catch {
6816
+ }
6817
+ return "No entries generated; previous timeline kept.";
6818
+ }
6819
+ renameSync6(temp, targetFile);
6820
+ const state2 = sessions.get(rootId);
6821
+ if (state2) {
6822
+ state2.cursor = cursor;
6823
+ state2.failures = 0;
6824
+ state2.pending = false;
6825
+ }
6826
+ return `Rebuilt ${messages.length} messages into ${entries} entries with ${calls} summarizer calls.`;
6827
+ } catch (error) {
6828
+ try {
6829
+ rmSync3(temp, { force: true });
6830
+ } catch {
6831
+ }
6832
+ return `Reprocess failed: ${String(error)}. Previous timeline kept.`;
6833
+ } finally {
6834
+ reprocessing.delete(rootId);
6835
+ }
6836
+ }
6837
+ const timelineReprocess = tool2({
6838
+ description: "Rebuild the root session's entire timeline from its transcript, replacing the existing entries. Used by /reprocess-timeline.",
6839
+ args: {},
6840
+ execute: async (_args, ctx) => {
6841
+ const output = await reprocess(ctx.sessionID, ctx.abort);
6842
+ return { title: "Timeline reprocess", output };
6843
+ }
6844
+ });
6845
+ return {
6846
+ event: async ({ event }) => {
6847
+ try {
6848
+ switch (event.type) {
6849
+ case "message.updated": {
6850
+ const id = event.properties.info?.sessionID;
6851
+ if (!id) return;
6852
+ if (owned.has(id)) return;
6853
+ const role = event.properties.info?.role;
6854
+ if (role === "user" || role === "assistant") note(id);
6855
+ return;
6856
+ }
6857
+ case "message.part.updated": {
6858
+ const id = event.properties.part?.sessionID;
6859
+ if (!id) return;
6860
+ if (owned.has(id)) return;
6861
+ const type = event.properties.part?.type;
6862
+ if (type === "text" || type === "tool") note(id);
6863
+ return;
6864
+ }
6865
+ case "session.created": {
6866
+ const info = event.properties.info;
6867
+ if (!info) return;
6868
+ if (isInternalTitle(info.title)) owned.add(info.id);
6869
+ return;
6870
+ }
6871
+ case "session.deleted": {
6872
+ const id = event.properties.info?.id;
6873
+ if (!id) return;
6874
+ const state2 = sessions.get(id);
6875
+ if (state2?.timer) {
6876
+ clearTimeout(state2.timer);
6877
+ state2.timer = void 0;
6878
+ }
6879
+ sessions.delete(id);
6880
+ removeEntries(id);
6881
+ owned.delete(id);
6882
+ return;
6883
+ }
6884
+ default:
6885
+ return;
6886
+ }
6887
+ } catch (error) {
6888
+ debug("event failed", error);
6889
+ }
6890
+ },
6891
+ dispose: async () => {
6892
+ disposed = true;
6893
+ for (const state2 of sessions.values()) {
6894
+ if (state2.timer) {
6895
+ clearTimeout(state2.timer);
6896
+ state2.timer = void 0;
6897
+ }
6898
+ }
6899
+ reprocessing.clear();
6900
+ },
6901
+ tool: { timeline_reprocess: timelineReprocess }
6902
+ };
6903
+ }
6904
+
5754
6905
  // src/index.ts
5755
6906
  var inertHooks = {
5756
6907
  config: async () => {
@@ -5786,7 +6937,7 @@ var LaCode = (async (input, options) => {
5786
6937
  return void 0;
5787
6938
  }
5788
6939
  };
5789
- const agentSet = buildAgentSet();
6940
+ const agentSet = buildAgentSet(resolveModels({ models: opts.models, modelsFile: opts.modelsFile }).slots.length);
5790
6941
  const pool = await settled("agent-pool", () => agentPoolHooks(input, options)) ?? inertHooks;
5791
6942
  const autoupdate = await settled("context-autoupdate", () => contextAutoUpdateHooks(input, options)) ?? inertHooks;
5792
6943
  const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
@@ -5794,6 +6945,7 @@ var LaCode = (async (input, options) => {
5794
6945
  const feedback = await settled("feedback", () => feedbackHooks(input, options)) ?? inertHooks;
5795
6946
  const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
5796
6947
  const imagepaste = await settled("image-paste", () => imagePasteHooks(input, opts.imagePaste)) ?? inertHooks;
6948
+ const timeline = await settled("timeline", () => timelineHooks(input, options)) ?? inertHooks;
5797
6949
  const config = async (cfg) => {
5798
6950
  try {
5799
6951
  const injected = applyAgents(cfg, agentSet);
@@ -5817,6 +6969,8 @@ var LaCode = (async (input, options) => {
5817
6969
  autoupdate.event?.(payload).catch(() => {
5818
6970
  }),
5819
6971
  notifier.event?.(payload).catch(() => {
6972
+ }),
6973
+ timeline.event?.(payload).catch(() => {
5820
6974
  })
5821
6975
  ]);
5822
6976
  };
@@ -5827,7 +6981,7 @@ var LaCode = (async (input, options) => {
5827
6981
  // only thing that routes task calls, so there is nothing to merge them with.
5828
6982
  "tool.execute.before": pool["tool.execute.before"],
5829
6983
  "tool.execute.after": pool["tool.execute.after"],
5830
- tool: pool.tool,
6984
+ tool: { ...pool.tool ?? {}, ...timeline.tool ?? {} },
5831
6985
  "command.execute.before": imagepaste["command.execute.before"],
5832
6986
  dispose: async () => {
5833
6987
  await pool.dispose?.();
@@ -5837,6 +6991,7 @@ var LaCode = (async (input, options) => {
5837
6991
  await feedback.dispose?.();
5838
6992
  await omniroute.dispose?.();
5839
6993
  await imagepaste.dispose?.();
6994
+ await timeline.dispose?.();
5840
6995
  },
5841
6996
  "experimental.chat.system.transform": async (_input, output) => {
5842
6997
  if (!Array.isArray(output?.system)) return;