@lawaty/lacode 0.7.1 → 0.9.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
@@ -34,12 +34,15 @@ var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
34
34
  var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
35
35
  var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
36
36
  var MAX_WEIGHT = 100;
37
+ var SLOT_CEILING_FACTOR = 2;
38
+ var slotCeiling = (slot) => slot.weight * SLOT_CEILING_FACTOR;
37
39
  var MAX_SLOTS = 4;
38
40
  var COOLDOWN_BASE_MS = 60 * 1e3;
39
41
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
40
42
  var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
41
43
  var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
42
44
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
45
+ var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
43
46
  var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
44
47
  var BASES = ["explore", "implement", "context"];
45
48
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
@@ -48,10 +51,10 @@ var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
48
51
  var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
49
52
  var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
50
53
  var FALLBACK_SLOTS = [
51
- { index: 1, model: "opencode/space-bunny-free", weight: 3 },
52
- { index: 2, model: "opencode-go/longcat-2.5-preview-free", weight: 2 },
53
- { index: 3, model: "opencode/big-pickle", weight: 1 },
54
- { index: 4, model: "opencode/longcat-2.5-preview-free", weight: 1 }
54
+ { index: 1, model: "opencode/big-pickle", weight: 3 },
55
+ { index: 2, model: "opencode-go/minimax-m3.1-flash-free", weight: 2 },
56
+ { index: 3, model: "opencode/deepseek-v4.1-flash-free", weight: 1 },
57
+ { index: 4, model: "opencode/glm-5.3-flash-free", weight: 1 }
55
58
  ];
