@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/CHANGELOG.md +224 -2
- package/README.md +24 -6
- package/dist/agents.js +100 -14
- package/dist/commands.js +10 -5
- package/dist/index.js +740 -77
- package/dist/pool.js +153 -12
- package/docs/CONTEXT.md +21 -6
- package/docs/OMNIROUTE.md +17 -15
- package/docs/USER-STORIES.md +2 -2
- package/package.json +1 -1
- package/presets/README.md +8 -8
- package/presets/free-tier.json +26 -9
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/
|
|
52
|
-
{ index: 2, model: "opencode-go/
|
|
53
|
-
{ index: 3, model: "opencode/
|
|
54
|
-
{ index: 4, model: "opencode/
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
|
659
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
|
1327
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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)
|
|
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
|
|
3036
|
-
import { homedir as
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
3130
|
-
|
|
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
|
-
|
|
3813
|
+
unlinkSync3(path4);
|
|
3151
3814
|
} catch {
|
|
3152
3815
|
}
|
|
3153
3816
|
};
|
|
3154
3817
|
};
|
|
3155
3818
|
try {
|
|
3156
|
-
|
|
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(
|
|
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
|
-
|
|
3838
|
+
unlinkSync3(path4);
|
|
3176
3839
|
} catch {
|
|
3177
3840
|
return void 0;
|
|
3178
3841
|
}
|