56
59
  function parseSlots(parsed, origin) {
57
60
  const problems = [];
@@ -207,12 +210,13 @@ var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
207
210
  var TRANSIENT_GATEWAY_PATTERNS = [/queue budget/i, /exceeded queue/i, /aborted due to timeout/i, /timeout/i];
208
211
  var isTransientGatewayText = (text) => TRANSIENT_GATEWAY_PATTERNS.some((re) => re.test(text));
209
212
  var statedCooldown = (text) => {
210
- const hm = /resets?\s+in\s+(\d+)\s*(?:h|hr|hour)s?\s*(\d+)?\s*(?:m|min|minute)?s?/i.exec(text);
211
- if (hm) return (Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3;
212
- const m = /resets?\s+in\s+(\d+)\s*(?:m|min|minute)s?/i.exec(text);
213
- if (m) return Number(m[1]) * 60 * 1e3;
214
- const s = /retry[\s-]?in\s+(\d+)\s*(?:s|sec|second)s?/i.exec(text);
215
- if (s) return Number(s[1]) * 1e3;
213
+ const bounded = (ms) => Number.isFinite(ms) && ms > 0 && ms <= STATED_COOLDOWN_MAX_MS ? ms : void 0;
214
+ const hm = /resets?\s+in\s+(\d+)\s*(?:hour|hr|h)s?\s*(\d+)?\s*(?:minute|min|m)?s?/i.exec(text);
215
+ if (hm) return bounded((Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3);
216
+ const m = /resets?\s+in\s+(\d+)\s*(?:minute|min|m)s?/i.exec(text);
217
+ if (m) return bounded(Number(m[1]) * 60 * 1e3);
218
+ const s = /retry[\s-]?in\s+(\d+)\s*(?:second|sec|s)s?/i.exec(text);
219
+ if (s) return bounded(Number(s[1]) * 1e3);
216
220
  return void 0;
217
221
  };
218
222
  var retryAfterMs = (headers) => {
@@ -246,10 +250,14 @@ function state(opts = {}) {
246
250
  cooling: /* @__PURE__ */ new Map(),
247
251
  strikes: /* @__PURE__ */ new Map(),
248
252
  activity: /* @__PURE__ */ new Map(),
253
+ probes: /* @__PURE__ */ new Set(),
254
+ probing: false,
255
+ lastProbe: 0,
249
256
  children: /* @__PURE__ */ new Map(),
250
257
  reaped: 0,
251
258
  reaping: false,
252
259
  lastSweep: 0,
260
+ lastDirSweep: 0,
253
261
  id,
254
262
  dir,
255
263
  modelsFile,
@@ -286,10 +294,88 @@ function init(s, onWarn) {
286
294
  onWarn(`claim dir ${s.dir} unusable; pool load tracked in memory only`);
287
295
  }
288
296
  }
297
+ var STATE_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
298
+ function sweepStateDir(s) {
299
+ const removed = [];
300
+ if (!s.dirReady) return removed;
301
+ let entries;
302
+ try {
303
+ entries = readdirSync(s.dir);
304
+ } catch {
305
+ return removed;
306
+ }
307
+ const now = Date.now();
308
+ const drop = (name) => {
309
+ try {
310
+ unlinkSync(join2(s.dir, name));
311
+ removed.push(name);
312
+ } catch {
313
+ }
314
+ };
315
+ for (const entry of entries) {
316
+ let age;
317
+ try {
318
+ age = now - statSync(join2(s.dir, entry)).mtimeMs;
319
+ } catch {
320
+ continue;
321
+ }
322
+ if (entry.endsWith(".tmp")) {
323
+ if (age > ORPHAN_TMP_MS) drop(entry);
324
+ continue;
325
+ }
326
+ const claim = /^claims\.(.+)\.json$/.exec(entry);
327
+ if (claim) {
328
+ if (claim[1] === s.id) continue;
329
+ if (/^\d+$/.test(claim[1]) && !isPidAlive(Number(claim[1]))) {
330
+ drop(entry);
331
+ continue;
332
+ }
333
+ if (age > DEAD_FILE_MS) drop(entry);
334
+ continue;
335
+ }
336
+ if (entry.startsWith("limit.") && entry.endsWith(".json")) {
337
+ try {
338
+ const strike = JSON.parse(readFileSync2(join2(s.dir, entry), "utf8"));
339
+ if (typeof strike.until !== "number") {
340
+ drop(entry);
341
+ continue;
342
+ }
343
+ if (strike.until > now) continue;
344
+ if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) continue;
345
+ drop(entry);
346
+ } catch {
347
+ drop(entry);
348
+ }
349
+ }
350
+ }
351
+ return removed;
352
+ }
289
353
  function pruneOwn(s) {
290
354
  const now = Date.now();
291
355
  for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) > CLAIM_TTL_MS) s.own.delete(key);
292
356
  }
357
+ function pruneActivity(s, now) {
358
+ if (s.activity.size === 0) return;
359
+ const live = /* @__PURE__ */ new Set();
360
+ for (const claim of s.own.values()) if (claim.g) live.add(claim.g);
361
+ for (const [id, seen] of s.activity) {
362
+ if (live.has(id)) continue;
363
+ if (now - seen > CLAIM_TTL_MS) s.activity.delete(id);
364
+ }
365
+ }
366
+ function pruneChildren(s, now) {
367
+ if (s.children.size === 0) return;
368
+ for (const [parent, ring] of s.children) {
369
+ const fresh = ring.filter((child) => now - child.t <= CHILD_MEMORY_MS);
370
+ if (fresh.length === 0) s.children.delete(parent);
371
+ else if (fresh.length !== ring.length) s.children.set(parent, fresh);
372
+ }
373
+ }
374
+ function pruneSessions(s) {
375
+ const now = Date.now();
376
+ pruneActivity(s, now);
377
+ pruneChildren(s, now);
378
+ }
293
379
  function publish(s) {
294
380
  if (!s.dirReady) return;
295
381
  const now = Date.now();
@@ -435,6 +521,50 @@ function coolUnavailable(s, model) {
435
521
  }
436
522
  return strike;
437
523
  }
524
+ function clearCooldowns(s, models) {
525
+ const cleared = [];
526
+ const now = Date.now();
527
+ const wanted = models && models.length > 0 ? new Set(models) : void 0;
528
+ let entries;
529
+ try {
530
+ entries = readdirSync(s.dir);
531
+ } catch {
532
+ return cleared;
533
+ }
534
+ for (const entry of entries) {
535
+ if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
536
+ const path4 = join2(s.dir, entry);
537
+ let strike;
538
+ try {
539
+ const parsed = JSON.parse(readFileSync2(path4, "utf8"));
540
+ if (parsed && typeof parsed.model === "string") strike = parsed;
541
+ } catch {
542
+ strike = void 0;
543
+ }
544
+ if (wanted && (strike === void 0 || !wanted.has(strike.model))) continue;
545
+ try {
546
+ unlinkSync(path4);
547
+ } catch {
548
+ continue;
549
+ }
550
+ if (strike === void 0) {
551
+ cleared.push({ model: entry, reason: "unreadable cooldown file", strikes: 0, secondsLeft: 0 });
552
+ continue;
553
+ }
554
+ s.cooling.delete(strike.model);
555
+ s.strikes.delete(strike.model);
556
+ cleared.push({
557
+ model: strike.model,
558
+ reason: typeof strike.reason === "string" ? strike.reason : "unknown",
559
+ strikes: typeof strike.strikes === "number" ? strike.strikes : 0,
560
+ secondsLeft: typeof strike.until === "number" ? Math.max(0, Math.ceil((strike.until - now) / 1e3)) : 0
561
+ });
562
+ }
563
+ return cleared;
564
+ }
565
+ var markProbe = (s, sessionID) => s.probes.add(sessionID);
566
+ var unmarkProbe = (s, sessionID) => s.probes.delete(sessionID);
567
+ var isProbe = (s, sessionID) => s.probes.has(sessionID);
438
568
  function refreshCatalog(s, serverUrl) {
439
569
  if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
440
570
  s.catalogAt = Date.now();
@@ -457,6 +587,7 @@ function isAvailable(s, model, cooling) {
457
587
  }
458
588
  function snapshot(s) {
459
589
  pruneOwn(s);
590
+ pruneSessions(s);
460
591
  const siblings = readSiblings(s);
461
592
  const cooling = readLimits(s);
462
593
  const counts = /* @__PURE__ */ new Map();
@@ -466,7 +597,7 @@ function snapshot(s) {
466
597
  return { counts, cooling, siblings, own: s.own, dirReady: s.dirReady };
467
598
  }
468
599
  var loadOf = (counts, model) => counts.get(model) ?? 0;
469
- var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slot.weight;
600
+ var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
470
601
  function roundRobinSequence(slots) {
471
602
  const seq = [];
472
603
  for (const slot of slots) for (let turn = 0; turn < slot.weight; turn++) seq.push(slot.index);
@@ -655,8 +786,8 @@ failing, or a runtime message saying no slot is available \u2014 that is not a r
655
786
  to do the work yourself, and not a reason to stop and ask.
656
787
 
657
788
  **Retry.** A refused or cooling slot is transient and costs you nothing but a
658
- turn. Spawn the bare base again. Do not escalate a \`-N\` variant when a refusal
659
- names one \u2014 the refusal means the pool has a routing problem, not that you
789
+ turn. Spawn the bare base again. Do not escalate to a numbered variant after a
790
+ refusal \u2014 the refusal means the pool has a routing problem, not that you
660
791
  picked the wrong number.
661
792
 
662
793
  Retry is always available and never wrong, which is why it is the default. But
@@ -1015,7 +1146,7 @@ if the blocker is understanding rather than execution, report that instead.`,
1015
1146
  }
1016
1147
  },
1017
1148
  "context": {
1018
- "description": "Repository cartographer and memory maintainer. Bootstraps and incrementally maintains .opencode/context/ (architecture, contexts, conventions, workflows, decisions) as a small navigation map for future agents. Runs automatically after sessions that changed files; can also be invoked with /context-init, /context-update, /context-review. Never use it for general coding tasks.",
1149
+ "description": "Repository cartographer and memory maintainer. Bootstraps and incrementally maintains .opencode/context/ (architecture, contexts, conventions, workflows, decisions) as a small navigation map for future agents, and keeps .opencode/context/active.md \u2014 the active backlog of work in flight, grouped by week. Runs automatically after sessions that changed files; can also be invoked with /context-init, /context-update, /context-review. Never use it for general coding tasks.",
1019
1150
  "mode": "subagent",
1020
1151
  "temperature": 0.1,
1021
1152
  prompt: `# Role
@@ -1031,7 +1162,7 @@ map is a derived index of it.
1031
1162
 
1032
1163
  # Owned files
1033
1164
 
1034
- You own exactly these five files and nothing else:
1165
+ You own exactly these files and nothing else:
1035
1166
 
1036
1167
  - \`.opencode/context/architecture.md\` \u2014 high-level architecture, major layers,
1037
1168
  boundaries, important entry points, dependency rules. Target 50\u2013150 lines.
@@ -1046,6 +1177,9 @@ You own exactly these five files and nothing else:
1046
1177
  - \`.opencode/context/decisions.md\` \u2014 architectural decisions discovered
1047
1178
  during work that are not already recorded in the project's own decision
1048
1179
  log. Concise; variable length.
1180
+ - \`.opencode/context/active.md\` \u2014 the active backlog: work in flight, grouped
1181
+ by the week it was opened. Not part of the navigation map; it is described
1182
+ under "Task backlog" below.
1049
1183
 
1050
1184
  # Current-state only
1051
1185
 
@@ -1085,7 +1219,8 @@ Explore progressively and stop as soon as you have a usable map:
1085
1219
  You are mapping a city, not auditing every house. Do not attempt to
1086
1220
  understand every file.
1087
1221
 
1088
- Then write the five files. For each significant subsystem, give a short
1222
+ Then write the five files. Also create \`.opencode/context/active.md\` with its
1223
+ heading and no tasks. For each significant subsystem, give a short
1089
1224
  "Start here" list \u2014 at most about five canonical files, each with a one-phrase
1090
1225
  reason. This is the single most valuable thing you produce: where an agent
1091
1226
  should open its editor next.
@@ -1109,7 +1244,7 @@ Trigger: \`.opencode/context/\` exists and work has just been done here.
1109
1244
  - an architectural decision
1110
1245
  - a new dependency between subsystems
1111
1246
  - a renamed or relocated subsystem
1112
- - a previously undocumented invariant
1247
+ - a previously undocumented invariant
1113
1248
 
1114
1249
  Do not update documentation merely because source files changed. Do not
1115
1250
  rewrite files wholesale. Do not make timestamp-only edits. Do not add a fact
@@ -1117,6 +1252,38 @@ that is already stated in another context file \u2014 one fact has one home, and
1117
1252
  the other files link to it. If nothing material changed, make no edits and
1118
1253
  report "no update needed".
1119
1254
 
1255
+ Also keep \`.opencode/context/active.md\` current: record work that is in
1256
+ flight, and mark a task finished \u2014 with the date it finished \u2014 when it is.
1257
+
1258
+ # Task backlog
1259
+
1260
+ \`.opencode/context/active.md\` holds the work in flight. It is a rolling
1261
+ window, not a history: it answers "what is open, and what just finished".
1262
+
1263
+ Format \u2014 one line per task, under the ISO week the task was opened in:
1264
+
1265
+ ## 2026-W43
1266
+
1267
+ - [ ] Ship the release notes \u2014 opened 2026-W43
1268
+ - [x] Fix the flaky test \u2014 opened 2026-W43, finished 2026-10-19
1269
+
1270
+ The rules:
1271
+
1272
+ - A task that is not marked \`[x]\` is OPEN. An open task is never removed,
1273
+ however old it is.
1274
+ - A task is FINISHED when it is marked \`[x]\` AND carries a \`finished\`
1275
+ date. Record the date it actually finished, as \`YYYY-MM-DD\`.
1276
+ - The week is \`YYYY-Www\`, Monday to Sunday. \`date +%G-W%V\` prints it.
1277
+ - A finished task is dropped once it is more than a week old. The plugin
1278
+ enforces that: record the finished task with its date and let the window
1279
+ close on its own. Do not delete old finished tasks by hand, and do not
1280
+ keep a finished task back to preserve it \u2014 the file is meant to stay
1281
+ small.
1282
+ - Never invent a task. Record work that actually happened, and mark a task
1283
+ finished only once it is finished.
1284
+ - Keep the file inside the window. If the backlog has grown past a handful
1285
+ of weeks, that is a signal to close finished tasks, not to add sections.
1286
+
1120
1287
  # Minimal exploration
1121
1288
 
1122
1289
  Default shape:
@@ -1203,7 +1370,7 @@ the lowest-value ones, and link out to authoritative project docs.
1203
1370
 
1204
1371
  Trigger: an explicit review (see the \`context-review\` command).
1205
1372
 
1206
- Audit the five files for:
1373
+ Audit the map files and the backlog for:
1207
1374
 
1208
1375
  - stale information \u2014 verify claims against the code
1209
1376
  - contradictions between files, or between the map and the code
@@ -1211,10 +1378,12 @@ Audit the five files for:
1211
1378
  - duplicated information \u2014 one canonical place per fact
1212
1379
  - missing canonical entry points for significant subsystems
1213
1380
  - unsupported assumptions \u2014 anything unmarked that you cannot verify
1381
+ - backlog tasks that are finished but carry no date \u2014 the retention window
1382
+ cannot age those out, so they stay forever
1214
1383
 
1215
- Fix problems only in the five files you own. Never modify source code
1216
- during a review. Report what you found, what you fixed, and what you
1217
- could not verify.
1384
+ Fix problems only in the files you own. Never modify source code during a
1385
+ review. Report what you found, what you fixed, and what you could not
1386
+ verify.
1218
1387
 
1219
1388
  # Reporting
1220
1389
 
@@ -1319,20 +1488,46 @@ on that.
1319
1488
  write only what the client asked for.
1320
1489
  - Say plainly in your report that you created the file.
1321
1490
 
1491
+ # Before you write
1492
+
1493
+ Read \`.opencode/context/\` first when it is there \u2014 \`architecture.md\`,
1494
+ \`conventions.md\` or \`contexts.md\`, whichever bears on the directive \u2014 so you do
1495
+ not record a fact the project has already settled, and do not contradict one you
1496
+ do not know yet. Read only what is relevant, and carry on if it is missing.
1497
+
1498
+ That map is READ-ONLY to you: the \`context\` agent is its only writer. If the
1499
+ directive contradicts it, say so plainly in your report and still write what the
1500
+ client asked for. Never edit the map to agree with \`AGENTS.md\` \u2014 the two are
1501
+ different files with different jobs, and this one is yours alone.
1502
+
1322
1503
  # Reporting
1323
1504
 
1324
1505
  Report briefly what you changed and where. Do not edit any other file.`,
1325
1506
  permission: {
1326
- // Read is scoped to the one file too: the directive is about AGENTS.md, and
1327
- // nothing else is any of this agent's business.
1507
+ // Read reaches AGENTS.md and the context map, and nothing else.
1508
+ //
1509
+ // The map is here because the directive is about how work is DONE in this
1510
+ // project and the map is the maintained record of what the project IS: reading
1511
+ // it is how this agent avoids recording a fact the code already settles, and
1512
+ // avoids contradicting one it does not know yet. It stays READ-ONLY — `edit`
1513
+ // below still denies the map, so this grant cannot turn the one-writer rule
1514
+ // into a two-writer rule, and the writer guard does not have to care about it
1515
+ // (it reads `edit` alone).
1516
+ //
1517
+ // Both spellings are required, and every allow must sit AFTER the broad deny:
1518
+ // LAST match wins, so an allow written before "*": "deny" is silently inert.
1328
1519
  read: {
1329
1520
  "*": "deny",
1330
1521
  "AGENTS.md": "allow",
1331
- "*/AGENTS.md": "allow"
1522
+ "*/AGENTS.md": "allow",
1523
+ "*/.opencode/context/**": "allow",
1524
+ ".opencode/context/**": "allow"
1332
1525
  },
1333
1526
  glob: "deny",
1334
1527
  grep: "deny",
1335
1528
  list: "deny",
1529
+ // Still exactly one writable path. The map is readable (see above) and stays
1530
+ // unwritable: `context` is its only writer, and this agent is not it.
1336
1531
  edit: {
1337
1532
  "*": "deny",
1338
1533
  "AGENTS.md": "allow",
@@ -1356,7 +1551,28 @@ function buildAgentSet() {
1356
1551
  const variant = {
1357
1552
  ...def,
1358
1553
  description: `Hidden pool variant ${base}-${slot}: slot ${slot}. 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.`,
1359
- hidden: true
1554
+ hidden: true,
1555
+ // `mode: "primary"` is what keeps a variant OUT of the model's system
1556
+ // prompt. `hidden` cannot do it.
1557
+ //
1558
+ // opencode builds the "Available agent types" block by filtering the
1559
+ // agent registry on `mode !== "primary"` and on the caller's task
1560
+ // permission. It never looks at `hidden`: that flag is honoured only by
1561
+ // the TUI and the @-mention pickers, by the "agent not found" message,
1562
+ // and by default-agent resolution. A variant left as `mode: "subagent"`
1563
+ // is therefore listed to the model under its own name -- "explore-1:
1564
+ // Hidden pool variant explore-1" -- and the model copies that name
1565
+ // straight into its next spawn. The pool then refuses it, because
1566
+ // naming a variant is how a caller would pick the provider and skip the
1567
+ // rotation. The refusal is right; the leak that provokes it is not.
1568
+ //
1569
+ // `mode` is the only field that filter respects, and setting it here is
1570
+ // safe: the task tool never reads an agent's mode -- it resolves the
1571
+ // agent by name and runs it -- and every other place that filters on
1572
+ // `mode !== "primary"` also filters on `!hidden`, which these already
1573
+ // are. opencode uses this same pair for its own compaction, title and
1574
+ // summary agents.
1575
+ mode: "primary"
1360
1576
  };
1361
1577
  delete variant.model;
1362
1578
  set[`${base}-${slot}`] = variant;
@@ -1396,7 +1612,8 @@ your system prompt.
1396
1612
  Otherwise follow the bootstrap procedure in your system prompt: progressive
1397
1613
  exploration (README \u2192 existing docs \u2192 manifests \u2192 top-level structure \u2192 build
1398
1614
  and framework config \u2192 entry points \u2192 selective source), then write the five
1399
- context files inside their size targets.
1615
+ context files inside their size targets. Also create
1616
+ \`.opencode/context/active.md\` with its heading and no tasks.
1400
1617
 
1401
1618
  Remember: write conclusions, not inventories. Link to the project's existing
1402
1619
  documentation instead of duplicating it.
@@ -1433,10 +1650,10 @@ timestamp-only edits. If nothing material changed, make no edits and report
1433
1650
  subtask: true,
1434
1651
  template: `Context review.
1435
1652
 
1436
- Everything outside the five context files is read-only for this run. Do not
1653
+ Everything outside \`.opencode/context/\` is read-only for this run. Do not
1437
1654
  modify source code under any circumstances.
1438
1655
 
1439
- Audit the five context files for:
1656
+ Audit the context files and the backlog for:
1440
1657
 
1441
1658
  - stale information \u2014 verify claims against the actual code
1442
1659
  - contradictions between context files, or between the map and the code
@@ -1447,6 +1664,8 @@ Audit the five context files for:
1447
1664
  - missing canonical entry points for significant subsystems
1448
1665
  - unsupported assumptions \u2014 anything stated as fact that you cannot verify,
1449
1666
  or that lacks a confidence marker
1667
+ - backlog tasks that are finished but carry no date \u2014 the retention window
1668
+ cannot age those out, so they stay forever
1450
1669
 
1451
1670
  Fix what you find in \`.opencode/context/\` only, then report: findings, fixes,
1452
1671
  and anything you could not verify.`
@@ -1468,7 +1687,8 @@ Follow the rules for the file in your system prompt, then report what you change
1468
1687
  Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
1469
1688
 
1470
1689
  1. Pool status \u2014 call the \`pool_status\` tool. Report the slot list, each
1471
- slot's load against its weight ceiling, and which models are cooling.
1690
+ slot's load against its ceiling (twice the slot's weight), and which models
1691
+ are cooling.
1472
1692
 
1473
1693
  2. OmniRoute configured \u2014 inspect the \`@lawaty/lacode\` plugin options in the
1474
1694
  user's opencode config for an \`omniroute\` block with \`enabled: true\`. If it
@@ -1505,7 +1725,7 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
1505
1725
  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:
1506
1726
  - one slot per line: \`<provider>/<model-id> [weight]\`
1507
1727
  - at most ${MAX_SLOTS} slots, and a model may appear only once
1508
- - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the soft ceiling on concurrent claims and the slot's share of the rotation \u2014 NOT a priority; pick order follows line order.
1728
+ - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's share of the rotation \u2014 how many turns it takes per cycle \u2014 and its concurrency ceiling is twice that. NOT a priority; pick order follows line order.
1509
1729
  - \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
1510
1730
  If the instruction names a model ambiguously, or would need more than ${MAX_SLOTS} slots, stop and ask rather than guessing.
1511
1731
 
@@ -1540,11 +1760,126 @@ function applyCommands(cfg, commands = COMMANDS) {
1540
1760
 
1541
1761
  // src/plugins/agent-pool.ts
1542
1762
  import { tool } from "@opencode-ai/plugin";
1763
+
1764
+ // src/lib/probe.ts
1765
+ var PROBE_SYSTEM = "You are a connection health check. Reply with the single word: ok";
1766
+ var PROBE_PROMPT = "ok";
1767
+ var PROBE_TIMEOUT_MS = 45e3;
1768
+ var failureText = (error) => {
1769
+ if (error === void 0 || error === null) return "";
1770
+ if (typeof error === "string") return error;
1771
+ try {
1772
+ return JSON.stringify(error) ?? String(error);
1773
+ } catch {
1774
+ return String(error);
1775
+ }
1776
+ };
1777
+ var shortReason = (text) => {
1778
+ if (!text) return "failed";
1779
+ const matched = text.match(/rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded/i)?.[0];
1780
+ return matched ? matched.toLowerCase().replace(/[\s_]/g, "-") : isLimitText(text) ? "limit" : "failed";
1781
+ };
1782
+ var probeOne = async (client, opts, model, leftSeconds) => {
1783
+ const { state: s, directory } = opts;
1784
+ const { provider, id: modelID } = splitModel(model);
1785
+ const settle = (ok, reason, left) => ({
1786
+ model,
1787
+ ok,
1788
+ reason,
1789
+ hadSeconds: leftSeconds,
1790
+ leftSeconds: left
1791
+ });
1792
+ const created = await client.session.create({ body: { title: `pool probe (${model})` }, query: { directory } }).then((r) => r?.data?.id).catch(() => void 0);
1793
+ if (!created) return settle(false, "no probe session", leftSeconds);
1794
+ markProbe(s, created);
1795
+ let timer;
1796
+ try {
1797
+ const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
1798
+ const clock = opts.now ?? Date.now;
1799
+ let timedOut = false;
1800
+ const deadline = new Promise((resolve3) => {
1801
+ timer = setTimeout(() => {
1802
+ timedOut = true;
1803
+ void client.session.abort({ path: { id: created }, query: { directory } }).catch(() => {
1804
+ });
1805
+ resolve3(void 0);
1806
+ }, timeoutMs);
1807
+ });
1808
+ const answer = client.session.prompt({
1809
+ path: { id: created },
1810
+ query: { directory },
1811
+ body: {
1812
+ model: { providerID: provider, modelID },
1813
+ system: PROBE_SYSTEM,
1814
+ parts: [{ type: "text", text: PROBE_PROMPT }]
1815
+ }
1816
+ }).then(
1817
+ (result2) => result2,
1818
+ (error) => ({ error })
1819
+ );
1820
+ const result = await Promise.race([answer, deadline]);
1821
+ const failure = result?.error ?? result?.data?.info?.error;
1822
+ if (!timedOut && failure === void 0 && result?.data?.info !== void 0) {
1823
+ clearCooldowns(s, [model]);
1824
+ return settle(true, "answered", 0);
1825
+ }
1826
+ const text = failureText(failure);
1827
+ const stated = statedCooldown(text);
1828
+ if (stated !== void 0) recordStrike(s, model, shortReason(text), stated);
1829
+ const still = readLimits(s).get(model);
1830
+ return settle(
1831
+ false,
1832
+ timedOut ? `no answer in ${Math.round(timeoutMs / 1e3)}s` : shortReason(text),
1833
+ still && still.until > clock() ? Math.ceil((still.until - clock()) / 1e3) : 0
1834
+ );
1835
+ } finally {
1836
+ if (timer) clearTimeout(timer);
1837
+ const deleted = await client.session.delete({ path: { id: created }, query: { directory } }).then(() => true, () => false);
1838
+ if (deleted) unmarkProbe(s, created);
1839
+ }
1840
+ };
1841
+ async function probeCooling(client, opts) {
1842
+ const s = opts.state;
1843
+ const now = opts.now ?? Date.now;
1844
+ const empty = { outcomes: [], cleared: [], kept: [], changed: false };
1845
+ if (s.probing) return empty;
1846
+ const cooling = readLimits(s);
1847
+ const at = now();
1848
+ const wanted = opts.models && opts.models.length > 0 ? new Set(opts.models) : void 0;
1849
+ const candidates = [...cooling.values()].filter(
1850
+ (strike) => strike.until > at && (wanted === void 0 || wanted.has(strike.model))
1851
+ );
1852
+ if (candidates.length === 0) return empty;
1853
+ s.probing = true;
1854
+ const outcomes = [];
1855
+ try {
1856
+ for (const strike of candidates.sort((a, b) => a.until - b.until)) {
1857
+ outcomes.push(await probeOne(client, opts, strike.model, Math.ceil((strike.until - at) / 1e3)));
1858
+ }
1859
+ } finally {
1860
+ s.probing = false;
1861
+ s.lastProbe = Date.now();
1862
+ }
1863
+ const reAdmitted = outcomes.filter((o) => o.ok).map((o) => o.model);
1864
+ const stillLimited = outcomes.filter((o) => !o.ok);
1865
+ return {
1866
+ outcomes,
1867
+ cleared: reAdmitted,
1868
+ kept: stillLimited.map((o) => o.model),
1869
+ // A window the provider restated is a change too: the pool now waits a
1870
+ // different length than it did, and the user is the one who set that.
1871
+ changed: reAdmitted.length > 0 || stillLimited.some((o) => o.leftSeconds !== o.hadSeconds)
1872
+ };
1873
+ }
1874
+
1875
+ // src/plugins/agent-pool.ts
1543
1876
  var SERVICE = "agent-pool";
1544
1877
  var REAP_INTERVAL_MS = 3e4;
1545
1878
  var HANG_FALLBACK_MS = 10 * 60 * 1e3;
1879
+ var PROBE_INTERVAL_MS = 15 * 60 * 1e3;
1546
1880
  var ABORT_TIMEOUT_MS = 1e4;
1547
- var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1881
+ var formatWindow = (seconds) => seconds < 60 ? `${seconds}s` : `${Math.ceil(seconds / 60)}m`;
1882
+ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
1548
1883
  const opts = options ?? {};
1549
1884
  const s = state(opts);
1550
1885
  ensureUserConfigDir();
@@ -1556,6 +1891,13 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1556
1891
  await log("warn", message);
1557
1892
  };
1558
1893
  init(s, (m) => void warnOnce(m));
1894
+ const sweepDirIfDue = () => {
1895
+ if (Date.now() - s.lastDirSweep < STATE_SWEEP_INTERVAL_MS) return;
1896
+ s.lastDirSweep = Date.now();
1897
+ const removed = sweepStateDir(s);
1898
+ if (removed.length > 0) void log("info", `swept ${removed.length} stale pool file(s): ${removed.join(", ")}`);
1899
+ };
1900
+ sweepDirIfDue();
1559
1901
  const abortSession = async (sessionID) => {
1560
1902
  if (!serverUrl) return { ok: false, why: "no serverUrl: this process has no HTTP server to abort through" };
1561
1903
  try {
@@ -1597,6 +1939,8 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1597
1939
  s.reaping = true;
1598
1940
  s.lastSweep = Date.now();
1599
1941
  try {
1942
+ pruneSessions(s);
1943
+ sweepDirIfDue();
1600
1944
  const stuck = findStuck(s);
1601
1945
  if (stuck.length === 0) return;
1602
1946
  const released = [];
@@ -1661,6 +2005,46 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1661
2005
  "info",
1662
2006
  `reaper armed: every ${Math.round(reapMs / 1e3)}s, aborts a claim older than ${Math.round(STUCK_MIN_AGE_MS / 6e4)}m whose target has been silent ${Math.round(STUCK_IDLE_MS / 6e4)}m, or any claim past the ${Math.round(MAX_CLAIM_AGE_MS / 6e4)}m ceiling however chatty`
1663
2007
  );
2008
+ const EMPTY_REPORT = { outcomes: [], cleared: [], kept: [], changed: false };
2009
+ const reportTitle = (report) => report.outcomes.length === 0 ? "nothing cooling to probe" : `probed ${report.outcomes.length} cooling model(s), re-admitted ${report.cleared.length}`;
2010
+ const reportText = (report) => {
2011
+ if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
2012
+ return [
2013
+ ...report.outcomes.map(
2014
+ (outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the rotation` : `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`
2015
+ ),
2016
+ report.cleared.length > 0 ? `re-admitted: ${report.cleared.join(", ")}. They route again on the next pooled spawn \u2014 nothing needs restarting or reloading.` : "nothing was re-admitted; the pool is short exactly as much as it was."
2017
+ ].join("\n");
2018
+ };
2019
+ const announce = async (report) => {
2020
+ if (!report.changed) return;
2021
+ const message = report.cleared.length > 0 ? `Re-admitted ${report.cleared.join(", ")} \u2014 they answer again.` : `${report.kept.length} cooling model(s) re-checked, still limited.`;
2022
+ await log("info", `pool probe: ${message}`, { cleared: report.cleared, kept: report.kept });
2023
+ try {
2024
+ await client.tui?.showToast?.({
2025
+ body: { title: "pool recheck", message, variant: report.cleared.length > 0 ? "success" : "info", duration: 8e3 }
2026
+ });
2027
+ } catch {
2028
+ }
2029
+ };
2030
+ const runProbe = async (models) => {
2031
+ let report = EMPTY_REPORT;
2032
+ try {
2033
+ report = await probeCooling(client, { state: s, directory, models });
2034
+ } catch (error) {
2035
+ await log("error", `pool probe failed: ${String(error)}`);
2036
+ return EMPTY_REPORT;
2037
+ }
2038
+ await announce(report);
2039
+ return report;
2040
+ };
2041
+ const probeMs = opts.probeMs ?? PROBE_INTERVAL_MS;
2042
+ const probeTimer = probeMs > 0 ? setInterval(() => void runProbe(), probeMs) : void 0;
2043
+ probeTimer?.unref?.();
2044
+ void log(
2045
+ "info",
2046
+ probeMs > 0 ? `cooldown probe armed: every ${Math.round(probeMs / 6e4)}m, checks the models that are cooling by using them (one request each, one at a time); 0 to disable` : "cooldown probe disabled (probeMs: 0); use the pool_recheck tool to check on demand"
2047
+ );
1664
2048
  return {
1665
2049
  config: async (cfg) => {
1666
2050
  const slots = ensureSlots(s, (m) => void warnOnce(m));
@@ -1712,6 +2096,10 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1712
2096
  const reason2 = status2?.action?.reason ?? "";
1713
2097
  if (status2?.type === "retry" && status2.action?.provider && (reason2 === "free_tier_limit" || isLimitText(reason2))) {
1714
2098
  const provider = status2.action.provider;
2099
+ if (sessionID && isProbe(s, sessionID)) {
2100
+ await log("debug", `probe on provider ${provider} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
2101
+ return;
2102
+ }
1715
2103
  const until = typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
1716
2104
  const cooled = coolProvider(s, provider, until, reason2 || "rate-limited");
1717
2105
  if (cooled.length > 0) {
@@ -1746,6 +2134,10 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1746
2134
  const transientGateway = gateway5xx || isTransientGatewayText(haystack);
1747
2135
  if (!limited && !transientGateway) return;
1748
2136
  const model = `${info.providerID ?? "?"}/${info.modelID ?? "?"}`;
2137
+ if (info.sessionID && isProbe(s, info.sessionID)) {
2138
+ await log("debug", `probe of ${model} hit a limit (${isLimitText(haystack) ? "limit" : "transient"}); leaving the cooldown to the probe`);
2139
+ return;
2140
+ }
1749
2141
  if (isUnavailableText(haystack) || !limited && transientGateway) {
1750
2142
  const transient = coolUnavailable(s, model);
1751
2143
  if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
@@ -1766,12 +2158,11 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1766
2158
  if (typeof requested !== "string") return;
1767
2159
  const manual = manualVariant(requested);
1768
2160
  if (manual) {
1769
- await log("warn", `refused manual spawn of slot variant ${requested}; spawn "${manual}" instead`, {
1770
- variant: requested,
2161
+ await log("warn", `refused manual spawn of a pool slot variant; spawn "${manual}" instead`, {
1771
2162
  base: manual
1772
2163
  });
1773
2164
  throw new Error(
1774
- `${requested} is a pool slot variant and cannot be spawned by name. Naming it lets the caller pick the provider: it skips the round-robin rotation and the slot's ceiling, so one provider takes every concurrent claim. Spawn "${manual}" and let the pool choose the slot.`
2165
+ `That agent is a pool slot variant and cannot be spawned by name. Naming it lets the caller pick the provider: it skips the round-robin rotation and the slot's ceiling, so one provider takes every concurrent claim. Spawn "${manual}" and let the pool choose the slot.`
1775
2166
  );
1776
2167
  }
1777
2168
  try {
@@ -1787,15 +2178,22 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1787
2178
  }
1788
2179
  acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
1789
2180
  output.args.subagent_type = variant;
1790
- await log("info", `routed ${requested} -> ${variant}`, {
2181
+ await log("info", `routed ${requested} to slot ${slot.index} on ${slot.model}`, {
1791
2182
  slot: slot.index,
1792
2183
  weight: slot.weight,
1793
2184
  model: slot.model,
1794
- ceiling: slot.weight,
2185
+ ceiling: slotCeiling(slot),
1795
2186
  load: (snap.counts.get(slot.model) ?? 0) + 1
1796
2187
  });
1797
2188
  return;
1798
2189
  }
2190
+ const pinned = s.models.get(requested);
2191
+ const bench = pinned ? snap.cooling.get(pinned) : void 0;
2192
+ if (pinned && bench) {
2193
+ await warnOnce(
2194
+ `${requested} runs ${pinned}, which is cooling (${bench.reason}, strike ${bench.strikes}) -- it cannot fail over, because only ${BASES.join("/")} are pooled. Drop the model line from ${requested} to route it through the pool, or wait out the cooldown.`
2195
+ );
2196
+ }
1799
2197
  } catch (error) {
1800
2198
  await log("error", `routing failed, spawn unchanged: ${String(error)}`);
1801
2199
  }
@@ -1805,8 +2203,18 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1805
2203
  release(s, claimKey(input.sessionID, input.callID));
1806
2204
  },
1807
2205
  tool: {
2206
+ pool_recheck: tool({
2207
+ description: "Probe the models the pool has benched and re-admit the ones that answer. Each probe is a real request on that exact model, so it settles what a cooldown can only guess: if the model replies, its cooldown is cleared and it is back in the rotation; if it is still limited, the existing cooldown stands. Call it after resetting a quota in the provider's own console, when the pool feels short of capacity, or to check now instead of waiting for the next automatic pass.",
2208
+ args: {
2209
+ models: tool.schema.array(tool.schema.string()).optional().describe("Model ids to probe, as provider/model-id. Omit to probe every model that is cooling.")
2210
+ },
2211
+ async execute(args) {
2212
+ const report = await runProbe(args?.models);
2213
+ return { title: `agent-pool: ${reportTitle(report)}`, output: reportText(report) };
2214
+ }
2215
+ }),
1808
2216
  pool_status: tool({
1809
- description: "Show live pool load across every opencode process, per free model and per agent type, each slot's load against its weight ceiling, the slot marked <- next that the next spawn would take, and which models are cooling after a rate limit or a stuck task. Check it before launching many parallel subagents.",
2217
+ 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. Check it before launching many parallel subagents.",
1810
2218
  args: {},
1811
2219
  async execute() {
1812
2220
  refreshCatalog(s, serverUrl);
@@ -1835,7 +2243,7 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1835
2243
  const marker = slot.index === next.index ? " <- next" : "";
1836
2244
  const load = entry?.total ?? 0;
1837
2245
  const atCeiling = !underCeiling(slot, snap.counts);
1838
- return ` slot ${slot.index} w${slot.weight} ${slot.model} load=${load}/${slot.weight}${atCeiling ? " FULL" : ""}${marker} [${users}${notes.length ? "; " + notes.join("; ") : ""}]`;
2246
+ return ` slot ${slot.index} w${slot.weight} ${slot.model} load=${load}/${slotCeiling(slot)}${atCeiling ? " FULL" : ""}${marker} [${users}${notes.length ? "; " + notes.join("; ") : ""}]`;
1839
2247
  };
1840
2248
  const total = [...snap.counts.values()].reduce((sum, n) => sum + n, 0);
1841
2249
  const coolingNow = [...snap.cooling.entries()].filter(([, st]) => st.until > now);
@@ -1857,6 +2265,10 @@ round robin in slot order, each slot taking as many turns as its weight says, so
1857
2265
  `hung past ${Math.round(STUCK_MIN_AGE_MS / 6e4)}min with ${Math.round(STUCK_IDLE_MS / 6e4)}min silence: ${hung} (auto-aborted); reaped total: ${s.reaped}.`,
1858
2266
  `overran past ${Math.round(MAX_CLAIM_AGE_MS / 6e4)}min: ${overran} (auto-aborted).`,
1859
2267
  `watchdog alive: last sweep ${s.lastSweep ? `${Math.max(0, Math.round((now - s.lastSweep) / 1e3))}s ago` : "never"} (every ${Math.round(reapMs / 1e3)}s).`,
2268
+ // When the cooled models were last USED rather than inferred, because
2269
+ // "still cooling" and "nobody has actually asked it lately" are
2270
+ // different situations and this is the line that tells them apart.
2271
+ probeMs > 0 ? `cooldown probe: last ${s.lastProbe ? `${Math.max(0, Math.round((now - s.lastProbe) / 1e3))}s ago` : "never (nothing has been cooling)"}, every ${Math.round(probeMs / 6e4)}m; call the pool_recheck tool to ask now.` : "cooldown probe: off (probeMs: 0); call the pool_recheck tool to ask now.",
1860
2272
  coolingNow.length === 0 ? "no models cooling down." : `cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
1861
2273
  ].join("\n")
1862
2274
  };
@@ -1865,16 +2277,120 @@ round robin in slot order, each slot taking as many turns as its weight says, so
1865
2277
  },
1866
2278
  dispose: async () => {
1867
2279
  clearInterval(timer);
2280
+ if (probeTimer) clearInterval(probeTimer);
1868
2281
  s.own.clear();
2282
+ s.activity.clear();
2283
+ s.children.clear();
1869
2284
  publish(s);
1870
2285
  }
1871
2286
  };
1872
2287
  });
1873
2288
 
1874
2289
  // src/plugins/context-autoupdate.ts
1875
- import { existsSync as existsSync2, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
2290
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
2291
+ import { homedir as homedir2 } from "node:os";
1876
2292
  import * as path2 from "node:path";
1877
2293
 
2294
+ // src/lib/active-backlog.ts
2295
+ var ACTIVE_BACKLOG_FILE = "active.md";
2296
+ var RETENTION_DAYS = 7;
2297
+ var DAY_MS = 24 * 60 * 60 * 1e3;
2298
+ var HEADER = `# Active backlog
2299
+
2300
+ Tasks in flight, grouped by the week they were opened. A finished task stays
2301
+ here for a week after it was finished and is then dropped; an open task stays
2302
+ however long it takes. Add a line under the right week, and give a finished
2303
+ task its date: \`- [x] Title \u2014 opened 2026-W43, finished 2026-10-19\`.`;
2304
+ var TASK_LINE = /^\s*(?:[-*+]|\d+[.)])\s*\[([ xX])\]\s*(.*)$/;
2305
+ var META = /(?:—|–|-)\s*opened\s+(\d{4}-W(?:0[1-9]|[1-4]\d|5[0-3]))|[,—–-]\s*finished\s+(\d{4}-\d{2}-\d{2})/gi;
2306
+ var isCalendarDate = (s) => {
2307
+ const at = Date.parse(`${s}T00:00:00Z`);
2308
+ return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === s;
2309
+ };
2310
+ var parseBacklog = (text) => {
2311
+ const entries = [];
2312
+ for (const raw of text.split(/\r?\n/)) {
2313
+ const line = TASK_LINE.exec(raw);
2314
+ if (!line) continue;
2315
+ const finished = line[1] !== " ";
2316
+ let week;
2317
+ let finishedAt;
2318
+ const stripped = line[2].replace(META, (fragment, openedWeek, finishedDate) => {
2319
+ if (openedWeek !== void 0) {
2320
+ week ??= openedWeek.toUpperCase();
2321
+ return "";
2322
+ }
2323
+ if (finishedDate !== void 0 && isCalendarDate(finishedDate)) {
2324
+ finishedAt ??= finishedDate;
2325
+ return "";
2326
+ }
2327
+ return fragment;
2328
+ });
2329
+ const title = stripped.replace(/^[\s—–-]+|[\s—–-]+$/g, "");
2330
+ if (!title) continue;
2331
+ entries.push({ title, ...week ? { week } : {}, finished, ...finishedAt ? { finishedAt } : {} });
2332
+ }
2333
+ return entries;
2334
+ };
2335
+ var ISO_WEEK = /^(\d{4})-W(\d{2})$/i;
2336
+ var newestFirst = (a, b) => {
2337
+ const ma = ISO_WEEK.exec(a);
2338
+ const mb = ISO_WEEK.exec(b);
2339
+ if (ma && mb) return Number(mb[1]) - Number(ma[1]) || Number(mb[2]) - Number(ma[2]);
2340
+ if (ma) return -1;
2341
+ if (mb) return 1;
2342
+ return a < b ? 1 : a > b ? -1 : 0;
2343
+ };
2344
+ var renderLine = (entry) => {
2345
+ const box = entry.finished ? "[x]" : "[ ]";
2346
+ const meta = [];
2347
+ if (entry.week) meta.push(`opened ${entry.week}`);
2348
+ if (entry.finished && entry.finishedAt) meta.push(`finished ${entry.finishedAt}`);
2349
+ const head = `- ${box} ${entry.title}`;
2350
+ if (meta.length === 0) return head;
2351
+ return entry.week ? `${head} \u2014 ${meta.join(", ")}` : `${head}, ${meta.join(", ")}`;
2352
+ };
2353
+ var renderBacklog = (entries) => {
2354
+ const buckets = /* @__PURE__ */ new Map();
2355
+ for (const entry of entries) {
2356
+ const key = entry.week ? entry.week : null;
2357
+ const bucket = buckets.get(key);
2358
+ if (bucket) bucket.push(entry);
2359
+ else buckets.set(key, [entry]);
2360
+ }
2361
+ const weeks = [...buckets.keys()].filter((week) => week !== null).sort(newestFirst);
2362
+ let out = HEADER;
2363
+ for (const week of weeks) out += `
2364
+
2365
+ ## ${week}
2366
+
2367
+ ${buckets.get(week).map(renderLine).join("\n")}`;
2368
+ const unscheduled = buckets.get(null);
2369
+ if (unscheduled) out += `
2370
+
2371
+ ## Unscheduled
2372
+
2373
+ ${unscheduled.map(renderLine).join("\n")}`;
2374
+ return `${out}
2375
+ `;
2376
+ };
2377
+ var normalizeBacklog = (text, now = Date.now()) => {
2378
+ const entries = parseBacklog(text);
2379
+ const kept = [];
2380
+ const removed = [];
2381
+ for (const entry of entries) {
2382
+ if (entry.finished && entry.finishedAt) {
2383
+ const at = Date.parse(`${entry.finishedAt}T00:00:00Z`);
2384
+ if (Number.isFinite(at) && now - at > RETENTION_DAYS * DAY_MS) {
2385
+ removed.push(entry.title);
2386
+ continue;
2387
+ }
2388
+ }
2389
+ kept.push(entry);
2390
+ }
2391
+ return { text: renderBacklog(kept), removed };
2392
+ };
2393
+
1878
2394
  // src/lib/jsonc.ts
1879
2395
  var stripJsonc = (raw) => {
1880
2396
  let out = "";
@@ -2028,8 +2544,24 @@ var IGNORED_SEGMENTS = /* @__PURE__ */ new Set([
2028
2544
  ".next",
2029
2545
  ".turbo",
2030
2546
  ".cache",
2031
- ".git"
2547
+ ".git",
2548
+ // Generated payloads. These are OUTPUT, not source: a single decoded corpus or
2549
+ // derived report in one of these is routinely tens of megabytes, and an agent
2550
+ // that samples one with `head`/`cat` produces a tool output large enough to
2551
+ // be worth recording on its own.
2552
+ "derived_outputs",
2553
+ "derived",
2554
+ "artifacts",
2555
+ "corpus",
2556
+ "corpora"
2032
2557
  ]);
2558
+ var NO_BULK_READS = [
2559
+ "Do not read generated, vendored or corpus payloads: `node_modules/`, `dist/`,",
2560
+ "`build/`, `out/`, `*.min.js`, `derived_outputs/`, `*.bytes`, and large `.json`/`.csv`",
2561
+ "corpora. They are megabytes each and describe no architecture. Read the source",
2562
+ "that PRODUCES or CONSUMES them instead. If a payload must be sampled, use",
2563
+ "`head -c 2000` or `wc -c` \u2014 never `cat` or a bare `grep -r` across one."
2564
+ ].join(" ");
2033
2565
  var toPosix2 = (value) => value.split(path2.sep).join("/");
2034
2566
  var findProjectConfig = () => {
2035
2567
  let dir = import.meta.dir ?? path2.dirname(new URL(import.meta.url).pathname);
@@ -2064,6 +2596,43 @@ var changedSince = (before, dir) => {
2064
2596
  for (const name of before.keys()) if (!after.has(name)) changed++;
2065
2597
  return changed;
2066
2598
  };
2599
+ var RETAIN_TRANSCRIPTS = 20;
2600
+ var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(homedir2(), ".local", "share", "opencode", "lacode", "cartographer");
2601
+ var retireSession = async (client, directory, sessionID, mode, log) => {
2602
+ try {
2603
+ const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
2604
+ const dir = transcriptDir();
2605
+ mkdirSync2(dir, { recursive: true });
2606
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2607
+ const file = path2.join(dir, `${stamp}-${mode}.json`);
2608
+ writeFileSync2(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
2609
+ pruneTranscripts(dir);
2610
+ const gone = async () => await client.session.get({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0) === void 0;
2611
+ await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
2612
+ if (await gone()) return;
2613
+ await new Promise((resolve3) => setTimeout(resolve3, 500));
2614
+ await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
2615
+ if (await gone()) return;
2616
+ await log?.("warn", "the cartographer's session could not be removed from the session list; it stays as an empty entry", {
2617
+ sessionID,
2618
+ mode
2619
+ });
2620
+ } catch (error) {
2621
+ await log?.("warn", `could not retire the cartographer's session: ${String(error)}`, { sessionID, mode });
2622
+ }
2623
+ };
2624
+ var pruneTranscripts = (dir) => {
2625
+ try {
2626
+ const entries = readdirSync2(dir).filter((name) => name.endsWith(".json")).sort();
2627
+ for (const name of entries.slice(0, Math.max(0, entries.length - RETAIN_TRANSCRIPTS))) {
2628
+ try {
2629
+ unlinkSync2(path2.join(dir, name));
2630
+ } catch {
2631
+ }
2632
+ }
2633
+ } catch {
2634
+ }
2635
+ };
2067
2636
  var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2068
2637
  const pending = /* @__PURE__ */ new Set();
2069
2638
  const owned = /* @__PURE__ */ new Map();
@@ -2073,6 +2642,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2073
2642
  });
2074
2643
  const root = path2.resolve(directory);
2075
2644
  const contextAbs = path2.join(root, CONTEXT_DIR2);
2645
+ const activeBacklogPath = path2.join(contextAbs, ACTIVE_BACKLOG_FILE);
2076
2646
  const config = findProjectConfig();
2077
2647
  const writerProblem = config ? verifyWriterRule(config, contextAbs, root) : void 0;
2078
2648
  if (writerProblem) void log("error", writerProblem);
@@ -2082,6 +2652,23 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2082
2652
  if (absolute === contextAbs || absolute.startsWith(contextAbs + path2.sep)) return false;
2083
2653
  return !toPosix2(absolute).split("/").some((segment) => IGNORED_SEGMENTS.has(segment));
2084
2654
  };
2655
+ const normalizeActiveBacklog = async () => {
2656
+ try {
2657
+ if (!existsSync2(activeBacklogPath)) return { removed: 0 };
2658
+ const before = readFileSync4(activeBacklogPath, "utf8");
2659
+ const { text, removed } = normalizeBacklog(before);
2660
+ if (text !== before) writeFileSync2(activeBacklogPath, text);
2661
+ if (removed.length > 0) {
2662
+ await log("info", `active backlog: dropped ${removed.length} task(s) finished more than a week ago`, {
2663
+ removed: removed.length
2664
+ });
2665
+ }
2666
+ return { removed: removed.length };
2667
+ } catch {
2668
+ return { removed: 0 };
2669
+ }
2670
+ };
2671
+ void normalizeActiveBacklog();
2085
2672
  const listed = (files) => {
2086
2673
  const shown = files.slice(0, MAX_FILES_LISTED);
2087
2674
  const rest = files.length - shown.length;
@@ -2097,20 +2684,29 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2097
2684
  "This session touched these paths (starting context, not an exhaustive list):",
2098
2685
  listed(files),
2099
2686
  "",
2100
- "Follow the bootstrap procedure in your system prompt: progressive exploration, then write the five context files within their size targets. When the project already has architecture or decision documentation, link to it instead of duplicating it. Write conclusions, not inventories."
2687
+ NO_BULK_READS,
2688
+ "",
2689
+ "Follow the bootstrap procedure in your system prompt: progressive exploration, then write the five context files within their size targets. Also create `.opencode/context/active.md` with its heading and no tasks. When the project already has architecture or decision documentation, link to it instead of duplicating it. Write conclusions, not inventories."
2101
2690
  ].join("\n") : [
2102
2691
  "Incremental context update. Project root: " + directory,
2103
2692
  "",
2104
2693
  "This session touched these paths (start from these and their immediate architectural neighborhood):",
2105
2694
  listed(files),
2106
2695
  "",
2107
- 'Follow the incremental-update procedure in your system prompt. Update a context file only if this work introduced or revealed information that will materially help future agents: a new boundary or module, a new or moved canonical entry point, a changed workflow, a new convention, an architectural decision, a new dependency between subsystems, a renamed or moved subsystem, or a previously undocumented invariant. Do not edit documentation merely because source files changed. If nothing material changed, make no edits and report "no update needed".'
2696
+ NO_BULK_READS,
2697
+ "",
2698
+ 'Follow the incremental-update procedure in your system prompt. Update a context file only if this work introduced or revealed information that will materially help future agents: a new boundary or module, a new or moved canonical entry point, a changed workflow, a new convention, an architectural decision, a new dependency between subsystems, a renamed or moved subsystem, or a previously undocumented invariant. Do not edit documentation merely because source files changed. If nothing material changed, make no edits and report "no update needed". Also keep `.opencode/context/active.md` current: record work that is in flight, and mark a task finished \u2014 with the date it finished \u2014 when it is.'
2108
2699
  ].join("\n");
2109
2700
  return {
2110
2701
  event: async ({ event }) => {
2111
2702
  try {
2112
2703
  if (event.type === "file.edited") {
2113
- if (isTracked(event.properties.file)) pending.add(event.properties.file);
2704
+ const file = event.properties.file;
2705
+ if (path2.resolve(root, file) === activeBacklogPath) {
2706
+ await normalizeActiveBacklog();
2707
+ return;
2708
+ }
2709
+ if (isTracked(file)) pending.add(file);
2114
2710
  return;
2115
2711
  }
2116
2712
  if (event.type !== "session.idle") return;
@@ -2119,6 +2715,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2119
2715
  lastRun = Date.now();
2120
2716
  return;
2121
2717
  }
2718
+ if (isProbe(state2, sessionID)) return;
2122
2719
  const now = Date.now();
2123
2720
  for (const [id, startedAt] of owned) if (now - startedAt > OWNED_TTL_MS) owned.delete(id);
2124
2721
  if (owned.size > 0) return;
@@ -2148,21 +2745,36 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2148
2745
  return;
2149
2746
  }
2150
2747
  const claimKey2 = claimKey(target, "context-autoupdate");
2151
- acquire(state2, "context", slot, target, claimKey2, target);
2152
- await log("info", `borrowed pool slot ${slot.index} (${slot.model}, w${slot.weight}) for auto ${mode}`, {
2153
- slot: slot.index,
2154
- weight: slot.weight,
2155
- model: slot.model
2156
- });
2157
- lastRun = Date.now();
2158
- owned.set(target, Date.now());
2159
- const before = snapshotMap(contextAbs);
2160
- client.session.prompt({
2161
- path: { id: target },
2162
- query: { directory },
2163
- body: { agent, parts: [{ type: "text", text: compose(mode, files) }] }
2164
- }).then(async (result) => {
2748
+ let claimed = false;
2749
+ let before;
2750
+ let started;
2751
+ try {
2752
+ acquire(state2, "context", slot, target, claimKey2, target);
2753
+ claimed = true;
2754
+ await log("info", `borrowed pool slot ${slot.index} (${slot.model}, w${slot.weight}) for auto ${mode}`, {
2755
+ slot: slot.index,
2756
+ weight: slot.weight,
2757
+ model: slot.model
2758
+ });
2759
+ lastRun = Date.now();
2760
+ owned.set(target, Date.now());
2761
+ before = snapshotMap(contextAbs);
2762
+ started = client.session.prompt({
2763
+ path: { id: target },
2764
+ query: { directory },
2765
+ body: { agent, parts: [{ type: "text", text: compose(mode, files) }] }
2766
+ });
2767
+ } catch (error) {
2768
+ if (claimed) release(state2, claimKey2);
2769
+ owned.delete(target);
2770
+ await retireSession(client, directory, target, mode, log);
2771
+ requeue();
2772
+ await log("error", `auto ${mode} could not start: ${String(error)}`);
2773
+ return;
2774
+ }
2775
+ started.then(async (result) => {
2165
2776
  release(state2, claimKey2);
2777
+ await retireSession(client, directory, target, mode, log);
2166
2778
  const failure = result.error ?? result.data?.info?.error;
2167
2779
  if (failure) {
2168
2780
  requeue();
@@ -2186,6 +2798,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
2186
2798
  }
2187
2799
  }).catch(async (error) => {
2188
2800
  release(state2, claimKey2);
2801
+ await retireSession(client, directory, target, mode, log);
2189
2802
  requeue();
2190
2803
  await log("error", `auto ${mode} failed: ${String(error)}`);
2191
2804
  });
@@ -2372,6 +2985,9 @@ var Notifier = class {
2372
2985
  // src/plugins/notify.ts
2373
2986
  var SERVICE3 = "notify";
2374
2987
  var COOLDOWN_MS2 = 5e3;
2988
+ var MAX_INFLIGHT_HELPERS = 4;
2989
+ var HELPER_TIMEOUT_MS = 3e4;
2990
+ var MAX_TRACKED_SESSIONS = 500;
2375
2991
  var SOUND_ENV = "LACODE_SOUND";
2376
2992
  var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
2377
2993
  var findHelper = () => {
@@ -2388,6 +3004,8 @@ var findHelper = () => {
2388
3004
  return void 0;
2389
3005
  };
2390
3006
  var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
3007
+ const maxInflight = deps.maxInflightHelpers ?? MAX_INFLIGHT_HELPERS;
3008
+ const helperTimeoutMs = deps.helperTimeoutMs ?? HELPER_TIMEOUT_MS;
2391
3009
  const opts = options ?? {};
2392
3010
  const soundEnv = (process.env[SOUND_ENV] ?? "").trim().toLowerCase();
2393
3011
  const soundOn = !SOUND_OFF_VALUES.includes(soundEnv) && opts.sound !== false;
@@ -2397,6 +3015,15 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2397
3015
  const pending = /* @__PURE__ */ new Set();
2398
3016
  const permissions = /* @__PURE__ */ new Set();
2399
3017
  let warnedHelper = false;
3018
+ let inflight = 0;
3019
+ const rememberActive = (sessionID) => {
3020
+ active.add(sessionID);
3021
+ while (active.size > MAX_TRACKED_SESSIONS) active.delete(active.values().next().value);
3022
+ };
3023
+ const rememberTitle = (id, title) => {
3024
+ titles.set(id, title);
3025
+ while (titles.size > MAX_TRACKED_SESSIONS) titles.delete(titles.keys().next().value);
3026
+ };
2400
3027
  const log = (level, message, extra) => client.app.log({ body: { service: SERVICE3, level, message, extra } }).catch(() => {
2401
3028
  });
2402
3029
  const fetchInfo = async (sessionID) => {
@@ -2404,6 +3031,11 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2404
3031
  return info;
2405
3032
  };
2406
3033
  const deliverViaHelper = (title, message, urgency, sound) => new Promise((resolve3) => {
3034
+ if (inflight >= maxInflight) {
3035
+ void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
3036
+ resolve3({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
3037
+ return;
3038
+ }
2407
3039
  const helper = findHelper();
2408
3040
  if (!helper) {
2409
3041
  if (!warnedHelper) {
@@ -2417,13 +3049,32 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2417
3049
  stdio: ["ignore", "ignore", "pipe"],
2418
3050
  detached: true
2419
3051
  });
3052
+ inflight++;
3053
+ let settled = false;
3054
+ let timer;
3055
+ const finish = (result) => {
3056
+ if (settled) return;
3057
+ settled = true;
3058
+ inflight--;
3059
+ if (timer) clearTimeout(timer);
3060
+ resolve3(result);
3061
+ };
3062
+ timer = setTimeout(() => {
3063
+ try {
3064
+ child.kill("SIGKILL");
3065
+ } catch {
3066
+ }
3067
+ void log("warn", `notification helper timed out after ${helperTimeoutMs}ms`, { title });
3068
+ finish({ ok: false, detail: `helper timed out after ${helperTimeoutMs}ms` });
3069
+ }, helperTimeoutMs);
3070
+ timer.unref?.();
2420
3071
  let stderr = "";
2421
3072
  child.stderr?.on("data", (chunk) => {
2422
3073
  if (stderr.length < 400) stderr += String(chunk);
2423
3074
  });
2424
3075
  child.on("error", (error) => {
2425
3076
  void log("warn", `notification helper failed: ${String(error)}`, { title });
2426
- resolve3({ ok: false, detail: String(error) });
3077
+ finish({ ok: false, detail: String(error) });
2427
3078
  });
2428
3079
  child.on("exit", (code) => {
2429
3080
  const noise = stderr.trim();
@@ -2434,7 +3085,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2434
3085
  message
2435
3086
  });
2436
3087
  }
2437
- resolve3({ ok, detail: noise || void 0 });
3088
+ finish({ ok, detail: noise || void 0 });
2438
3089
  });
2439
3090
  child.unref();
2440
3091
  });
@@ -2489,12 +3140,12 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2489
3140
  try {
2490
3141
  if (event.type === "session.status") {
2491
3142
  const { sessionID: sessionID2, status } = event.properties;
2492
- if (status.type === "busy" && sessionID2) active.add(sessionID2);
3143
+ if (status.type === "busy" && sessionID2) rememberActive(sessionID2);
2493
3144
  return;
2494
3145
  }
2495
3146
  if (event.type === "session.updated") {
2496
3147
  const info = event.properties.info;
2497
- if (info?.id) titles.set(info.id, info.title);
3148
+ if (info?.id) rememberTitle(info.id, info.title);
2498
3149
  return;
2499
3150
  }
2500
3151
  if (event.type === "question.asked") {
@@ -2502,7 +3153,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2502
3153
  const sessionID2 = properties?.sessionID;
2503
3154
  if (!sessionID2) return;
2504
3155
  pending.add(sessionID2);
2505
- active.add(sessionID2);
3156
+ rememberActive(sessionID2);
2506
3157
  const headline = questionHeadline(properties.questions);
2507
3158
  void announce(sessionID2, "question", headline || void 0);
2508
3159
  return;
@@ -2517,7 +3168,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
2517
3168
  const sessionID2 = properties?.sessionID;
2518
3169
  if (!sessionID2) return;
2519
3170
  permissions.add(sessionID2);
2520
- active.add(sessionID2);
3171
+ rememberActive(sessionID2);
2521
3172
  void announce(sessionID2, "permission", permissionHeadline(properties) || void 0);
2522
3173
  return;
2523
3174
  }
@@ -2730,6 +3381,12 @@ function createSelector(config, deps = {}) {
2730
3381
  const now = deps.now ?? Date.now;
2731
3382
  const stats = /* @__PURE__ */ new Map();
2732
3383
  const sticky = /* @__PURE__ */ new Map();
3384
+ const MAX_STICKY = 500;
3385
+ const rememberSticky = (sessionID, modelID) => {
3386
+ sticky.delete(sessionID);
3387
+ sticky.set(sessionID, modelID);
3388
+ while (sticky.size > MAX_STICKY) sticky.delete(sticky.keys().next().value);
3389
+ };
2733
3390
  let cands = [];
2734
3391
  const ensure = (modelID) => {
2735
3392
  let stat = stats.get(modelID);
@@ -2766,7 +3423,10 @@ function createSelector(config, deps = {}) {
2766
3423
  if (list.length === 0) return null;
2767
3424
  if (config.stickyPerSession) {
2768
3425
  const held = sticky.get(sessionID);
2769
- if (held !== void 0 && list.includes(held) && !cooling(held)) return held;
3426
+ if (held !== void 0 && list.includes(held) && !cooling(held)) {
3427
+ rememberSticky(sessionID, held);
3428
+ return held;
3429
+ }
2770
3430
  }
2771
3431
  let bestID = null;
2772
3432
  let bestStat = null;
@@ -2781,7 +3441,7 @@ function createSelector(config, deps = {}) {
2781
3441
  }
2782
3442
  }
2783
3443
  if (bestID === null) return null;
2784
- if (config.stickyPerSession) sticky.set(sessionID, bestID);
3444
+ if (config.stickyPerSession) rememberSticky(sessionID, bestID);
2785
3445
  return bestID;
2786
3446
  },
2787
3447
  stickyFor(sessionID) {
@@ -2813,6 +3473,9 @@ function createSelector(config, deps = {}) {
2813
3473
  const out = {};
2814
3474
  for (const [id, stat] of stats) out[id] = { ...stat };
2815
3475
  return out;
3476
+ },
3477
+ stickySize() {
3478
+ return sticky.size;
2816
3479
  }
2817
3480
  };
2818
3481
  }
@@ -3032,8 +3695,8 @@ var omniRouteHooks = async (input, options, deps = {}) => {
3032
3695
 
3033
3696
  // src/lib/selfupdate.ts
3034
3697
  import { spawn as spawn2 } from "node:child_process";
3035
- import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2, writeSync } from "node:fs";
3036
- import { homedir as homedir2 } from "node:os";
3698
+ import { closeSync, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync5, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3, writeSync } from "node:fs";
3699
+ import { homedir as homedir3 } from "node:os";
3037
3700
  import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
3038
3701
  var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
3039
3702
  var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
@@ -3043,7 +3706,7 @@ var LOCK_STALE_MS = 10 * 60 * 1e3;
3043
3706
  var CHILD_ENV = "LACODE_SELFUPDATE_CHILD";
3044
3707
  var OPT_OUT_ENV = "LACODE_NO_SELFUPDATE";
3045
3708
  var inTestRunner = (env = process.env) => typeof env.NODE_TEST_CONTEXT === "string" && env.NODE_TEST_CONTEXT.length > 0;
3046
- var STATE_DIR = join6(homedir2(), ".local", "share", "opencode", "lacode");
3709
+ var STATE_DIR = join6(homedir3(), ".local", "share", "opencode", "lacode");
3047
3710
  var STATE_PATH = join6(STATE_DIR, "selfupdate.json");
3048
3711
  var LOCK_PATH = join6(STATE_DIR, "selfupdate.lock");
3049
3712
  function compareSemver(a, b) {
@@ -3059,7 +3722,7 @@ function compareSemver(a, b) {
3059
3722
  }
3060
3723
  function readInstalledVersion(packageRoot, read = (path4) => {
3061
3724
  try {
3062
- return readFileSync4(path4, "utf8");
3725
+ return readFileSync5(path4, "utf8");
3063
3726
  } catch {
3064
3727
  return void 0;
3065
3728
  }
@@ -3100,7 +3763,7 @@ function resolveOpencodeBin(execPath = process.execPath, env = process.env, exis
3100
3763
  const candidate = join6(dir, "opencode");
3101
3764
  if (exists(candidate)) return candidate;
3102
3765
  }
3103
- const fallback = join6(homedir2(), ".opencode", "bin", "opencode");
3766
+ const fallback = join6(homedir3(), ".opencode", "bin", "opencode");
3104
3767
  return exists(fallback) ? fallback : void 0;
3105
3768
  }
3106
3769
  async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
@@ -3119,15 +3782,15 @@ async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
3119
3782
  }
3120
3783
  function readState(path4 = STATE_PATH) {
3121
3784
  try {
3122
- return JSON.parse(readFileSync4(path4, "utf8"));
3785
+ return JSON.parse(readFileSync5(path4, "utf8"));
3123
3786
  } catch {
3124
3787
  return {};
3125
3788
  }
3126
3789
  }
3127
3790
  function writeState(state2, path4 = STATE_PATH) {
3128
3791
  try {
3129
- mkdirSync2(dirname3(path4), { recursive: true });
3130
- writeFileSync2(path4, JSON.stringify(state2));
3792
+ mkdirSync3(dirname3(path4), { recursive: true });
3793
+ writeFileSync3(path4, JSON.stringify(state2));
3131
3794
  } catch {
3132
3795
  }
3133
3796
  }
@@ -3147,18 +3810,18 @@ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
3147
3810
  closeSync(fd);
3148
3811
  return () => {
3149
3812
  try {
3150
- unlinkSync2(path4);
3813
+ unlinkSync3(path4);
3151
3814
  } catch {
3152
3815
  }
3153
3816
  };
3154
3817
  };
3155
3818
  try {
3156
- mkdirSync2(dirname3(path4), { recursive: true });
3819
+ mkdirSync3(dirname3(path4), { recursive: true });
3157
3820
  return claim();
3158
3821
  } catch {
3159
3822
  let orphaned = false;
3160
3823
  try {
3161
- const pid = Number.parseInt(readFileSync4(path4, "utf8").trim(), 10);
3824
+ const pid = Number.parseInt(readFileSync5(path4, "utf8").trim(), 10);
3162
3825
  orphaned = !Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid);
3163
3826
  } catch {
3164
3827
  orphaned = true;
@@ -3172,7 +3835,7 @@ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
3172
3835
  }
3173
3836
  if (!orphaned) return void 0;
3174
3837
  try {
3175
- unlinkSync2(path4);
3838
+ unlinkSync3(path4);
3176
3839
  } catch {
3177
3840
  return void 0;
3178
3841
  }